diff --git a/README.md b/README.md index 700965256..e95254802 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # 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. 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. > 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 @@ -52,18 +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. +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 @@ -77,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 ba5ae5db9..c82a35674 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,10 @@ dependencies = [ "swc_ecma_parser", "swc_ecma_visit", "swc_error_reporters", - "swc_ts_fast_strip", + "swc_next_allocator", + "swc_next_ecma_ast", + "swc_next_ecma_parser", + "swc_next_type_strip", "tracing", "unicode-width 0.2.2", "wasm-bindgen", @@ -67,12 +61,6 @@ dependencies = [ "object", ] -[[package]] -name = "ascii" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" - [[package]] name = "ast_node" version = "7.0.0" @@ -90,22 +78,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 +93,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 +118,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 +135,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" +name = "cow-utils" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -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 +148,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" @@ -285,6 +169,12 @@ 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 +194,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 +209,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 +221,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 +384,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 +485,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 +502,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 +530,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 +545,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 +552,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]] @@ -809,12 +676,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 +700,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 +712,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 +768,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -958,17 +784,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 +827,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 +838,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 +861,7 @@ dependencies = [ "bytes-str", "either", "from_variant", - "num-bigint", + "num-bigint 0.4.8", "once_cell", "parking_lot", "rustc-hash", @@ -1072,39 +869,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,7 +883,7 @@ checksum = "2839d2cf4e611b944438c304ec59396221b3db4580acd172fcd70522bb450ee9" dependencies = [ "bitflags", "is-macro", - "num-bigint", + "num-bigint 0.4.8", "once_cell", "phf", "rustc-hash", @@ -1124,53 +894,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,7 +902,7 @@ checksum = "a032f869f6690d4eddd6e67757c8d849f2c8219d8c5f3bb05a7ec8b456867642" dependencies = [ "bitflags", "either", - "num-bigint", + "num-bigint 0.4.8", "phf", "rustc-hash", "seq-macro", @@ -1191,90 +914,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 +921,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 +965,74 @@ dependencies = [ ] [[package]] -name = "swc_sourcemap" -version = "10.0.2" +name = "swc_next_allocator" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c421e5e39e43a4b1b70c07922d7bffd5c22e8eff1340c0b15d0bfd0328822ee" +checksum = "55f1a23ca12e39672cc7b5a88878629c4ea4a33eeab8870a722e6a83dfd688c7" 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.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "141cd43e3678dcc7f70416bf4cb5124b55665f802d01a93e1ec990dbd48114b5" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "swc_next_ecma_ast" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d63f86b7e9bb61f1cd4cb8be535d3b0d5f7268fdd151091e5ab4d724fcfedc5" +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" +name = "swc_next_ecma_parser" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb19ef5bf46cb01138c4460ae0a20c210fc3eb8da6919a766a19751b1d6847c6" +checksum = "8686faddeda01c144b7369492081f9b9a7de4d79b381e7591aa6c5f47f8f685c" dependencies = [ - "anyhow", - "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", + "bitflags", + "indexmap", + "memchr", + "rustc-hash", + "swc_next_allocator", + "swc_next_ecma_ast", + "swc_next_simd", + "unicode-id-start", +] + +[[package]] +name = "swc_next_simd" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f2761d6e3e8012601d8cb60110f81587cbeec6642c2806d441244ef65afa578" + +[[package]] +name = "swc_next_type_strip" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be24208ddc7109b0a7853b4485dcc7cecbce40bc8ceb3d4c6bd04f6f26f9a19" +dependencies = [ + "swc_next_allocator", + "swc_next_ecma_ast", + "swc_next_ecma_parser", + "swc_next_simd", ] [[package]] @@ -1386,9 +1057,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 +1074,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 +1139,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 +1181,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 +1289,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 +1308,7 @@ checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -1715,7 +1349,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -1751,7 +1385,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] diff --git a/crates/amaro/Cargo.toml b/crates/amaro/Cargo.toml index 65d7f9480..1fd2fa4a0 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,20 @@ 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 crates.io release. +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/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..50c03dc53 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,13 @@ 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; /// TypeScript declarations for the WebAssembly module, emitted into `wasm.d.ts`. #[wasm_bindgen(typescript_custom_section)] @@ -147,8 +149,8 @@ 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), }) } diff --git a/crates/amaro/src/options.rs b/crates/amaro/src/options.rs new file mode 100644 index 000000000..e3a38e821 --- /dev/null +++ b/crates/amaro/src/options.rs @@ -0,0 +1,46 @@ +//! Public options for fixed-width TypeScript stripping. +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 deprecated_ts_module_as_error: Option, +} + +fn default_parser() -> TsSyntax { + TsSyntax { + decorators: true, + ..Default::default() + } +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Mode { + #[default] + StripOnly, +} + +#[derive(Debug, Serialize)] +pub struct TransformOutput { + pub code: String, +} + +#[wasm_bindgen(typescript_custom_section)] +const TYPES: &str = r#" +interface Options { + module?: boolean; + filename?: string; + mode?: "strip-only"; + deprecatedTsModuleAsError?: boolean; +} +interface TransformOutput { code: string; } +"#; diff --git a/crates/amaro/src/strip.rs b/crates/amaro/src/strip.rs new file mode 100644 index 000000000..dbd30a505 --- /dev/null +++ b/crates/amaro/src/strip.rs @@ -0,0 +1,735 @@ +//! 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, 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, + 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, + 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: Vec<_> = output + .diagnostics + .into_iter() + .map(|diagnostic| (ErrorCode::of_strip_diagnostic(&diagnostic), diagnostic)) + .collect(); + + validate( + &input, + &output.code, + &options, + strip_options, + &mut diagnostics, + ); + report(cm, handler, &input, &options, diagnostics)?; + + Ok(TransformOutput { + code: output.code.into_owned(), + }) +} + +fn validate( + input: &str, + stripped: &str, + options: &Options, + strip_options: StripOptions, + 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(); + 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 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(ast), + ), + )); + } + } + } + if reject_deprecated_modules { + let mut collector = DeprecatedModules { + ast, + spans: Vec::new(), + }; + ast.root_program().visit_with(&mut collector); + for span in collector.spans { + diagnostics.push(( + ErrorCode::UnsupportedSyntax, + Diagnostic::error( + "`module` keyword is not supported. Use `namespace` instead.", + span, + ), + )); + } + } + } + } +} + +fn report( + cm: &Lrc, + handler: &Handler, + input: &str, + options: &Options, + mut diagnostics: Vec<(ErrorCode, Diagnostic<'_>)>, +) -> anyhow::Result<()> { + if diagnostics.is_empty() { + return 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 +/// `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 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 bytes = input.as_bytes(); + let is_candidate = |start| is_deprecated_module_candidate(bytes, start); + if has_diagnostics { + return input + .match_indices("module") + .any(|(start, _)| is_candidate(start)); + } + find_erased_module_word(bytes, stripped.as_bytes(), is_candidate) +} + +/// Calls `is_candidate` with the byte offset of each `module` word in `input` +/// that `output` erases or replaces, until it returns `true`. +/// +/// `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; + } + 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; + } + } + i += 8; + j += 8; + continue; + } + } + + 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; + } + } +} + +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 +} + +#[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; + } + + // 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); + } + Some([b'/', b'/', rest @ ..]) => { + i = rest + .iter() + .position(|&b| matches!(b, b'\n' | b'\r')) + .map_or(bytes.len(), |end| i + 2 + end); + } + _ => break, + } + } + + bytes.get(i).is_some_and(|&next| { + next.is_ascii_alphabetic() || matches!(next, b'_' | b'$' | b'\\') || next >= 0x80 + }) +} + +fn is_ident_part(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' || b == b'$' +} + +/// 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> { + 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() + ); + } + + #[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 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 { + 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()); + } + + 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 [ + "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 {}", + "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. + "// module handles requests", + " * module graph", + ] { + assert!(has_candidate(source), "{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", + "// this module handles requests", + "/** its module dependency graph */", + "namespace module {}", + "const a = 1; type module = string", + "x\tmodule Foo", + ] { + assert!(!has_candidate(source), "{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;", + // 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:?}"); + } + + 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 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), + ] { + 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:?}"); + } + + // 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] + 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 }", + "const a = 1\nmodule Foo { export type A = 1 }", + "export declare module Foo { export type A = 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;", + "namespace module { export type A = 1 }", + ] { + assert_eq!(code(source), None, "{source:?}"); + } + } +} 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 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 b0257792f..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, [ @@ -23,44 +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/); - 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 and inaccurate stracktrace", 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:7:11/); // inaccurate - 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 + match( + result.stderr, + /This syntax is not allowed when `erasableSyntaxOnly` is enabled/, + ); strictEqual(result.code, 1); }); @@ -76,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", @@ -96,7 +50,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); }); @@ -112,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", @@ -136,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", @@ -160,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", @@ -183,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 0f01b345f..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": 8 -} -`; - -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\\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" -`; - -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" -`; - -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\\"}" -`; - -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" -`; - -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\\"}" -`; - -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" -`; - -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]}" -`; - -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" -`; - -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\\"}" -`; - -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" -`; - -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" -`; - -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`] = ` -"(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" -`; - -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 86fcab486..000000000 --- a/test/transform.test.js +++ /dev/null @@ -1,369 +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"); - -// 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); -});