From 3c7d0462164118242d97eb2a28c53b80ee8392d2 Mon Sep 17 00:00:00 2001 From: DongYun Kang Date: Fri, 30 Oct 2026 09:44:57 +0900 Subject: [PATCH 1/9] feat: use SWC Next for TypeScript stripping and transformation --- README.md | 14 +- crates/amaro/Cargo.lock | 707 +++++++--------------- crates/amaro/Cargo.toml | 15 +- crates/amaro/rust-toolchain.toml | 2 +- crates/amaro/src/lib.rs | 10 +- crates/amaro/src/options.rs | 144 +++++ crates/amaro/src/strip.rs | 232 +++++++ crates/amaro/src/transform.rs | 189 ++++++ test/loader.test.js | 14 +- test/snapshots/transform.test.js.snapshot | 36 +- test/transform.test.js | 96 +++ 11 files changed, 949 insertions(+), 510 deletions(-) create mode 100644 crates/amaro/src/options.rs create mode 100644 crates/amaro/src/strip.rs create mode 100644 crates/amaro/src/transform.rs diff --git a/README.md b/README.md index 700965256..76c3d8564 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Amaro -Amaro is a Node.js wrapper around the [SWC](https://swc.rs) TypeScript parser and type stripper. Its Rust core in `crates/amaro` depends on the published SWC crates and is compiled to WebAssembly. +Amaro is a Node.js wrapper around the [SWC Next](https://github.com/swc-project/swc-next) TypeScript type stripper and transformer. Its Rust core in `crates/amaro` is compiled to WebAssembly. Both `strip-only` and `transform` modes use SWC Next at a pinned Git revision. Node.js REPL helpers and diagnostic formatting still use the published [SWC](https://swc.rs) crates. +Diagnostics retain Amaro's error codes and location fields, but their messages and highlighted ranges come from SWC Next. It's used as an internal in Node.js for [Type Stripping](https://nodejs.org/api/typescript.html#type-stripping) but can also be used as a standalone package. > Amaro means "bitter" in Italian. It's a reference to [Mount Amaro](https://en.wikipedia.org/wiki/Monte_Amaro_(Abruzzo)) on whose slopes this package was conceived. @@ -61,6 +62,17 @@ node --enable-source-maps --import="amaro/transform" file.ts > Note that the `amaro/transform` loader should be used with `--enable-source-maps` > to preserve accurate source-mapped stack traces. +The SWC Next transformer supports enums, namespaces, constructor parameter +properties, and source maps. It preserves decorators and resource declarations +for the runtime. Generated code and source maps differ from SWC's output. + +`verbatimModuleSyntax` and `noEmptyExport` are supported. `nativeClassProperties` +defaults to `true`; setting it to `false` is unsupported. Only `Classic` is +supported for `importExportAssignConfig`, which emits `require(...)` and +`module.exports`. `tsEnumIsMutable: true` and `importNotUsedAsValues: "preserve"` +require `verbatimModuleSyntax: true`. Unsupported option combinations throw an +`UnsupportedSyntax` diagnostic. + #### Programmatic registration with `module.registerHooks()` Amaro's `strip` and `transform` entry points use `module.registerHooks()` to register synchronous hooks on Node.js versions that provide it, and fall back to `module.register()` on older versions. If you want TypeScript to "just work" in an existing codebase without passing `--import` every time, create a small bootstrap file that imports Amaro before loading your TS entrypoint. diff --git a/crates/amaro/Cargo.lock b/crates/amaro/Cargo.lock index ba5ae5db9..0818b6ab4 100644 --- a/crates/amaro/Cargo.lock +++ b/crates/amaro/Cargo.lock @@ -14,15 +14,6 @@ dependencies = [ "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" @@ -45,7 +36,13 @@ dependencies = [ "swc_ecma_parser", "swc_ecma_visit", "swc_error_reporters", - "swc_ts_fast_strip", + "swc_next_allocator", + "swc_next_ecma_ast", + "swc_next_ecma_codegen", + "swc_next_ecma_parser", + "swc_next_ecma_semantic", + "swc_next_ecma_transformer", + "swc_next_type_strip", "tracing", "unicode-width 0.2.2", "wasm-bindgen", @@ -68,10 +65,10 @@ dependencies = [ ] [[package]] -name = "ascii" -version = "1.1.0" +name = "arrayvec" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "ast_node" @@ -90,22 +87,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[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" @@ -121,27 +102,6 @@ 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 = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "bumpalo" version = "3.20.3" @@ -167,15 +127,6 @@ dependencies = [ "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.4.7" @@ -193,62 +144,10 @@ 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 = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" +name = "cow-utils" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[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 = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] +checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" [[package]] name = "displaydoc" @@ -258,15 +157,9 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "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.18.0" @@ -279,12 +172,24 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" +[[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" @@ -304,22 +209,6 @@ dependencies = [ "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 = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "hashbrown" version = "0.14.5" @@ -335,6 +224,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "heck" @@ -342,12 +236,6 @@ 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 = "4.0.2" @@ -511,12 +399,6 @@ dependencies = [ "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" @@ -618,6 +500,12 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + [[package]] name = "num-bigint" version = "0.4.8" @@ -629,6 +517,16 @@ dependencies = [ "serde", ] +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-integer" version = "0.1.47" @@ -647,16 +545,6 @@ 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" @@ -672,12 +560,6 @@ 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 = "owo-colors" version = "4.4.0" @@ -685,12 +567,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" [[package]] -name = "par-core" -version = "2.0.0" +name = "oxc_index" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e96cbd21255b7fb29a5d51ef38a779b517a91abd59e2756c039583f43ef4c90f" +checksum = "191884bee6c3744909a51acc7d78d4ae370d817b25875b10642f632327b6296e" dependencies = [ - "once_cell", + "nonmax", ] [[package]] @@ -728,8 +610,19 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", + "serde", ] [[package]] @@ -738,18 +631,41 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand", ] +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", +] + [[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", + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -764,6 +680,15 @@ dependencies = [ "siphasher 1.0.3", ] +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher 1.0.3", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -809,12 +734,6 @@ 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" @@ -839,35 +758,6 @@ dependencies = [ "bitflags", ] -[[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" @@ -880,12 +770,6 @@ 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 = "scoped-tls" version = "1.0.1" @@ -942,7 +826,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -958,17 +842,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "shlex" version = "2.0.1" @@ -1012,12 +885,6 @@ dependencies = [ "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" @@ -1029,18 +896,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "swc_allocator" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb41c2f41afa7357a86109f7e058f6b140ab415b8cd196c1a7b54f2703c85417" -dependencies = [ - "allocator-api2", - "bumpalo", - "hashbrown 0.14.5", - "rustc-hash", -] - [[package]] name = "swc_atoms" version = "10.0.0" @@ -1064,7 +919,7 @@ dependencies = [ "bytes-str", "either", "from_variant", - "num-bigint", + "num-bigint 0.4.8", "once_cell", "parking_lot", "rustc-hash", @@ -1072,39 +927,12 @@ dependencies = [ "siphasher 0.3.11", "swc_atoms", "swc_eq_ignore_macros", - "swc_sourcemap", "swc_visit", "tracing", "unicode-width 0.2.2", "url", ] -[[package]] -name = "swc_config" -version = "6.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf6924ef781f1f8af3dfdac9674e94f0ab0c20af6b88b2e116af57b1e755b591" -dependencies = [ - "anyhow", - "bytes-str", - "indexmap", - "serde", - "serde_json", - "swc_config_macro", -] - -[[package]] -name = "swc_config_macro" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b416e8ce6de17dc5ea496e10c7012b35bbc0e3fef38d2e065eed936490db0b3" -dependencies = [ - "proc-macro2", - "quote", - "swc_macros_common", - "syn 2.0.119", -] - [[package]] name = "swc_ecma_ast" version = "29.0.2" @@ -1113,9 +941,9 @@ checksum = "2839d2cf4e611b944438c304ec59396221b3db4580acd172fcd70522bb450ee9" dependencies = [ "bitflags", "is-macro", - "num-bigint", + "num-bigint 0.4.8", "once_cell", - "phf", + "phf 0.11.3", "rustc-hash", "string_enum", "swc_atoms", @@ -1124,53 +952,6 @@ dependencies = [ "unicode-id-start", ] -[[package]] -name = "swc_ecma_codegen" -version = "32.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "967aff044e24cd2c725cdc3067835824fcde9a918ef3222512db8b08bb479966" -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", - "swc_ecma_utils", - "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_hooks" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3abc7b19ffc01d358fe6dfce904cc0a9c30c4db6deed11011b67c320f71682c" -dependencies = [ - "swc_atoms", - "swc_common", - "swc_ecma_ast", - "swc_ecma_visit", -] - [[package]] name = "swc_ecma_parser" version = "46.1.2" @@ -1179,8 +960,8 @@ checksum = "a032f869f6690d4eddd6e67757c8d849f2c8219d8c5f3bb05a7ec8b456867642" dependencies = [ "bitflags", "either", - "num-bigint", - "phf", + "num-bigint 0.4.8", + "phf 0.11.3", "rustc-hash", "seq-macro", "serde", @@ -1191,90 +972,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "swc_ecma_transforms_base" -version = "50.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23900618b7d3080d867559bdea5c4b696160ab7cdc97b4781b2353d2b5b9c485" -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_transforms_react" -version = "56.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b8afd049010c50a4da9a46622a80ad89961a399f91d987ff30e5d36bad6beb4" -dependencies = [ - "base64", - "bytes-str", - "indexmap", - "once_cell", - "rustc-hash", - "serde", - "sha1", - "string_enum", - "swc_atoms", - "swc_common", - "swc_config", - "swc_ecma_ast", - "swc_ecma_hooks", - "swc_ecma_parser", - "swc_ecma_transforms_base", - "swc_ecma_utils", - "swc_ecma_visit", -] - -[[package]] -name = "swc_ecma_transforms_typescript" -version = "56.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1a8eb6123ae1d50351098cc218799f7cc028d147a7c8b7f700e2d7237c0aaf" -dependencies = [ - "bytes-str", - "rustc-hash", - "serde", - "swc_atoms", - "swc_common", - "swc_ecma_ast", - "swc_ecma_transforms_base", - "swc_ecma_transforms_react", - "swc_ecma_utils", - "swc_ecma_visit", -] - -[[package]] -name = "swc_ecma_utils" -version = "35.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecd5288b59983c77a838f49498a8f88aa7ff9c1c5a059885dceb244293a6701b" -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 = "29.0.0" @@ -1282,7 +979,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a830a070fea84a3a8a8c556463f0551e3bfc6cf2f8bdeecd62fa753e22a72289" dependencies = [ "new_debug_unreachable", - "num-bigint", + "num-bigint 0.4.8", "swc_atoms", "swc_common", "swc_ecma_ast", @@ -1326,42 +1023,123 @@ dependencies = [ ] [[package]] -name = "swc_sourcemap" -version = "10.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c421e5e39e43a4b1b70c07922d7bffd5c22e8eff1340c0b15d0bfd0328822ee" +name = "swc_next_allocator" +version = "0.4.1" +source = "git+https://github.com/swc-project/swc-next?rev=2be98eee84461163d9068612f691c649ba18bd36#2be98eee84461163d9068612f691c649ba18bd36" dependencies = [ - "base64-simd", - "bitvec", - "bytes-str", - "data-encoding", - "debugid", - "if_chain", + "allocator-api2", + "bumpalo", + "hashbrown 0.17.1", + "oxc_index", "rustc-hash", - "serde", - "serde_json", +] + +[[package]] +name = "swc_next_ast_macros" +version = "0.4.1" +source = "git+https://github.com/swc-project/swc-next?rev=2be98eee84461163d9068612f691c649ba18bd36#2be98eee84461163d9068612f691c649ba18bd36" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "swc_next_ecma_ast" +version = "0.4.1" +source = "git+https://github.com/swc-project/swc-next?rev=2be98eee84461163d9068612f691c649ba18bd36#2be98eee84461163d9068612f691c649ba18bd36" +dependencies = [ + "cow-utils", + "num-bigint 0.5.1", + "swc_next_allocator", + "swc_next_ast_macros", "unicode-id-start", - "url", ] [[package]] -name = "swc_ts_fast_strip" -version = "59.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb19ef5bf46cb01138c4460ae0a20c210fc3eb8da6919a766a19751b1d6847c6" +name = "swc_next_ecma_codegen" +version = "0.4.1" +source = "git+https://github.com/swc-project/swc-next?rev=2be98eee84461163d9068612f691c649ba18bd36#2be98eee84461163d9068612f691c649ba18bd36" dependencies = [ - "anyhow", + "arrayvec", + "bitflags", + "memchr", + "rustc-hash", + "swc_next_allocator", + "swc_next_ecma_ast", + "swc_next_simd", + "zmij_ecma", +] + +[[package]] +name = "swc_next_ecma_parser" +version = "0.4.1" +source = "git+https://github.com/swc-project/swc-next?rev=2be98eee84461163d9068612f691c649ba18bd36#2be98eee84461163d9068612f691c649ba18bd36" +dependencies = [ + "bitflags", + "indexmap", + "memchr", + "rustc-hash", + "swc_next_allocator", + "swc_next_ecma_ast", + "swc_next_simd", + "unicode-id-start", +] + +[[package]] +name = "swc_next_ecma_semantic" +version = "0.4.1" +source = "git+https://github.com/swc-project/swc-next?rev=2be98eee84461163d9068612f691c649ba18bd36#2be98eee84461163d9068612f691c649ba18bd36" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "memchr", + "rustc-hash", + "swc_next_allocator", + "swc_next_ecma_ast", + "swc_next_regular_expression", + "zmij_ecma", +] + +[[package]] +name = "swc_next_ecma_transformer" +version = "0.4.1" +source = "git+https://github.com/swc-project/swc-next?rev=2be98eee84461163d9068612f691c649ba18bd36#2be98eee84461163d9068612f691c649ba18bd36" +dependencies = [ + "rustc-hash", "serde", - "swc_atoms", - "swc_common", - "swc_ecma_ast", - "swc_ecma_codegen", - "swc_ecma_parser", - "swc_ecma_transforms_base", - "swc_ecma_transforms_react", - "swc_ecma_transforms_typescript", - "swc_ecma_visit", - "wasm-bindgen", + "swc_next_allocator", + "swc_next_ecma_ast", + "swc_next_ecma_semantic", + "zmij_ecma", +] + +[[package]] +name = "swc_next_regular_expression" +version = "0.4.1" +source = "git+https://github.com/swc-project/swc-next?rev=2be98eee84461163d9068612f691c649ba18bd36#2be98eee84461163d9068612f691c649ba18bd36" +dependencies = [ + "bitflags", + "phf 0.14.0", + "rustc-hash", + "swc_next_allocator", + "unicode-id-start", +] + +[[package]] +name = "swc_next_simd" +version = "0.4.1" +source = "git+https://github.com/swc-project/swc-next?rev=2be98eee84461163d9068612f691c649ba18bd36#2be98eee84461163d9068612f691c649ba18bd36" + +[[package]] +name = "swc_next_type_strip" +version = "0.4.1" +source = "git+https://github.com/swc-project/swc-next?rev=2be98eee84461163d9068612f691c649ba18bd36#2be98eee84461163d9068612f691c649ba18bd36" +dependencies = [ + "swc_next_allocator", + "swc_next_ecma_ast", + "swc_next_ecma_parser", + "swc_next_simd", ] [[package]] @@ -1386,9 +1164,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -1403,15 +1181,9 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "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 = "textwrap" version = "0.16.4" @@ -1474,12 +1246,6 @@ dependencies = [ "stable_deref_trait", ] -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - [[package]] name = "unicode-id-start" version = "1.5.0" @@ -1522,28 +1288,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "uuid" -version = "1.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" -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.100" @@ -1646,15 +1396,6 @@ 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 = "yoke" version = "0.8.3" @@ -1674,7 +1415,7 @@ checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -1715,7 +1456,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -1751,7 +1492,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1759,3 +1500,9 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zmij_ecma" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458b3e7a977f14499066b6bdb63d69da30ce337d6c160f077e6c575c82a44db9" diff --git a/crates/amaro/Cargo.toml b/crates/amaro/Cargo.toml index 65d7f9480..fec9517ea 100644 --- a/crates/amaro/Cargo.toml +++ b/crates/amaro/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "amaro" version = "1.2.0" -description = "Node.js TypeScript and REPL helpers built on SWC, compiled to WebAssembly" +description = "Node.js TypeScript stripping built on SWC Next, compiled to WebAssembly" license = "Apache-2.0" repository = "https://github.com/nodejs/amaro.git" readme = "../../README.md" @@ -26,14 +26,23 @@ unicode-width = "0.2.1" wasm-bindgen = { version = "0.2.100", features = ["enable-interning"] } wasm-bindgen-futures = "0.4.50" -# SWC crates. Pinned exactly because they only build together in the +# SWC compatibility crates for REPL helpers and diagnostics. +# Pinned exactly because they only build together in the # combinations SWC publishes; Dependabot bumps them as one group. swc_common = { version = "=26.0.0" } swc_ecma_ast = { version = "=29.0.2" } swc_ecma_parser = { version = "=46.1.2", features = ["unstable"] } swc_ecma_visit = { version = "=29.0.0" } swc_error_reporters = { version = "=28.0.0" } -swc_ts_fast_strip = { version = "=59.0.2", features = ["wasm-bindgen"] } + +# Pin SWC Next's experimental Rust API to a tested revision. +swc_next_allocator = { git = "https://github.com/swc-project/swc-next", rev = "2be98eee84461163d9068612f691c649ba18bd36" } +swc_next_ecma_ast = { git = "https://github.com/swc-project/swc-next", rev = "2be98eee84461163d9068612f691c649ba18bd36" } +swc_next_ecma_parser = { git = "https://github.com/swc-project/swc-next", rev = "2be98eee84461163d9068612f691c649ba18bd36" } +swc_next_ecma_transformer = { git = "https://github.com/swc-project/swc-next", rev = "2be98eee84461163d9068612f691c649ba18bd36" } +swc_next_ecma_semantic = { git = "https://github.com/swc-project/swc-next", rev = "2be98eee84461163d9068612f691c649ba18bd36" } +swc_next_ecma_codegen = { git = "https://github.com/swc-project/swc-next", rev = "2be98eee84461163d9068612f691c649ba18bd36" } +swc_next_type_strip = { git = "https://github.com/swc-project/swc-next", rev = "2be98eee84461163d9068612f691c649ba18bd36" } [profile.release] lto = "fat" diff --git a/crates/amaro/rust-toolchain.toml b/crates/amaro/rust-toolchain.toml index 5a66f1c2a..3b8d7c54e 100644 --- a/crates/amaro/rust-toolchain.toml +++ b/crates/amaro/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.98.1" +channel = "1.99.0" targets = ["wasm32-unknown-unknown"] diff --git a/crates/amaro/src/lib.rs b/crates/amaro/src/lib.rs index 02114f05c..26a8058ec 100644 --- a/crates/amaro/src/lib.rs +++ b/crates/amaro/src/lib.rs @@ -8,6 +8,7 @@ use anyhow::Error; use error_reporter::SwcReportHandler; use js_sys::Uint8Array; use miette::{GraphicalTheme, LabeledSpan, ThemeCharacters, ThemeStyles}; +use options::{Mode, Options, TransformOutput}; use serde::Serialize; use swc_common::{ errors::{DiagnosticBuilder, DiagnosticId, Emitter, Handler, HANDLER}, @@ -15,12 +16,14 @@ use swc_common::{ SourceMap, Span, GLOBALS, }; use swc_error_reporters::{convert_span, to_pretty_source_code}; -use swc_ts_fast_strip::{Options, TransformOutput}; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::{future_to_promise, js_sys::Promise}; mod error_reporter; mod nodejs; +mod options; +mod strip; +mod transform; /// TypeScript declarations for the WebAssembly module, emitted into `wasm.d.ts`. #[wasm_bindgen(typescript_custom_section)] @@ -147,8 +150,9 @@ fn coerce_input(input: JsValue) -> Result { fn operate(input: String, options: Options) -> Result> { let cm = Lrc::new(SourceMap::default()); - try_with_json_handler(cm.clone(), |handler| { - swc_ts_fast_strip::operate(&cm, handler, input, options).map_err(anyhow::Error::new) + try_with_json_handler(cm.clone(), |handler| match options.mode { + Mode::StripOnly => strip::operate(&cm, handler, input, options), + Mode::Transform => transform::operate(&cm, handler, input, options), }) } diff --git a/crates/amaro/src/options.rs b/crates/amaro/src/options.rs new file mode 100644 index 000000000..c093e1d0e --- /dev/null +++ b/crates/amaro/src/options.rs @@ -0,0 +1,144 @@ +//! Public options shared by the SWC Next adapters. +use serde::{Deserialize, Serialize}; +use swc_ecma_parser::TsSyntax; +use wasm_bindgen::prelude::*; + +#[derive(Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Options { + pub module: Option, + pub filename: Option, + #[serde(default = "default_parser")] + pub parser: TsSyntax, + #[serde(default)] + pub mode: Mode, + pub transform: Option, + pub deprecated_ts_module_as_error: Option, + #[serde(default)] + pub source_map: bool, +} + +fn default_parser() -> TsSyntax { + TsSyntax { + decorators: true, + ..Default::default() + } +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Mode { + #[default] + StripOnly, + Transform, +} + +#[derive(Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TransformConfig { + pub verbatim_module_syntax: bool, + pub native_class_properties: bool, + pub no_empty_export: bool, + pub import_not_used_as_values: ImportsNotUsedAsValues, + pub import_export_assign_config: ImportExportAssignConfig, + pub ts_enum_is_mutable: bool, + pub jsx: Option, +} + +impl Default for TransformConfig { + fn default() -> Self { + Self { + verbatim_module_syntax: false, + native_class_properties: true, + no_empty_export: false, + import_not_used_as_values: ImportsNotUsedAsValues::Remove, + import_export_assign_config: ImportExportAssignConfig::Classic, + ts_enum_is_mutable: false, + jsx: None, + } + } +} + +#[derive(Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ImportsNotUsedAsValues { + #[default] + Remove, + Preserve, +} + +#[derive(Default, Deserialize, PartialEq, Eq)] +pub enum ImportExportAssignConfig { + #[default] + Classic, + Preserve, + NodeNext, + EsNext, +} + +#[derive(Debug, Serialize)] +pub struct TransformOutput { + pub code: String, + pub map: Option, +} + +#[wasm_bindgen(typescript_custom_section)] +const TYPES: &str = r#" +interface Options { + module?: boolean; + filename?: string; + mode?: Mode; + transform?: TransformConfig; + deprecatedTsModuleAsError?: boolean; + sourceMap?: boolean; +} + +interface TransformConfig { + /** + * @see https://www.typescriptlang.org/tsconfig#verbatimModuleSyntax + */ + verbatimModuleSyntax?: boolean; + /** + * Native class properties support. Defaults to true; false is unsupported. + */ + nativeClassProperties?: boolean; + /** "preserve" requires verbatimModuleSyntax: true. */ + importNotUsedAsValues?: "remove" | "preserve"; + /** + * Don't create `export {}`. + * By default, strip creates `export {}` for modules to preserve module + * context. + * + * @see https://github.com/swc-project/swc/issues/1698 + */ + noEmptyExport?: boolean; + /** Only Classic is supported by the SWC Next adapter. */ + importExportAssignConfig?: "Classic" | "Preserve" | "NodeNext" | "EsNext"; + /** + * Disables an optimization that inlines TS enum member values + * within the same module that assumes the enum member values + * are never modified. + * + * Defaults to false. + */ + /** true requires verbatimModuleSyntax: true. */ + tsEnumIsMutable?: boolean; + + /** + * JSX lowering is unsupported by the SWC Next adapter. + */ + jsx?: JsxConfig; +} + +interface JsxConfig { + /** + * How to transform JSX. + * + * @default "react-jsx" + */ + transform?: "react-jsx" | "react-jsxdev"; +} + +type Mode = "strip-only" | "transform"; +interface TransformOutput { code: string; map?: string; } +"#; diff --git a/crates/amaro/src/strip.rs b/crates/amaro/src/strip.rs new file mode 100644 index 000000000..46fe8d580 --- /dev/null +++ b/crates/amaro/src/strip.rs @@ -0,0 +1,232 @@ +//! SWC Next adapter for fixed-width TypeScript stripping. + +use crate::options::{Options, TransformOutput}; +use swc_common::{ + errors::{DiagnosticId, Handler}, + sync::Lrc, + BytePos, FileName, SourceMap, Span, +}; +use swc_next_allocator::Allocator; +use swc_next_ecma_ast::{ + Ast, GetSpan, TsModuleDeclaration, TsModuleDeclarationKind, Visit, VisitWith, +}; +use swc_next_ecma_parser::{NoTokenParserConfig, Parser}; +use swc_next_type_strip::{strip_types, Lang, Options as StripOptions, SourceType}; + +pub fn operate( + cm: &Lrc, + handler: &Handler, + input: String, + options: Options, +) -> anyhow::Result { + let strip_options = StripOptions { + source_type: match options.module { + Some(true) => SourceType::Module, + Some(false) => SourceType::Script, + None => SourceType::Unambiguous, + }, + lang: if options.parser.tsx { + Lang::Tsx + } else { + Lang::Ts + }, + }; + let output = strip_types(&input, strip_options); + let mut diagnostics = output.diagnostics; + + validate(&input, &options, strip_options, &mut diagnostics); + report(cm, handler, &input, &options, diagnostics)?; + + Ok(TransformOutput { + code: output.code.into_owned(), + map: None, + }) +} + +pub(super) fn validate( + input: &str, + options: &Options, + strip_options: StripOptions, + diagnostics: &mut Vec>, +) { + // Keep Amaro's restriction on the deprecated identifier-named `module` + // declaration. String-named ambient modules remain supported. + let reject_deprecated_modules = + options.deprecated_ts_module_as_error.unwrap_or_default() && input.contains("module"); + if reject_deprecated_modules || options.module == Some(false) { + let allocator = Allocator::default(); + let parsed = Parser::init( + &allocator, + &input, + strip_options.into(), + NoTokenParserConfig, + ) + .parse(); + if parsed.diagnostics.is_empty() { + if options.module == Some(false) { + for statement in parsed.ast.root_program().body(&parsed.ast).iter() { + let statement = parsed.ast.get_node_in_sub_range(statement); + if statement.is_module_indicator(&parsed.ast) { + diagnostics.push(swc_next_type_strip::Diagnostic::error( + "Import and export declarations are not allowed in scripts", + statement.span(&parsed.ast), + )); + } + } + } + if reject_deprecated_modules { + let mut collector = DeprecatedModules { + ast: &parsed.ast, + spans: Vec::new(), + }; + parsed.ast.root_program().visit_with(&mut collector); + for span in collector.spans { + diagnostics.push(swc_next_type_strip::Diagnostic::error( + "`module` keyword is not supported. Use `namespace` instead.", + span, + )); + } + } + } + } +} + +pub(super) fn report( + cm: &Lrc, + handler: &Handler, + input: &str, + options: &Options, + diagnostics: Vec>, +) -> anyhow::Result<()> { + if !diagnostics.is_empty() { + let filename = options + .filename + .clone() + .map_or(FileName::Anon, |name| FileName::Real(name.into())); + let file = cm.new_source_file(filename.into(), input.to_owned()); + for diagnostic in diagnostics { + let unsupported = diagnostic.message.contains("erasableSyntaxOnly") + || diagnostic.message.starts_with("`module` keyword") + || diagnostic + .message + .starts_with("Unsupported transform option"); + let span = Span::new( + BytePos(file.start_pos.0 + diagnostic.span.start), + BytePos(file.start_pos.0 + diagnostic.span.end), + ); + handler + .struct_span_err(span, diagnostic.message.as_ref()) + .code(DiagnosticId::Error( + if unsupported { + "UnsupportedSyntax" + } else { + "InvalidSyntax" + } + .into(), + )) + .emit(); + } + anyhow::bail!("TypeScript processing failed"); + } + + Ok(()) +} + +struct DeprecatedModules<'ast> { + ast: &'ast Ast<'ast>, + spans: Vec, +} + +impl<'ast> Visit<'ast> for DeprecatedModules<'ast> { + fn ast(&self) -> &Ast<'ast> { + self.ast + } + + fn visit_ts_module_declaration(&mut self, node: TsModuleDeclaration) { + if node.kind(self.ast) == TsModuleDeclarationKind::Module + && !node.id(self.ast).is_string_literal(self.ast) + { + self.spans.push(node.span(self.ast)); + } + node.visit_children_with(self); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strip( + source: &str, + options: Options, + ) -> Result> { + let cm = Lrc::new(SourceMap::default()); + crate::try_with_json_handler(cm.clone(), |handler| { + operate(&cm, handler, source.into(), options) + }) + } + + #[test] + fn preserves_source_positions() { + let source = "const value: 'πŸ˜€' = 'ok';\r\nvalue;"; + let result = strip(source, Options::default()).unwrap_or_else(|_| panic!("strip failed")); + assert_eq!(result.code, "const value = 'ok';\r\nvalue;"); + assert_eq!( + result.code.encode_utf16().count(), + source.encode_utf16().count() + ); + assert!(result.map.is_none()); + } + + #[test] + fn reports_errors_without_returning_recovery_output() { + let errors = strip("enum Value { A }", Options::default()).err().unwrap(); + assert_eq!(errors[0].code.as_deref(), Some("UnsupportedSyntax")); + assert_eq!(errors[0].start_line, Some(1)); + + let errors = strip("const value;", Options::default()).err().unwrap(); + assert_eq!(errors[0].code.as_deref(), Some("InvalidSyntax")); + } + + #[test] + fn forwards_module_and_filename_options() { + let options = Options { + module: Some(false), + filename: Some("input.ts".into()), + ..Default::default() + }; + let errors = strip("export const value: number = 1;", options) + .err() + .unwrap(); + assert_eq!(errors[0].filename.as_deref(), Some("input.ts")); + + let options = Options { + module: Some(true), + ..Default::default() + }; + assert!(strip("export const value: number = 1;", options).is_ok()); + } + + #[test] + fn preserves_deprecated_module_option() { + let source = "module Types { export type Value = number }"; + assert!(strip(source, Options::default()).is_ok()); + + let options = Options { + deprecated_ts_module_as_error: Some(true), + ..Default::default() + }; + let errors = strip(source, options).err().unwrap(); + assert_eq!(errors[0].code.as_deref(), Some("UnsupportedSyntax")); + + let options = Options { + deprecated_ts_module_as_error: Some(true), + ..Default::default() + }; + assert!(strip( + "declare module 'types' { export type Value = number }", + options + ) + .is_ok()); + } +} diff --git a/crates/amaro/src/transform.rs b/crates/amaro/src/transform.rs new file mode 100644 index 000000000..cfe2b7655 --- /dev/null +++ b/crates/amaro/src/transform.rs @@ -0,0 +1,189 @@ +//! TypeScript transformation through SWC Next's semantic analysis and printer. + +use crate::options::{Options, TransformOutput}; +use swc_common::{errors::Handler, sync::Lrc, SourceMap}; +use swc_next_allocator::Allocator; +use swc_next_ecma_ast::{ + Argument, AssignmentOperator, AssignmentTarget, Ast, Expr, ExprData, GetSpan, Lang, SourceType, + Span, Stmt, StmtData, VisitMut, VisitMutWith, +}; +use swc_next_ecma_codegen::{print, Options as PrintOptions}; +use swc_next_ecma_parser::{NoTokenParserConfig, Options as ParserOptions, Parser}; +use swc_next_ecma_semantic::{analyze, AnalyzeOptions, SyntaxChecks}; +use swc_next_ecma_transformer::{ + normalize::Normalizer, typescript::TypeScriptOptions, Transformer, TransformerOptions, +}; + +pub fn operate( + cm: &Lrc, + handler: &Handler, + input: String, + options: Options, +) -> anyhow::Result { + let allocator = Allocator::default(); + let parser_options = ParserOptions { + source_type: match options.module { + Some(true) => SourceType::Module, + Some(false) => SourceType::Script, + None => SourceType::Unambiguous, + }, + lang: if options.parser.tsx { + Lang::Tsx + } else { + Lang::Ts + }, + ..Default::default() + }; + let parsed = Parser::init(&allocator, &input, parser_options, NoTokenParserConfig).parse(); + let mut diagnostics = parsed.diagnostics; + crate::strip::validate(&input, &options, parser_options.into(), &mut diagnostics); + crate::strip::report(cm, handler, &input, &options, diagnostics)?; + let mut ast = parsed.ast; + Normalizer::normalize_runtime(ast.root_program(), &mut ast); + let analyzed = analyze( + &ast, + AnalyzeOptions { + // Amaro preserves resource declarations for Node to evaluate. + check_syntax: SyntaxChecks::all() - SyntaxChecks::USING_DECLARATIONS, + }, + ); + crate::strip::report(cm, handler, &input, &options, analyzed.diagnostics)?; + + let default_config = crate::options::TransformConfig::default(); + let config = options.transform.as_ref().unwrap_or(&default_config); + let unsupported = if config.jsx.is_some() { + Some("jsx") + } else if !config.native_class_properties { + Some("nativeClassProperties: false") + } else if config.import_export_assign_config + != crate::options::ImportExportAssignConfig::Classic + { + Some("importExportAssignConfig other than Classic") + } else if config.ts_enum_is_mutable && !config.verbatim_module_syntax { + Some("tsEnumIsMutable without verbatimModuleSyntax") + } else if config.import_not_used_as_values == crate::options::ImportsNotUsedAsValues::Preserve + && !config.verbatim_module_syntax + { + Some("importNotUsedAsValues: preserve without verbatimModuleSyntax") + } else { + None + }; + if let Some(option) = unsupported { + let mut diagnostic = + swc_next_ecma_ast::Diagnostic::error("Unsupported transform option", Span::new(0, 0)); + diagnostic.message = format!("Unsupported transform option for SWC Next: {option}").into(); + crate::strip::report(cm, handler, &input, &options, vec![diagnostic])?; + } + Transformer::transform( + &mut ast, + analyzed.semantic, + TransformerOptions { + typescript: Some(TypeScriptOptions { + verbatim_module_syntax: config.verbatim_module_syntax, + ..Default::default() + }), + ..Default::default() + }, + ); + + let program = ast.root_program(); + program.visit_mut_with(&mut NodeModuleAssignments { ast: &mut ast }); + + // SWC Next inserts a synthetic ESM marker when all module syntax is erased. + // Honor Amaro's noEmptyExport option without removing explicit exports. + if config.no_empty_export { + let program = ast.root_program(); + let body = program + .body(&ast) + .iter() + .map(|id| ast.get_node_in_sub_range(id)) + .filter(|statement| { + !matches!(ast.stmt_data(*statement), StmtData::ExportNamedDeclaration(export) + if export.span(&ast) == Span::new(0, 0) + && export.declaration(&ast).is_none() + && export.source(&ast).is_none() + && export.specifiers(&ast).is_empty()) + }) + .collect::>(); + let body = ast.add_sub_range(body); + program.set_body(&mut ast, body); + } + + let output = print( + &ast, + PrintOptions { + source_map_path: options + .source_map + .then(|| options.filename.as_deref().unwrap_or("").into()), + ..Default::default() + }, + ); + let map = output.map.map(|map| { + let text = |bytes: &[u8]| String::from_utf8_lossy(bytes).into_owned(); + serde_json::json!({ + "version": map.version(), + "sources": map.sources().iter().map(|source| text(source)).collect::>(), + "sourcesContent": [input.as_str()], + "names": map.names().iter().map(|name| text(name)).collect::>(), + "mappings": text(map.mappings()), + }) + .to_string() + }); + Ok(TransformOutput { + code: output.code, + map, + }) +} + +/// Adapt SWC Next's synchronous module loading to Node's CommonJS runtime. +struct NodeModuleAssignments<'visit, 'ast> { + ast: &'visit mut Ast<'ast>, +} + +impl<'ast> VisitMut<'ast> for NodeModuleAssignments<'_, 'ast> { + fn ast(&mut self) -> &mut Ast<'ast> { + self.ast + } + + fn visit_mut_expr(&mut self, node: Expr) -> Option { + node.visit_mut_children_with(self); + let ExprData::ImportExpression(import) = self.ast.expr_data(node) else { + return None; + }; + if !import.sync(self.ast) { + return None; + } + let span = import.span(self.ast); + let source = import.source(self.ast); + let name = self.ast.add_utf8("require"); + let callee = self.ast.expr_identifier_reference(span, name); + let arguments = self.ast.add_sub_range([Argument::Expr(source)]); + Some( + self.ast + .expr_call_expression(span, callee, None, arguments, false), + ) + } + + fn visit_mut_stmt(&mut self, node: Stmt) -> Option { + node.visit_mut_children_with(self); + let StmtData::TsExportAssignment(export) = self.ast.stmt_data(node) else { + return None; + }; + let span = export.span(self.ast); + let right = export.expression(self.ast); + let name = self.ast.add_utf8("module"); + let object = self.ast.expr_identifier_reference(span, name); + let name = self.ast.add_utf8("exports"); + let key = self.ast.property_key_identifier_name(span, name); + let left = self + .ast + .simple_assignment_target_member_expression(span, object, key, false, false); + let assignment = self.ast.expr_assignment_expression( + span, + AssignmentTarget::SimpleAssignmentTarget(left), + right, + AssignmentOperator::Assign, + ); + Some(self.ast.stmt_expression_statement(span, assignment)) + } +} diff --git a/test/loader.test.js b/test/loader.test.js index b0257792f..1167d7ef7 100644 --- a/test/loader.test.js +++ b/test/loader.test.js @@ -23,7 +23,10 @@ test("should not work with enums", async (t) => { strictEqual(result.stdout, ""); match(result.stderr, /UnsupportedSyntaxError/); - match(result.stderr, /TypeScript enum is not supported in strip-only mode/); + match( + result.stderr, + /This syntax is not allowed when `erasableSyntaxOnly` is enabled/, + ); strictEqual(result.code, 1); }); @@ -39,7 +42,7 @@ test("should work with enums", async () => { strictEqual(result.code, 0); }); -test("should warn and inaccurate stracktrace", async () => { +test("should warn when source maps are disabled", async () => { const result = await spawnPromisified(process.execPath, [ "--import=./dist/register-transform.mjs", fixturesPath("stacktrace.ts"), @@ -47,7 +50,7 @@ test("should warn and inaccurate stracktrace", async () => { strictEqual(result.stdout, ""); match(result.stderr, /Source maps are disabled/); - match(result.stderr, /stacktrace.ts:7:11/); // inaccurate + match(result.stderr, /stacktrace.ts:\d+:\d+/); strictEqual(result.code, 1); }); @@ -96,7 +99,10 @@ test("should throw syntax error for invalid typescript", async (t) => { ]); strictEqual(result.stdout, ""); match(result.stderr, /SyntaxError/); - match(result.stderr, /await isn't allowed in non-async function/); + match( + result.stderr, + /Await expressions are only allowed within async functions/, + ); strictEqual(result.code, 1); }); diff --git a/test/snapshots/transform.test.js.snapshot b/test/snapshots/transform.test.js.snapshot index 0f01b345f..ef1a67a3b 100644 --- a/test/snapshots/transform.test.js.snapshot +++ b/test/snapshots/transform.test.js.snapshot @@ -6,12 +6,12 @@ exports[`should have proper error code 1`] = ` { "code": "UnsupportedSyntax", "message": "\`module\` keyword is not supported. Use \`namespace\` instead.", - "snippet": "module F { export type x = number }\\n^^^^^^^^\\n", + "snippet": "module F { export type x = number }\\n^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n", "filename": "", "startLine": 1, "startColumn": 0, "endLine": 1, - "endColumn": 8 + "endColumn": 35 } `; @@ -37,67 +37,67 @@ exports[`should not polyfill using Symbol.asyncDispose 1`] = ` `; exports[`should not transform TypeScript class decorators with multiple decorators 1`] = ` -"@sealed\\n@log\\nclass BugReport {\\n type = \\"report\\";\\n title;\\n constructor(t){\\n this.title = t;\\n }\\n}\\nfunction sealed(constructor) {\\n Object.seal(constructor);\\n Object.seal(constructor.prototype);\\n}\\nfunction log(constructor) {\\n console.log(\\"Creating instance of\\", constructor.name);\\n}\\noutput = new BugReport(\\"Test\\");\\n" +"@sealed @log class BugReport {\\n type = \\"report\\";\\n title;\\n constructor(t) {\\n this.title = t;\\n }\\n}\\nfunction sealed(constructor) {\\n Object.seal(constructor);\\n Object.seal(constructor.prototype);\\n}\\nfunction log(constructor) {\\n console.log(\\"Creating instance of\\", constructor.name);\\n}\\noutput = new BugReport(\\"Test\\");\\n" `; exports[`should perform transformation with error 1`] = ` -"var Foo = /*#__PURE__*/ function(Foo) {\\n Foo[Foo[\\"Bar\\"] = 7] = \\"Bar\\";\\n Foo[Foo[\\"Baz\\"] = 2] = \\"Baz\\";\\n return Foo;\\n}(Foo || {});\\noutput = 7;\\nthrow new Error(\\"foo\\");\\n" +"var Foo;\\n(function(Foo) {\\n Foo[Foo[\\"Bar\\"] = 7] = \\"Bar\\";\\n Foo[Foo[\\"Baz\\"] = 2] = \\"Baz\\";\\n})(Foo || (Foo = {}));\\noutput = Foo.Bar;\\nthrow new Error(\\"foo\\");\\n" `; exports[`should perform transformation with error 2`] = ` -"{\\"version\\":3,\\"sources\\":[\\"foo.ts\\"],\\"names\\":[],\\"mappings\\":\\"AACI,IAAA,AAAK,6BAAA;qBACK;qBACA;WAFL;EAAA;AAIL,SAHU;AAIV,MAAM,IAAI,MAAM\\"}" +"{\\"mappings\\":\\"AACI,IAAKA;AADT,UAAAA,KAAA;AAAA,EAAAA,IAAA,aAEQ,CAFR;AAAA,EAAAA,IAAA,aAGQ,CAHR;AAAA,GAAAA,cAAA;AAKI,SAASA,IAAI;AACb,MAAM,IAAIC,MAAM,KAAK;\\",\\"names\\":[\\"Foo\\",\\"Error\\"],\\"sources\\":[\\"foo.ts\\"],\\"sourcesContent\\":[\\"\\\\n enum Foo {\\\\n Bar = 7,\\\\n Baz = 2,\\\\n }\\\\n output = Foo.Bar;\\\\n throw new Error(\\\\\\"foo\\\\\\");\\"],\\"version\\":3}" `; exports[`should perform transformation with source maps 1`] = ` -"var Foo = /*#__PURE__*/ function(Foo) {\\n Foo[Foo[\\"Bar\\"] = 7] = \\"Bar\\";\\n Foo[Foo[\\"Baz\\"] = 2] = \\"Baz\\";\\n return Foo;\\n}(Foo || {});\\noutput = 7;\\n" +"var Foo;\\n(function(Foo) {\\n Foo[Foo[\\"Bar\\"] = 7] = \\"Bar\\";\\n Foo[Foo[\\"Baz\\"] = 2] = \\"Baz\\";\\n})(Foo || (Foo = {}));\\noutput = Foo.Bar;\\n" `; exports[`should perform transformation with source maps 2`] = ` -"{\\"version\\":3,\\"sources\\":[\\"foo.ts\\"],\\"names\\":[],\\"mappings\\":\\"AACI,IAAA,AAAK,6BAAA;qBACK;qBACA;WAFL;EAAA;AAKL,SAJU\\"}" +"{\\"mappings\\":\\"AACI,IAAKA;AADT,UAAAA,KAAA;AAAA,EAAAA,IAAA,aAEQ,CAFR;AAAA,EAAAA,IAAA,aAGQ,CAHR;AAAA,GAAAA,cAAA;AAMI,SAASA,IAAI;\\",\\"names\\":[\\"Foo\\"],\\"sources\\":[\\"foo.ts\\"],\\"sourcesContent\\":[\\"\\\\n enum Foo {\\\\n Bar = 7,\\\\n Baz = 2,\\\\n }\\\\n\\\\n output = Foo.Bar\\"],\\"version\\":3}" `; exports[`should perform transformation with source maps no filename 1`] = ` -"var Foo = /*#__PURE__*/ function(Foo) {\\n Foo[Foo[\\"Bar\\"] = 7] = \\"Bar\\";\\n Foo[Foo[\\"Baz\\"] = 2] = \\"Baz\\";\\n return Foo;\\n}(Foo || {});\\noutput = 7;\\n" +"var Foo;\\n(function(Foo) {\\n Foo[Foo[\\"Bar\\"] = 7] = \\"Bar\\";\\n Foo[Foo[\\"Baz\\"] = 2] = \\"Baz\\";\\n})(Foo || (Foo = {}));\\noutput = Foo.Bar;\\n" `; exports[`should perform transformation with source maps no filename 2`] = ` -"{\\"version\\":3,\\"sources\\":[\\"\\"],\\"sourcesContent\\":[\\"\\\\n enum Foo {\\\\n Bar = 7,\\\\n Baz = 2,\\\\n }\\\\n output = Foo.Bar\\"],\\"names\\":[],\\"mappings\\":\\"AACI,IAAA,AAAK,6BAAA;qBACK;qBACA;WAFL;EAAA;AAIL,SAHU\\",\\"ignoreList\\":[0]}" +"{\\"mappings\\":\\"AACI,IAAKA;AADT,UAAAA,KAAA;AAAA,EAAAA,IAAA,aAEQ,CAFR;AAAA,EAAAA,IAAA,aAGQ,CAHR;AAAA,GAAAA,cAAA;AAKI,SAASA,IAAI;\\",\\"names\\":[\\"Foo\\"],\\"sources\\":[\\"\\"],\\"sourcesContent\\":[\\"\\\\n enum Foo {\\\\n Bar = 7,\\\\n Baz = 2,\\\\n }\\\\n output = Foo.Bar\\"],\\"version\\":3}" `; exports[`should perform transformation with source maps with uncommon chars 1`] = ` -"var Foo = /*#__PURE__*/ function(Foo) {\\n Foo[Foo[\\"Bar\\"] = 7] = \\"Bar\\";\\n Foo[Foo[\\"Baz\\"] = 2] = \\"Baz\\";\\n return Foo;\\n}(Foo || {});\\noutput = 7;\\n" +"var Foo;\\n(function(Foo) {\\n Foo[Foo[\\"Bar\\"] = 7] = \\"Bar\\";\\n Foo[Foo[\\"Baz\\"] = 2] = \\"Baz\\";\\n})(Foo || (Foo = {}));\\noutput = Foo.Bar;\\n" `; exports[`should perform transformation with source maps with uncommon chars 2`] = ` -"{\\"version\\":3,\\"sources\\":[\\"dir%20with $unusual\\\\\\"chars?'Γ₯ΓŸβˆ‚Ζ’Β©βˆ†Β¬β€¦\`.cts\\"],\\"names\\":[],\\"mappings\\":\\"AACI,IAAA,AAAK,6BAAA;qBACK;qBACA;WAFL;EAAA;AAKL,SAJU\\"}" +"{\\"mappings\\":\\"AACI,IAAKA;AADT,UAAAA,KAAA;AAAA,EAAAA,IAAA,aAEQ,CAFR;AAAA,EAAAA,IAAA,aAGQ,CAHR;AAAA,GAAAA,cAAA;AAMI,SAASA,IAAI;\\",\\"names\\":[\\"Foo\\"],\\"sources\\":[\\"dir%20with $unusual\\\\\\"chars?'Γ₯ΓŸβˆ‚Ζ’Β©βˆ†Β¬β€¦\`.cts\\"],\\"sourcesContent\\":[\\"\\\\n enum Foo {\\\\n Bar = 7,\\\\n Baz = 2,\\\\n }\\\\n\\\\n output = Foo.Bar\\"],\\"version\\":3}" `; exports[`should perform transformation without source maps 1`] = ` -"var Foo = /*#__PURE__*/ function(Foo) {\\n Foo[Foo[\\"Bar\\"] = 7] = \\"Bar\\";\\n Foo[Foo[\\"Baz\\"] = 2] = \\"Baz\\";\\n return Foo;\\n}(Foo || {});\\noutput = 7;\\n" +"var Foo;\\n(function(Foo) {\\n Foo[Foo[\\"Bar\\"] = 7] = \\"Bar\\";\\n Foo[Foo[\\"Baz\\"] = 2] = \\"Baz\\";\\n})(Foo || (Foo = {}));\\noutput = Foo.Bar;\\n" `; exports[`should perform transformation without source maps and filename 1`] = ` -"var Foo = /*#__PURE__*/ function(Foo) {\\n Foo[Foo[\\"Bar\\"] = 7] = \\"Bar\\";\\n Foo[Foo[\\"Baz\\"] = 2] = \\"Baz\\";\\n return Foo;\\n}(Foo || {});\\noutput = 7;\\n" +"var Foo;\\n(function(Foo) {\\n Foo[Foo[\\"Bar\\"] = 7] = \\"Bar\\";\\n Foo[Foo[\\"Baz\\"] = 2] = \\"Baz\\";\\n})(Foo || (Foo = {}));\\noutput = Foo.Bar;\\n" `; exports[`should transform TypeScript class fields 1`] = ` -"class Counter {\\n count = 0;\\n increment() {\\n this.count++;\\n }\\n}\\nconst counter = new Counter();\\ncounter.increment();\\noutput = counter.count;\\n" +"class Counter {\\n count = 0;\\n increment() {\\n this.count++;\\n }\\n}\\nconst counter = new Counter();\\ncounter.increment();\\noutput = counter.count;\\n" `; exports[`should transform TypeScript namespaces with additional functionality 1`] = ` -"(function(Validation) {\\n const lettersRegexp = /^[A-Za-z]+$/;\\n class LettersOnlyValidator {\\n isAcceptable(s) {\\n return lettersRegexp.test(s);\\n }\\n static createValidator() {\\n return new LettersOnlyValidator();\\n }\\n }\\n Validation.LettersOnlyValidator = LettersOnlyValidator;\\n})(Validation || (Validation = {}));\\nconst validator = Validation.LettersOnlyValidator.createValidator();\\nconst isValid = validator.isAcceptable(\\"test\\");\\noutput = {\\n validator,\\n isValid\\n};\\nvar Validation;\\n" +"var Validation;\\n(function(Validation) {\\n const lettersRegexp = /^[A-Za-z]+$/;\\n class LettersOnlyValidator {\\n isAcceptable(s) {\\n return lettersRegexp.test(s);\\n }\\n static createValidator() {\\n return new LettersOnlyValidator();\\n }\\n }\\n Validation.LettersOnlyValidator = LettersOnlyValidator;\\n})(Validation || (Validation = {}));\\nconst validator = Validation.LettersOnlyValidator.createValidator();\\nconst isValid = validator.isAcceptable(\\"test\\");\\noutput = { validator, isValid };\\n" `; exports[`should transform TypeScript private class fields 1`] = ` -"class Counter {\\n #count = 0;\\n increment() {\\n this.#count++;\\n }\\n getCount() {\\n return this.#count;\\n }\\n}\\nconst counter = new Counter();\\ncounter.increment();\\noutput = counter.getCount();\\n" +"class Counter {\\n #count = 0;\\n increment() {\\n this.#count++;\\n }\\n getCount() {\\n return this.#count;\\n }\\n}\\nconst counter = new Counter();\\ncounter.increment();\\noutput = counter.getCount();\\n" `; exports[`should transform TypeScript type annotations and type guards 1`] = ` -"function isString(value) {\\n return typeof value === 'string';\\n}\\nconst check = isString(\\"hello\\");\\noutput = check;\\n" +"function isString(value) {\\n return typeof value === \\"string\\";\\n}\\nconst check = isString(\\"hello\\");\\noutput = check;\\n" `; exports[`test native class properties 1`] = ` -"class Foo {\\n y;\\n x = console.log(1);\\n constructor(y = console.log(2)){\\n this.y = y;\\n console.log(3);\\n }\\n}\\n" +"class Foo {\\n y;\\n x = console.log(1);\\n constructor(y = console.log(2)) {\\n this.y = y;\\n console.log(3);\\n }\\n}\\n" `; exports[`test noEmptyExport 1`] = ` diff --git a/test/transform.test.js b/test/transform.test.js index 86fcab486..fcd844c31 100644 --- a/test/transform.test.js +++ b/test/transform.test.js @@ -3,6 +3,7 @@ const { transformSync } = require("../dist/index.js"); const path = require("node:path"); const assert = require("node:assert"); const vm = require("node:vm"); +const { SourceMap } = require("node:module"); // Set the path for the snapshots directory snapshot.setResolveSnapshotPath((testPath) => { @@ -367,3 +368,98 @@ test("should handle short-circuit assignments (&&=, ||=, ??=)", (t) => { }); t.assert.snapshot(code); }); + +test("should lower parameter properties after super and preserve initialization order", () => { + const source = ` + const events: string[] = []; + class Base { constructor() { events.push("base"); } } + class Derived extends Base { + field = events.push("field"); + constructor(public value: number) { + super(); + events.push(String(this.value)); + } + } + new Derived(7); + output = events.join(",");`; + const { code } = transformSync(source, { mode: "transform" }); + assert.strictEqual(vm.runInNewContext(code), "base,field,7"); +}); + +test("should lower CommonJS import and export assignments", () => { + const source = + 'import path = require("node:path"); export = path.basename("/a/b");'; + const { code } = transformSync(source, { mode: "transform" }); + const context = { require, module: { exports: undefined } }; + vm.runInNewContext(code, context); + assert.strictEqual(context.module.exports, "b"); + assert.doesNotMatch(code, /import\.sync/); +}); + +test("should keep source module markers according to noEmptyExport", () => { + const source = "export type Value = number;"; + assert.doesNotMatch( + transformSync(source, { mode: "transform" }).code, + /export/, + ); + const { code } = transformSync(source, { + mode: "transform", + transform: { noEmptyExport: false }, + }); + assert.match(code, /export\s*\{\s*\}/); + assert.match( + transformSync("export {};", { mode: "transform" }).code, + /export\s*\{\s*\}/, + ); +}); + +test("should forward verbatimModuleSyntax to SWC Next", () => { + const source = 'import { value } from "dependency"; output = 1;'; + assert.match(transformSync(source, { mode: "transform" }).code, /import/); + const { code } = transformSync(source, { + mode: "transform", + transform: { verbatimModuleSyntax: false, noEmptyExport: true }, + }); + assert.doesNotMatch(code, /import|export/); + assert.strictEqual(vm.runInNewContext(code), 1); +}); + +test("should reject unsupported transform options", () => { + const check = (transform) => + assert.throws( + () => + transformSync("const value: number = 1;", { + mode: "transform", + transform, + }), + { code: "UnsupportedSyntax", message: /Unsupported transform option/ }, + ); + check({ nativeClassProperties: false }); + check({ importExportAssignConfig: "NodeNext" }); + check({ tsEnumIsMutable: true }); + check({ importNotUsedAsValues: "preserve" }); + check({ jsx: { transform: "react-jsx" } }); +}); + +test("should map generated locations to UTF-16 columns in the original source", () => { + const source = + 'enum Value { A = 7 }\n"πŸ˜€"; throw new Error(String(Value.A));'; + const filename = "dir with spaces/ν•œκΈ€.ts"; + const { code, map } = transformSync(source, { + mode: "transform", + sourceMap: true, + filename, + }); + const payload = JSON.parse(map); + assert.deepStrictEqual(payload.sources, [filename]); + assert.deepStrictEqual(payload.sourcesContent, [source]); + const lines = code.split("\n"); + const line = lines.findIndex((line) => line.includes("new Error")); + const column = lines[line].indexOf("new Error"); + const entry = new SourceMap(payload).findEntry(line, column); + assert.strictEqual(entry.originalLine, 1); + assert.strictEqual( + entry.originalColumn, + source.split("\n")[1].indexOf("new Error"), + ); +}); From 348edb71c8dd0866f8007a8663e3c663da29f3cc Mon Sep 17 00:00:00 2001 From: DongYun Kang Date: Fri, 30 Oct 2026 11:00:20 +0900 Subject: [PATCH 2/9] feat!: remove TypeScript transform mode --- README.md | 30 +- crates/amaro/Cargo.lock | 133 +------ crates/amaro/Cargo.toml | 3 - crates/amaro/src/lib.rs | 2 - crates/amaro/src/options.rs | 104 +---- crates/amaro/src/strip.rs | 6 +- crates/amaro/src/transform.rs | 189 --------- esbuild.config.mjs | 7 +- package.json | 3 +- src/internal.ts | 17 +- src/register/register-transform.mjs | 18 - src/transform-loader.ts | 75 ---- test/fixtures/stacktrace.ts | 7 +- test/index.test.js | 13 + test/loader.test.js | 99 +---- test/register-hooks.test.js | 98 +++-- test/snapshots/transform.test.js.snapshot | 105 ----- test/transform.test.js | 465 ---------------------- 18 files changed, 84 insertions(+), 1290 deletions(-) delete mode 100644 crates/amaro/src/transform.rs delete mode 100644 src/register/register-transform.mjs delete mode 100644 src/transform-loader.ts delete mode 100644 test/snapshots/transform.test.js.snapshot delete mode 100644 test/transform.test.js diff --git a/README.md b/README.md index 76c3d8564..fba6cebb2 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Amaro -Amaro is a Node.js wrapper around the [SWC Next](https://github.com/swc-project/swc-next) TypeScript type stripper and transformer. Its Rust core in `crates/amaro` is compiled to WebAssembly. Both `strip-only` and `transform` modes use SWC Next at a pinned Git revision. Node.js REPL helpers and diagnostic formatting still use the published [SWC](https://swc.rs) crates. +Amaro is a Node.js wrapper around the [SWC Next](https://github.com/swc-project/swc-next) TypeScript type stripper. Its Rust core in `crates/amaro` is compiled to WebAssembly. Type stripping uses SWC Next at a pinned Git revision. Node.js REPL helpers and diagnostic formatting still use the published [SWC](https://swc.rs) crates. Diagnostics retain Amaro's error codes and location fields, but their messages and highlighted ranges come from SWC Next. It's used as an internal in Node.js for [Type Stripping](https://nodejs.org/api/typescript.html#type-stripping) but can also be used as a standalone package. > Amaro means "bitter" in Italian. It's a reference to [Mount Amaro](https://en.wikipedia.org/wiki/Monte_Amaro_(Abruzzo)) on whose slopes this package was conceived. -This package provides a stable API for the TypeScript parser and allows users to upgrade to the latest version of TypeScript transpiler independently from the one used internally in Node.js. +This package provides a stable API for the TypeScript parser and allows users to upgrade to the latest version of the TypeScript type stripper independently from the one used internally in Node.js. ## Building from source @@ -53,29 +53,13 @@ node --import="amaro/strip" file.ts > In node v22.18.0 and later you can omit the `--experimental-strip-types` flag, as it is enabled by default. -Enabling TypeScript feature transformation: - -```bash -node --enable-source-maps --import="amaro/transform" file.ts -``` - -> Note that the `amaro/transform` loader should be used with `--enable-source-maps` -> to preserve accurate source-mapped stack traces. - -The SWC Next transformer supports enums, namespaces, constructor parameter -properties, and source maps. It preserves decorators and resource declarations -for the runtime. Generated code and source maps differ from SWC's output. - -`verbatimModuleSyntax` and `noEmptyExport` are supported. `nativeClassProperties` -defaults to `true`; setting it to `false` is unsupported. Only `Classic` is -supported for `importExportAssignConfig`, which emits `require(...)` and -`module.exports`. `tsEnumIsMutable: true` and `importNotUsedAsValues: "preserve"` -require `verbatimModuleSyntax: true`. Unsupported option combinations throw an -`UnsupportedSyntax` diagnostic. +Amaro supports only `strip-only`. Syntax that requires runtime code generation, +such as enums and constructor parameter properties, is rejected. The former +`transform` mode and `amaro/transform` loader are no longer available. #### Programmatic registration with `module.registerHooks()` -Amaro's `strip` and `transform` entry points use `module.registerHooks()` to register synchronous hooks on Node.js versions that provide it, and fall back to `module.register()` on older versions. If you want TypeScript to "just work" in an existing codebase without passing `--import` every time, create a small bootstrap file that imports Amaro before loading your TS entrypoint. +Amaro's `strip` entry point uses `module.registerHooks()` to register synchronous hooks on Node.js versions that provide it, and falls back to `module.register()` on older versions. If you want TypeScript to "just work" in an existing codebase without passing `--import` every time, create a small bootstrap file that imports Amaro before loading your TS entrypoint. ```mjs // bootstrap.mjs @@ -89,8 +73,6 @@ Then start your app through the bootstrap file: node --watch ./bootstrap.mjs ``` -For transform mode, swap `amaro/strip` with `amaro/transform` and run Node with `--enable-source-maps`. - ### Monorepo usage Amaro makes working in monorepos smoother by removing the need to rebuild internal packages during development. When used with the [`--conditions`](https://nodejs.org/docs/latest/api/cli.html#-c-condition---conditionscondition) flag, you can reference TypeScript source files directly in exports: diff --git a/crates/amaro/Cargo.lock b/crates/amaro/Cargo.lock index 0818b6ab4..1ff93bdaa 100644 --- a/crates/amaro/Cargo.lock +++ b/crates/amaro/Cargo.lock @@ -38,10 +38,7 @@ dependencies = [ "swc_error_reporters", "swc_next_allocator", "swc_next_ecma_ast", - "swc_next_ecma_codegen", "swc_next_ecma_parser", - "swc_next_ecma_semantic", - "swc_next_ecma_transformer", "swc_next_type_strip", "tracing", "unicode-width 0.2.2", @@ -64,12 +61,6 @@ dependencies = [ "object", ] -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - [[package]] name = "ast_node" version = "7.0.0" @@ -172,12 +163,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - [[package]] name = "find-msvc-tools" version = "0.1.13" @@ -610,19 +595,8 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" -dependencies = [ - "phf_macros 0.14.0", - "phf_shared 0.14.0", - "serde", + "phf_macros", + "phf_shared", ] [[package]] @@ -631,41 +605,18 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared 0.11.3", + "phf_shared", "rand", ] -[[package]] -name = "phf_generator" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" -dependencies = [ - "fastrand", - "phf_shared 0.14.0", -] - [[package]] name = "phf_macros" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "phf_macros" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" -dependencies = [ - "phf_generator 0.14.0", - "phf_shared 0.14.0", + "phf_generator", + "phf_shared", "proc-macro2", "quote", "syn 2.0.119", @@ -680,15 +631,6 @@ dependencies = [ "siphasher 1.0.3", ] -[[package]] -name = "phf_shared" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" -dependencies = [ - "siphasher 1.0.3", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -943,7 +885,7 @@ dependencies = [ "is-macro", "num-bigint 0.4.8", "once_cell", - "phf 0.11.3", + "phf", "rustc-hash", "string_enum", "swc_atoms", @@ -961,7 +903,7 @@ dependencies = [ "bitflags", "either", "num-bigint 0.4.8", - "phf 0.11.3", + "phf", "rustc-hash", "seq-macro", "serde", @@ -1056,21 +998,6 @@ dependencies = [ "unicode-id-start", ] -[[package]] -name = "swc_next_ecma_codegen" -version = "0.4.1" -source = "git+https://github.com/swc-project/swc-next?rev=2be98eee84461163d9068612f691c649ba18bd36#2be98eee84461163d9068612f691c649ba18bd36" -dependencies = [ - "arrayvec", - "bitflags", - "memchr", - "rustc-hash", - "swc_next_allocator", - "swc_next_ecma_ast", - "swc_next_simd", - "zmij_ecma", -] - [[package]] name = "swc_next_ecma_parser" version = "0.4.1" @@ -1086,46 +1013,6 @@ dependencies = [ "unicode-id-start", ] -[[package]] -name = "swc_next_ecma_semantic" -version = "0.4.1" -source = "git+https://github.com/swc-project/swc-next?rev=2be98eee84461163d9068612f691c649ba18bd36#2be98eee84461163d9068612f691c649ba18bd36" -dependencies = [ - "bitflags", - "hashbrown 0.17.1", - "memchr", - "rustc-hash", - "swc_next_allocator", - "swc_next_ecma_ast", - "swc_next_regular_expression", - "zmij_ecma", -] - -[[package]] -name = "swc_next_ecma_transformer" -version = "0.4.1" -source = "git+https://github.com/swc-project/swc-next?rev=2be98eee84461163d9068612f691c649ba18bd36#2be98eee84461163d9068612f691c649ba18bd36" -dependencies = [ - "rustc-hash", - "serde", - "swc_next_allocator", - "swc_next_ecma_ast", - "swc_next_ecma_semantic", - "zmij_ecma", -] - -[[package]] -name = "swc_next_regular_expression" -version = "0.4.1" -source = "git+https://github.com/swc-project/swc-next?rev=2be98eee84461163d9068612f691c649ba18bd36#2be98eee84461163d9068612f691c649ba18bd36" -dependencies = [ - "bitflags", - "phf 0.14.0", - "rustc-hash", - "swc_next_allocator", - "unicode-id-start", -] - [[package]] name = "swc_next_simd" version = "0.4.1" @@ -1500,9 +1387,3 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zmij_ecma" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "458b3e7a977f14499066b6bdb63d69da30ce337d6c160f077e6c575c82a44db9" diff --git a/crates/amaro/Cargo.toml b/crates/amaro/Cargo.toml index fec9517ea..2c136230e 100644 --- a/crates/amaro/Cargo.toml +++ b/crates/amaro/Cargo.toml @@ -39,9 +39,6 @@ swc_error_reporters = { version = "=28.0.0" } swc_next_allocator = { git = "https://github.com/swc-project/swc-next", rev = "2be98eee84461163d9068612f691c649ba18bd36" } swc_next_ecma_ast = { git = "https://github.com/swc-project/swc-next", rev = "2be98eee84461163d9068612f691c649ba18bd36" } swc_next_ecma_parser = { git = "https://github.com/swc-project/swc-next", rev = "2be98eee84461163d9068612f691c649ba18bd36" } -swc_next_ecma_transformer = { git = "https://github.com/swc-project/swc-next", rev = "2be98eee84461163d9068612f691c649ba18bd36" } -swc_next_ecma_semantic = { git = "https://github.com/swc-project/swc-next", rev = "2be98eee84461163d9068612f691c649ba18bd36" } -swc_next_ecma_codegen = { git = "https://github.com/swc-project/swc-next", rev = "2be98eee84461163d9068612f691c649ba18bd36" } swc_next_type_strip = { git = "https://github.com/swc-project/swc-next", rev = "2be98eee84461163d9068612f691c649ba18bd36" } [profile.release] diff --git a/crates/amaro/src/lib.rs b/crates/amaro/src/lib.rs index 26a8058ec..50c03dc53 100644 --- a/crates/amaro/src/lib.rs +++ b/crates/amaro/src/lib.rs @@ -23,7 +23,6 @@ mod error_reporter; mod nodejs; mod options; mod strip; -mod transform; /// TypeScript declarations for the WebAssembly module, emitted into `wasm.d.ts`. #[wasm_bindgen(typescript_custom_section)] @@ -152,7 +151,6 @@ fn operate(input: String, options: Options) -> Result strip::operate(&cm, handler, input, options), - Mode::Transform => transform::operate(&cm, handler, input, options), }) } diff --git a/crates/amaro/src/options.rs b/crates/amaro/src/options.rs index c093e1d0e..e3a38e821 100644 --- a/crates/amaro/src/options.rs +++ b/crates/amaro/src/options.rs @@ -1,4 +1,4 @@ -//! Public options shared by the SWC Next adapters. +//! Public options for fixed-width TypeScript stripping. use serde::{Deserialize, Serialize}; use swc_ecma_parser::TsSyntax; use wasm_bindgen::prelude::*; @@ -12,10 +12,7 @@ pub struct Options { pub parser: TsSyntax, #[serde(default)] pub mode: Mode, - pub transform: Option, pub deprecated_ts_module_as_error: Option, - #[serde(default)] - pub source_map: bool, } fn default_parser() -> TsSyntax { @@ -30,56 +27,11 @@ fn default_parser() -> TsSyntax { pub enum Mode { #[default] StripOnly, - Transform, -} - -#[derive(Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct TransformConfig { - pub verbatim_module_syntax: bool, - pub native_class_properties: bool, - pub no_empty_export: bool, - pub import_not_used_as_values: ImportsNotUsedAsValues, - pub import_export_assign_config: ImportExportAssignConfig, - pub ts_enum_is_mutable: bool, - pub jsx: Option, -} - -impl Default for TransformConfig { - fn default() -> Self { - Self { - verbatim_module_syntax: false, - native_class_properties: true, - no_empty_export: false, - import_not_used_as_values: ImportsNotUsedAsValues::Remove, - import_export_assign_config: ImportExportAssignConfig::Classic, - ts_enum_is_mutable: false, - jsx: None, - } - } -} - -#[derive(Default, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum ImportsNotUsedAsValues { - #[default] - Remove, - Preserve, -} - -#[derive(Default, Deserialize, PartialEq, Eq)] -pub enum ImportExportAssignConfig { - #[default] - Classic, - Preserve, - NodeNext, - EsNext, } #[derive(Debug, Serialize)] pub struct TransformOutput { pub code: String, - pub map: Option, } #[wasm_bindgen(typescript_custom_section)] @@ -87,58 +39,8 @@ const TYPES: &str = r#" interface Options { module?: boolean; filename?: string; - mode?: Mode; - transform?: TransformConfig; + mode?: "strip-only"; deprecatedTsModuleAsError?: boolean; - sourceMap?: boolean; } - -interface TransformConfig { - /** - * @see https://www.typescriptlang.org/tsconfig#verbatimModuleSyntax - */ - verbatimModuleSyntax?: boolean; - /** - * Native class properties support. Defaults to true; false is unsupported. - */ - nativeClassProperties?: boolean; - /** "preserve" requires verbatimModuleSyntax: true. */ - importNotUsedAsValues?: "remove" | "preserve"; - /** - * Don't create `export {}`. - * By default, strip creates `export {}` for modules to preserve module - * context. - * - * @see https://github.com/swc-project/swc/issues/1698 - */ - noEmptyExport?: boolean; - /** Only Classic is supported by the SWC Next adapter. */ - importExportAssignConfig?: "Classic" | "Preserve" | "NodeNext" | "EsNext"; - /** - * Disables an optimization that inlines TS enum member values - * within the same module that assumes the enum member values - * are never modified. - * - * Defaults to false. - */ - /** true requires verbatimModuleSyntax: true. */ - tsEnumIsMutable?: boolean; - - /** - * JSX lowering is unsupported by the SWC Next adapter. - */ - jsx?: JsxConfig; -} - -interface JsxConfig { - /** - * How to transform JSX. - * - * @default "react-jsx" - */ - transform?: "react-jsx" | "react-jsxdev"; -} - -type Mode = "strip-only" | "transform"; -interface TransformOutput { code: string; map?: string; } +interface TransformOutput { code: string; } "#; diff --git a/crates/amaro/src/strip.rs b/crates/amaro/src/strip.rs index 46fe8d580..52d076e0a 100644 --- a/crates/amaro/src/strip.rs +++ b/crates/amaro/src/strip.rs @@ -39,11 +39,10 @@ pub fn operate( Ok(TransformOutput { code: output.code.into_owned(), - map: None, }) } -pub(super) fn validate( +fn validate( input: &str, options: &Options, strip_options: StripOptions, @@ -91,7 +90,7 @@ pub(super) fn validate( } } -pub(super) fn report( +fn report( cm: &Lrc, handler: &Handler, input: &str, @@ -175,7 +174,6 @@ mod tests { result.code.encode_utf16().count(), source.encode_utf16().count() ); - assert!(result.map.is_none()); } #[test] diff --git a/crates/amaro/src/transform.rs b/crates/amaro/src/transform.rs deleted file mode 100644 index cfe2b7655..000000000 --- a/crates/amaro/src/transform.rs +++ /dev/null @@ -1,189 +0,0 @@ -//! TypeScript transformation through SWC Next's semantic analysis and printer. - -use crate::options::{Options, TransformOutput}; -use swc_common::{errors::Handler, sync::Lrc, SourceMap}; -use swc_next_allocator::Allocator; -use swc_next_ecma_ast::{ - Argument, AssignmentOperator, AssignmentTarget, Ast, Expr, ExprData, GetSpan, Lang, SourceType, - Span, Stmt, StmtData, VisitMut, VisitMutWith, -}; -use swc_next_ecma_codegen::{print, Options as PrintOptions}; -use swc_next_ecma_parser::{NoTokenParserConfig, Options as ParserOptions, Parser}; -use swc_next_ecma_semantic::{analyze, AnalyzeOptions, SyntaxChecks}; -use swc_next_ecma_transformer::{ - normalize::Normalizer, typescript::TypeScriptOptions, Transformer, TransformerOptions, -}; - -pub fn operate( - cm: &Lrc, - handler: &Handler, - input: String, - options: Options, -) -> anyhow::Result { - let allocator = Allocator::default(); - let parser_options = ParserOptions { - source_type: match options.module { - Some(true) => SourceType::Module, - Some(false) => SourceType::Script, - None => SourceType::Unambiguous, - }, - lang: if options.parser.tsx { - Lang::Tsx - } else { - Lang::Ts - }, - ..Default::default() - }; - let parsed = Parser::init(&allocator, &input, parser_options, NoTokenParserConfig).parse(); - let mut diagnostics = parsed.diagnostics; - crate::strip::validate(&input, &options, parser_options.into(), &mut diagnostics); - crate::strip::report(cm, handler, &input, &options, diagnostics)?; - let mut ast = parsed.ast; - Normalizer::normalize_runtime(ast.root_program(), &mut ast); - let analyzed = analyze( - &ast, - AnalyzeOptions { - // Amaro preserves resource declarations for Node to evaluate. - check_syntax: SyntaxChecks::all() - SyntaxChecks::USING_DECLARATIONS, - }, - ); - crate::strip::report(cm, handler, &input, &options, analyzed.diagnostics)?; - - let default_config = crate::options::TransformConfig::default(); - let config = options.transform.as_ref().unwrap_or(&default_config); - let unsupported = if config.jsx.is_some() { - Some("jsx") - } else if !config.native_class_properties { - Some("nativeClassProperties: false") - } else if config.import_export_assign_config - != crate::options::ImportExportAssignConfig::Classic - { - Some("importExportAssignConfig other than Classic") - } else if config.ts_enum_is_mutable && !config.verbatim_module_syntax { - Some("tsEnumIsMutable without verbatimModuleSyntax") - } else if config.import_not_used_as_values == crate::options::ImportsNotUsedAsValues::Preserve - && !config.verbatim_module_syntax - { - Some("importNotUsedAsValues: preserve without verbatimModuleSyntax") - } else { - None - }; - if let Some(option) = unsupported { - let mut diagnostic = - swc_next_ecma_ast::Diagnostic::error("Unsupported transform option", Span::new(0, 0)); - diagnostic.message = format!("Unsupported transform option for SWC Next: {option}").into(); - crate::strip::report(cm, handler, &input, &options, vec![diagnostic])?; - } - Transformer::transform( - &mut ast, - analyzed.semantic, - TransformerOptions { - typescript: Some(TypeScriptOptions { - verbatim_module_syntax: config.verbatim_module_syntax, - ..Default::default() - }), - ..Default::default() - }, - ); - - let program = ast.root_program(); - program.visit_mut_with(&mut NodeModuleAssignments { ast: &mut ast }); - - // SWC Next inserts a synthetic ESM marker when all module syntax is erased. - // Honor Amaro's noEmptyExport option without removing explicit exports. - if config.no_empty_export { - let program = ast.root_program(); - let body = program - .body(&ast) - .iter() - .map(|id| ast.get_node_in_sub_range(id)) - .filter(|statement| { - !matches!(ast.stmt_data(*statement), StmtData::ExportNamedDeclaration(export) - if export.span(&ast) == Span::new(0, 0) - && export.declaration(&ast).is_none() - && export.source(&ast).is_none() - && export.specifiers(&ast).is_empty()) - }) - .collect::>(); - let body = ast.add_sub_range(body); - program.set_body(&mut ast, body); - } - - let output = print( - &ast, - PrintOptions { - source_map_path: options - .source_map - .then(|| options.filename.as_deref().unwrap_or("").into()), - ..Default::default() - }, - ); - let map = output.map.map(|map| { - let text = |bytes: &[u8]| String::from_utf8_lossy(bytes).into_owned(); - serde_json::json!({ - "version": map.version(), - "sources": map.sources().iter().map(|source| text(source)).collect::>(), - "sourcesContent": [input.as_str()], - "names": map.names().iter().map(|name| text(name)).collect::>(), - "mappings": text(map.mappings()), - }) - .to_string() - }); - Ok(TransformOutput { - code: output.code, - map, - }) -} - -/// Adapt SWC Next's synchronous module loading to Node's CommonJS runtime. -struct NodeModuleAssignments<'visit, 'ast> { - ast: &'visit mut Ast<'ast>, -} - -impl<'ast> VisitMut<'ast> for NodeModuleAssignments<'_, 'ast> { - fn ast(&mut self) -> &mut Ast<'ast> { - self.ast - } - - fn visit_mut_expr(&mut self, node: Expr) -> Option { - node.visit_mut_children_with(self); - let ExprData::ImportExpression(import) = self.ast.expr_data(node) else { - return None; - }; - if !import.sync(self.ast) { - return None; - } - let span = import.span(self.ast); - let source = import.source(self.ast); - let name = self.ast.add_utf8("require"); - let callee = self.ast.expr_identifier_reference(span, name); - let arguments = self.ast.add_sub_range([Argument::Expr(source)]); - Some( - self.ast - .expr_call_expression(span, callee, None, arguments, false), - ) - } - - fn visit_mut_stmt(&mut self, node: Stmt) -> Option { - node.visit_mut_children_with(self); - let StmtData::TsExportAssignment(export) = self.ast.stmt_data(node) else { - return None; - }; - let span = export.span(self.ast); - let right = export.expression(self.ast); - let name = self.ast.add_utf8("module"); - let object = self.ast.expr_identifier_reference(span, name); - let name = self.ast.add_utf8("exports"); - let key = self.ast.property_key_identifier_name(span, name); - let left = self - .ast - .simple_assignment_target_member_expression(span, object, key, false, false); - let assignment = self.ast.expr_assignment_expression( - span, - AssignmentTarget::SimpleAssignmentTarget(left), - right, - AssignmentOperator::Assign, - ); - Some(self.ast.stmt_expression_statement(span, assignment)) - } -} diff --git a/esbuild.config.mjs b/esbuild.config.mjs index bbebf4aca..dfc1c0cc3 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -31,12 +31,7 @@ await build({ // Everything else is a thin wrapper that requires `./internal.js`. await build({ ...common, - entryPoints: [ - "src/index.ts", - "src/errors.ts", - "src/strip-loader.ts", - "src/transform-loader.ts", - ], + entryPoints: ["src/index.ts", "src/errors.ts", "src/strip-loader.ts"], bundle: false, outdir: "dist", outbase: "src", diff --git a/package.json b/package.json index dd9df54e6..c94783b47 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,7 @@ }, "exports": { ".": "./dist/index.js", - "./strip": "./dist/register-strip.mjs", - "./transform": "./dist/register-transform.mjs" + "./strip": "./dist/register-strip.mjs" }, "files": [ "dist", diff --git a/src/internal.ts b/src/internal.ts index ec138ab2b..f3e48589e 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -15,28 +15,13 @@ const DEFAULT_OPTIONS = { deprecatedTsModuleAsError: true, } satisfies Options; -// swc only reads `transform` in "transform" mode, so these defaults are kept -// out of strip-only calls to avoid deserializing them on every call. -const DEFAULT_TRANSFORM_OPTIONS = { - mode: "transform", - deprecatedTsModuleAsError: true, - transform: { - verbatimModuleSyntax: true, - nativeClassProperties: true, - noEmptyExport: true, - importNotUsedAsValues: "preserve", - }, -} satisfies Options; - export function transformSync( source: string, options?: Options, ): TransformOutput { // Ensure that the source code is a string const input = `${source ?? ""}`; - const defaults = - options?.mode === "transform" ? DEFAULT_TRANSFORM_OPTIONS : DEFAULT_OPTIONS; - return swc.transformSync(input, { ...defaults, ...options }); + return swc.transformSync(input, { ...DEFAULT_OPTIONS, ...options }); } export function transformModuleSyntax( diff --git a/src/register/register-transform.mjs b/src/register/register-transform.mjs deleted file mode 100644 index 54f8b0bca..000000000 --- a/src/register/register-transform.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import module from "node:module"; -import { emitWarning, env, execArgv } from "node:process"; - -const hasSourceMaps = - execArgv.includes("--enable-source-maps") || - env.NODE_OPTIONS?.includes("--enable-source-maps"); - -if (!hasSourceMaps) { - emitWarning("Source maps are disabled, stack traces will not be accurate"); -} - -if (typeof module.registerHooks === "function") { - const { default: loader } = await import("./transform-loader.js"); - module.registerHooks({ load: loader.loadSync }); -} else { - // Node.js versions without `module.registerHooks()`. - module.register("./transform-loader.js", import.meta.url); -} diff --git a/src/transform-loader.ts b/src/transform-loader.ts deleted file mode 100644 index 1e07c449c..000000000 --- a/src/transform-loader.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { LoadFnOutput, LoadHookContext } from "node:module"; -import { fileURLToPath } from "node:url"; -import type { Options } from "../lib/wasm"; -import { isSwcError, wrapAndReThrowSwcError } from "./errors.js"; -import { transformSync } from "./index.js"; - -type NextLoad = ( - url: string, - context?: Partial, -) => LoadFnOutput; - -function transform( - url: string, - format: string, - source: LoadFnOutput["source"], -) { - try { - // biome-ignore lint/style/noNonNullAssertion: If module exists, it will have a source - const { code, map } = transformSync(source!.toString(), { - mode: "transform", - sourceMap: true, - filename: fileURLToPath(url), - } as Options); - - let output = code; - - if (map) { - const base64SourceMap = Buffer.from(map).toString("base64"); - output = `${code}\n\n//# sourceMappingURL=data:application/json;base64,${base64SourceMap}`; - } - - return { - format: format.replace("-typescript", ""), - source: `${output}\n\n//# sourceURL=${url}`, - }; - } catch (error) { - if (isSwcError(error)) { - wrapAndReThrowSwcError(error); - } - // If the error is not an SwcError, rethrow it - throw error; - } -} - -// Synchronous hook for `module.registerHooks()`. -export function loadSync( - url: string, - context: LoadHookContext, - nextLoad: NextLoad, -) { - const { format } = context; - if (format?.endsWith("-typescript")) { - const { source } = nextLoad(url, { ...context, format }); - return transform(url, format, source); - } - return nextLoad(url, context); -} - -// Asynchronous hook for `module.register()`, used on Node.js versions that -// do not provide `module.registerHooks()`. -export async function load( - url: string, - context: LoadHookContext, - nextLoad: ( - url: string, - context?: Partial, - ) => LoadFnOutput | Promise, -) { - const { format } = context; - if (format?.endsWith("-typescript")) { - const { source } = await nextLoad(url, { ...context, format }); - return transform(url, format, source); - } - return nextLoad(url, context); -} diff --git a/test/fixtures/stacktrace.ts b/test/fixtures/stacktrace.ts index 9a155e864..f796c3ffc 100644 --- a/test/fixtures/stacktrace.ts +++ b/test/fixtures/stacktrace.ts @@ -1,8 +1,5 @@ -enum Foo { - A = "Hello, TypeScript!", -} +const message: string = "Hello, TypeScript!"; -// Allow warnings about source maps to be shown for testing purposes. setImmediate(() => { - throw new Error(Foo.A); + throw new Error(message); }); diff --git a/test/index.test.js b/test/index.test.js index 216af6066..d74ef79ef 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -4,6 +4,19 @@ const path = require("node:path"); const assert = require("node:assert"); const vm = require("node:vm"); +test("should reject the removed transform mode", () => { + assert.throws( + () => transformSync("const value: number = 1;", { mode: "transform" }), + /unknown variant.*transform/, + ); +}); + +test("should not export the removed transform loader", () => { + assert.throws(() => require.resolve("amaro/transform"), { + code: "ERR_PACKAGE_PATH_NOT_EXPORTED", + }); +}); + // Set the path for the snapshots directory snapshot.setResolveSnapshotPath((testPath) => { return path.join( diff --git a/test/loader.test.js b/test/loader.test.js index 1167d7ef7..8fd5e2201 100644 --- a/test/loader.test.js +++ b/test/loader.test.js @@ -1,6 +1,6 @@ const { spawnPromisified, fixturesPath } = require("./util/util.js"); const { test } = require("node:test"); -const { match, doesNotMatch, strictEqual } = require("node:assert"); +const { match, strictEqual } = require("node:assert"); test("should work as a loader", async () => { const result = await spawnPromisified(process.execPath, [ @@ -30,43 +30,6 @@ test("should not work with enums", async (t) => { strictEqual(result.code, 1); }); -test("should work with enums", async () => { - const result = await spawnPromisified(process.execPath, [ - "--no-warnings", - "--import=./dist/register-transform.mjs", - fixturesPath("enum.ts"), - ]); - - match(result.stdout, /Hello, TypeScript!/); - strictEqual(result.stderr, ""); - strictEqual(result.code, 0); -}); - -test("should warn when source maps are disabled", async () => { - const result = await spawnPromisified(process.execPath, [ - "--import=./dist/register-transform.mjs", - fixturesPath("stacktrace.ts"), - ]); - - strictEqual(result.stdout, ""); - match(result.stderr, /Source maps are disabled/); - match(result.stderr, /stacktrace.ts:\d+:\d+/); - strictEqual(result.code, 1); -}); - -test("should not warn and accurate stracktrace", async () => { - const result = await spawnPromisified(process.execPath, [ - "--enable-source-maps", - "--import=./dist/register-transform.mjs", - fixturesPath("stacktrace.ts"), - ]); - - doesNotMatch(result.stderr, /Source maps are disabled/); - strictEqual(result.stdout, ""); - match(result.stderr, /stacktrace.ts:7:9/); // accurate - strictEqual(result.code, 1); -}); - test("should call nextLoader for non-typescript files for striping", async () => { const result = await spawnPromisified(process.execPath, [ "--no-warnings", @@ -79,18 +42,6 @@ test("should call nextLoader for non-typescript files for striping", async () => strictEqual(result.code, 0); }); -test("should call nextLoader for non-typescript files for transform", async () => { - const result = await spawnPromisified(process.execPath, [ - "--no-warnings", - "--import=./dist/register-transform.mjs", - fixturesPath("hello.js"), - ]); - - strictEqual(result.stderr, ""); - match(result.stdout, /Hello, JavaScript!/); - strictEqual(result.code, 0); -}); - test("should throw syntax error for invalid typescript", async (t) => { const result = await spawnPromisified(process.execPath, [ "--no-warnings", @@ -118,18 +69,6 @@ test("should work with commonjs typescript files using strip", async () => { strictEqual(result.code, 0); }); -test("should work with commonjs typescript files using transform", async () => { - const result = await spawnPromisified(process.execPath, [ - "--no-warnings", - "--import=./dist/register-transform.mjs", - fixturesPath("commonjs.ts"), - ]); - - strictEqual(result.stderr, ""); - match(result.stdout, /Hello from CommonJS TypeScript!/); - strictEqual(result.code, 0); -}); - test("should work with .cts files using strip", async () => { const result = await spawnPromisified(process.execPath, [ "--no-warnings", @@ -142,18 +81,6 @@ test("should work with .cts files using strip", async () => { strictEqual(result.code, 0); }); -test("should work with .cts files using transform", async () => { - const result = await spawnPromisified(process.execPath, [ - "--no-warnings", - "--import=./dist/register-transform.mjs", - fixturesPath("commonjs.cts"), - ]); - - strictEqual(result.stderr, ""); - match(result.stdout, /Hello from CommonJS TypeScript \(\.cts\)!/); - strictEqual(result.code, 0); -}); - test("should work with esm typescript files using strip", async () => { const result = await spawnPromisified(process.execPath, [ "--no-warnings", @@ -166,18 +93,6 @@ test("should work with esm typescript files using strip", async () => { strictEqual(result.code, 0); }); -test("should work with esm typescript files using transform", async () => { - const result = await spawnPromisified(process.execPath, [ - "--no-warnings", - "--import=./dist/register-transform.mjs", - fixturesPath("esm/esm.ts"), - ]); - - strictEqual(result.stderr, ""); - match(result.stdout, /Hello from ESM TypeScript!/); - strictEqual(result.code, 0); -}); - test("should work with .mts files using strip", async () => { const result = await spawnPromisified(process.execPath, [ "--no-warnings", @@ -189,15 +104,3 @@ test("should work with .mts files using strip", async () => { match(result.stdout, /Hello from ESM TypeScript!/); strictEqual(result.code, 0); }); - -test("should work with .mts files using transform", async () => { - const result = await spawnPromisified(process.execPath, [ - "--no-warnings", - "--import=./dist/register-transform.mjs", - fixturesPath("esm.mts"), - ]); - - strictEqual(result.stderr, ""); - match(result.stdout, /Hello from ESM TypeScript!/); - strictEqual(result.code, 0); -}); diff --git a/test/register-hooks.test.js b/test/register-hooks.test.js index 2e1613c27..205fdb25f 100644 --- a/test/register-hooks.test.js +++ b/test/register-hooks.test.js @@ -13,56 +13,54 @@ const otherHook = pathToFileURL(fixturesPath("other-hook.mjs")).href; const run = (args) => spawnPromisified(process.execPath, ["--no-warnings", ...args]); -for (const mode of ["strip", "transform"]) { - test(`should apply ${mode} hooks to require() of TypeScript`, async () => { - const result = await run([ - `--import=./dist/register-${mode}.mjs`, - fixturesPath("require-ts.js"), - ]); +test("should apply strip hooks to require() of TypeScript", async () => { + const result = await run([ + "--import=./dist/register-strip.mjs", + fixturesPath("require-ts.js"), + ]); - strictEqual(result.stderr, ""); - match(result.stdout, /Hello from CommonJS TypeScript! - required/); - strictEqual(result.code, 0); - }); + strictEqual(result.stderr, ""); + match(result.stdout, /Hello from CommonJS TypeScript! - required/); + strictEqual(result.code, 0); +}); - test(`should apply ${mode} hooks to dynamic import() of TypeScript`, async () => { - const result = await run([ - `--import=./dist/register-${mode}.mjs`, - "--input-type=module", - "--eval", - `await import(${JSON.stringify(pathToFileURL(fixturesPath("esm/esm.ts")).href)});`, - ]); +test("should apply strip hooks to dynamic import() of TypeScript", async () => { + const result = await run([ + "--import=./dist/register-strip.mjs", + "--input-type=module", + "--eval", + `await import(${JSON.stringify(pathToFileURL(fixturesPath("esm/esm.ts")).href)});`, + ]); - strictEqual(result.stderr, ""); - match(result.stdout, /Hello from ESM TypeScript!/); - strictEqual(result.code, 0); - }); + strictEqual(result.stderr, ""); + match(result.stdout, /Hello from ESM TypeScript!/); + strictEqual(result.code, 0); +}); - test( - `should compose ${mode} hooks with other registered hooks`, - { skip }, - async () => { - const typescript = await run([ - `--import=./dist/register-${mode}.mjs`, - `--import=${otherHook}`, - fixturesPath("hello.ts"), - ]); - strictEqual(typescript.stderr, ""); - match(typescript.stdout, /Hello, TypeScript!/); - strictEqual(typescript.code, 0); +test( + "should compose strip hooks with other registered hooks", + { skip }, + async () => { + const typescript = await run([ + "--import=./dist/register-strip.mjs", + `--import=${otherHook}`, + fixturesPath("hello.ts"), + ]); + strictEqual(typescript.stderr, ""); + match(typescript.stdout, /Hello, TypeScript!/); + strictEqual(typescript.code, 0); - const javascript = await run([ - `--import=${otherHook}`, - `--import=./dist/register-${mode}.mjs`, - fixturesPath("hello.js"), - ]); - strictEqual(javascript.stderr, ""); - match(javascript.stdout, /other hook saw hello\.js/); - match(javascript.stdout, /Hello, JavaScript!/); - strictEqual(javascript.code, 0); - }, - ); -} + const javascript = await run([ + `--import=${otherHook}`, + "--import=./dist/register-strip.mjs", + fixturesPath("hello.js"), + ]); + strictEqual(javascript.stderr, ""); + match(javascript.stdout, /other hook saw hello\.js/); + match(javascript.stdout, /Hello, JavaScript!/); + strictEqual(javascript.code, 0); + }, +); test("should work from a programmatic bootstrap file", async () => { const result = await run([fixturesPath("bootstrap-strip.mjs")]); @@ -85,17 +83,15 @@ test("should throw a syntax error from require() of invalid TypeScript", async ( }); test( - "should map transformed require() stack traces with source maps", + "should preserve require() stack trace locations after stripping", { skip }, async () => { - const result = await spawnPromisified(process.execPath, [ - "--enable-source-maps", - "--import=./dist/register-transform.mjs", + const result = await run([ + "--import=./dist/register-strip.mjs", "--eval", `require(${JSON.stringify(fixturesPath("stacktrace.ts"))});`, ]); - - match(result.stderr, /stacktrace.ts:7:9/); + match(result.stderr, /stacktrace.ts:4:9/); strictEqual(result.code, 1); }, ); diff --git a/test/snapshots/transform.test.js.snapshot b/test/snapshots/transform.test.js.snapshot deleted file mode 100644 index ef1a67a3b..000000000 --- a/test/snapshots/transform.test.js.snapshot +++ /dev/null @@ -1,105 +0,0 @@ -exports[`should handle short-circuit assignments (&&=, ||=, ??=) 1`] = ` -"const a = {};\\na.foo &&= \`\${a.foo}1\`;\\nconsole.log(a.foo);\\nconst b = {};\\nb.foo ||= \`\${b.foo}1\`;\\nconsole.log(b.foo);\\nconst c = {};\\nc.foo ??= \`\${c.foo}1\`;\\nconsole.log(c.foo);\\n" -`; - -exports[`should have proper error code 1`] = ` -{ - "code": "UnsupportedSyntax", - "message": "\`module\` keyword is not supported. Use \`namespace\` instead.", - "snippet": "module F { export type x = number }\\n^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\n", - "filename": "", - "startLine": 1, - "startColumn": 0, - "endLine": 1, - "endColumn": 35 -} -`; - -exports[`should have proper error code 2`] = ` -{ - "code": "InvalidSyntax", - "message": "'const' declarations must be initialized", - "snippet": "const foo;\\n ^^^\\n", - "filename": "", - "startLine": 1, - "startColumn": 6, - "endLine": 1, - "endColumn": 9 -} -`; - -exports[`should not polyfill using Symbol.Dispose 1`] = ` -"using r = 0;\\n" -`; - -exports[`should not polyfill using Symbol.asyncDispose 1`] = ` -"await using r = 0;\\n" -`; - -exports[`should not transform TypeScript class decorators with multiple decorators 1`] = ` -"@sealed @log class BugReport {\\n type = \\"report\\";\\n title;\\n constructor(t) {\\n this.title = t;\\n }\\n}\\nfunction sealed(constructor) {\\n Object.seal(constructor);\\n Object.seal(constructor.prototype);\\n}\\nfunction log(constructor) {\\n console.log(\\"Creating instance of\\", constructor.name);\\n}\\noutput = new BugReport(\\"Test\\");\\n" -`; - -exports[`should perform transformation with error 1`] = ` -"var Foo;\\n(function(Foo) {\\n Foo[Foo[\\"Bar\\"] = 7] = \\"Bar\\";\\n Foo[Foo[\\"Baz\\"] = 2] = \\"Baz\\";\\n})(Foo || (Foo = {}));\\noutput = Foo.Bar;\\nthrow new Error(\\"foo\\");\\n" -`; - -exports[`should perform transformation with error 2`] = ` -"{\\"mappings\\":\\"AACI,IAAKA;AADT,UAAAA,KAAA;AAAA,EAAAA,IAAA,aAEQ,CAFR;AAAA,EAAAA,IAAA,aAGQ,CAHR;AAAA,GAAAA,cAAA;AAKI,SAASA,IAAI;AACb,MAAM,IAAIC,MAAM,KAAK;\\",\\"names\\":[\\"Foo\\",\\"Error\\"],\\"sources\\":[\\"foo.ts\\"],\\"sourcesContent\\":[\\"\\\\n enum Foo {\\\\n Bar = 7,\\\\n Baz = 2,\\\\n }\\\\n output = Foo.Bar;\\\\n throw new Error(\\\\\\"foo\\\\\\");\\"],\\"version\\":3}" -`; - -exports[`should perform transformation with source maps 1`] = ` -"var Foo;\\n(function(Foo) {\\n Foo[Foo[\\"Bar\\"] = 7] = \\"Bar\\";\\n Foo[Foo[\\"Baz\\"] = 2] = \\"Baz\\";\\n})(Foo || (Foo = {}));\\noutput = Foo.Bar;\\n" -`; - -exports[`should perform transformation with source maps 2`] = ` -"{\\"mappings\\":\\"AACI,IAAKA;AADT,UAAAA,KAAA;AAAA,EAAAA,IAAA,aAEQ,CAFR;AAAA,EAAAA,IAAA,aAGQ,CAHR;AAAA,GAAAA,cAAA;AAMI,SAASA,IAAI;\\",\\"names\\":[\\"Foo\\"],\\"sources\\":[\\"foo.ts\\"],\\"sourcesContent\\":[\\"\\\\n enum Foo {\\\\n Bar = 7,\\\\n Baz = 2,\\\\n }\\\\n\\\\n output = Foo.Bar\\"],\\"version\\":3}" -`; - -exports[`should perform transformation with source maps no filename 1`] = ` -"var Foo;\\n(function(Foo) {\\n Foo[Foo[\\"Bar\\"] = 7] = \\"Bar\\";\\n Foo[Foo[\\"Baz\\"] = 2] = \\"Baz\\";\\n})(Foo || (Foo = {}));\\noutput = Foo.Bar;\\n" -`; - -exports[`should perform transformation with source maps no filename 2`] = ` -"{\\"mappings\\":\\"AACI,IAAKA;AADT,UAAAA,KAAA;AAAA,EAAAA,IAAA,aAEQ,CAFR;AAAA,EAAAA,IAAA,aAGQ,CAHR;AAAA,GAAAA,cAAA;AAKI,SAASA,IAAI;\\",\\"names\\":[\\"Foo\\"],\\"sources\\":[\\"\\"],\\"sourcesContent\\":[\\"\\\\n enum Foo {\\\\n Bar = 7,\\\\n Baz = 2,\\\\n }\\\\n output = Foo.Bar\\"],\\"version\\":3}" -`; - -exports[`should perform transformation with source maps with uncommon chars 1`] = ` -"var Foo;\\n(function(Foo) {\\n Foo[Foo[\\"Bar\\"] = 7] = \\"Bar\\";\\n Foo[Foo[\\"Baz\\"] = 2] = \\"Baz\\";\\n})(Foo || (Foo = {}));\\noutput = Foo.Bar;\\n" -`; - -exports[`should perform transformation with source maps with uncommon chars 2`] = ` -"{\\"mappings\\":\\"AACI,IAAKA;AADT,UAAAA,KAAA;AAAA,EAAAA,IAAA,aAEQ,CAFR;AAAA,EAAAA,IAAA,aAGQ,CAHR;AAAA,GAAAA,cAAA;AAMI,SAASA,IAAI;\\",\\"names\\":[\\"Foo\\"],\\"sources\\":[\\"dir%20with $unusual\\\\\\"chars?'Γ₯ΓŸβˆ‚Ζ’Β©βˆ†Β¬β€¦\`.cts\\"],\\"sourcesContent\\":[\\"\\\\n enum Foo {\\\\n Bar = 7,\\\\n Baz = 2,\\\\n }\\\\n\\\\n output = Foo.Bar\\"],\\"version\\":3}" -`; - -exports[`should perform transformation without source maps 1`] = ` -"var Foo;\\n(function(Foo) {\\n Foo[Foo[\\"Bar\\"] = 7] = \\"Bar\\";\\n Foo[Foo[\\"Baz\\"] = 2] = \\"Baz\\";\\n})(Foo || (Foo = {}));\\noutput = Foo.Bar;\\n" -`; - -exports[`should perform transformation without source maps and filename 1`] = ` -"var Foo;\\n(function(Foo) {\\n Foo[Foo[\\"Bar\\"] = 7] = \\"Bar\\";\\n Foo[Foo[\\"Baz\\"] = 2] = \\"Baz\\";\\n})(Foo || (Foo = {}));\\noutput = Foo.Bar;\\n" -`; - -exports[`should transform TypeScript class fields 1`] = ` -"class Counter {\\n count = 0;\\n increment() {\\n this.count++;\\n }\\n}\\nconst counter = new Counter();\\ncounter.increment();\\noutput = counter.count;\\n" -`; - -exports[`should transform TypeScript namespaces with additional functionality 1`] = ` -"var Validation;\\n(function(Validation) {\\n const lettersRegexp = /^[A-Za-z]+$/;\\n class LettersOnlyValidator {\\n isAcceptable(s) {\\n return lettersRegexp.test(s);\\n }\\n static createValidator() {\\n return new LettersOnlyValidator();\\n }\\n }\\n Validation.LettersOnlyValidator = LettersOnlyValidator;\\n})(Validation || (Validation = {}));\\nconst validator = Validation.LettersOnlyValidator.createValidator();\\nconst isValid = validator.isAcceptable(\\"test\\");\\noutput = { validator, isValid };\\n" -`; - -exports[`should transform TypeScript private class fields 1`] = ` -"class Counter {\\n #count = 0;\\n increment() {\\n this.#count++;\\n }\\n getCount() {\\n return this.#count;\\n }\\n}\\nconst counter = new Counter();\\ncounter.increment();\\noutput = counter.getCount();\\n" -`; - -exports[`should transform TypeScript type annotations and type guards 1`] = ` -"function isString(value) {\\n return typeof value === \\"string\\";\\n}\\nconst check = isString(\\"hello\\");\\noutput = check;\\n" -`; - -exports[`test native class properties 1`] = ` -"class Foo {\\n y;\\n x = console.log(1);\\n constructor(y = console.log(2)) {\\n this.y = y;\\n console.log(3);\\n }\\n}\\n" -`; - -exports[`test noEmptyExport 1`] = ` -"const fs = require(\\"fs\\");\\n" -`; diff --git a/test/transform.test.js b/test/transform.test.js deleted file mode 100644 index fcd844c31..000000000 --- a/test/transform.test.js +++ /dev/null @@ -1,465 +0,0 @@ -const { test, snapshot } = require("node:test"); -const { transformSync } = require("../dist/index.js"); -const path = require("node:path"); -const assert = require("node:assert"); -const vm = require("node:vm"); -const { SourceMap } = require("node:module"); - -// Set the path for the snapshots directory -snapshot.setResolveSnapshotPath((testPath) => { - return path.join( - __dirname, - "snapshots", - `${path.basename(testPath)}.snapshot`, - ); -}); - -test("should perform transformation without source maps", (t) => { - const tsCode = ` - enum Foo { - Bar = 7, - Baz = 2, - } - output = Foo.Bar`; - - const { code, map } = transformSync(tsCode, { - mode: "transform", - }); - - t.assert.snapshot(code); - const result = vm.runInContext(code, vm.createContext()); - assert.strictEqual(result, 7); - assert.strictEqual(map, undefined); -}); - -test("should perform transformation without source maps and filename", (t) => { - const tsCode = ` - enum Foo { - Bar = 7, - Baz = 2, - } - output = Foo.Bar`; - - const { code, map } = transformSync(tsCode, { - mode: "transform", - filename: "foo.ts", - }); - - t.assert.snapshot(code); - const result = vm.runInContext(code, vm.createContext()); - assert.strictEqual(result, 7); - assert.strictEqual(map, undefined); -}); - -test("should perform transformation with source maps", (t) => { - const tsCode = ` - enum Foo { - Bar = 7, - Baz = 2, - } - - output = Foo.Bar`; - const { code, map } = transformSync(tsCode, { - mode: "transform", - sourceMap: true, - filename: "foo.ts", - }); - - t.assert.snapshot(code); - t.assert.snapshot(map); - const result = vm.runInContext(code, vm.createContext()); - assert.strictEqual(result, 7); -}); - -test("should perform transformation with source maps with uncommon chars", (t) => { - const tsCode = ` - enum Foo { - Bar = 7, - Baz = 2, - } - - output = Foo.Bar`; - const { code, map } = transformSync(tsCode, { - mode: "transform", - sourceMap: true, - filename: "dir%20with $unusual\"chars?'Γ₯ΓŸβˆ‚Ζ’Β©βˆ†Β¬β€¦`.cts", - }); - - t.assert.snapshot(code); - t.assert.snapshot(map); - const result = vm.runInContext(code, vm.createContext()); - assert.strictEqual(result, 7); -}); - -test("should perform transformation with source maps no filename", (t) => { - const tsCode = ` - enum Foo { - Bar = 7, - Baz = 2, - } - output = Foo.Bar`; - - const { code, map } = transformSync(tsCode, { - mode: "transform", - sourceMap: true, - }); - - t.assert.snapshot(code); - t.assert.snapshot(map); - const result = vm.runInContext(code, vm.createContext()); - assert.strictEqual(result, 7); -}); - -test("should perform transformation with error", (t) => { - const tsCode = ` - enum Foo { - Bar = 7, - Baz = 2, - } - output = Foo.Bar; - throw new Error("foo");`; - - const { code, map } = transformSync(tsCode, { - mode: "transform", - sourceMap: true, - filename: "foo.ts", - }); - - t.assert.snapshot(code); - t.assert.snapshot(map); - const context = {}; - try { - assert.throws(vm.runInContext(code, vm.createContext(context))); - } catch (error) { - assert.strictEqual(error.message, "foo"); - } - assert.strictEqual(context.output, 7); -}); - -test("should transform TypeScript class fields", (t) => { - const inputCode = ` - class Counter { - count: number = 0; - increment() { - this.count++; - } - } - - const counter = new Counter(); - counter.increment(); - output = counter.count;`; - - const { code } = transformSync(inputCode, { - mode: "transform", - sourceMap: true, - }); - - t.assert.snapshot(code); - const result = vm.runInContext(code, vm.createContext()); - assert.strictEqual(result, 1); -}); - -test("should transform TypeScript private class fields", (t) => { - const inputCode = ` - class Counter { - #count: number = 0; - increment() { - this.#count++; - } - getCount(): number { - return this.#count; - } - } - - const counter = new Counter(); - counter.increment(); - output = counter.getCount();`; - - const { code } = transformSync(inputCode, { - mode: "transform", - sourceMap: true, - }); - - t.assert.snapshot(code); - const result = vm.runInContext(code, vm.createContext()); - assert.strictEqual(result, 1); -}); - -test("should transform TypeScript type annotations and type guards", (t) => { - const inputCode = ` - function isString(value: unknown): value is string { - return typeof value === 'string'; - } - const check = isString("hello"); - output = check;`; - - const { code } = transformSync(inputCode, { - mode: "transform", - sourceMaps: true, - }); - - t.assert.snapshot(code); - const result = vm.runInContext(code, vm.createContext()); - assert.strictEqual(result, true); -}); - -test("should not transform TypeScript class decorators with multiple decorators", (t) => { - const inputCode = ` - @sealed - @log - class BugReport { - type = "report"; - title: string; - constructor(t: string) { - this.title = t; - } - } - - function sealed(constructor: Function) { - Object.seal(constructor); - Object.seal(constructor.prototype); - } - - function log(constructor: Function) { - console.log("Creating instance of", constructor.name); - } - - output = new BugReport("Test");`; - - const { code } = transformSync(inputCode, { - mode: "transform", - sourceMap: true, - }); - - t.assert.snapshot(code); - const context = {}; - try { - assert.throws(vm.runInContext(code, vm.createContext(context))); - } catch (error) { - assert.strictEqual(error.message, "Invalid or unexpected token"); - } -}); - -test("should transform TypeScript namespaces with additional functionality", (t) => { - const inputCode = ` - namespace Validation { - export interface StringValidator { - isAcceptable(s: string): boolean; - } - const lettersRegexp = /^[A-Za-z]+$/; - export class LettersOnlyValidator implements StringValidator { - isAcceptable(s: string) { - return lettersRegexp.test(s); - } - static createValidator(): LettersOnlyValidator { - return new LettersOnlyValidator(); - } - } - } - const validator = Validation.LettersOnlyValidator.createValidator(); - const isValid = validator.isAcceptable("test"); - output = { - validator, - isValid - }`; - - const { code } = transformSync(inputCode, { - mode: "transform", - sourceMap: true, - }); - - t.assert.snapshot(code); - const result = vm.runInContext(code, vm.createContext()); - assert.ok(result.validator); - assert.strictEqual(result.isValid, true); -}); - -test("test native class properties", (t) => { - const inputCode = ` - class Foo { - x = console.log(1) - constructor(public y = console.log(2)) { - console.log(3) - } - }`; - const { code } = transformSync(inputCode, { - mode: "transform", - sourceMap: true, - }); - t.assert.snapshot(code); -}); - -test("test noEmptyExport", (t) => { - const inputCode = ` - import fs = require("fs"); - `; - const { code } = transformSync(inputCode, { - mode: "transform", - sourceMap: true, - }); - t.assert.snapshot(code); -}); - -test("should not polyfill using Symbol.Dispose", (t) => { - const inputCode = ` - using r = 0;`; - const { code } = transformSync(inputCode, { - mode: "transform", - sourceMap: true, - }); - t.assert.snapshot(code); -}); - -test("should not polyfill using Symbol.asyncDispose", (t) => { - const inputCode = ` - await using r = 0;`; - const { code } = transformSync(inputCode, { - mode: "transform", - sourceMap: true, - }); - t.assert.snapshot(code); -}); - -test("should have proper error code", (t) => { - const inputCode = "module F { export type x = number }"; - try { - transformSync(inputCode, { - mode: "transform", - }); - } catch (error) { - t.assert.snapshot(error); - assert.strictEqual(error.code, "UnsupportedSyntax"); - } -}); - -test("should have proper error code", (t) => { - const inputCode = "const foo;"; - try { - transformSync(inputCode, { - mode: "transform", - }); - } catch (error) { - t.assert.snapshot(error); - assert.strictEqual(error.code, "InvalidSyntax"); - } -}); - -test("should handle short-circuit assignments (&&=, ||=, ??=)", (t) => { - const inputCode = ` - const a = {} - - a.foo &&= \`\${a.foo}1\` - console.log(a.foo) - - const b = {} - - b.foo ||= \`\${b.foo}1\` - console.log(b.foo) - - const c = {} - - c.foo ??= \`\${c.foo}1\` - console.log(c.foo) - `; - - const { code } = transformSync(inputCode, { - mode: "transform", - sourceMap: true, - }); - t.assert.snapshot(code); -}); - -test("should lower parameter properties after super and preserve initialization order", () => { - const source = ` - const events: string[] = []; - class Base { constructor() { events.push("base"); } } - class Derived extends Base { - field = events.push("field"); - constructor(public value: number) { - super(); - events.push(String(this.value)); - } - } - new Derived(7); - output = events.join(",");`; - const { code } = transformSync(source, { mode: "transform" }); - assert.strictEqual(vm.runInNewContext(code), "base,field,7"); -}); - -test("should lower CommonJS import and export assignments", () => { - const source = - 'import path = require("node:path"); export = path.basename("/a/b");'; - const { code } = transformSync(source, { mode: "transform" }); - const context = { require, module: { exports: undefined } }; - vm.runInNewContext(code, context); - assert.strictEqual(context.module.exports, "b"); - assert.doesNotMatch(code, /import\.sync/); -}); - -test("should keep source module markers according to noEmptyExport", () => { - const source = "export type Value = number;"; - assert.doesNotMatch( - transformSync(source, { mode: "transform" }).code, - /export/, - ); - const { code } = transformSync(source, { - mode: "transform", - transform: { noEmptyExport: false }, - }); - assert.match(code, /export\s*\{\s*\}/); - assert.match( - transformSync("export {};", { mode: "transform" }).code, - /export\s*\{\s*\}/, - ); -}); - -test("should forward verbatimModuleSyntax to SWC Next", () => { - const source = 'import { value } from "dependency"; output = 1;'; - assert.match(transformSync(source, { mode: "transform" }).code, /import/); - const { code } = transformSync(source, { - mode: "transform", - transform: { verbatimModuleSyntax: false, noEmptyExport: true }, - }); - assert.doesNotMatch(code, /import|export/); - assert.strictEqual(vm.runInNewContext(code), 1); -}); - -test("should reject unsupported transform options", () => { - const check = (transform) => - assert.throws( - () => - transformSync("const value: number = 1;", { - mode: "transform", - transform, - }), - { code: "UnsupportedSyntax", message: /Unsupported transform option/ }, - ); - check({ nativeClassProperties: false }); - check({ importExportAssignConfig: "NodeNext" }); - check({ tsEnumIsMutable: true }); - check({ importNotUsedAsValues: "preserve" }); - check({ jsx: { transform: "react-jsx" } }); -}); - -test("should map generated locations to UTF-16 columns in the original source", () => { - const source = - 'enum Value { A = 7 }\n"πŸ˜€"; throw new Error(String(Value.A));'; - const filename = "dir with spaces/ν•œκΈ€.ts"; - const { code, map } = transformSync(source, { - mode: "transform", - sourceMap: true, - filename, - }); - const payload = JSON.parse(map); - assert.deepStrictEqual(payload.sources, [filename]); - assert.deepStrictEqual(payload.sourcesContent, [source]); - const lines = code.split("\n"); - const line = lines.findIndex((line) => line.includes("new Error")); - const column = lines[line].indexOf("new Error"); - const entry = new SourceMap(payload).findEntry(line, column); - assert.strictEqual(entry.originalLine, 1); - assert.strictEqual( - entry.originalColumn, - source.split("\n")[1].indexOf("new Error"), - ); -}); From 8da62c210a0f421923ffca838ae39f52ac50cba9 Mon Sep 17 00:00:00 2001 From: DongYun Kang Date: Fri, 30 Oct 2026 11:03:58 +0900 Subject: [PATCH 3/9] chore(deps): use published SWC Next crates --- README.md | 2 +- crates/amaro/Cargo.lock | 18 ++++++++++++------ crates/amaro/Cargo.toml | 10 +++++----- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index fba6cebb2..e95254802 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Amaro -Amaro is a Node.js wrapper around the [SWC Next](https://github.com/swc-project/swc-next) TypeScript type stripper. Its Rust core in `crates/amaro` is compiled to WebAssembly. Type stripping uses SWC Next at a pinned Git revision. Node.js REPL helpers and diagnostic formatting still use the published [SWC](https://swc.rs) crates. +Amaro is a Node.js wrapper around the [SWC Next](https://github.com/swc-project/swc-next) TypeScript type stripper. Its Rust core in `crates/amaro` is compiled to WebAssembly. Type stripping uses pinned SWC Next releases from crates.io. Node.js REPL helpers and diagnostic formatting still use the published [SWC](https://swc.rs) crates. Diagnostics retain Amaro's error codes and location fields, but their messages and highlighted ranges come from SWC Next. It's used as an internal in Node.js for [Type Stripping](https://nodejs.org/api/typescript.html#type-stripping) but can also be used as a standalone package. diff --git a/crates/amaro/Cargo.lock b/crates/amaro/Cargo.lock index 1ff93bdaa..8c25cbc88 100644 --- a/crates/amaro/Cargo.lock +++ b/crates/amaro/Cargo.lock @@ -967,7 +967,8 @@ dependencies = [ [[package]] name = "swc_next_allocator" version = "0.4.1" -source = "git+https://github.com/swc-project/swc-next?rev=2be98eee84461163d9068612f691c649ba18bd36#2be98eee84461163d9068612f691c649ba18bd36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74af8524e014feb881c5a62c9ed2ef7b7659ebd9cbfc2a9c82b53cb82afd696" dependencies = [ "allocator-api2", "bumpalo", @@ -979,7 +980,8 @@ dependencies = [ [[package]] name = "swc_next_ast_macros" version = "0.4.1" -source = "git+https://github.com/swc-project/swc-next?rev=2be98eee84461163d9068612f691c649ba18bd36#2be98eee84461163d9068612f691c649ba18bd36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d59a4fdc755bf7eb88281ea3e79984875f30284d4309629ae3c959e78ff9d6d" dependencies = [ "proc-macro2", "quote", @@ -989,7 +991,8 @@ dependencies = [ [[package]] name = "swc_next_ecma_ast" version = "0.4.1" -source = "git+https://github.com/swc-project/swc-next?rev=2be98eee84461163d9068612f691c649ba18bd36#2be98eee84461163d9068612f691c649ba18bd36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7001312ca0138a833f16146589e86768b819cac7ebfc73d49644da8a023f4e99" dependencies = [ "cow-utils", "num-bigint 0.5.1", @@ -1001,7 +1004,8 @@ dependencies = [ [[package]] name = "swc_next_ecma_parser" version = "0.4.1" -source = "git+https://github.com/swc-project/swc-next?rev=2be98eee84461163d9068612f691c649ba18bd36#2be98eee84461163d9068612f691c649ba18bd36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c12ee569197b12d889cd17ea49f65610ed53c7762dc84ef66756fb03ea2e00" dependencies = [ "bitflags", "indexmap", @@ -1016,12 +1020,14 @@ dependencies = [ [[package]] name = "swc_next_simd" version = "0.4.1" -source = "git+https://github.com/swc-project/swc-next?rev=2be98eee84461163d9068612f691c649ba18bd36#2be98eee84461163d9068612f691c649ba18bd36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d949da076ab686ff1e3e8a2e56761ef662fdf8b759855f718381b8a334720d1c" [[package]] name = "swc_next_type_strip" version = "0.4.1" -source = "git+https://github.com/swc-project/swc-next?rev=2be98eee84461163d9068612f691c649ba18bd36#2be98eee84461163d9068612f691c649ba18bd36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "539e1d1140ae4d1d75813bfc4ccd3ba078fa474efb4c0062e427a1917b0eadb9" dependencies = [ "swc_next_allocator", "swc_next_ecma_ast", diff --git a/crates/amaro/Cargo.toml b/crates/amaro/Cargo.toml index 2c136230e..ef54ef57a 100644 --- a/crates/amaro/Cargo.toml +++ b/crates/amaro/Cargo.toml @@ -35,11 +35,11 @@ swc_ecma_parser = { version = "=46.1.2", features = ["unstable"] } swc_ecma_visit = { version = "=29.0.0" } swc_error_reporters = { version = "=28.0.0" } -# Pin SWC Next's experimental Rust API to a tested revision. -swc_next_allocator = { git = "https://github.com/swc-project/swc-next", rev = "2be98eee84461163d9068612f691c649ba18bd36" } -swc_next_ecma_ast = { git = "https://github.com/swc-project/swc-next", rev = "2be98eee84461163d9068612f691c649ba18bd36" } -swc_next_ecma_parser = { git = "https://github.com/swc-project/swc-next", rev = "2be98eee84461163d9068612f691c649ba18bd36" } -swc_next_type_strip = { git = "https://github.com/swc-project/swc-next", rev = "2be98eee84461163d9068612f691c649ba18bd36" } +# Pin SWC Next's experimental Rust API to a tested crates.io release. +swc_next_allocator = { version = "=0.4.1" } +swc_next_ecma_ast = { version = "=0.4.1" } +swc_next_ecma_parser = { version = "=0.4.1" } +swc_next_type_strip = { version = "=0.4.1" } [profile.release] lto = "fat" From 9981c731d3662d79e53be75355315d95595345b7 Mon Sep 17 00:00:00 2001 From: DongYun Kang Date: Mon, 2 Nov 2026 18:59:11 +0900 Subject: [PATCH 4/9] chore(deps): update SWC Next to 0.5.0 --- crates/amaro/Cargo.lock | 24 ++++++++++++------------ crates/amaro/Cargo.toml | 8 ++++---- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/amaro/Cargo.lock b/crates/amaro/Cargo.lock index 8c25cbc88..aee494ed4 100644 --- a/crates/amaro/Cargo.lock +++ b/crates/amaro/Cargo.lock @@ -966,9 +966,9 @@ dependencies = [ [[package]] name = "swc_next_allocator" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74af8524e014feb881c5a62c9ed2ef7b7659ebd9cbfc2a9c82b53cb82afd696" +checksum = "1e36ff07d640899f4bcd2361666d40beb128e5fb659e746c0a73bf9bcae7a0e3" dependencies = [ "allocator-api2", "bumpalo", @@ -979,9 +979,9 @@ dependencies = [ [[package]] name = "swc_next_ast_macros" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d59a4fdc755bf7eb88281ea3e79984875f30284d4309629ae3c959e78ff9d6d" +checksum = "c6c387714f59c7990b1ff7862bb4e3a221936b91ba036b28e74f6254c646aef6" dependencies = [ "proc-macro2", "quote", @@ -990,9 +990,9 @@ dependencies = [ [[package]] name = "swc_next_ecma_ast" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7001312ca0138a833f16146589e86768b819cac7ebfc73d49644da8a023f4e99" +checksum = "54b7ac90abb85f3c9a69950527835ffb02fb4cdfda53e63ee0c70be9937b4e21" dependencies = [ "cow-utils", "num-bigint 0.5.1", @@ -1003,9 +1003,9 @@ dependencies = [ [[package]] name = "swc_next_ecma_parser" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07c12ee569197b12d889cd17ea49f65610ed53c7762dc84ef66756fb03ea2e00" +checksum = "7574c4a7c58a685f75bdd4b250260ebb2e436812a6f0eb6d5212a9f35e059830" dependencies = [ "bitflags", "indexmap", @@ -1019,15 +1019,15 @@ dependencies = [ [[package]] name = "swc_next_simd" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d949da076ab686ff1e3e8a2e56761ef662fdf8b759855f718381b8a334720d1c" +checksum = "d08016b2aee6f27884457d17018468ec473df17332c34d08478db71c977e8492" [[package]] name = "swc_next_type_strip" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "539e1d1140ae4d1d75813bfc4ccd3ba078fa474efb4c0062e427a1917b0eadb9" +checksum = "b30b5a2f8a8f2ebf0fd3e7e5e571a7eb7f2b47dd0c935fb7ea9a730f164b1c0c" dependencies = [ "swc_next_allocator", "swc_next_ecma_ast", diff --git a/crates/amaro/Cargo.toml b/crates/amaro/Cargo.toml index ef54ef57a..d686ff1f6 100644 --- a/crates/amaro/Cargo.toml +++ b/crates/amaro/Cargo.toml @@ -36,10 +36,10 @@ swc_ecma_visit = { version = "=29.0.0" } swc_error_reporters = { version = "=28.0.0" } # Pin SWC Next's experimental Rust API to a tested crates.io release. -swc_next_allocator = { version = "=0.4.1" } -swc_next_ecma_ast = { version = "=0.4.1" } -swc_next_ecma_parser = { version = "=0.4.1" } -swc_next_type_strip = { version = "=0.4.1" } +swc_next_allocator = { version = "=0.5.0" } +swc_next_ecma_ast = { version = "=0.5.0" } +swc_next_ecma_parser = { version = "=0.5.0" } +swc_next_type_strip = { version = "=0.5.0" } [profile.release] lto = "fat" From fd7f0eef841e97e4ee1659d19c977164b148a9b2 Mon Sep 17 00:00:00 2001 From: DongYun Kang Date: Mon, 2 Nov 2026 19:46:36 +0900 Subject: [PATCH 5/9] perf: skip the deprecated `module` parse when no declaration exists `deprecatedTsModuleAsError` is enabled by default, and the check parsed the whole input a second time when the text contained "module". Most real files contain that word in `module.exports`, `node:module`, or comments, so most inputs were parsed twice. Run the second parse only when a `module` word is followed by a possible declaration name and that word is erased in the stripped output. `strip_types` erases every `module` declaration as a whole statement, so a keyword that survives unchanged is in a comment, a string, or an expression. Inputs with strip diagnostics keep the previous behavior. Results for 442 corpus variants, including injected declarations, are identical to the previous build. This includes error codes, messages, and locations. Median `transformSync` time in the release WASM build: - hono-types.ts (76 KB): 1.11 ms -> 0.68 ms - checker.ts (3 MB): 34.7 ms -> 21.0 ms - typescript.js (8 MB): 100.7 ms -> 62.9 ms - astro-config.ts (125 KB): 0.97 ms -> 1.05 ms (the parse is still needed because a comment in an erased interface contains "module ...") Co-Authored-By: Claude Opus 5.5 --- crates/amaro/src/strip.rs | 338 +++++++++++++++++++++++++++++++++++++- 1 file changed, 335 insertions(+), 3 deletions(-) diff --git a/crates/amaro/src/strip.rs b/crates/amaro/src/strip.rs index 52d076e0a..bba073f17 100644 --- a/crates/amaro/src/strip.rs +++ b/crates/amaro/src/strip.rs @@ -34,7 +34,13 @@ pub fn operate( let output = strip_types(&input, strip_options); let mut diagnostics = output.diagnostics; - validate(&input, &options, strip_options, &mut diagnostics); + validate( + &input, + &output.code, + &options, + strip_options, + &mut diagnostics, + ); report(cm, handler, &input, &options, diagnostics)?; Ok(TransformOutput { @@ -44,14 +50,15 @@ pub fn operate( fn validate( input: &str, + stripped: &str, options: &Options, strip_options: StripOptions, diagnostics: &mut Vec>, ) { // Keep Amaro's restriction on the deprecated identifier-named `module` // declaration. String-named ambient modules remain supported. - let reject_deprecated_modules = - options.deprecated_ts_module_as_error.unwrap_or_default() && input.contains("module"); + let reject_deprecated_modules = options.deprecated_ts_module_as_error.unwrap_or_default() + && needs_deprecated_module_check(input, stripped, !diagnostics.is_empty()); if reject_deprecated_modules || options.module == Some(false) { let allocator = Allocator::default(); let parsed = Parser::init( @@ -131,6 +138,168 @@ fn report( Ok(()) } +/// Returns `false` only when `input` cannot contain an identifier-named +/// `module` declaration, so the second parse in [`validate`] can be skipped. +/// +/// `stripped` is the successful `strip_types` output for `input`. It is used +/// only when `strip_types` reported no diagnostics. `strip_types` erases +/// every `module` declaration as a whole statement, so a declaration keyword +/// cannot survive unchanged in that output. A candidate that is still +/// `module` in the output is in a comment, a string, or an expression. +fn needs_deprecated_module_check(input: &str, stripped: &str, has_diagnostics: bool) -> bool { + let mut candidates = deprecated_module_candidates(input).peekable(); + if candidates.peek().is_none() { + return false; + } + if has_diagnostics { + return true; + } + + let mut offsets = Utf16OffsetMapper::new(input, stripped); + candidates.any(|start| match offsets.map(start) { + Some(start) => !stripped[start..].starts_with("module"), + None => true, + }) +} + +/// Returns the byte offsets of every `module` word that may start an +/// identifier-named `module` declaration. +/// +/// A declaration needs a name after `module`. Common non-declaration uses +/// such as `module.exports`, `"node:module"`, and `declare module "pkg"` are +/// followed by punctuation or a quote and are skipped. Prose in comments or +/// strings and non-ASCII neighbors are conservatively kept as candidates. +fn deprecated_module_candidates(input: &str) -> impl Iterator + '_ { + const KEYWORD: &str = "module"; + let bytes = input.as_bytes(); + let is_ident_part = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$'; + + input.match_indices(KEYWORD).filter_map(move |(start, _)| { + if start > 0 && is_ident_part(bytes[start - 1]) { + return None; + } + + let mut i = start + KEYWORD.len(); + if bytes.get(i).is_some_and(|&b| is_ident_part(b)) { + return None; + } + + // Skip whitespace and comments between `module` and the next token. + loop { + match bytes.get(i..) { + Some([b' ' | b'\t' | b'\n' | b'\r' | b'\x0b' | b'\x0c', ..]) => i += 1, + Some([b'/', b'*', ..]) => { + i = input[i + 2..] + .find("*/") + .map_or(bytes.len(), |end| i + 2 + end + 2); + } + Some([b'/', b'/', ..]) => { + i = input[i + 2..] + .find(['\n', '\r']) + .map_or(bytes.len(), |end| i + 2 + end); + } + _ => break, + } + } + + let next = *bytes.get(i)?; + (next.is_ascii_alphabetic() || matches!(next, b'_' | b'$' | b'\\') || next >= 0x80) + .then_some(start) + }) +} + +/// Maps ascending byte offsets in the input to byte offsets in the stripped +/// output. `strip_types` preserves UTF-16 offsets, but byte offsets move +/// when erased text contains non-ASCII characters. +struct Utf16OffsetMapper<'a> { + input: &'a str, + output: &'a str, + input_pos: usize, + output_pos: usize, +} + +impl<'a> Utf16OffsetMapper<'a> { + fn new(input: &'a str, output: &'a str) -> Self { + Self { + input, + output, + input_pos: 0, + output_pos: 0, + } + } + + /// Returns `None` if the offset has no matching character boundary in + /// the output. + fn map(&mut self, input_offset: usize) -> Option { + let segment = self.input.as_bytes().get(self.input_pos..input_offset)?; + let units = utf16_len(segment); + self.input_pos = input_offset; + + let output = self.output.as_bytes(); + let mut pos = self.output_pos; + let mut seen = 0; + // Skip whole chunks first; the byte walk below finds the boundary. + for chunk in output[pos..].chunks(64) { + let chunk_units = utf16_len(chunk); + if seen + chunk_units >= units { + break; + } + seen += chunk_units; + pos += chunk.len(); + } + while let Some(&b) = output.get(pos) { + if is_utf8_lead(b) { + if seen == units { + break; + } + seen += utf16_units(b); + if seen > units { + return None; + } + } + pos += 1; + } + if seen != units { + return None; + } + + self.output_pos = pos; + Some(pos) + } +} + +/// Returns the UTF-16 length of UTF-8 `bytes`, counting a character in the +/// chunk where its first byte is. +fn utf16_len(bytes: &[u8]) -> usize { + const HIGH_BITS: u64 = 0x8080_8080_8080_8080; + let (words, rest) = bytes.as_chunks::<8>(); + let mut units = 0; + for word in words { + let w = u64::from_le_bytes(*word); + if w & HIGH_BITS == 0 { + units += 8; + continue; + } + // Continuation bytes are `10xxxxxx`; four-byte leads are `11110xxx` + // and need a surrogate pair. + let continuation = w & !(w << 1) & HIGH_BITS; + let four_byte_lead = w & (w << 1) & (w << 2) & (w << 3) & HIGH_BITS; + units += 8 - continuation.count_ones() as usize + four_byte_lead.count_ones() as usize; + } + units + rest.iter().map(|&b| utf16_units(b)).sum::() +} + +/// Returns whether `b` starts a UTF-8 encoded character. +fn is_utf8_lead(b: u8) -> bool { + (b as i8) >= -0x40 +} + +/// Returns the UTF-16 length of the character that starts with `b`, or 0 for +/// a continuation byte. +fn utf16_units(b: u8) -> usize { + usize::from(is_utf8_lead(b)) + usize::from(b >= 0xf0) +} + struct DeprecatedModules<'ast> { ast: &'ast Ast<'ast>, spans: Vec, @@ -227,4 +396,167 @@ mod tests { ) .is_ok()); } + + #[test] + fn detects_possible_deprecated_modules() { + for source in [ + "module Foo {}", + "declare module Foo {}", + "export module Foo {}", + "namespace X { module Y {} }", + "module A.B {}", + "module /* comment */ Foo {}", + "module // comment\nFoo {}", + "module\nFoo {}", + "module\u{a0}Foo {}", + "module Γ‘ {}", + "module \\u0046oo {}", + "module _Foo {}", + "module $Foo {}", + "module.exports = 1; module Foo {}", + // Prose can trigger the parse; the parser makes the final decision. + "// this module handles requests", + ] { + assert!( + deprecated_module_candidates(source).next().is_some(), + "{source:?}" + ); + } + + for source in [ + "", + "const value = 1;", + "module.exports = value;", + "module", + "module;", + "module/* unterminated", + "module // comment", + "require('node:module');", + "import { createRequire } from \"node:module\";", + "declare module 'pkg' {}", + "declare module \"pkg\" {}", + "declare module `pkg` {}", + "modules Foo {}", + "submodule Foo {}", + "module_name Foo", + "fn(module, other)", + "module: Foo", + "import.meta.module = 1", + ] { + assert!( + deprecated_module_candidates(source).next().is_none(), + "{source:?}" + ); + } + } + + #[test] + fn skips_the_module_check_for_surviving_candidates() { + let needs_check = |source: &str| { + let output = strip_types( + source, + StripOptions { + source_type: SourceType::Unambiguous, + lang: Lang::Ts, + }, + ); + needs_deprecated_module_check(source, &output.code, !output.diagnostics.is_empty()) + }; + + for source in [ + "// this module handles requests\nconst value: number = 1;", + "/* the module Foo */ const value: number = 1;", + "const message = 'module Foo';", + "const value: 'πŸ˜€' = 1; // module handles this", + "interface A { x: 'πŸ˜€' }\nconst s = `module ${1} x`; // module y", + "module.exports = value;", + ] { + assert!(!needs_check(source), "{source:?}"); + } + + for source in [ + "module Foo { export type A = 1 }", + "const value: 'πŸ˜€' = 1;\nmodule Foo { export type A = 1 }", + "// module text\ninterface A { x: 'πŸ˜€πŸ˜€' }\nmodule Foo { export type A = 1 }", + // A comment inside an erased statement is erased too. + "interface A {\n // module handles this\n x: number\n}", + // Diagnostics keep the previous behavior. + "enum E { A } // module handles this", + ] { + assert!(needs_check(source), "{source:?}"); + } + } + + #[test] + fn maps_offsets_across_erased_non_ascii_text() { + let input = "aπŸ˜€Γ©b€c\nmodule x"; + let output = "a b c\nmodule x"; + let mut mapper = Utf16OffsetMapper::new(input, output); + for (from, to) in [ + (0, 0), + (1, 1), + (5, 3), + (7, 4), + (8, 5), + (11, 6), + (12, 7), + (13, 8), + ] { + assert_eq!(mapper.map(from), Some(to), "{from}"); + } + assert_eq!(mapper.map(input.len()), Some(output.len())); + + for text in ["", "abc", "aπŸ˜€Γ©b€c\n", &"xπŸ˜€Γ©β‚¬".repeat(20)] { + assert_eq!( + utf16_len(text.as_bytes()), + text.encode_utf16().count(), + "{text:?}" + ); + } + + // Offsets inside a surrogate pair have no output boundary. + assert_eq!(Utf16OffsetMapper::new("ab", "πŸ˜€").map(1), None); + } + + #[test] + fn keeps_deprecated_module_errors_for_edge_cases() { + let options = || Options { + deprecated_ts_module_as_error: Some(true), + ..Default::default() + }; + let code = |source: &str| { + strip(source, options()) + .err() + .map(|errors| errors[0].code.clone().unwrap()) + }; + + for source in [ + "module Foo { export type A = 1 }", + "module /* c */ Foo { export type A = 1 }", + "module A.B { export type A = 1 }", + "declare module Foo { export type A = 1 }", + "export module Foo { export type A = 1 }", + "namespace X { module Y { export type A = 1 } }", + "module Γ‘ { export type A = 1 }", + "module \\u0046oo { export type A = 1 }", + "module\u{a0}Foo { export type A = 1 }", + "const value: 'πŸ˜€' = 1;\nmodule Foo { export type A = 1 }", + "const value: 'πŸ˜€' = 1; // module x\nmodule Foo { export type A = 1 }", + "module Foo { export const value = 1 }", + ] { + assert_eq!( + code(source).as_deref(), + Some("UnsupportedSyntax"), + "{source:?}" + ); + } + + for source in [ + "declare module \"foo\" { export type A = 1 }", + "module.exports = 1; const m = require(\"node:module\");", + "// this module handles requests\nconst value: number = 1;", + ] { + assert_eq!(code(source), None, "{source:?}"); + } + } } From d67bd923582222360be46cdf4ad3f26180420623 Mon Sep 17 00:00:00 2001 From: DongYun Kang Date: Tue, 3 Nov 2026 02:34:40 +0900 Subject: [PATCH 6/9] perf: scan only erased text for deprecated `module` declarations `strip_types` erases every `module` declaration, so compare the input with the stripped output and check only `module` words in erased text. This replaces the full-text `match_indices` search and the UTF-16 offset mapping. Also skip a `module` word that follows a non-keyword word on the same line, because it cannot start a statement. This avoids the second parse for prose such as `its module dependency` in comments inside erased interfaces. Co-Authored-By: Claude Opus 5.5 --- crates/amaro/src/strip.rs | 387 ++++++++++++++++++++++---------------- 1 file changed, 225 insertions(+), 162 deletions(-) diff --git a/crates/amaro/src/strip.rs b/crates/amaro/src/strip.rs index bba073f17..58df6772b 100644 --- a/crates/amaro/src/strip.rs +++ b/crates/amaro/src/strip.rs @@ -143,161 +143,188 @@ fn report( /// /// `stripped` is the successful `strip_types` output for `input`. It is used /// only when `strip_types` reported no diagnostics. `strip_types` erases -/// every `module` declaration as a whole statement, so a declaration keyword -/// cannot survive unchanged in that output. A candidate that is still -/// `module` in the output is in a comment, a string, or an expression. +/// every `module` declaration as a whole statement, so only a `module` word +/// that was erased can be a declaration keyword. fn needs_deprecated_module_check(input: &str, stripped: &str, has_diagnostics: bool) -> bool { - let mut candidates = deprecated_module_candidates(input).peekable(); - if candidates.peek().is_none() { - return false; - } + let bytes = input.as_bytes(); + let is_candidate = |start| is_deprecated_module_candidate(bytes, start); if has_diagnostics { - return true; + return input + .match_indices("module") + .any(|(start, _)| is_candidate(start)); } - - let mut offsets = Utf16OffsetMapper::new(input, stripped); - candidates.any(|start| match offsets.map(start) { - Some(start) => !stripped[start..].starts_with("module"), - None => true, - }) + find_erased_module_word(bytes, stripped.as_bytes(), is_candidate) } -/// Returns the byte offsets of every `module` word that may start an -/// identifier-named `module` declaration. +/// Calls `is_candidate` with the byte offset of each `module` word in `input` +/// that `output` erases or replaces, until it returns `true`. /// -/// A declaration needs a name after `module`. Common non-declaration uses -/// such as `module.exports`, `"node:module"`, and `declare module "pkg"` are -/// followed by punctuation or a quote and are skipped. Prose in comments or -/// strings and non-ASCII neighbors are conservatively kept as candidates. -fn deprecated_module_candidates(input: &str) -> impl Iterator + '_ { - const KEYWORD: &str = "module"; - let bytes = input.as_bytes(); - let is_ident_part = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$'; - - input.match_indices(KEYWORD).filter_map(move |(start, _)| { - if start > 0 && is_ident_part(bytes[start - 1]) { - return None; - } - - let mut i = start + KEYWORD.len(); - if bytes.get(i).is_some_and(|&b| is_ident_part(b)) { - return None; - } - - // Skip whitespace and comments between `module` and the next token. - loop { - match bytes.get(i..) { - Some([b' ' | b'\t' | b'\n' | b'\r' | b'\x0b' | b'\x0c', ..]) => i += 1, - Some([b'/', b'*', ..]) => { - i = input[i + 2..] - .find("*/") - .map_or(bytes.len(), |end| i + 2 + end + 2); +/// `strip_types` keeps every UTF-16 offset. An ASCII character becomes one +/// output byte. A non-ASCII character is either kept or replaced by one ASCII +/// byte per UTF-16 unit. Identical words are skipped eight bytes at a time. +/// Returns `true` if `is_candidate` returns `true` or if the texts do not +/// correspond as expected. +fn find_erased_module_word( + input: &[u8], + output: &[u8], + mut is_candidate: impl FnMut(usize) -> bool, +) -> bool { + const M: u64 = 0x6d6d_6d6d_6d6d_6d6d; + + let (mut i, mut j) = (0, 0); + loop { + if i + 8 <= input.len() && j + 8 <= output.len() { + let (a, b) = (word(input, i), word(output, j)); + if a == b { + i += 8; + j += 8; + while i + 32 <= input.len() && j + 32 <= output.len() { + let diff = (word(input, i) ^ word(output, j)) + | (word(input, i + 8) ^ word(output, j + 8)) + | (word(input, i + 16) ^ word(output, j + 16)) + | (word(input, i + 24) ^ word(output, j + 24)); + if diff != 0 { + break; + } + i += 32; + j += 32; } - Some([b'/', b'/', ..]) => { - i = input[i + 2..] - .find(['\n', '\r']) - .map_or(bytes.len(), |end| i + 2 + end); + continue; + } + if (a | b) & HIGH_BITS == 0 { + // ASCII bytes correspond one to one. Check each changed `m`. + let mut m = zero_bytes(a ^ M) & !zero_bytes(a ^ b); + while m != 0 { + let start = i + m.trailing_zeros() as usize / 8; + m &= m - 1; + if input[start..].starts_with(b"module") && is_candidate(start) { + return true; + } } - _ => break, + i += 8; + j += 8; + continue; } } - let next = *bytes.get(i)?; - (next.is_ascii_alphabetic() || matches!(next, b'_' | b'$' | b'\\') || next >= 0x80) - .then_some(start) - }) + let Some(&c) = input.get(i) else { + return j != output.len(); + }; + let Some(&d) = output.get(j) else { + return true; + }; + if c < 0x80 { + if c == b'm' && d != b'm' && input[i..].starts_with(b"module") && is_candidate(i) { + return true; + } + i += 1; + j += 1; + } else { + let len = match c { + 0xf0.. => 4, + 0xe0.. => 3, + _ => 2, + }; + let end = (i + len).min(input.len()); + j += if output[j..].starts_with(&input[i..end]) { + end - i + } else { + 1 + usize::from(c >= 0xf0) + }; + i = end; + } + } } -/// Maps ascending byte offsets in the input to byte offsets in the stripped -/// output. `strip_types` preserves UTF-16 offsets, but byte offsets move -/// when erased text contains non-ASCII characters. -struct Utf16OffsetMapper<'a> { - input: &'a str, - output: &'a str, - input_pos: usize, - output_pos: usize, +const HIGH_BITS: u64 = 0x8080_8080_8080_8080; + +/// Sets the high bit of each zero byte in `x`. +#[inline(always)] +fn zero_bytes(x: u64) -> u64 { + const LOW_BITS: u64 = !HIGH_BITS; + // Only zero bytes keep the high bit clear after this addition. + !(((x & LOW_BITS) + LOW_BITS) | x) & HIGH_BITS } -impl<'a> Utf16OffsetMapper<'a> { - fn new(input: &'a str, output: &'a str) -> Self { - Self { - input, - output, - input_pos: 0, - output_pos: 0, - } +#[inline(always)] +fn word(bytes: &[u8], at: usize) -> u64 { + u64::from_le_bytes(bytes[at..at + 8].try_into().unwrap()) +} + +/// Returns whether the `module` word at `start` may start an +/// identifier-named `module` declaration. +/// +/// A declaration needs a name after `module`. Common non-declaration uses +/// such as `module.exports`, `"node:module"`, and `declare module "pkg"` are +/// followed by punctuation or a quote and are skipped. A declaration also +/// starts a statement, so prose such as `its module graph` is skipped. Other +/// prose and non-ASCII neighbors are conservatively kept as candidates. +fn is_deprecated_module_candidate(bytes: &[u8], start: usize) -> bool { + const KEYWORD: &[u8] = b"module"; + debug_assert!(bytes[start..].starts_with(KEYWORD)); + + if start > 0 && is_ident_part(bytes[start - 1]) { + return false; + } + let mut i = start + KEYWORD.len(); + if bytes.get(i).is_some_and(|&b| is_ident_part(b)) { + return false; + } + if !may_start_statement(bytes, start) { + return false; } - /// Returns `None` if the offset has no matching character boundary in - /// the output. - fn map(&mut self, input_offset: usize) -> Option { - let segment = self.input.as_bytes().get(self.input_pos..input_offset)?; - let units = utf16_len(segment); - self.input_pos = input_offset; - - let output = self.output.as_bytes(); - let mut pos = self.output_pos; - let mut seen = 0; - // Skip whole chunks first; the byte walk below finds the boundary. - for chunk in output[pos..].chunks(64) { - let chunk_units = utf16_len(chunk); - if seen + chunk_units >= units { - break; + // Skip whitespace and comments between `module` and the next token. + loop { + match bytes.get(i..) { + Some([b' ' | b'\t' | b'\n' | b'\r' | b'\x0b' | b'\x0c', ..]) => i += 1, + Some([b'/', b'*', rest @ ..]) => { + i = rest + .windows(2) + .position(|w| w == b"*/") + .map_or(bytes.len(), |end| i + 2 + end + 2); } - seen += chunk_units; - pos += chunk.len(); - } - while let Some(&b) = output.get(pos) { - if is_utf8_lead(b) { - if seen == units { - break; - } - seen += utf16_units(b); - if seen > units { - return None; - } + Some([b'/', b'/', rest @ ..]) => { + i = rest + .iter() + .position(|&b| matches!(b, b'\n' | b'\r')) + .map_or(bytes.len(), |end| i + 2 + end); } - pos += 1; - } - if seen != units { - return None; + _ => break, } - - self.output_pos = pos; - Some(pos) } -} -/// Returns the UTF-16 length of UTF-8 `bytes`, counting a character in the -/// chunk where its first byte is. -fn utf16_len(bytes: &[u8]) -> usize { - const HIGH_BITS: u64 = 0x8080_8080_8080_8080; - let (words, rest) = bytes.as_chunks::<8>(); - let mut units = 0; - for word in words { - let w = u64::from_le_bytes(*word); - if w & HIGH_BITS == 0 { - units += 8; - continue; - } - // Continuation bytes are `10xxxxxx`; four-byte leads are `11110xxx` - // and need a surrogate pair. - let continuation = w & !(w << 1) & HIGH_BITS; - let four_byte_lead = w & (w << 1) & (w << 2) & (w << 3) & HIGH_BITS; - units += 8 - continuation.count_ones() as usize + four_byte_lead.count_ones() as usize; - } - units + rest.iter().map(|&b| utf16_units(b)).sum::() + bytes.get(i).is_some_and(|&next| { + next.is_ascii_alphabetic() || matches!(next, b'_' | b'$' | b'\\') || next >= 0x80 + }) } -/// Returns whether `b` starts a UTF-8 encoded character. -fn is_utf8_lead(b: u8) -> bool { - (b as i8) >= -0x40 +fn is_ident_part(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' || b == b'$' } -/// Returns the UTF-16 length of the character that starts with `b`, or 0 for -/// a continuation byte. -fn utf16_units(b: u8) -> usize { - usize::from(is_utf8_lead(b)) + usize::from(b >= 0xf0) +/// Returns whether a `module` word at `start` can begin a statement. +/// +/// A statement cannot directly follow a word on the same line, except a +/// keyword that introduces a statement or declaration. Any other preceding +/// character, a line break, or a word that may contain an escape or a +/// non-ASCII character keeps the candidate. +fn may_start_statement(bytes: &[u8], start: usize) -> bool { + let mut end = start; + while end > 0 && matches!(bytes[end - 1], b' ' | b'\t') { + end -= 1; + } + let mut word_start = end; + while word_start > 0 && is_ident_part(bytes[word_start - 1]) { + word_start -= 1; + } + if word_start == end || (word_start > 0 && matches!(bytes[word_start - 1], b'\\' | 0x80..)) { + return true; + } + matches!( + &bytes[word_start..end], + b"export" | b"declare" | b"default" | b"else" | b"do" + ) } struct DeprecatedModules<'ast> { @@ -397,6 +424,12 @@ mod tests { .is_ok()); } + fn has_candidate(source: &str) -> bool { + source + .match_indices("module") + .any(|(start, _)| is_deprecated_module_candidate(source.as_bytes(), start)) + } + #[test] fn detects_possible_deprecated_modules() { for source in [ @@ -414,13 +447,18 @@ mod tests { "module _Foo {}", "module $Foo {}", "module.exports = 1; module Foo {}", + "const a = 1\nmodule Foo {}", + "export declare module Foo {}", + "if (a) {} else module Foo {}", + "do module Foo {} while (a)", + "/* c */ module Foo {}", + "naΓ―ve module Foo", + "\\u0061 module Foo", // Prose can trigger the parse; the parser makes the final decision. - "// this module handles requests", + "// module handles requests", + " * module graph", ] { - assert!( - deprecated_module_candidates(source).next().is_some(), - "{source:?}" - ); + assert!(has_candidate(source), "{source:?}"); } for source in [ @@ -442,11 +480,13 @@ mod tests { "fn(module, other)", "module: Foo", "import.meta.module = 1", + "// this module handles requests", + "/** its module dependency graph */", + "namespace module {}", + "const a = 1; type module = string", + "x\tmodule Foo", ] { - assert!( - deprecated_module_candidates(source).next().is_none(), - "{source:?}" - ); + assert!(!has_candidate(source), "{source:?}"); } } @@ -470,6 +510,9 @@ mod tests { "const value: 'πŸ˜€' = 1; // module handles this", "interface A { x: 'πŸ˜€' }\nconst s = `module ${1} x`; // module y", "module.exports = value;", + // A word before `module` rules out a declaration, even when an + // erased statement erases the comment. + "interface A {\n /** its module dependency */\n x: number\n}", ] { assert!(!needs_check(source), "{source:?}"); } @@ -488,34 +531,51 @@ mod tests { } #[test] - fn maps_offsets_across_erased_non_ascii_text() { - let input = "aπŸ˜€Γ©b€c\nmodule x"; - let output = "a b c\nmodule x"; - let mut mapper = Utf16OffsetMapper::new(input, output); - for (from, to) in [ - (0, 0), - (1, 1), - (5, 3), - (7, 4), - (8, 5), - (11, 6), - (12, 7), - (13, 8), + fn finds_erased_module_words() { + let filler = "const pad = 1;".repeat(5); + for source in [ + String::new(), + "module Foo { export type A = 1 }".into(), + "const value: 'πŸ˜€Γ©β‚¬' = 1;\nmodule Foo { export type A = 1 }".into(), + "interface A { x: 'πŸ˜€'; /* module y */ }\nconst s = 'module'; // module z".into(), + format!("{filler}type T = 'Γ©';{filler}\nmodule A {{}}{filler}// module x\n"), + format!("{filler}let v: '𝕏' = 1; module.exports = v;{filler}declare module B {{}}"), + format!("{filler}interface I {{ /** its module Γ© graph */ x: 1 }}{filler}"), + "let mmodule = module;\n".repeat(9), ] { - assert_eq!(mapper.map(from), Some(to), "{from}"); - } - assert_eq!(mapper.map(input.len()), Some(output.len())); - - for text in ["", "abc", "aπŸ˜€Γ©b€c\n", &"xπŸ˜€Γ©β‚¬".repeat(20)] { - assert_eq!( - utf16_len(text.as_bytes()), - text.encode_utf16().count(), - "{text:?}" + let output = strip_types( + &source, + StripOptions { + source_type: SourceType::Unambiguous, + lang: Lang::Ts, + }, ); + assert!(output.diagnostics.is_empty(), "{source:?}"); + let output_units: Vec = output.code.encode_utf16().collect(); + let module_units: Vec = "module".encode_utf16().collect(); + let expected: Vec = source + .match_indices("module") + .map(|(start, _)| start) + .filter(|&start| { + let at = source[..start].encode_utf16().count(); + !output_units[at..].starts_with(&module_units) + }) + .collect(); + + let mut found = Vec::new(); + let stopped = + find_erased_module_word(source.as_bytes(), output.code.as_bytes(), |start| { + found.push(start); + false + }); + assert!(!stopped, "{source:?}"); + assert_eq!(found, expected, "{source:?}"); } - // Offsets inside a surrogate pair have no output boundary. - assert_eq!(Utf16OffsetMapper::new("ab", "πŸ˜€").map(1), None); + // Texts that do not correspond keep the check. + assert!(find_erased_module_word(b"ab", "πŸ˜€".as_bytes(), |_| false)); + assert!(find_erased_module_word(b"abc", b"ab", |_| false)); + assert!(find_erased_module_word(b"ab", b"abc", |_| false)); } #[test] @@ -543,6 +603,8 @@ mod tests { "const value: 'πŸ˜€' = 1;\nmodule Foo { export type A = 1 }", "const value: 'πŸ˜€' = 1; // module x\nmodule Foo { export type A = 1 }", "module Foo { export const value = 1 }", + "const a = 1\nmodule Foo { export type A = 1 }", + "export declare module Foo { export type A = 1 }", ] { assert_eq!( code(source).as_deref(), @@ -555,6 +617,7 @@ mod tests { "declare module \"foo\" { export type A = 1 }", "module.exports = 1; const m = require(\"node:module\");", "// this module handles requests\nconst value: number = 1;", + "namespace module { export type A = 1 }", ] { assert_eq!(code(source), None, "{source:?}"); } From a153902f60bb5b2a1ddc0f756f6805f0552a3bac Mon Sep 17 00:00:00 2001 From: DongYun Kang Date: Wed, 4 Nov 2026 11:10:39 +0900 Subject: [PATCH 7/9] chore: allow the foldhash Zlib license --- deny.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/deny.toml b/deny.toml index 5cf3de0ea..65fdd3fdb 100644 --- a/deny.toml +++ b/deny.toml @@ -23,7 +23,9 @@ allow = [ "Unicode-3.0" ] confidence-threshold = 0.8 -exceptions = [] +exceptions = [ + { name = "foldhash", allow = ["Zlib"] } +] [licenses.private] ignore = false From 558cbb2901048a2067945c76a79a2662fce41957 Mon Sep 17 00:00:00 2001 From: DongYun Kang Date: Wed, 4 Nov 2026 11:34:15 +0900 Subject: [PATCH 8/9] fix: handle a leading BOM and report errors in source order `SourceMap::new_source_file` removes a leading BOM, but SWC Next spans include it. With a BOM, every diagnostic was shifted by three bytes, and a shifted span inside a multi-byte character made snippet rendering panic, which traps in WebAssembly. A BOM before a hashbang also caused an `InvalidSyntax` error. Remove the BOM before stripping, as `swc_ts_fast_strip` did, and clamp diagnostic spans to character boundaries. Sort all diagnostics so that the first reported error is the first one in the source, also for the errors that `validate` adds. Keep an explicit error code with each diagnostic instead of matching messages, and remove the unused `Unsupported transform option` match. Co-Authored-By: Claude Opus 5.5 --- crates/amaro/src/strip.rs | 204 +++++++++++++++++++++++++++++--------- 1 file changed, 156 insertions(+), 48 deletions(-) diff --git a/crates/amaro/src/strip.rs b/crates/amaro/src/strip.rs index 58df6772b..075e7df6c 100644 --- a/crates/amaro/src/strip.rs +++ b/crates/amaro/src/strip.rs @@ -11,14 +11,50 @@ use swc_next_ecma_ast::{ Ast, GetSpan, TsModuleDeclaration, TsModuleDeclarationKind, Visit, VisitWith, }; use swc_next_ecma_parser::{NoTokenParserConfig, Parser}; -use swc_next_type_strip::{strip_types, Lang, Options as StripOptions, SourceType}; +use swc_next_type_strip::{strip_types, Diagnostic, Lang, Options as StripOptions, SourceType}; + +const BOM: char = '\u{feff}'; + +/// Amaro error code of a diagnostic. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ErrorCode { + InvalidSyntax, + UnsupportedSyntax, +} + +impl ErrorCode { + fn as_str(self) -> &'static str { + match self { + Self::InvalidSyntax => "InvalidSyntax", + Self::UnsupportedSyntax => "UnsupportedSyntax", + } + } + + /// Classifies a `strip_types` diagnostic. Only the `erasableSyntaxOnly` + /// diagnostic reports valid TypeScript that needs code generation. + fn of_strip_diagnostic(diagnostic: &Diagnostic<'_>) -> Self { + if diagnostic.message.contains("erasableSyntaxOnly") { + Self::UnsupportedSyntax + } else { + Self::InvalidSyntax + } + } +} pub fn operate( cm: &Lrc, handler: &Handler, - input: String, + mut input: String, options: Options, ) -> anyhow::Result { + // `SourceMap::new_source_file` removes a leading BOM, so diagnostic + // offsets are only valid for text without it. A BOM before a hashbang + // also makes the output invalid JavaScript. `swc_ts_fast_strip` removed + // it from the output too. + if input.starts_with(BOM) { + input.drain(..BOM.len_utf8()); + } + let strip_options = StripOptions { source_type: match options.module { Some(true) => SourceType::Module, @@ -32,7 +68,11 @@ pub fn operate( }, }; let output = strip_types(&input, strip_options); - let mut diagnostics = output.diagnostics; + let mut diagnostics: Vec<_> = output + .diagnostics + .into_iter() + .map(|diagnostic| (ErrorCode::of_strip_diagnostic(&diagnostic), diagnostic)) + .collect(); validate( &input, @@ -53,29 +93,29 @@ fn validate( stripped: &str, options: &Options, strip_options: StripOptions, - diagnostics: &mut Vec>, + diagnostics: &mut Vec<(ErrorCode, Diagnostic<'static>)>, ) { // Keep Amaro's restriction on the deprecated identifier-named `module` // declaration. String-named ambient modules remain supported. let reject_deprecated_modules = options.deprecated_ts_module_as_error.unwrap_or_default() && needs_deprecated_module_check(input, stripped, !diagnostics.is_empty()); + // SWC Next accepts import and export declarations in scripts, so check + // them here. if reject_deprecated_modules || options.module == Some(false) { let allocator = Allocator::default(); - let parsed = Parser::init( - &allocator, - &input, - strip_options.into(), - NoTokenParserConfig, - ) - .parse(); + let parsed = + Parser::init(&allocator, input, strip_options.into(), NoTokenParserConfig).parse(); if parsed.diagnostics.is_empty() { if options.module == Some(false) { for statement in parsed.ast.root_program().body(&parsed.ast).iter() { let statement = parsed.ast.get_node_in_sub_range(statement); if statement.is_module_indicator(&parsed.ast) { - diagnostics.push(swc_next_type_strip::Diagnostic::error( - "Import and export declarations are not allowed in scripts", - statement.span(&parsed.ast), + diagnostics.push(( + ErrorCode::InvalidSyntax, + Diagnostic::error( + "Import and export declarations are not allowed in scripts", + statement.span(&parsed.ast), + ), )); } } @@ -87,9 +127,12 @@ fn validate( }; parsed.ast.root_program().visit_with(&mut collector); for span in collector.spans { - diagnostics.push(swc_next_type_strip::Diagnostic::error( - "`module` keyword is not supported. Use `namespace` instead.", - span, + diagnostics.push(( + ErrorCode::UnsupportedSyntax, + Diagnostic::error( + "`module` keyword is not supported. Use `namespace` instead.", + span, + ), )); } } @@ -102,40 +145,42 @@ fn report( handler: &Handler, input: &str, options: &Options, - diagnostics: Vec>, + mut diagnostics: Vec<(ErrorCode, Diagnostic<'_>)>, ) -> anyhow::Result<()> { - if !diagnostics.is_empty() { - let filename = options - .filename - .clone() - .map_or(FileName::Anon, |name| FileName::Real(name.into())); - let file = cm.new_source_file(filename.into(), input.to_owned()); - for diagnostic in diagnostics { - let unsupported = diagnostic.message.contains("erasableSyntaxOnly") - || diagnostic.message.starts_with("`module` keyword") - || diagnostic - .message - .starts_with("Unsupported transform option"); - let span = Span::new( - BytePos(file.start_pos.0 + diagnostic.span.start), - BytePos(file.start_pos.0 + diagnostic.span.end), - ); - handler - .struct_span_err(span, diagnostic.message.as_ref()) - .code(DiagnosticId::Error( - if unsupported { - "UnsupportedSyntax" - } else { - "InvalidSyntax" - } - .into(), - )) - .emit(); - } - anyhow::bail!("TypeScript processing failed"); + if diagnostics.is_empty() { + return Ok(()); } - Ok(()) + // Callers report the first diagnostic, so keep source order across the + // `strip_types` diagnostics and the ones added by `validate`. + diagnostics.sort_by_key(|(_, diagnostic)| (diagnostic.span.start, diagnostic.span.end)); + + let filename = options + .filename + .clone() + .map_or(FileName::Anon, |name| FileName::Real(name.into())); + let file = cm.new_source_file(filename.into(), input.to_owned()); + debug_assert_eq!(file.src.len(), input.len()); + // Snippet rendering slices the source and panics, which aborts in + // WebAssembly, if a span does not fall on character boundaries. + let position = |offset: u32, round_up: bool| { + let offset = (offset as usize).min(input.len()); + let offset = if round_up { + input.ceil_char_boundary(offset) + } else { + input.floor_char_boundary(offset) + }; + BytePos(file.start_pos.0 + offset as u32) + }; + for (code, diagnostic) in diagnostics { + let lo = position(diagnostic.span.start, false); + let hi = position(diagnostic.span.end, true).max(lo); + handler + .struct_span_err(Span::new(lo, hi), diagnostic.message.as_ref()) + .code(DiagnosticId::Error(code.as_str().into())) + .emit(); + } + anyhow::bail!("TypeScript processing failed") } /// Returns `false` only when `input` cannot contain an identifier-named @@ -382,6 +427,69 @@ mod tests { assert_eq!(errors[0].code.as_deref(), Some("InvalidSyntax")); } + #[test] + fn removes_a_leading_bom() { + let result = strip("\u{feff}const value: number = 1;", Options::default()) + .unwrap_or_else(|_| panic!("strip failed")); + assert_eq!(result.code, "const value = 1;"); + + // A BOM before a hashbang is not valid JavaScript. + let result = strip( + "\u{feff}#!/usr/bin/env node\nconst value: number = 1;", + Options::default(), + ) + .unwrap_or_else(|_| panic!("strip failed")); + assert_eq!(result.code, "#!/usr/bin/env node\nconst value = 1;"); + + // Diagnostics use positions in the text after the BOM. + let errors = strip("\u{feff}enum Value { A }", Options::default()) + .err() + .unwrap(); + assert_eq!(errors[0].code.as_deref(), Some("UnsupportedSyntax")); + assert_eq!(errors[0].start_column, Some(0)); + + // An offset shifted by the BOM must not split a character. + let source = "const value = '\u{1d4b3}' }"; + let errors = strip(&format!("\u{feff}{source}"), Options::default()) + .err() + .unwrap(); + let expected = strip(source, Options::default()).err().unwrap(); + assert_eq!(errors[0].code.as_deref(), Some("InvalidSyntax")); + assert_eq!(errors[0].start_line, expected[0].start_line); + assert_eq!(errors[0].start_column, expected[0].start_column); + assert_eq!(errors[0].end_column, expected[0].end_column); + } + + #[test] + fn reports_the_first_error_in_source_order() { + let options = Options { + deprecated_ts_module_as_error: Some(true), + ..Default::default() + }; + let errors = strip( + "module Types { export type A = 1 }\nenum Value { A }", + options, + ) + .err() + .unwrap(); + assert_eq!(errors.len(), 2); + assert_eq!(errors[0].start_line, Some(1)); + assert!(errors[0].message.starts_with("`module` keyword")); + assert_eq!(errors[1].start_line, Some(2)); + + let options = Options { + module: Some(false), + ..Default::default() + }; + let errors = strip("import value from 'value';\nenum Value { A }", options) + .err() + .unwrap(); + assert_eq!(errors.len(), 2); + assert_eq!(errors[0].code.as_deref(), Some("InvalidSyntax")); + assert_eq!(errors[0].start_line, Some(1)); + assert_eq!(errors[1].code.as_deref(), Some("UnsupportedSyntax")); + } + #[test] fn forwards_module_and_filename_options() { let options = Options { From 900756d6efaf9ae3ec2d63cc9316b74764ef1a9d Mon Sep 17 00:00:00 2001 From: DongYun Kang Date: Mon, 9 Nov 2026 15:48:14 +0900 Subject: [PATCH 9/9] chore(deps): update SWC Next to 0.6.0 --- crates/amaro/Cargo.lock | 24 ++++++++++++------------ crates/amaro/Cargo.toml | 8 ++++---- crates/amaro/src/strip.rs | 14 ++++++++------ 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/crates/amaro/Cargo.lock b/crates/amaro/Cargo.lock index aee494ed4..c82a35674 100644 --- a/crates/amaro/Cargo.lock +++ b/crates/amaro/Cargo.lock @@ -966,9 +966,9 @@ dependencies = [ [[package]] name = "swc_next_allocator" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e36ff07d640899f4bcd2361666d40beb128e5fb659e746c0a73bf9bcae7a0e3" +checksum = "55f1a23ca12e39672cc7b5a88878629c4ea4a33eeab8870a722e6a83dfd688c7" dependencies = [ "allocator-api2", "bumpalo", @@ -979,9 +979,9 @@ dependencies = [ [[package]] name = "swc_next_ast_macros" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6c387714f59c7990b1ff7862bb4e3a221936b91ba036b28e74f6254c646aef6" +checksum = "141cd43e3678dcc7f70416bf4cb5124b55665f802d01a93e1ec990dbd48114b5" dependencies = [ "proc-macro2", "quote", @@ -990,9 +990,9 @@ dependencies = [ [[package]] name = "swc_next_ecma_ast" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b7ac90abb85f3c9a69950527835ffb02fb4cdfda53e63ee0c70be9937b4e21" +checksum = "2d63f86b7e9bb61f1cd4cb8be535d3b0d5f7268fdd151091e5ab4d724fcfedc5" dependencies = [ "cow-utils", "num-bigint 0.5.1", @@ -1003,9 +1003,9 @@ dependencies = [ [[package]] name = "swc_next_ecma_parser" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7574c4a7c58a685f75bdd4b250260ebb2e436812a6f0eb6d5212a9f35e059830" +checksum = "8686faddeda01c144b7369492081f9b9a7de4d79b381e7591aa6c5f47f8f685c" dependencies = [ "bitflags", "indexmap", @@ -1019,15 +1019,15 @@ dependencies = [ [[package]] name = "swc_next_simd" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d08016b2aee6f27884457d17018468ec473df17332c34d08478db71c977e8492" +checksum = "6f2761d6e3e8012601d8cb60110f81587cbeec6642c2806d441244ef65afa578" [[package]] name = "swc_next_type_strip" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b30b5a2f8a8f2ebf0fd3e7e5e571a7eb7f2b47dd0c935fb7ea9a730f164b1c0c" +checksum = "7be24208ddc7109b0a7853b4485dcc7cecbce40bc8ceb3d4c6bd04f6f26f9a19" dependencies = [ "swc_next_allocator", "swc_next_ecma_ast", diff --git a/crates/amaro/Cargo.toml b/crates/amaro/Cargo.toml index d686ff1f6..1fd2fa4a0 100644 --- a/crates/amaro/Cargo.toml +++ b/crates/amaro/Cargo.toml @@ -36,10 +36,10 @@ swc_ecma_visit = { version = "=29.0.0" } swc_error_reporters = { version = "=28.0.0" } # Pin SWC Next's experimental Rust API to a tested crates.io release. -swc_next_allocator = { version = "=0.5.0" } -swc_next_ecma_ast = { version = "=0.5.0" } -swc_next_ecma_parser = { version = "=0.5.0" } -swc_next_type_strip = { version = "=0.5.0" } +swc_next_allocator = { version = "=0.6.0" } +swc_next_ecma_ast = { version = "=0.6.0" } +swc_next_ecma_parser = { version = "=0.6.0" } +swc_next_type_strip = { version = "=0.6.0" } [profile.release] lto = "fat" diff --git a/crates/amaro/src/strip.rs b/crates/amaro/src/strip.rs index 075e7df6c..dbd30a505 100644 --- a/crates/amaro/src/strip.rs +++ b/crates/amaro/src/strip.rs @@ -106,15 +106,17 @@ fn validate( let parsed = Parser::init(&allocator, input, strip_options.into(), NoTokenParserConfig).parse(); if parsed.diagnostics.is_empty() { + // SAFETY: All handles come from this AST, which is never mutated. + let ast = unsafe { parsed.ast.ast() }; if options.module == Some(false) { - for statement in parsed.ast.root_program().body(&parsed.ast).iter() { - let statement = parsed.ast.get_node_in_sub_range(statement); - if statement.is_module_indicator(&parsed.ast) { + for statement in ast.root_program().body(ast).iter() { + let statement = ast.get_node_in_sub_range(statement); + if statement.is_module_indicator(ast) { diagnostics.push(( ErrorCode::InvalidSyntax, Diagnostic::error( "Import and export declarations are not allowed in scripts", - statement.span(&parsed.ast), + statement.span(ast), ), )); } @@ -122,10 +124,10 @@ fn validate( } if reject_deprecated_modules { let mut collector = DeprecatedModules { - ast: &parsed.ast, + ast, spans: Vec::new(), }; - parsed.ast.root_program().visit_with(&mut collector); + ast.root_program().visit_with(&mut collector); for span in collector.spans { diagnostics.push(( ErrorCode::UnsupportedSyntax,