diff --git a/.changeset/nodejs-support-wasm.md b/.changeset/nodejs-support-wasm.md new file mode 100644 index 000000000000..a4085ba96faa --- /dev/null +++ b/.changeset/nodejs-support-wasm.md @@ -0,0 +1,5 @@ +--- +swc_core: patch +--- + +feat(wasm): Add `@swc/nodejs-support-wasm` for Node.js integrations diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 1b559df611cc..792650219d30 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -154,6 +154,7 @@ jobs: - binding_core_wasm - binding_minifier_wasm - binding_typescript_wasm + - binding_nodejs_support_wasm - binding_es_ast_viewer steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 diff --git a/.github/workflows/publish-npm-package.yml b/.github/workflows/publish-npm-package.yml index 2d978891335e..70b0e246d4bc 100644 --- a/.github/workflows/publish-npm-package.yml +++ b/.github/workflows/publish-npm-package.yml @@ -759,6 +759,11 @@ jobs: npm: "@swc\\/wasm-typescript-esm" build: "./scripts/esm.sh" out: "esm" + - package: "core" + crate: "binding_nodejs_support_wasm" + npm: "@swc\\/nodejs-support-wasm" + build: "./scripts/build.sh" + out: "pkg" - package: "html" crate: "binding_html_wasm" npm: "@swc\\/html-wasm" diff --git a/Cargo.lock b/Cargo.lock index 27cdee1d6607..dff6af306772 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -518,6 +518,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "binding_nodejs_support_wasm" +version = "1.15.43" +dependencies = [ + "anyhow", + "js-sys", + "miette", + "owo-colors", + "serde", + "serde-wasm-bindgen", + "serde_json", + "swc_common", + "swc_ecma_ast", + "swc_ecma_parser", + "swc_ecma_visit", + "swc_error_reporters", + "swc_ts_fast_strip", + "tracing", + "unicode-width 0.2.2", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "binding_react_compiler_node" version = "0.2.0" diff --git a/bindings/binding_nodejs_support_wasm/.cargo/config.toml b/bindings/binding_nodejs_support_wasm/.cargo/config.toml new file mode 100644 index 000000000000..dee02885d373 --- /dev/null +++ b/bindings/binding_nodejs_support_wasm/.cargo/config.toml @@ -0,0 +1,3 @@ +[target.wasm32-unknown-unknown] +# support more stack size due to issue: https://github.com/swc-project/swc/issues/10207 +rustflags = ["-C", "link-args=-z stack-size=2097152"] diff --git a/bindings/binding_nodejs_support_wasm/Cargo.toml b/bindings/binding_nodejs_support_wasm/Cargo.toml new file mode 100644 index 000000000000..9fec8b04a157 --- /dev/null +++ b/bindings/binding_nodejs_support_wasm/Cargo.toml @@ -0,0 +1,40 @@ +[package] +authors = ["강동윤 "] +description = "Node.js support helpers compiled to WebAssembly" +edition = { workspace = true } +license = { workspace = true } +name = "binding_nodejs_support_wasm" +publish = false +repository = { workspace = true } +version = "1.15.43" + +[lib] +bench = false +crate-type = ["cdylib"] + +[features] +nightly = ["swc_ts_fast_strip/nightly"] + +[dependencies] +anyhow = { workspace = true } +js-sys = { workspace = true } +miette = { workspace = true } +owo-colors = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde-wasm-bindgen = { workspace = true } +serde_json = { workspace = true } +swc_common = { path = "../../crates/swc_common" } +swc_ecma_ast = { path = "../../crates/swc_ecma_ast" } +swc_ecma_parser = { path = "../../crates/swc_ecma_parser" } +swc_ecma_visit = { path = "../../crates/swc_ecma_visit" } +swc_error_reporters = { path = "../../crates/swc_error_reporters" } +swc_ts_fast_strip = { path = "../../crates/swc_ts_fast_strip", features = [ + "wasm-bindgen", +] } +tracing = { workspace = true, features = ["max_level_off"] } +unicode-width = { workspace = true } +wasm-bindgen = { workspace = true, features = ["enable-interning"] } +wasm-bindgen-futures = { workspace = true } + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false diff --git a/bindings/binding_nodejs_support_wasm/LICENSE b/bindings/binding_nodejs_support_wasm/LICENSE new file mode 100644 index 000000000000..8f3eaa8198dc --- /dev/null +++ b/bindings/binding_nodejs_support_wasm/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2024 SWC contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/bindings/binding_nodejs_support_wasm/README.md b/bindings/binding_nodejs_support_wasm/README.md new file mode 100644 index 000000000000..abd29b9bb046 --- /dev/null +++ b/bindings/binding_nodejs_support_wasm/README.md @@ -0,0 +1,26 @@ +# @swc/nodejs-support-wasm + +This package provides a standalone WebAssembly binding for Node.js +integrations. Its Rust crate, source, build, and test setup are independent from +`@swc/wasm-typescript`. + +It includes the TypeScript transform API from `@swc/wasm-typescript` and +additional helpers for module syntax transformation, assertion locations, and +syntax validation. + +## API + +- `transform` and `transformSync` +- `transformModuleSyntax` +- `getFirstExpression` +- `isValidSyntax` +- `isRecoverableError` + +## Contributing + +See [the main repository](https://github.com/swc-project/swc)'s contributing +guide. + +## License + +Apache 2.0 diff --git a/bindings/binding_nodejs_support_wasm/__tests__/nodejs-support.js b/bindings/binding_nodejs_support_wasm/__tests__/nodejs-support.js new file mode 100644 index 000000000000..eb42c37b8a4f --- /dev/null +++ b/bindings/binding_nodejs_support_wasm/__tests__/nodejs-support.js @@ -0,0 +1,163 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const nodejsSupport = require("../pkg"); + +function validatedImport(specifier, exports) { + let out = `__nodeREPLDynamicImport(${JSON.stringify(specifier)})`; + + if (exports.length > 0) { + out += `.then((m) => { `; + for (const exportName of exports) { + const message = `The requested module '${specifier}' does not provide an export named '${exportName}'`; + out += `if (!(${JSON.stringify( + exportName + )} in m)) throw new SyntaxError(${JSON.stringify(message)}); `; + } + out += `return m; })`; + } + + return out; +} + +describe("@swc/nodejs-support-wasm", () => { + it("exposes the TypeScript transform and Node.js helpers at the top level", () => { + expect(typeof nodejsSupport.transform).toBe("function"); + expect(typeof nodejsSupport.transformSync).toBe("function"); + expect(typeof nodejsSupport.transformModuleSyntax).toBe("function"); + expect(typeof nodejsSupport.getFirstExpression).toBe("function"); + expect(typeof nodejsSupport.isValidSyntax).toBe("function"); + expect(typeof nodejsSupport.isRecoverableError).toBe("function"); + expect(nodejsSupport.nodejs).toBeUndefined(); + expect(nodejsSupport.__nodejsTransformModuleSyntax).toBeUndefined(); + }); + + it("preserves the existing TypeScript transform behavior", async () => { + const source = "export const value: number = 1;"; + const expected = { + code: "export const value = 1;", + map: undefined, + }; + + expect(nodejsSupport.transformSync(source, {})).toEqual(expected); + await expect(nodejsSupport.transform(source, {})).resolves.toEqual( + expected + ); + + const bytes = new TextEncoder().encode(source); + expect(nodejsSupport.transformSync(bytes, {})).toEqual(expected); + await expect(nodejsSupport.transform(bytes, {})).resolves.toEqual( + expected + ); + }); + + it("preserves TypeScript transform errors", async () => { + expect(() => nodejsSupport.transformSync("enum Foo {}", {})).toThrow(); + await expect( + nodejsSupport.transform("enum Foo {}", {}) + ).rejects.toBeDefined(); + }); + + it("transforms module syntax without exposing an AST", () => { + expect( + nodejsSupport.transformModuleSyntax(`import fs from "node:fs";`) + ).toEqual({ + code: `const __nodeREPLImport0 = await ${validatedImport( + "node:fs", + ["default"] + )};`, + hadModuleSyntax: true, + }); + + expect( + nodejsSupport.transformModuleSyntax( + `import { readFile as rf } from "node:fs";\nrf();` + ) + ).toEqual({ + code: `const __nodeREPLImport0 = await ${validatedImport( + "node:fs", + ["readFile"] + )};\n__nodeREPLImport0.readFile();`, + hadModuleSyntax: true, + }); + + expect( + nodejsSupport.transformModuleSyntax(`console.log(import.meta.url);`) + ).toEqual({ + code: `console.log((() => { throw new SyntaxError("Cannot use import.meta outside a module"); })().url);`, + hadModuleSyntax: true, + }); + + expect( + nodejsSupport.transformModuleSyntax(`const importName = "import";`) + ).toEqual({ + code: `const importName = "import";`, + hadModuleSyntax: false, + }); + }); + + it("supports Uint8Array input", () => { + const input = new TextEncoder().encode(`import "node:fs";`); + + expect(nodejsSupport.transformModuleSyntax(input)).toEqual({ + code: `await __nodeREPLDynamicImport("node:fs");`, + hadModuleSyntax: true, + }); + }); + + it("extracts the first expression for assertion locations", () => { + expect(nodejsSupport.getFirstExpression("assert.ok(value)", 9)).toBe( + "assert.ok(value)" + ); + expect(nodejsSupport.getFirstExpression("assert(value)", 6)).toBe( + "assert(value)" + ); + expect( + nodejsSupport.getFirstExpression("a(); assert.ok(value); b()", 13) + ).toBe("assert.ok(value)"); + expect( + nodejsSupport.getFirstExpression( + "assert[method + suffix](value)", + 23 + ) + ).toBe("assert[method + suffix](value)"); + }); + + it("checks syntax validity and recoverability", () => { + expect(nodejsSupport.isValidSyntax("await value")).toBe(true); + expect(nodejsSupport.isValidSyntax("const x: number = 1")).toBe(true); + expect(nodejsSupport.isValidSyntax("function foo(")).toBe(false); + expect(nodejsSupport.isRecoverableError("function foo() {")).toBe(true); + expect(nodejsSupport.isRecoverableError("{ value: 1 }")).toBe(false); + expect(nodejsSupport.isRecoverableError("{}")).toBe(false); + expect(nodejsSupport.isRecoverableError("const x: number = 1")).toBe( + false + ); + expect(nodejsSupport.isRecoverableError("2e")).toBe(false); + }); + + it("generates package metadata and top-level TypeScript declarations", () => { + const packageDir = path.join(__dirname, "../pkg"); + const packageJson = JSON.parse( + fs.readFileSync(path.join(packageDir, "package.json"), "utf8") + ); + const types = fs.readFileSync( + path.join(packageDir, "wasm.d.ts"), + "utf8" + ); + + expect(packageJson.name).toBe("@swc/nodejs-support-wasm"); + expect(packageJson.description).toBe( + "SWC WebAssembly helpers for Node.js" + ); + expect(packageJson.files).not.toContain("wasm_bg.wasm"); + expect(fs.existsSync(path.join(packageDir, "wasm_bg.wasm"))).toBe( + false + ); + expect(types).toContain( + "export declare function transformModuleSyntax" + ); + expect(types).toContain("export interface ModuleSyntaxTransformOutput"); + expect(types).not.toContain("namespace nodejs"); + expect(types).not.toContain("__nodejsTransformModuleSyntax"); + }); +}); diff --git a/bindings/binding_nodejs_support_wasm/package.json b/bindings/binding_nodejs_support_wasm/package.json new file mode 100644 index 000000000000..728a4a61453c --- /dev/null +++ b/bindings/binding_nodejs_support_wasm/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "@rstest/core": "^0.7.8" + } +} diff --git a/bindings/binding_nodejs_support_wasm/rstest.config.mjs b/bindings/binding_nodejs_support_wasm/rstest.config.mjs new file mode 100644 index 000000000000..0e541bbdde66 --- /dev/null +++ b/bindings/binding_nodejs_support_wasm/rstest.config.mjs @@ -0,0 +1,4 @@ +export default { + include: ["__tests__/**/*.{js,jsx,ts,tsx}"], + globals: true, +}; diff --git a/bindings/binding_nodejs_support_wasm/scripts/build.sh b/bindings/binding_nodejs_support_wasm/scripts/build.sh new file mode 100755 index 000000000000..c5aaa25ce2e1 --- /dev/null +++ b/bindings/binding_nodejs_support_wasm/scripts/build.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -eux + +export CARGO_PROFILE_RELEASE_LTO="fat" +export CARGO_PROFILE_RELEASE_OPT_LEVEL="z" + +wasm-pack build \ + --out-name wasm \ + --release \ + --scope=swc \ + --target nodejs \ + "$@" +ls -al ./pkg + +node ./scripts/patch.mjs +cp ./README.md ./pkg/README.md diff --git a/bindings/binding_nodejs_support_wasm/scripts/patch.mjs b/bindings/binding_nodejs_support_wasm/scripts/patch.mjs new file mode 100644 index 000000000000..e9f96cdef16d --- /dev/null +++ b/bindings/binding_nodejs_support_wasm/scripts/patch.mjs @@ -0,0 +1,31 @@ +import fs from "node:fs/promises"; + +const rawWasmPath = "pkg/wasm_bg.wasm"; +const wasmJsPath = "pkg/wasm.js"; +const packageJsonPath = "pkg/package.json"; +const rawWasmFile = await fs.readFile(rawWasmPath); +const origJsFile = await fs.readFile(wasmJsPath, "utf8"); + +const base64 = rawWasmFile.toString("base64"); +const patchedJsFile = origJsFile + .replace( + `const path = require('path').join(__dirname, 'wasm_bg.wasm');`, + "" + ) + .replace(", fatal: true", "") + .replace( + `const bytes = require('fs').readFileSync(path);`, + ` +const { Buffer } = require('node:buffer'); +const bytes = Buffer.from('${base64}', 'base64');` + ); + +await fs.writeFile(wasmJsPath, patchedJsFile); +await fs.unlink(rawWasmPath); + +const pkgJsonFile = await fs.readFile(packageJsonPath, "utf8"); +const pkgJson = JSON.parse(pkgJsonFile); +pkgJson.name = "@swc/nodejs-support-wasm"; +pkgJson.description = "SWC WebAssembly helpers for Node.js"; +pkgJson.files = pkgJson.files.filter((file) => file !== "wasm_bg.wasm"); +await fs.writeFile(packageJsonPath, JSON.stringify(pkgJson, null, 2)); diff --git a/bindings/binding_nodejs_support_wasm/scripts/test.sh b/bindings/binding_nodejs_support_wasm/scripts/test.sh new file mode 100755 index 000000000000..4c3174b058e0 --- /dev/null +++ b/bindings/binding_nodejs_support_wasm/scripts/test.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -eu + +./scripts/build.sh +npx rstest "$@" + +./scripts/build.sh --features nightly +npx rstest "$@" diff --git a/bindings/binding_nodejs_support_wasm/src/error_reporter.rs b/bindings/binding_nodejs_support_wasm/src/error_reporter.rs new file mode 100644 index 000000000000..684ba3073464 --- /dev/null +++ b/bindings/binding_nodejs_support_wasm/src/error_reporter.rs @@ -0,0 +1,881 @@ +use std::fmt::{self}; + +use miette::{ + Diagnostic, GraphicalTheme, LabeledSpan, ReportHandler, SourceCode, SourceSpan, ThemeCharacters, +}; +use owo_colors::{OwoColorize, Style}; +use unicode_width::UnicodeWidthChar; + +/** +A [`ReportHandler`] that displays a given [`Report`](crate::Report) in a +quasi-graphical way, using terminal colors, unicode drawing characters, and +other such things. + +This is the default reporter bundled with `miette`. + +This printer can be customized by using [`new_themed()`](GraphicalReportHandler::new_themed) and handing it a +[`GraphicalTheme`] of your own creation (or using one of its own defaults!) + +See [`set_hook()`](crate::set_hook) for more details on customizing your global +printer. +*/ +#[derive(Debug, Clone)] +pub struct SwcReportHandler { + pub(crate) theme: GraphicalTheme, + pub(crate) context_lines: usize, + pub(crate) tab_width: usize, +} + +impl SwcReportHandler { + /// Create a new `GraphicalReportHandler` with the default + /// [`GraphicalTheme`]. This will use both unicode characters and colors. + pub fn new() -> Self { + Self { + theme: GraphicalTheme::default(), + context_lines: 1, + tab_width: 4, + } + } + + /// Set a theme for this handler. + pub fn with_theme(mut self, theme: GraphicalTheme) -> Self { + self.theme = theme; + self + } +} + +impl Default for SwcReportHandler { + fn default() -> Self { + Self::new() + } +} + +impl SwcReportHandler { + /// Render a [`Diagnostic`]. This function is mostly internal and meant to + /// be called by the toplevel [`ReportHandler`] handler, but is made public + /// to make it easier (possible) to test in isolation from global state. + pub fn render_report( + &self, + f: &mut impl fmt::Write, + diagnostic: &dyn Diagnostic, + ) -> fmt::Result { + self.render_report_inner(f, diagnostic, diagnostic.source_code()) + } + + fn render_report_inner( + &self, + f: &mut impl fmt::Write, + diagnostic: &dyn Diagnostic, + parent_src: Option<&dyn SourceCode>, + ) -> fmt::Result { + let src = diagnostic.source_code().or(parent_src); + self.render_snippets(f, diagnostic, src)?; + + Ok(()) + } + + fn render_snippets( + &self, + f: &mut impl fmt::Write, + diagnostic: &dyn Diagnostic, + opt_source: Option<&dyn SourceCode>, + ) -> fmt::Result { + let source = match opt_source { + Some(source) => source, + None => return Ok(()), + }; + let labels = match diagnostic.labels() { + Some(labels) => labels, + None => return Ok(()), + }; + + let mut labels = labels.collect::>(); + labels.sort_unstable_by_key(|l| l.inner().offset()); + + let mut contexts = Vec::with_capacity(labels.len()); + for right in labels.iter().cloned() { + let right_conts = + match source.read_span(right.inner(), self.context_lines, self.context_lines) { + Ok(cont) => cont, + Err(err) => { + writeln!( + f, + " [{} `{}` (offset: {}, length: {}): {:?}]", + "Failed to read contents for label".style(self.theme.styles.error), + right + .label() + .unwrap_or("") + .style(self.theme.styles.link), + right.offset().style(self.theme.styles.link), + right.len().style(self.theme.styles.link), + err.style(self.theme.styles.warning) + )?; + return Ok(()); + } + }; + + if contexts.is_empty() { + contexts.push((right, right_conts)); + continue; + } + + let (left, left_conts) = contexts.last().unwrap(); + if left_conts.line() + left_conts.line_count() >= right_conts.line() { + // The snippets will overlap, so we create one Big Chunky Boi + let left_end = left.offset() + left.len(); + let right_end = right.offset() + right.len(); + let new_end = std::cmp::max(left_end, right_end); + + let new_span = LabeledSpan::new( + left.label().map(String::from), + left.offset(), + new_end - left.offset(), + ); + // Check that the two contexts can be combined + if let Ok(new_conts) = + source.read_span(new_span.inner(), self.context_lines, self.context_lines) + { + contexts.pop(); + // We'll throw the contents away later + contexts.push((new_span, new_conts)); + continue; + } + } + + contexts.push((right, right_conts)); + } + for (ctx, _) in contexts { + self.render_context(f, source, &ctx, &labels[..])?; + } + + Ok(()) + } + + fn render_context( + &self, + f: &mut impl fmt::Write, + source: &dyn SourceCode, + context: &LabeledSpan, + labels: &[LabeledSpan], + ) -> fmt::Result { + let lines = self.get_lines(source, context.inner())?; + + // sorting is your friend + let labels = labels + .iter() + .zip(self.theme.styles.highlights.iter().cloned().cycle()) + .map(|(label, st)| FancySpan::new(label.label().map(String::from), *label.inner(), st)) + .collect::>(); + + // The max number of gutter-lines that will be active at any given + // point. We need this to figure out indentation, so we do one loop + // over the lines to see what the damage is gonna be. + let mut max_gutter = 0usize; + for line in &lines { + let mut num_highlights = 0; + for hl in &labels { + if !line.span_line_only(hl) && line.span_applies_gutter(hl) { + num_highlights += 1; + } + } + max_gutter = std::cmp::max(max_gutter, num_highlights); + } + + // Now it's time for the fun part--actually rendering everything! + for line in &lines { + // Then, we need to print the gutter, along with any fly-bys We + // have separate gutters depending on whether we're on the actual + // line, or on one of the "highlight lines" below it. + self.render_line_gutter(f, max_gutter, line, &labels)?; + + // And _now_ we can print out the line text itself! + self.render_line_text(f, &line.text)?; + + // Next, we write all the highlights that apply to this particular line. + let (single_line, multi_line): (Vec<_>, Vec<_>) = labels + .iter() + .filter(|hl| line.span_applies(hl)) + .partition(|hl| line.span_line_only(hl)); + if !single_line.is_empty() { + // gutter _again_ + self.render_highlight_gutter( + f, + max_gutter, + line, + &labels, + LabelRenderMode::SingleLine, + )?; + self.render_single_line_highlights(f, line, max_gutter, &single_line, &labels)?; + } + for hl in multi_line { + if hl.label().is_some() && line.span_ends(hl) && !line.span_starts(hl) { + self.render_multi_line_end(f, &labels, max_gutter, line, hl)?; + } + } + } + + Ok(()) + } + + fn render_multi_line_end( + &self, + f: &mut impl fmt::Write, + labels: &[FancySpan], + max_gutter: usize, + line: &Line, + label: &FancySpan, + ) -> fmt::Result { + if let Some(label_parts) = label.label_parts() { + // if it has a label, how long is it? + let (first, rest) = label_parts.split_first().expect( + "cannot crash because rest would have been None, see docs on the `label` field of \ + FancySpan", + ); + + if rest.is_empty() { + // gutter _again_ + self.render_highlight_gutter( + f, + max_gutter, + line, + labels, + LabelRenderMode::SingleLine, + )?; + + self.render_multi_line_end_single( + f, + first, + label.style, + LabelRenderMode::SingleLine, + )?; + } else { + // gutter _again_ + self.render_highlight_gutter( + f, + max_gutter, + line, + labels, + LabelRenderMode::MultiLineFirst, + )?; + + self.render_multi_line_end_single( + f, + first, + label.style, + LabelRenderMode::MultiLineFirst, + )?; + for label_line in rest { + // gutter _again_ + self.render_highlight_gutter( + f, + max_gutter, + line, + labels, + LabelRenderMode::MultiLineRest, + )?; + self.render_multi_line_end_single( + f, + label_line, + label.style, + LabelRenderMode::MultiLineRest, + )?; + } + } + } else { + // gutter _again_ + self.render_highlight_gutter(f, max_gutter, line, labels, LabelRenderMode::SingleLine)?; + // has no label + writeln!(f, "{}", self.theme.characters.hbar.style(label.style))?; + } + + Ok(()) + } + + fn render_line_gutter( + &self, + f: &mut impl fmt::Write, + max_gutter: usize, + line: &Line, + highlights: &[FancySpan], + ) -> fmt::Result { + if max_gutter == 0 { + return Ok(()); + } + let chars = &self.theme.characters; + let mut gutter = String::new(); + let applicable = highlights.iter().filter(|hl| line.span_applies_gutter(hl)); + let mut arrow = false; + for (i, hl) in applicable.enumerate() { + if line.span_starts(hl) { + gutter.push_str(&chars.ltop.style(hl.style).to_string()); + gutter.push_str( + &chars + .hbar + .to_string() + .repeat(max_gutter.saturating_sub(i)) + .style(hl.style) + .to_string(), + ); + gutter.push_str(&chars.rarrow.style(hl.style).to_string()); + arrow = true; + break; + } else if line.span_ends(hl) { + if hl.label().is_some() { + gutter.push_str(&chars.lcross.style(hl.style).to_string()); + } else { + gutter.push_str(&chars.lbot.style(hl.style).to_string()); + } + gutter.push_str( + &chars + .hbar + .to_string() + .repeat(max_gutter.saturating_sub(i)) + .style(hl.style) + .to_string(), + ); + gutter.push_str(&chars.rarrow.style(hl.style).to_string()); + arrow = true; + break; + } else if line.span_flyby(hl) { + gutter.push_str(&chars.vbar.style(hl.style).to_string()); + } else { + gutter.push(' '); + } + } + write!( + f, + "{}{}", + gutter, + " ".repeat( + if arrow { 1 } else { 3 } + max_gutter.saturating_sub(gutter.chars().count()) + ) + )?; + Ok(()) + } + + fn render_highlight_gutter( + &self, + f: &mut impl fmt::Write, + max_gutter: usize, + line: &Line, + highlights: &[FancySpan], + render_mode: LabelRenderMode, + ) -> fmt::Result { + if max_gutter == 0 { + return Ok(()); + } + + // keeps track of how many columns wide the gutter is + // important for ansi since simply measuring the size of the final string + // gives the wrong result when the string contains ansi codes. + let mut gutter_cols = 0; + + let chars = &self.theme.characters; + let mut gutter = String::new(); + let applicable = highlights.iter().filter(|hl| line.span_applies_gutter(hl)); + for (i, hl) in applicable.enumerate() { + if !line.span_line_only(hl) && line.span_ends(hl) { + if render_mode == LabelRenderMode::MultiLineRest { + // this is to make multiline labels work. We want to make the right amount + // of horizontal space for them, but not actually draw the lines + let horizontal_space = max_gutter.saturating_sub(i) + 2; + for _ in 0..horizontal_space { + gutter.push(' '); + } + // account for one more horizontal space, since in multiline mode + // we also add in the vertical line before the label like this: + // 2 │ ╭─▶ text + // 3 │ ├─▶ here + // · ╰──┤ these two lines + // · │ are the problem + // ^this + gutter_cols += horizontal_space + 1; + } else { + let num_repeat = max_gutter.saturating_sub(i) + 2; + + gutter.push_str(&chars.lbot.style(hl.style).to_string()); + + gutter.push_str( + &chars + .hbar + .to_string() + .repeat( + num_repeat + // if we are rendering a multiline label, then leave a bit of space for the + // rcross character + - if render_mode == LabelRenderMode::MultiLineFirst { + 1 + } else { + 0 + }, + ) + .style(hl.style) + .to_string(), + ); + + // we count 1 for the lbot char, and then a few more, the same number + // as we just repeated for. For each repeat we only add 1, even though + // due to ansi escape codes the number of bytes in the string could grow + // a lot each time. + gutter_cols += num_repeat + 1; + } + break; + } else { + gutter.push_str(&chars.vbar.style(hl.style).to_string()); + + // we may push many bytes for the ansi escape codes style adds, + // but we still only add a single character-width to the string in a terminal + gutter_cols += 1; + } + } + + // now calculate how many spaces to add based on how many columns we just + // created. it's the max width of the gutter, minus how many + // character-widths we just generated capped at 0 (though this should + // never go below in reality), and then we add 3 to account for + // arrowheads when a gutter line ends + let num_spaces = (max_gutter + 3).saturating_sub(gutter_cols); + // we then write the gutter and as many spaces as we need + write!(f, "{}{:width$}", gutter, "", width = num_spaces)?; + Ok(()) + } + + /// Returns an iterator over the visual width of each character in a line. + fn line_visual_char_width<'a>(&self, text: &'a str) -> impl Iterator + 'a { + let mut column = 0; + let mut escaped = false; + let tab_width = self.tab_width; + text.chars().map(move |c| { + let width = match (escaped, c) { + // Round up to the next multiple of tab_width + (false, '\t') => tab_width - column % tab_width, + // start of ANSI escape + (false, '\x1b') => { + escaped = true; + 0 + } + // use Unicode width for all other characters + (false, c) => c.width().unwrap_or(0), + // end of ANSI escape + (true, 'm') => { + escaped = false; + 0 + } + // characters are zero width within escape sequence + (true, _) => 0, + }; + column += width; + width + }) + } + + /// Returns the visual column position of a byte offset on a specific line. + /// + /// If the offset occurs in the middle of a character, the returned column + /// corresponds to that character's first column in `start` is true, or its + /// last column if `start` is false. + fn visual_offset(&self, line: &Line, offset: usize, start: bool) -> usize { + let line_range = line.offset..=(line.offset + line.length); + assert!(line_range.contains(&offset)); + + let mut text_index = offset - line.offset; + while text_index <= line.text.len() && !line.text.is_char_boundary(text_index) { + if start { + text_index -= 1; + } else { + text_index += 1; + } + } + let text = &line.text[..text_index.min(line.text.len())]; + let text_width = self.line_visual_char_width(text).sum(); + if text_index > line.text.len() { + // Spans extending past the end of the line are always rendered as + // one column past the end of the visible line. + // + // This doesn't necessarily correspond to a specific byte-offset, + // since a span extending past the end of the line could contain: + // - an actual \n character (1 byte) + // - a CRLF (2 bytes) + // - EOF (0 bytes) + text_width + 1 + } else { + text_width + } + } + + /// Renders a line to the output formatter, replacing tabs with spaces. + fn render_line_text(&self, f: &mut impl fmt::Write, text: &str) -> fmt::Result { + for (c, width) in text.chars().zip(self.line_visual_char_width(text)) { + if c == '\t' { + for _ in 0..width { + f.write_char(' ')?; + } + } else { + f.write_char(c)?; + } + } + f.write_char('\n')?; + Ok(()) + } + + fn render_single_line_highlights( + &self, + f: &mut impl fmt::Write, + line: &Line, + max_gutter: usize, + single_liners: &[&FancySpan], + all_highlights: &[FancySpan], + ) -> fmt::Result { + let mut underlines = String::new(); + let mut highest = 0; + + let chars = &self.theme.characters; + let vbar_offsets: Vec<_> = single_liners + .iter() + .map(|hl| { + let byte_start = hl.offset(); + let byte_end = hl.offset() + hl.len(); + let start = self.visual_offset(line, byte_start, true).max(highest); + let end = if hl.len() == 0 { + start + 1 + } else { + self.visual_offset(line, byte_end, false).max(start + 1) + }; + + let vbar_offset = (start + end) / 2; + let num_left = vbar_offset - start; + let num_right = end - vbar_offset - 1; + underlines.push_str( + &format!( + "{:width$}{}{}{}", + "", + chars.underline.to_string().repeat(num_left), + if hl.len() == 0 { + chars.uarrow + } else if hl.label().is_some() { + chars.underbar + } else { + chars.underline + }, + chars.underline.to_string().repeat(num_right), + width = start.saturating_sub(highest), + ) + .style(hl.style) + .to_string(), + ); + highest = std::cmp::max(highest, end); + + (hl, vbar_offset) + }) + .collect(); + writeln!(f, "{underlines}")?; + + for hl in single_liners.iter().rev() { + if let Some(label) = hl.label_parts() { + if label.len() == 1 { + self.write_label_text( + f, + line, + max_gutter, + all_highlights, + chars, + &vbar_offsets, + hl, + &label[0], + LabelRenderMode::SingleLine, + )?; + } else { + let mut first = true; + for label_line in &label { + self.write_label_text( + f, + line, + max_gutter, + all_highlights, + chars, + &vbar_offsets, + hl, + label_line, + if first { + LabelRenderMode::MultiLineFirst + } else { + LabelRenderMode::MultiLineRest + }, + )?; + first = false; + } + } + } + } + Ok(()) + } + + // I know it's not good practice, but making this a function makes a lot of + // sense and making a struct for this does not... + #[allow(clippy::too_many_arguments)] + fn write_label_text( + &self, + f: &mut impl fmt::Write, + line: &Line, + max_gutter: usize, + all_highlights: &[FancySpan], + chars: &ThemeCharacters, + vbar_offsets: &[(&&FancySpan, usize)], + hl: &&FancySpan, + label: &str, + render_mode: LabelRenderMode, + ) -> fmt::Result { + self.render_highlight_gutter( + f, + max_gutter, + line, + all_highlights, + LabelRenderMode::SingleLine, + )?; + let mut curr_offset = 1usize; + for (offset_hl, vbar_offset) in vbar_offsets { + while curr_offset < *vbar_offset + 1 { + write!(f, " ")?; + curr_offset += 1; + } + if *offset_hl != hl { + write!(f, "{}", chars.vbar.to_string().style(offset_hl.style))?; + curr_offset += 1; + } else { + let lines = match render_mode { + LabelRenderMode::SingleLine => format!( + "{}{} {}", + chars.lbot, + chars.hbar.to_string().repeat(2), + label, + ), + LabelRenderMode::MultiLineFirst => { + format!("{}{}{} {}", chars.lbot, chars.hbar, chars.rcross, label,) + } + LabelRenderMode::MultiLineRest => { + format!(" {} {}", chars.vbar, label,) + } + }; + writeln!(f, "{}", lines.style(hl.style))?; + break; + } + } + Ok(()) + } + + fn render_multi_line_end_single( + &self, + f: &mut impl fmt::Write, + label: &str, + style: Style, + render_mode: LabelRenderMode, + ) -> fmt::Result { + match render_mode { + LabelRenderMode::SingleLine => { + writeln!(f, "{} {}", self.theme.characters.hbar.style(style), label)?; + } + LabelRenderMode::MultiLineFirst => { + writeln!(f, "{} {}", self.theme.characters.rcross.style(style), label)?; + } + LabelRenderMode::MultiLineRest => { + writeln!(f, "{} {}", self.theme.characters.vbar.style(style), label)?; + } + } + + Ok(()) + } + + fn get_lines<'a>( + &'a self, + source: &'a dyn SourceCode, + context_span: &'a SourceSpan, + ) -> Result, fmt::Error> { + let context_data = source + .read_span(context_span, self.context_lines, self.context_lines) + .map_err(|_| fmt::Error)?; + let context = String::from_utf8_lossy(context_data.data()); + let mut column = context_data.column(); + let mut offset = context_data.span().offset(); + let mut line_offset = offset; + let mut line_str = String::with_capacity(context.len()); + let mut lines = Vec::with_capacity(1); + let mut iter = context.chars().peekable(); + while let Some(ch) = iter.next() { + offset += ch.len_utf8(); + match ch { + '\r' if iter.next_if_eq(&'\n').is_some() => { + offset += 1; + column = 0; + } + '\r' => { + line_str.push(ch); + column += 1; + } + '\n' => { + column = 0; + } + _ => { + line_str.push(ch); + column += 1; + } + } + + if column == 0 || iter.peek().is_none() { + lines.push(Line { + offset: line_offset, + length: offset - line_offset, + text: line_str.clone(), + }); + line_str.clear(); + line_offset = offset; + } + } + Ok(lines) + } +} + +impl ReportHandler for SwcReportHandler { + fn debug(&self, diagnostic: &dyn Diagnostic, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if f.alternate() { + return fmt::Debug::fmt(diagnostic, f); + } + + self.render_report(f, diagnostic) + } +} + +/* +Support types +*/ + +#[derive(PartialEq, Debug)] +enum LabelRenderMode { + /// we're rendering a single line label (or not rendering in any special + /// way) + SingleLine, + /// we're rendering a multiline label + MultiLineFirst, + /// we're rendering the rest of a multiline label + MultiLineRest, +} + +#[derive(Debug)] +struct Line { + offset: usize, + length: usize, + text: String, +} + +impl Line { + fn span_line_only(&self, span: &FancySpan) -> bool { + span.offset() >= self.offset && span.offset() + span.len() <= self.offset + self.length + } + + /// Returns whether `span` should be visible on this line, either in the + /// gutter or under the text on this line + fn span_applies(&self, span: &FancySpan) -> bool { + let spanlen = if span.len() == 0 { 1 } else { span.len() }; + // Span starts in this line + + (span.offset() >= self.offset && span.offset() < self.offset + self.length) + // Span passes through this line + || (span.offset() < self.offset && span.offset() + spanlen > self.offset + self.length) //todo + // Span ends on this line + || (span.offset() + spanlen > self.offset && span.offset() + spanlen <= self.offset + self.length) + } + + /// Returns whether `span` should be visible on this line in the gutter (so + /// this excludes spans that are only visible on this line and do not + /// span multiple lines) + fn span_applies_gutter(&self, span: &FancySpan) -> bool { + let spanlen = if span.len() == 0 { 1 } else { span.len() }; + // Span starts in this line + self.span_applies(span) + && !( + // as long as it doesn't start *and* end on this line + (span.offset() >= self.offset && span.offset() < self.offset + self.length) + && (span.offset() + spanlen > self.offset + && span.offset() + spanlen <= self.offset + self.length) + ) + } + + // A 'flyby' is a multi-line span that technically covers this line, but + // does not begin or end within the line itself. This method is used to + // calculate gutters. + fn span_flyby(&self, span: &FancySpan) -> bool { + // The span itself starts before this line's starting offset (so, in a + // prev line). + span.offset() < self.offset + // ...and it stops after this line's end. + && span.offset() + span.len() > self.offset + self.length + } + + // Does this line contain the *beginning* of this multiline span? + // This assumes self.span_applies() is true already. + fn span_starts(&self, span: &FancySpan) -> bool { + span.offset() >= self.offset + } + + // Does this line contain the *end* of this multiline span? + // This assumes self.span_applies() is true already. + fn span_ends(&self, span: &FancySpan) -> bool { + span.offset() + span.len() >= self.offset + && span.offset() + span.len() <= self.offset + self.length + } +} + +#[derive(Debug, Clone)] +struct FancySpan { + /// this is deliberately an option of a vec because I wanted to be very + /// explicit that there can also be *no* label. If there is a label, it + /// can have multiple lines which is what the vec is for. + label: Option>, + span: SourceSpan, + style: Style, +} + +impl PartialEq for FancySpan { + fn eq(&self, other: &Self) -> bool { + self.label == other.label && self.span == other.span + } +} + +fn split_label(v: String) -> Vec { + v.split('\n').map(|i| i.to_string()).collect() +} + +impl FancySpan { + fn new(label: Option, span: SourceSpan, style: Style) -> Self { + FancySpan { + label: label.map(split_label), + span, + style, + } + } + + fn style(&self) -> Style { + self.style + } + + fn label(&self) -> Option { + self.label + .as_ref() + .map(|l| l.join("\n").style(self.style()).to_string()) + } + + fn label_parts(&self) -> Option> { + self.label.as_ref().map(|l| { + l.iter() + .map(|i| i.style(self.style()).to_string()) + .collect() + }) + } + + fn offset(&self) -> usize { + self.span.offset() + } + + fn len(&self) -> usize { + self.span.len() + } +} diff --git a/bindings/binding_nodejs_support_wasm/src/lib.rs b/bindings/binding_nodejs_support_wasm/src/lib.rs new file mode 100644 index 000000000000..115b5700e155 --- /dev/null +++ b/bindings/binding_nodejs_support_wasm/src/lib.rs @@ -0,0 +1,302 @@ +use std::{ + iter::once, + mem::take, + sync::{Arc, Mutex}, +}; + +use anyhow::Error; +use error_reporter::SwcReportHandler; +use js_sys::Uint8Array; +use miette::{GraphicalTheme, LabeledSpan, ThemeCharacters, ThemeStyles}; +use serde::Serialize; +use swc_common::{ + errors::{DiagnosticBuilder, DiagnosticId, Emitter, Handler, HANDLER}, + sync::Lrc, + 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; + +/// Custom interface definitions for `@swc/nodejs-support-wasm`. +#[wasm_bindgen(typescript_custom_section)] +const INTERFACE_DEFINITIONS: &'static str = r#" +export declare function transform(src: string | Uint8Array, opts?: Options): Promise; +export declare function transformSync(src: string | Uint8Array, opts?: Options): TransformOutput; +export declare function transformModuleSyntax(src: string | Uint8Array): ModuleSyntaxTransformOutput; +export declare function getFirstExpression(src: string | Uint8Array, startColumn: number): string; +export declare function isValidSyntax(src: string | Uint8Array): boolean; +export declare function isRecoverableError(src: string | Uint8Array): boolean; +export type { Options, TransformOutput }; + +export interface ModuleSyntaxTransformOutput { + code: string; + hadModuleSyntax: boolean; +} +"#; + +#[wasm_bindgen(skip_typescript)] +pub fn transform(input: JsValue, options: JsValue) -> Promise { + future_to_promise(async move { transform_sync(input, options) }) +} + +#[wasm_bindgen(js_name = "transformSync", skip_typescript)] +pub fn transform_sync(input: JsValue, options: JsValue) -> Result { + let options: Options = if options.is_falsy() { + Default::default() + } else { + serde_wasm_bindgen::from_value(options)? + }; + + let input = coerce_input(input)?; + + let result = GLOBALS.set(&Default::default(), || operate(input, options)); + + match result { + Ok(v) => Ok(serde_wasm_bindgen::to_value(&v)?), + Err(errors) => Err(serde_wasm_bindgen::to_value(&errors[0])?), + } +} + +#[wasm_bindgen(js_name = "transformModuleSyntax", skip_typescript)] +pub fn transform_module_syntax(input: JsValue) -> Result { + let input = coerce_input(input)?; + let output = nodejs::transform_module_syntax(input); + + Ok(serde_wasm_bindgen::to_value(&output)?) +} + +#[wasm_bindgen(js_name = "getFirstExpression", skip_typescript)] +pub fn get_first_expression(input: JsValue, start_column: u32) -> Result { + let input = coerce_input(input)?; + + Ok(nodejs::get_first_expression(input, start_column)) +} + +#[wasm_bindgen(js_name = "isValidSyntax", skip_typescript)] +pub fn is_valid_syntax(input: JsValue) -> Result { + let input = coerce_input(input)?; + + Ok(nodejs::is_valid_syntax(input)) +} + +#[wasm_bindgen(js_name = "isRecoverableError", skip_typescript)] +pub fn is_recoverable_error(input: JsValue) -> Result { + let input = coerce_input(input)?; + + Ok(nodejs::is_recoverable_error(input)) +} + +fn coerce_input(input: JsValue) -> Result { + match input.as_string() { + Some(input) => Ok(input), + None => { + if input.is_instance_of::() { + let input = input.unchecked_into::(); + + String::from_utf8(input.to_vec()) + .map_err(|_| JsValue::from_str("Input Uint8Array is not valid utf-8")) + } else { + Err(JsValue::from_str("Input is not a string or Uint8Array")) + } + } + } +} + +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) + }) +} + +#[derive(Clone)] +struct JsonErrorWriter { + errors: Arc>>, + reporter: SwcReportHandler, + cm: Lrc, +} + +fn try_with_json_handler(cm: Lrc, op: F) -> Result> +where + F: FnOnce(&Handler) -> Result, +{ + let wr = JsonErrorWriter { + errors: Default::default(), + reporter: SwcReportHandler::default().with_theme(GraphicalTheme { + characters: ThemeCharacters { + hbar: ' ', + vbar: ' ', + xbar: ' ', + vbar_break: ' ', + ltop: ' ', + rtop: ' ', + mtop: ' ', + lbot: ' ', + rbot: ' ', + mbot: ' ', + error: "".into(), + warning: "".into(), + advice: "".into(), + ..ThemeCharacters::ascii() + }, + styles: ThemeStyles::none(), + }), + cm, + }; + let emitter: Box = Box::new(wr.clone()); + + let handler = Handler::with_emitter(true, false, emitter); + + let ret = HANDLER.set(&handler, || op(&handler)); + + if handler.has_errors() { + let mut lock = wr.errors.lock().unwrap(); + let error = take(&mut *lock); + + Err(error) + } else { + Ok(ret.expect("it should not fail without emitting errors to handler")) + } +} + +impl Emitter for JsonErrorWriter { + fn emit(&mut self, db: &mut DiagnosticBuilder) { + let d = &**db; + + let snippet = d.span.primary_span().and_then(|span| { + let mut snippet = String::new(); + match self.reporter.render_report( + &mut snippet, + &Snippet { + source_code: &to_pretty_source_code(&self.cm, true), + span, + }, + ) { + Ok(()) => Some(snippet), + Err(_) => None, + } + }); + + let children = d + .children + .iter() + .map(|d| todo!("json subdiagnostic: {d:?}")) + .collect::>(); + + let error_code = match &d.code { + Some(DiagnosticId::Error(s)) => Some(&**s), + Some(DiagnosticId::Lint(s)) => Some(&**s), + None => None, + }; + + let start = d + .span + .primary_span() + .and_then(|span| self.cm.try_lookup_char_pos(span.lo()).ok()); + + let end = d + .span + .primary_span() + .and_then(|span| self.cm.try_lookup_char_pos(span.hi()).ok()); + + let filename = start.as_ref().map(|loc| loc.file.name.to_string()); + + let error = JsonDiagnostic { + code: error_code.map(|s| s.to_string()), + message: d.message[0].0.to_string(), + snippet, + filename, + start_line: start.as_ref().map(|loc| loc.line), + start_column: start.as_ref().map(|loc| loc.col_display), + end_line: end.as_ref().map(|loc| loc.line), + end_column: end.as_ref().map(|loc| loc.col_display), + children, + }; + + self.errors.lock().unwrap().push(error); + } + + fn take_diagnostics(&mut self) -> Vec { + Default::default() + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonDiagnostic { + /// Error code + #[serde(skip_serializing_if = "Option::is_none")] + code: Option, + message: String, + + #[serde(skip_serializing_if = "Option::is_none")] + snippet: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + filename: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + start_line: Option, + #[serde(skip_serializing_if = "Option::is_none")] + start_column: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + end_line: Option, + #[serde(skip_serializing_if = "Option::is_none")] + end_column: Option, + + #[serde(skip_serializing_if = "Vec::is_empty")] + children: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonSubdiagnostic { + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + snippet: Option, + filename: String, + line: usize, +} + +struct Snippet<'a> { + source_code: &'a dyn miette::SourceCode, + span: Span, +} + +impl miette::Diagnostic for Snippet<'_> { + fn source_code(&self) -> Option<&dyn miette::SourceCode> { + if self.span.lo().is_dummy() || self.span.hi().is_dummy() { + return None; + } + + Some(self.source_code) + } + + fn labels(&self) -> Option + '_>> { + Some(Box::new(once(LabeledSpan::new_with_span( + None, + convert_span(self.span), + )))) + } +} + +impl std::error::Error for Snippet<'_> {} + +impl std::fmt::Display for Snippet<'_> { + fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Ok(()) + } +} + +impl std::fmt::Debug for Snippet<'_> { + fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Ok(()) + } +} diff --git a/bindings/binding_nodejs_support_wasm/src/nodejs.rs b/bindings/binding_nodejs_support_wasm/src/nodejs.rs new file mode 100644 index 000000000000..cef6bf21e655 --- /dev/null +++ b/bindings/binding_nodejs_support_wasm/src/nodejs.rs @@ -0,0 +1,1749 @@ +//! Node.js-specific helpers for `@swc/nodejs-support-wasm`. +//! +//! These functions intentionally expose only the compact operations Node needs +//! from Rust and avoid serializing SWC ASTs into JavaScript. + +use serde::Serialize; +use swc_common::{comments::SingleThreadedComments, sync::Lrc, FileName, SourceMap, Span, Spanned}; +use swc_ecma_ast::{ + ArrowExpr, AssignExpr, AssignTarget, AssignTargetPat, BindingIdent, BlockStmt, BlockStmtOrExpr, + CallExpr, Callee, CatchClause, Class, ClassDecl, Decl, DefaultDecl, EsVersion, ExportSpecifier, + Expr, FnDecl, Function, ImportDecl, ImportSpecifier, MetaPropExpr, MetaPropKind, Module, + ModuleDecl, ModuleExportName, ModuleItem, NamedExport, ObjectLit, ObjectPatProp, Pat, Prop, + SimpleAssignTarget, Stmt, UpdateExpr, VarDecl, VarDeclKind, +}; +use swc_ecma_parser::{ + error::{Error as ParseError, SyntaxError}, + lexer::Lexer, + unstable::{Token, TokenAndSpan}, + EsSyntax, Parser, StringInput, Syntax, TsSyntax, +}; +use swc_ecma_visit::{Visit, VisitWith}; + +const DYNAMIC_IMPORT_NAME: &str = "__nodeREPLDynamicImport"; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModuleSyntaxTransformOutput { + pub code: String, + pub had_module_syntax: bool, +} + +/// Rewrites Node REPL module syntax into script syntax. +/// +/// The transform follows Node's edit-list approach: static imports become +/// awaited dynamic imports, dynamic `import(...)` is routed through Node's REPL +/// helper, and exports are unwrapped or dropped depending on whether they have +/// runtime declarations. +pub fn transform_module_syntax(code: String) -> ModuleSyntaxTransformOutput { + if !code.contains("import") && !code.contains("export") { + return ModuleSyntaxTransformOutput { + code, + had_module_syntax: false, + }; + } + + let Some(module) = parse_module(&code) else { + return ModuleSyntaxTransformOutput { + code, + had_module_syntax: false, + }; + }; + + let (edits, hoisted) = { + let mut transform = ModuleSyntaxTransform::new(&code); + + for item in &module.body { + transform.collect_module_item_edit(item); + } + + let replaced_ranges = transform + .edits + .iter() + .map(|edit| (edit.start, edit.end)) + .collect::>(); + module.visit_with(&mut ModuleFeatureCollector { + code: &code, + edits: &mut transform.edits, + replaced_ranges: &replaced_ranges, + }); + module.visit_with(&mut ImportBindingReferenceCollector { + code: &code, + edits: &mut transform.edits, + replaced_ranges: &replaced_ranges, + bindings: &transform.import_bindings, + scopes: vec![Vec::new()], + }); + + if transform.edits.is_empty() && transform.hoisted.is_empty() { + return ModuleSyntaxTransformOutput { + code, + had_module_syntax: false, + }; + } + + (transform.edits, transform.hoisted) + }; + + ModuleSyntaxTransformOutput { + code: prepend_hoisted_imports(apply_edits(code, edits), hoisted), + had_module_syntax: true, + } +} + +/// Extracts the first source expression associated with a V8 error column. +/// +/// `start_column` is interpreted as a JavaScript UTF-16 code-unit column to +/// match Node/V8 source locations. The implementation uses only lexer tokens +/// because the source line can be incomplete JavaScript. +pub fn get_first_expression(code: String, start_column: u32) -> String { + let start_byte = utf16_column_to_byte_pos(&code, start_column); + let mut last_token = None; + let mut potential_expression_start_token = None; + let mut first_member_access_name_token = None; + let mut pending_optional_chain_name_token = None; + let mut terminating_byte = None; + let mut paren_level = 0u32; + let mut member_bracket_depth = 0u32; + + for token in tokenize(&code) { + if token.token == Token::Eof { + break; + } + + let Some((token_start, token_end)) = span_range(token.span) else { + continue; + }; + if token_start < start_byte { + if token.token == Token::Semi { + potential_expression_start_token = None; + first_member_access_name_token = None; + pending_optional_chain_name_token = None; + member_bracket_depth = 0; + last_token = None; + continue; + } + + if member_bracket_depth > 0 { + match token.token { + Token::LBracket => member_bracket_depth += 1, + Token::RBracket => { + member_bracket_depth = member_bracket_depth.saturating_sub(1); + } + _ => {} + } + last_token = Some(token); + continue; + } + + if is_member_identifier_token(&code, &token) + && first_member_access_name_token.is_none() + && potential_expression_start_token.is_none() + { + potential_expression_start_token = Some(token); + } + + if token.token == Token::QuestionMark + && last_token + .as_ref() + .is_some_and(|last: &TokenAndSpan| is_member_identifier_token(&code, last)) + { + pending_optional_chain_name_token = last_token; + last_token = Some(token); + continue; + } + + if token.token == Token::Dot + && last_token + .as_ref() + .is_some_and(|last: &TokenAndSpan| last.token == Token::QuestionMark) + { + if let Some(name_token) = pending_optional_chain_name_token.take() { + first_member_access_name_token.get_or_insert(name_token); + last_token = Some(token); + continue; + } + } else if pending_optional_chain_name_token.take().is_some() { + first_member_access_name_token = None; + } + + if token.token == Token::LBracket + && last_token + .as_ref() + .is_some_and(|last: &TokenAndSpan| is_member_identifier_token(&code, last)) + { + first_member_access_name_token + .get_or_insert(potential_expression_start_token.unwrap_or(last_token.unwrap())); + member_bracket_depth = 1; + last_token = Some(token); + continue; + } + + if is_member_access_token(token.token) { + if last_token + .as_ref() + .is_some_and(|last: &TokenAndSpan| is_member_identifier_token(&code, last)) + { + first_member_access_name_token.get_or_insert( + potential_expression_start_token.unwrap_or(last_token.unwrap()), + ); + last_token = Some(token); + continue; + } + } else if token.token != Token::LParen && !is_member_name_token(&code, &token) { + potential_expression_start_token = None; + first_member_access_name_token = None; + } + + if token.token == Token::LParen && first_member_access_name_token.is_some() { + paren_level += 1; + last_token = Some(token); + continue; + } + + if token.token == Token::LParen + && last_token + .as_ref() + .is_some_and(|last: &TokenAndSpan| is_member_identifier_token(&code, last)) + { + first_member_access_name_token + .get_or_insert(potential_expression_start_token.unwrap_or(last_token.unwrap())); + paren_level += 1; + last_token = Some(token); + continue; + } + + last_token = Some(token); + continue; + } + + if token.token == Token::LParen + && last_token + .as_ref() + .is_some_and(|last: &TokenAndSpan| is_member_identifier_token(&code, last)) + { + first_member_access_name_token + .get_or_insert(potential_expression_start_token.unwrap_or(last_token.unwrap())); + } + + match token.token { + Token::LParen => { + paren_level += 1; + } + Token::RParen => { + paren_level = paren_level.saturating_sub(1); + if paren_level == 0 { + terminating_byte = Some(token_end); + break; + } + } + Token::Semi => { + terminating_byte = Some(token_start); + break; + } + _ => {} + } + } + + let start = first_member_access_name_token + .and_then(|token| span_range(token.span).map(|(start, _)| start)) + .unwrap_or(start_byte); + let end = terminating_byte.unwrap_or(code.len()); + + if start >= code.len() + || start > end + || !code.is_char_boundary(start) + || !code.is_char_boundary(end) + { + return String::new(); + } + + code[start..end].to_string() +} + +/// Returns true when the source parses as a program or expression. +pub fn is_valid_syntax(code: String) -> bool { + parses_as_program(&code) || parses_as_program(&format!("_={code}")) +} + +/// Returns true for parser errors that should keep a Node REPL input open. +pub fn is_recoverable_error(code: String) -> bool { + match parse_program_result(&code) { + ParseOutcome::Valid => false, + ParseOutcome::Invalid(errors) => { + if errors + .iter() + .any(|error| is_recoverable_parse_error(&code, error)) + { + return true; + } + + code.trim_start().starts_with('{') && is_recoverable_error(format!("({code}")) + } + } +} + +struct ModuleSyntaxTransform<'a> { + code: &'a str, + edits: Vec, + hoisted: Vec, + import_bindings: Vec, + import_namespace_count: u32, +} + +impl<'a> ModuleSyntaxTransform<'a> { + fn new(code: &'a str) -> Self { + Self { + code, + edits: Vec::new(), + hoisted: Vec::new(), + import_bindings: Vec::new(), + import_namespace_count: 0, + } + } + + fn collect_module_item_edit(&mut self, item: &ModuleItem) { + let ModuleItem::ModuleDecl(decl) = item else { + return; + }; + + match decl { + ModuleDecl::Import(import) => { + self.delete_span(import.span); + if !import.type_only { + if let Some(script) = self.import_decl_to_script(import) { + self.hoisted.push(script); + } + } + } + ModuleDecl::ExportDecl(export) => { + if is_type_only_decl(&export.decl) { + self.delete_span(export.span); + } else if let (Some((start, _)), Some((decl_start, _))) = + (span_range(export.span), span_range(export.decl.span())) + { + push_range_edit(&mut self.edits, self.code, start, decl_start, String::new()); + } + } + ModuleDecl::ExportNamed(export) => { + self.delete_span(export.span); + if let Some(src) = &export.src { + if let Some(required_exports) = named_export_source_imports(export) { + self.hoist_validated_import( + &src.value.to_string_lossy(), + export.with.as_deref(), + &required_exports, + ); + } + } + } + ModuleDecl::ExportDefaultDecl(export) => { + self.unwrap_default_declaration(export.span, export.decl.span(), &export.decl); + } + ModuleDecl::ExportDefaultExpr(export) => { + self.unwrap_default_expression( + export.span, + export.expr.span(), + matches!(&*export.expr, Expr::Object(..)), + ); + } + ModuleDecl::ExportAll(export) => { + self.delete_span(export.span); + if !export.type_only { + self.hoist_import(&export.src.value.to_string_lossy(), export.with.as_deref()); + } + } + ModuleDecl::TsImportEquals(import) => { + if import.is_type_only { + self.delete_span(import.span); + } + } + ModuleDecl::TsNamespaceExport(export) => { + self.delete_span(export.span); + } + ModuleDecl::TsExportAssignment(..) => {} + } + } + + fn delete_span(&mut self, span: Span) { + let replacement = if needs_statement_separator(self.code, span) { + ";" + } else { + "" + }; + self.replace_span(span, replacement.to_string()); + } + + fn replace_span(&mut self, span: Span, text: String) { + push_span_edit(&mut self.edits, self.code, span, text); + } + + fn hoist_import(&mut self, specifier: &str, with: Option<&ObjectLit>) { + self.hoisted.push(format!( + "await {};", + await_import(self.code, specifier, with, &[]) + )); + } + + fn hoist_validated_import( + &mut self, + specifier: &str, + with: Option<&ObjectLit>, + required_exports: &[String], + ) { + self.hoisted.push(format!( + "await {};", + await_import(self.code, specifier, with, required_exports) + )); + } + + fn import_decl_to_script(&mut self, node: &ImportDecl) -> Option { + let specifier = node.src.value.to_string_lossy(); + let with = node.with.as_deref(); + + if node.specifiers.is_empty() { + return Some(format!( + "await {};", + await_import(self.code, &specifier, with, &[]) + )); + } + + let mut namespace_name = None; + let mut default_name = None; + let mut named = Vec::new(); + let mut required_exports = Vec::new(); + + for specifier in &node.specifiers { + match specifier { + ImportSpecifier::Namespace(specifier) => { + namespace_name = Some(specifier.local.sym.to_string()); + } + ImportSpecifier::Default(specifier) => { + default_name = Some(specifier.local.sym.to_string()); + push_required_export(&mut required_exports, "default".to_string()); + } + ImportSpecifier::Named(specifier) => { + if specifier.is_type_only { + continue; + } + + let local = specifier.local.sym.to_string(); + let imported = specifier + .imported + .as_ref() + .map(module_export_name) + .unwrap_or_else(|| local.clone()); + push_required_export(&mut required_exports, imported.clone()); + named.push((local, imported)); + } + } + } + + if namespace_name.is_none() && default_name.is_none() && named.is_empty() { + return None; + } + + let namespace_name = namespace_name.unwrap_or_else(|| self.fresh_import_namespace_name()); + let out = format!( + "const {namespace_name} = await {};", + await_import(self.code, &specifier, with, &required_exports) + ); + + if let Some(default_name) = default_name { + self.import_bindings.push(ImportBinding { + local: default_name, + namespace: namespace_name.clone(), + export_name: "default".to_string(), + }); + } + + for (local, export_name) in named { + self.import_bindings.push(ImportBinding { + local, + namespace: namespace_name.clone(), + export_name, + }); + } + + Some(out) + } + + fn fresh_import_namespace_name(&mut self) -> String { + loop { + let candidate = format!("__nodeREPLImport{}", self.import_namespace_count); + self.import_namespace_count += 1; + + if !self.code.contains(&candidate) { + return candidate; + } + } + } + + fn unwrap_default_declaration( + &mut self, + export_span: Span, + decl_span: Span, + decl: &DefaultDecl, + ) { + if matches!(decl, DefaultDecl::TsInterfaceDecl(..)) { + self.delete_span(export_span); + return; + } + + let (Some((export_start, _)), Some((decl_start, decl_end))) = + (span_range(export_span), span_range(decl_span)) + else { + return; + }; + + push_range_edit( + &mut self.edits, + self.code, + export_start, + decl_start, + String::new(), + ); + + if default_declaration_needs_parentheses(decl) { + push_range_edit( + &mut self.edits, + self.code, + decl_start, + decl_start, + "(".to_string(), + ); + push_range_edit( + &mut self.edits, + self.code, + decl_end, + decl_end, + ");".to_string(), + ); + } + } + + fn unwrap_default_expression( + &mut self, + export_span: Span, + expr_span: Span, + needs_parentheses: bool, + ) { + let (Some((export_start, export_end)), Some((expr_start, expr_end))) = + (span_range(export_span), span_range(expr_span)) + else { + return; + }; + + push_range_edit( + &mut self.edits, + self.code, + export_start, + expr_start, + String::new(), + ); + + if needs_parentheses { + push_range_edit( + &mut self.edits, + self.code, + expr_start, + expr_start, + "(".to_string(), + ); + } + + let has_separator = self + .code + .get(expr_end..export_end) + .is_some_and(|tail| tail.contains(';')); + if needs_parentheses || !has_separator { + let mut suffix = String::new(); + if needs_parentheses { + suffix.push(')'); + } + if !has_separator { + suffix.push(';'); + } + push_range_edit(&mut self.edits, self.code, expr_end, expr_end, suffix); + } + } +} + +fn await_import( + code: &str, + specifier: &str, + with: Option<&ObjectLit>, + required_exports: &[String], +) -> String { + let mut out = format!("{DYNAMIC_IMPORT_NAME}({}", json_string(specifier)); + if let Some(options) = import_options_to_dynamic_options(code, with) { + out.push_str(", "); + out.push_str(&options); + } + out.push(')'); + if !required_exports.is_empty() { + out.push_str(".then((m) => { "); + for export_name in required_exports { + out.push_str("if (!("); + out.push_str(&json_string(export_name)); + out.push_str(" in m)) throw new SyntaxError("); + out.push_str(&json_string(&format!( + "The requested module '{specifier}' does not provide an export named \ + '{export_name}'" + ))); + out.push_str("); "); + } + out.push_str("return m; })"); + } + out +} + +fn import_options_to_dynamic_options(code: &str, with: Option<&ObjectLit>) -> Option { + let attributes = slice_span(code, with?.span)?; + Some(format!("{{ with: {attributes} }}")) +} + +fn default_declaration_needs_parentheses(decl: &DefaultDecl) -> bool { + matches!(decl, DefaultDecl::Class(class) if class.ident.is_none()) + || matches!(decl, DefaultDecl::Fn(function) if function.ident.is_none()) +} + +fn is_type_only_decl(decl: &Decl) -> bool { + matches!(decl, Decl::TsInterface(..) | Decl::TsTypeAlias(..)) +} + +fn named_export_source_imports(export: &NamedExport) -> Option> { + if export.type_only { + return None; + } + + let mut loads_source = export.specifiers.is_empty(); + let mut required_exports = Vec::new(); + + for specifier in &export.specifiers { + match specifier { + ExportSpecifier::Named(named) => { + if named.is_type_only { + continue; + } + + loads_source = true; + push_required_export(&mut required_exports, module_export_name(&named.orig)); + } + ExportSpecifier::Default(..) => { + loads_source = true; + push_required_export(&mut required_exports, "default".to_string()); + } + ExportSpecifier::Namespace(..) => { + loads_source = true; + } + } + } + + loads_source.then_some(required_exports) +} + +fn push_required_export(required_exports: &mut Vec, export_name: String) { + if !required_exports + .iter() + .any(|required| required == &export_name) + { + required_exports.push(export_name); + } +} + +fn module_export_name(name: &ModuleExportName) -> String { + match name { + ModuleExportName::Ident(ident) => ident.sym.to_string(), + ModuleExportName::Str(string) => string.value.to_string_lossy().into_owned(), + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct Edit { + start: usize, + end: usize, + text: String, +} + +#[derive(Debug)] +struct ImportBinding { + local: String, + namespace: String, + export_name: String, +} + +fn push_span_edit(edits: &mut Vec, code: &str, span: Span, text: String) { + if let Some((start, end)) = span_range(span) { + push_range_edit(edits, code, start, end, text); + } +} + +fn push_range_edit(edits: &mut Vec, code: &str, start: usize, end: usize, text: String) { + if start <= end && end <= code.len() { + edits.push(Edit { start, end, text }); + } +} + +fn apply_edits(mut code: String, mut edits: Vec) -> String { + edits.sort_by(|a, b| b.start.cmp(&a.start).then_with(|| b.end.cmp(&a.end))); + + for edit in edits { + if edit.start <= edit.end + && edit.end <= code.len() + && code.is_char_boundary(edit.start) + && code.is_char_boundary(edit.end) + { + code.replace_range(edit.start..edit.end, &edit.text); + } + } + + code +} + +fn needs_statement_separator(code: &str, span: Span) -> bool { + let Some((start, end)) = span_range(span) else { + return false; + }; + + code[..start].chars().any(|ch| !ch.is_whitespace()) + && code[end..].chars().any(|ch| !ch.is_whitespace()) +} + +fn prepend_hoisted_imports(code: String, hoisted: Vec) -> String { + if hoisted.is_empty() { + return code; + } + + let imports = hoisted.join("\n"); + if code.starts_with("#!") { + if let Some(line_end) = code.find('\n') { + let insert_at = line_end + 1; + let (hashbang, rest) = code.split_at(insert_at); + if rest.is_empty() || rest.starts_with('\n') { + format!("{hashbang}{imports}{rest}") + } else { + format!("{hashbang}{imports}\n{rest}") + } + } else { + format!("{code}\n{imports}") + } + } else if code.is_empty() { + imports + } else if code.starts_with('\n') || code.starts_with("\r\n") { + format!("{imports}{code}") + } else { + format!("{imports}\n{code}") + } +} + +struct ModuleFeatureCollector<'a, 'b> { + code: &'a str, + edits: &'b mut Vec, + replaced_ranges: &'b [(usize, usize)], +} + +impl Visit for ModuleFeatureCollector<'_, '_> { + fn visit_call_expr(&mut self, node: &CallExpr) { + if matches!(node.callee, Callee::Import(..)) { + if let Some((start, end)) = span_range(node.span) { + if !range_is_replaced(self.replaced_ranges, start, end) { + let original = &self.code[start..end]; + if original.starts_with("import") { + self.edits.push(Edit { + start, + end: start + "import".len(), + text: DYNAMIC_IMPORT_NAME.to_string(), + }); + } + } + } + } + + node.visit_children_with(self); + } + + fn visit_meta_prop_expr(&mut self, node: &MetaPropExpr) { + if node.kind == MetaPropKind::ImportMeta { + if let Some((start, end)) = span_range(node.span) { + if !range_is_replaced(self.replaced_ranges, start, end) { + self.edits.push(Edit { + start, + end, + text: import_meta_error_expression(), + }); + } + } + } + } +} + +struct ImportBindingReferenceCollector<'a, 'b> { + code: &'a str, + edits: &'b mut Vec, + replaced_ranges: &'b [(usize, usize)], + bindings: &'b [ImportBinding], + scopes: Vec>, +} + +impl Visit for ImportBindingReferenceCollector<'_, '_> { + fn visit_assign_expr(&mut self, node: &AssignExpr) { + if self.import_binding_for_assign_target(&node.left).is_some() { + self.replace_expression_with_import_assignment_error(node.span); + return; + } + + node.visit_children_with(self); + } + + fn visit_update_expr(&mut self, node: &UpdateExpr) { + if let Expr::Ident(ident) = &*node.arg { + if self.binding_for_unshadowed(ident.sym.as_ref()).is_some() { + self.replace_expression_with_import_assignment_error(node.span); + return; + } + } + + node.visit_children_with(self); + } + + fn visit_function(&mut self, node: &Function) { + let shadowed = function_scope_shadowed_bindings(node, self.bindings); + self.with_scope(shadowed, |this| node.visit_children_with(this)); + } + + fn visit_arrow_expr(&mut self, node: &ArrowExpr) { + let shadowed = arrow_scope_shadowed_bindings(node, self.bindings); + self.with_scope(shadowed, |this| node.visit_children_with(this)); + } + + fn visit_block_stmt(&mut self, node: &BlockStmt) { + let shadowed = direct_block_shadowed_bindings(node, self.bindings); + self.with_scope(shadowed, |this| node.visit_children_with(this)); + } + + fn visit_catch_clause(&mut self, node: &CatchClause) { + let mut shadowed = Vec::new(); + if let Some(param) = &node.param { + collect_shadowed_pat_bindings(&mut shadowed, param, self.bindings); + } + + self.with_scope(shadowed, |this| node.visit_children_with(this)); + } + + fn visit_expr(&mut self, node: &Expr) { + if let Expr::Ident(ident) = node { + self.replace_identifier(ident.span, ident.sym.as_ref()); + return; + } + + node.visit_children_with(self); + } + + fn visit_prop(&mut self, node: &Prop) { + if let Prop::Shorthand(ident) = node { + if let Some(binding) = self.binding_for_unshadowed(ident.sym.as_ref()) { + if let Some((start, end)) = span_range(ident.span) { + if !range_is_replaced(self.replaced_ranges, start, end) { + let access = import_binding_access(binding); + self.edits.push(Edit { + start, + end, + text: format!("{}: {access}", ident.sym), + }); + } + } + } + return; + } + + node.visit_children_with(self); + } +} + +impl ImportBindingReferenceCollector<'_, '_> { + fn replace_identifier(&mut self, span: Span, local: &str) { + let Some(binding) = self.binding_for_unshadowed(local) else { + return; + }; + + let Some((start, end)) = span_range(span) else { + return; + }; + + if range_is_replaced(self.replaced_ranges, start, end) { + return; + } + + if !self.code.get(start..end).is_some_and(|text| text == local) { + return; + } + + self.edits.push(Edit { + start, + end, + text: import_binding_access(binding), + }); + } + + fn binding_for(&self, local: &str) -> Option<&ImportBinding> { + self.bindings.iter().find(|binding| binding.local == local) + } + + fn binding_for_unshadowed(&self, local: &str) -> Option<&ImportBinding> { + if self.is_shadowed(local) { + return None; + } + + self.binding_for(local) + } + + fn import_binding_for_assign_target(&self, target: &AssignTarget) -> Option<&ImportBinding> { + match target { + AssignTarget::Simple(SimpleAssignTarget::Ident(ident)) => { + self.binding_for_unshadowed(ident.id.sym.as_ref()) + } + AssignTarget::Pat(pat) => self.import_binding_for_assign_target_pat(pat), + _ => None, + } + } + + fn import_binding_for_assign_target_pat( + &self, + target: &AssignTargetPat, + ) -> Option<&ImportBinding> { + match target { + AssignTargetPat::Array(array) => array + .elems + .iter() + .flatten() + .find_map(|pat| self.import_binding_for_pat(pat)), + AssignTargetPat::Object(object) => object.props.iter().find_map(|prop| match prop { + ObjectPatProp::KeyValue(prop) => self.import_binding_for_pat(&prop.value), + ObjectPatProp::Assign(prop) => { + self.binding_for_unshadowed(prop.key.id.sym.as_ref()) + } + ObjectPatProp::Rest(prop) => self.import_binding_for_pat(&prop.arg), + }), + AssignTargetPat::Invalid(..) => None, + } + } + + fn import_binding_for_pat(&self, pat: &Pat) -> Option<&ImportBinding> { + match pat { + Pat::Ident(ident) => self.binding_for_unshadowed(ident.id.sym.as_ref()), + Pat::Array(array) => array + .elems + .iter() + .flatten() + .find_map(|pat| self.import_binding_for_pat(pat)), + Pat::Rest(rest) => self.import_binding_for_pat(&rest.arg), + Pat::Object(object) => object.props.iter().find_map(|prop| match prop { + ObjectPatProp::KeyValue(prop) => self.import_binding_for_pat(&prop.value), + ObjectPatProp::Assign(prop) => { + self.binding_for_unshadowed(prop.key.id.sym.as_ref()) + } + ObjectPatProp::Rest(prop) => self.import_binding_for_pat(&prop.arg), + }), + Pat::Assign(assign) => self.import_binding_for_pat(&assign.left), + Pat::Invalid(..) | Pat::Expr(..) => None, + } + } + + fn replace_expression_with_import_assignment_error(&mut self, span: Span) { + let Some((start, end)) = span_range(span) else { + return; + }; + + if range_is_replaced(self.replaced_ranges, start, end) { + return; + } + + self.edits.push(Edit { + start, + end, + text: import_assignment_error_expression(), + }); + } + + fn with_scope(&mut self, shadowed: Vec, op: impl FnOnce(&mut Self)) { + self.scopes.push(shadowed); + op(self); + self.scopes.pop(); + } + + fn is_shadowed(&self, local: &str) -> bool { + self.scopes + .iter() + .rev() + .any(|scope| scope.iter().any(|name| name == local)) + } +} + +fn function_scope_shadowed_bindings(node: &Function, bindings: &[ImportBinding]) -> Vec { + let mut shadowed = Vec::new(); + for param in &node.params { + collect_shadowed_pat_bindings(&mut shadowed, ¶m.pat, bindings); + } + if let Some(body) = &node.body { + collect_function_var_shadowed_bindings(&mut shadowed, body, bindings); + } + shadowed +} + +fn arrow_scope_shadowed_bindings(node: &ArrowExpr, bindings: &[ImportBinding]) -> Vec { + let mut shadowed = Vec::new(); + for param in &node.params { + collect_shadowed_pat_bindings(&mut shadowed, param, bindings); + } + if let BlockStmtOrExpr::BlockStmt(body) = &*node.body { + collect_function_var_shadowed_bindings(&mut shadowed, body, bindings); + } + shadowed +} + +fn collect_function_var_shadowed_bindings( + out: &mut Vec, + body: &BlockStmt, + bindings: &[ImportBinding], +) { + let mut collector = FunctionScopedVarCollector { + bindings, + shadowed: out, + }; + body.visit_with(&mut collector); +} + +struct FunctionScopedVarCollector<'a, 'b> { + bindings: &'a [ImportBinding], + shadowed: &'b mut Vec, +} + +impl Visit for FunctionScopedVarCollector<'_, '_> { + fn visit_var_decl(&mut self, node: &VarDecl) { + if node.kind == VarDeclKind::Var { + collect_shadowed_var_decl_bindings(self.shadowed, node, self.bindings); + } + } + + fn visit_function(&mut self, _: &Function) {} + + fn visit_arrow_expr(&mut self, _: &ArrowExpr) {} + + fn visit_class(&mut self, _: &Class) {} +} + +fn direct_block_shadowed_bindings(node: &BlockStmt, bindings: &[ImportBinding]) -> Vec { + let mut shadowed = Vec::new(); + + for stmt in &node.stmts { + if let Stmt::Decl(decl) = stmt { + collect_shadowed_decl_bindings(&mut shadowed, decl, bindings); + } + } + + shadowed +} + +fn collect_shadowed_decl_bindings(out: &mut Vec, decl: &Decl, bindings: &[ImportBinding]) { + match decl { + Decl::Class(ClassDecl { ident, .. }) | Decl::Fn(FnDecl { ident, .. }) => { + push_shadowed_ident_binding(out, ident.sym.as_ref(), bindings); + } + Decl::Var(var) => { + collect_shadowed_var_decl_bindings(out, var, bindings); + } + Decl::Using(using) => { + for declarator in &using.decls { + collect_shadowed_pat_bindings(out, &declarator.name, bindings); + } + } + Decl::TsInterface(..) | Decl::TsTypeAlias(..) | Decl::TsEnum(..) | Decl::TsModule(..) => {} + } +} + +fn collect_shadowed_var_decl_bindings( + out: &mut Vec, + decl: &VarDecl, + bindings: &[ImportBinding], +) { + for declarator in &decl.decls { + collect_shadowed_pat_bindings(out, &declarator.name, bindings); + } +} + +fn collect_shadowed_pat_bindings(out: &mut Vec, pat: &Pat, bindings: &[ImportBinding]) { + match pat { + Pat::Ident(ident) => push_shadowed_binding_ident(out, ident, bindings), + Pat::Array(array) => { + for elem in array.elems.iter().flatten() { + collect_shadowed_pat_bindings(out, elem, bindings); + } + } + Pat::Rest(rest) => collect_shadowed_pat_bindings(out, &rest.arg, bindings), + Pat::Object(object) => { + for prop in &object.props { + match prop { + ObjectPatProp::KeyValue(prop) => { + collect_shadowed_pat_bindings(out, &prop.value, bindings); + } + ObjectPatProp::Assign(prop) => { + push_shadowed_binding_ident(out, &prop.key, bindings); + } + ObjectPatProp::Rest(prop) => { + collect_shadowed_pat_bindings(out, &prop.arg, bindings); + } + } + } + } + Pat::Assign(assign) => collect_shadowed_pat_bindings(out, &assign.left, bindings), + Pat::Invalid(..) | Pat::Expr(..) => {} + } +} + +fn push_shadowed_binding_ident( + out: &mut Vec, + ident: &BindingIdent, + bindings: &[ImportBinding], +) { + push_shadowed_ident_binding(out, ident.id.sym.as_ref(), bindings); +} + +fn push_shadowed_ident_binding(out: &mut Vec, local: &str, bindings: &[ImportBinding]) { + if bindings.iter().any(|binding| binding.local == local) + && !out.iter().any(|name| name == local) + { + out.push(local.to_string()); + } +} + +fn range_is_replaced(replaced_ranges: &[(usize, usize)], start: usize, end: usize) -> bool { + replaced_ranges + .iter() + .any(|(replace_start, replace_end)| start >= *replace_start && end <= *replace_end) +} + +fn import_binding_access(binding: &ImportBinding) -> String { + format!( + "{}{}", + binding.namespace, + export_property_access(&binding.export_name) + ) +} + +fn export_property_access(export_name: &str) -> String { + if is_identifier_name(export_name) { + format!(".{export_name}") + } else { + format!("[{}]", json_string(export_name)) + } +} + +fn import_meta_error_expression() -> String { + "(() => { throw new SyntaxError(\"Cannot use import.meta outside a module\"); })()".to_string() +} + +fn import_assignment_error_expression() -> String { + "(() => { throw new TypeError(\"Assignment to constant variable.\"); })()".to_string() +} + +fn is_identifier_name(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + + if !(first == '_' || first == '$' || first.is_ascii_alphabetic()) { + return false; + } + + chars.all(|ch| ch == '_' || ch == '$' || ch.is_ascii_alphanumeric()) +} + +enum ParseOutcome { + Valid, + Invalid(Vec), +} + +fn parse_program_result(code: &str) -> ParseOutcome { + let es = parse_program_result_with_syntax(code, Syntax::Es(es_syntax())); + if matches!(es, ParseOutcome::Valid) { + return es; + } + + parse_program_result_with_syntax(code, Syntax::Typescript(ts_syntax())) +} + +fn parse_program_result_with_syntax(code: &str, syntax: Syntax) -> ParseOutcome { + let cm = Lrc::new(SourceMap::default()); + let fm = cm.new_source_file(FileName::Anon.into(), code.to_string()); + let comments = SingleThreadedComments::default(); + let lexer = Lexer::new( + syntax, + EsVersion::latest(), + StringInput::from(&*fm), + Some(&comments), + ); + let mut parser = Parser::new_from(lexer); + + match parser.parse_program() { + Ok(..) => { + let errors = parser.take_errors(); + if errors.is_empty() { + ParseOutcome::Valid + } else { + ParseOutcome::Invalid(errors) + } + } + Err(error) => { + let mut errors = vec![error]; + errors.extend(parser.take_errors()); + ParseOutcome::Invalid(errors) + } + } +} + +fn parse_module(code: &str) -> Option { + parse_module_with_syntax(code, Syntax::Es(es_syntax())) + .or_else(|| parse_module_with_syntax(code, Syntax::Typescript(ts_syntax()))) +} + +fn parse_module_with_syntax(code: &str, syntax: Syntax) -> Option { + let cm = Lrc::new(SourceMap::default()); + let fm = cm.new_source_file(FileName::Anon.into(), code.to_string()); + let comments = SingleThreadedComments::default(); + let lexer = Lexer::new( + syntax, + EsVersion::latest(), + StringInput::from(&*fm), + Some(&comments), + ); + let mut parser = Parser::new_from(lexer); + let module = parser.parse_module().ok()?; + + if parser.take_errors().is_empty() { + Some(module) + } else { + None + } +} + +fn parses_as_program(code: &str) -> bool { + matches!(parse_program_result(code), ParseOutcome::Valid) +} + +fn tokenize(code: &str) -> Vec { + let cm = Lrc::new(SourceMap::default()); + let fm = cm.new_source_file(FileName::Anon.into(), code.to_string()); + let comments = SingleThreadedComments::default(); + Lexer::new( + Syntax::Es(es_syntax()), + EsVersion::latest(), + StringInput::from(&*fm), + Some(&comments), + ) + .collect() +} + +fn es_syntax() -> EsSyntax { + EsSyntax { + auto_accessors: true, + explicit_resource_management: true, + import_attributes: true, + ..Default::default() + } +} + +fn ts_syntax() -> TsSyntax { + TsSyntax { + decorators: true, + ..Default::default() + } +} + +fn is_recoverable_parse_error(code: &str, error: &ParseError) -> bool { + match error.kind() { + SyntaxError::Eof | SyntaxError::UnterminatedTpl | SyntaxError::UnterminatedBlockComment => { + true + } + SyntaxError::UnterminatedStrLit => has_trailing_line_continuation(code), + SyntaxError::Expected(_, got) => got == "", + _ => false, + } +} + +fn has_trailing_line_continuation(code: &str) -> bool { + code.ends_with("\\\n") + || code.ends_with("\\\r") + || code.ends_with("\\\r\n") + || code.ends_with("\\\u{2028}") + || code.ends_with("\\\u{2029}") +} + +fn is_member_access_token(token: Token) -> bool { + matches!( + token, + Token::Dot | Token::OptionalChain | Token::LBracket | Token::RBracket + ) +} + +fn is_member_name_token(code: &str, token: &TokenAndSpan) -> bool { + matches!(token.token, Token::Ident | Token::Str | Token::Num) + || token.token.is_known_ident() + || token_text_is_identifier(code, token) +} + +fn is_member_identifier_token(code: &str, token: &TokenAndSpan) -> bool { + token.token == Token::Ident + || token.token.is_known_ident() + || token_text_is_identifier(code, token) +} + +fn token_text_is_identifier(code: &str, token: &TokenAndSpan) -> bool { + slice_span(code, token.span).is_some_and(is_identifier_name) +} + +fn slice_span(code: &str, span: Span) -> Option<&str> { + let (start, end) = span_range(span)?; + code.get(start..end) +} + +fn span_range(span: Span) -> Option<(usize, usize)> { + if span.lo.is_dummy() || span.hi.is_dummy() { + return None; + } + + let start = span.lo.0.checked_sub(1)? as usize; + let end = span.hi.0.checked_sub(1)? as usize; + Some((start, end)) +} + +fn utf16_column_to_byte_pos(code: &str, column: u32) -> usize { + let mut utf16_pos = 0u32; + + for (byte_pos, ch) in code.char_indices() { + if utf16_pos >= column { + return byte_pos; + } + + utf16_pos += ch.len_utf16() as u32; + if utf16_pos > column { + return byte_pos; + } + } + + code.len() +} + +fn json_string(value: &str) -> String { + serde_json::to_string(value).expect("serializing a string should not fail") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn validated_import(specifier: &str, required_exports: &[&str]) -> String { + validated_import_with_options(specifier, None, required_exports) + } + + fn validated_import_with_options( + specifier: &str, + options: Option<&str>, + required_exports: &[&str], + ) -> String { + let mut out = format!("{DYNAMIC_IMPORT_NAME}({}", json_string(specifier)); + if let Some(options) = options { + out.push_str(", "); + out.push_str(options); + } + out.push(')'); + + if !required_exports.is_empty() { + out.push_str(".then((m) => { "); + for export_name in required_exports { + out.push_str("if (!("); + out.push_str(&json_string(export_name)); + out.push_str(" in m)) throw new SyntaxError("); + out.push_str(&json_string(&format!( + "The requested module '{specifier}' does not provide an export named \ + '{export_name}'" + ))); + out.push_str("); "); + } + out.push_str("return m; })"); + } + + out + } + + #[test] + fn transform_import_forms() { + assert_eq!( + transform_module_syntax("import \"node:fs\";".into()).code, + "await __nodeREPLDynamicImport(\"node:fs\");" + ); + assert_eq!( + transform_module_syntax("import fs from \"node:fs\";".into()).code, + format!( + "const __nodeREPLImport0 = await {};", + validated_import("node:fs", &["default"]) + ) + ); + assert_eq!( + transform_module_syntax("import { readFile as rf } from \"node:fs\";".into()).code, + format!( + "const __nodeREPLImport0 = await {};", + validated_import("node:fs", &["readFile"]) + ) + ); + assert_eq!( + transform_module_syntax("import { readFile as rf } from \"node:fs\";\nrf();".into()) + .code, + format!( + "const __nodeREPLImport0 = await {};\n__nodeREPLImport0.readFile();", + validated_import("node:fs", &["readFile"]) + ) + ); + assert_eq!( + transform_module_syntax("import def, * as ns from \"mod\";".into()).code, + format!( + "const ns = await {};", + validated_import("mod", &["default"]) + ) + ); + assert_eq!( + transform_module_syntax("import def, * as ns from \"mod\";\ndef; ns;".into()).code, + format!( + "const ns = await {};\nns.default; ns;", + validated_import("mod", &["default"]) + ) + ); + assert_eq!( + transform_module_syntax("import { \"a-b\" as c } from \"mod\";".into()).code, + format!( + "const __nodeREPLImport0 = await {};", + validated_import("mod", &["a-b"]) + ) + ); + assert_eq!( + transform_module_syntax("import { \"a-b\" as c } from \"mod\";\nc;".into()).code, + format!( + "const __nodeREPLImport0 = await {};\n__nodeREPLImport0[\"a-b\"];", + validated_import("mod", &["a-b"]) + ) + ); + assert_eq!( + transform_module_syntax( + "import data from \"./data.json\" with { type: \"json\" };".into() + ) + .code, + format!( + "const __nodeREPLImport0 = await {};", + validated_import_with_options( + "./data.json", + Some("{ with: { type: \"json\" } }"), + &["default"] + ) + ) + ); + assert_eq!( + transform_module_syntax("console.log(typeof fs);\nimport fs from \"node:fs\";".into()) + .code, + format!( + "const __nodeREPLImport0 = await {};\nconsole.log(typeof \ + __nodeREPLImport0.default);\n", + validated_import("node:fs", &["default"]) + ) + ); + } + + #[test] + fn transform_export_forms() { + assert_eq!( + transform_module_syntax("export const x = 1;".into()).code, + "const x = 1;" + ); + assert_eq!( + transform_module_syntax("export default value;".into()).code, + "value;" + ); + assert_eq!( + transform_module_syntax("export default foo; doSomething();".into()).code, + "foo; doSomething();" + ); + assert_eq!( + transform_module_syntax("export default { ...config };".into()).code, + "({ ...config });" + ); + assert_eq!( + transform_module_syntax("export default function () {}".into()).code, + "(function () {});" + ); + assert_eq!( + transform_module_syntax( + "export default function f() { return import(\"node:fs\"); }".into() + ) + .code, + "function f() { return __nodeREPLDynamicImport(\"node:fs\"); }" + ); + assert_eq!( + transform_module_syntax( + "export default function () { return import(\"node:fs\"); }".into() + ) + .code, + "(function () { return __nodeREPLDynamicImport(\"node:fs\"); });" + ); + assert_eq!( + transform_module_syntax("export default class {}".into()).code, + "(class {});" + ); + assert_eq!(transform_module_syntax("export { x };".into()).code, ""); + assert_eq!( + transform_module_syntax("export * from \"mod\";".into()).code, + "await __nodeREPLDynamicImport(\"mod\");" + ); + assert_eq!( + transform_module_syntax("console.log(1);\nexport { value } from \"mod\";".into()).code, + format!( + "await {};\nconsole.log(1);\n", + validated_import("mod", &["value"]) + ) + ); + } + + #[test] + fn transform_preserves_missing_export_failures() { + let missing_default = transform_module_syntax("import missing from \"mod\";".into()); + assert!(missing_default.code.contains("\"default\" in m")); + assert!(missing_default.code.contains("export named 'default'")); + + let missing_named = transform_module_syntax("import { missing, ok } from \"mod\";".into()); + assert!(missing_named.code.contains("\"missing\" in m")); + assert!(missing_named.code.contains("\"ok\" in m")); + + let re_export = transform_module_syntax("export { missing } from \"mod\";".into()); + assert!(re_export.code.contains("\"missing\" in m")); + } + + #[test] + fn transform_preserves_import_binding_semantics() { + assert_eq!( + transform_module_syntax("import { spec } from \"m\";\nimport(spec);".into()).code, + format!( + "const __nodeREPLImport0 = await \ + {};\n__nodeREPLDynamicImport(__nodeREPLImport0.spec);", + validated_import("m", &["spec"]) + ) + ); + + assert_eq!( + transform_module_syntax( + "import { x } from \"m\";\nfunction f(x) { return x; }\nx;".into() + ) + .code, + format!( + "const __nodeREPLImport0 = await {};\nfunction f(x) {{ return x; \ + }}\n__nodeREPLImport0.x;", + validated_import("m", &["x"]) + ) + ); + + assert_eq!( + transform_module_syntax("import { x } from \"m\";\n{ const x = 1; x; }\nx;".into()) + .code, + format!( + "const __nodeREPLImport0 = await {};\n{{ const x = 1; x; }}\n__nodeREPLImport0.x;", + validated_import("m", &["x"]) + ) + ); + + assert_eq!( + transform_module_syntax("import { x } from \"m\";\ntry {} catch (x) { x; }\nx;".into()) + .code, + format!( + "const __nodeREPLImport0 = await {};\ntry {{}} catch (x) {{ x; \ + }}\n__nodeREPLImport0.x;", + validated_import("m", &["x"]) + ) + ); + + assert_eq!( + transform_module_syntax( + "import { x } from \"m\";\nfunction f(){ if (ok) { var x = 1; } return x; }\nx;" + .into() + ) + .code, + format!( + "const __nodeREPLImport0 = await {};\nfunction f(){{ if (ok) {{ var x = 1; }} \ + return x; }}\n__nodeREPLImport0.x;", + validated_import("m", &["x"]) + ) + ); + + assert_eq!( + transform_module_syntax("import { readFile } from \"node:fs\";\nreadFile = 1;".into()) + .code, + format!( + "const __nodeREPLImport0 = await {};\n(() => {{ throw new TypeError(\"Assignment \ + to constant variable.\"); }})();", + validated_import("node:fs", &["readFile"]) + ) + ); + + assert_eq!( + transform_module_syntax( + "import { readFile } from \"node:fs\";\n({ readFile } = obj);".into() + ) + .code, + format!( + "const __nodeREPLImport0 = await {};\n((() => {{ throw new TypeError(\"Assignment \ + to constant variable.\"); }})());", + validated_import("node:fs", &["readFile"]) + ) + ); + + assert_eq!( + transform_module_syntax("import { readFile } from \"node:fs\";\nreadFile++;".into()) + .code, + format!( + "const __nodeREPLImport0 = await {};\n(() => {{ throw new TypeError(\"Assignment \ + to constant variable.\"); }})();", + validated_import("node:fs", &["readFile"]) + ) + ); + } + + #[test] + fn transform_dynamic_import_and_noops() { + assert_eq!( + transform_module_syntax("const mod = import(\"node:fs\");".into()).code, + "const mod = __nodeREPLDynamicImport(\"node:fs\");" + ); + + let no_module_syntax = transform_module_syntax("const importName = \"import\";".into()); + assert!(!no_module_syntax.had_module_syntax); + assert_eq!(no_module_syntax.code, "const importName = \"import\";"); + + let incomplete = transform_module_syntax("import {".into()); + assert!(!incomplete.had_module_syntax); + assert_eq!(incomplete.code, "import {"); + + let import_meta = transform_module_syntax("console.log(import.meta.url);".into()); + assert!(import_meta.had_module_syntax); + assert!(import_meta + .code + .contains("Cannot use import.meta outside a module")); + } + + #[test] + fn transform_preserves_statement_boundaries() { + let hashbang = transform_module_syntax( + "#!/usr/bin/env node\nimport fs from \"node:fs\";\nfs.readFile;".into(), + ) + .code; + assert!(hashbang.starts_with("#!/usr/bin/env node\nconst __nodeREPLImport0 = await ")); + assert!(hashbang.contains("\n__nodeREPLImport0.default.readFile;")); + + assert_eq!( + transform_module_syntax("foo()\nimport \"x\";\n[1].forEach(bar)".into()).code, + "await __nodeREPLDynamicImport(\"x\");\nfoo()\n;\n[1].forEach(bar)" + ); + } + + #[test] + fn transform_typescript_module_syntax() { + assert_eq!( + transform_module_syntax("import fs from \"node:fs\";\nconst x: number = 1;".into()) + .code, + format!( + "const __nodeREPLImport0 = await {};\nconst x: number = 1;", + validated_import("node:fs", &["default"]) + ) + ); + + let type_only = transform_module_syntax( + "import type { Foo } from \"types\";\nexport type { Foo };\nconst x: number = 1;" + .into(), + ); + assert!(type_only.had_module_syntax); + assert!(!type_only.code.contains("__nodeREPLDynamicImport")); + assert!(!type_only.code.contains("import type")); + assert!(!type_only.code.contains("export type")); + + let re_export_type = + transform_module_syntax("export { type Foo } from \"types\";\nconst x = 1;".into()); + assert!(!re_export_type.code.contains("__nodeREPLDynamicImport")); + assert_eq!(re_export_type.code, "\nconst x = 1;"); + + let import_equals = + transform_module_syntax("import type Foo = require(\"foo\");\nconst x = 1;".into()); + assert!(import_equals.had_module_syntax); + assert_eq!(import_equals.code, "\nconst x = 1;"); + + let namespace_export = + transform_module_syntax("export as namespace Foo;\nconst x = 1;".into()); + assert!(namespace_export.had_module_syntax); + assert_eq!(namespace_export.code, "\nconst x = 1;"); + } + + #[test] + fn first_expression_from_error_column() { + assert_eq!( + get_first_expression("assert(value)".into(), 6), + "assert(value)" + ); + assert_eq!(get_first_expression("ok(value)".into(), 3), "ok(value)"); + assert_eq!( + get_first_expression("assert.ok(value)".into(), 9), + "assert.ok(value)" + ); + assert_eq!( + get_first_expression("assert['ok'](value)".into(), 12), + "assert['ok'](value)" + ); + assert_eq!( + get_first_expression("assert[method + suffix](value)".into(), 23), + "assert[method + suffix](value)" + ); + assert_eq!( + get_first_expression("assert.deepEqual(foo(), 1)".into(), 17), + "assert.deepEqual(foo(), 1)" + ); + assert_eq!( + get_first_expression("assert?.ok(value)".into(), 10), + "assert?.ok(value)" + ); + assert_eq!( + get_first_expression("a(); assert.ok(value); b()".into(), 13), + "assert.ok(value)" + ); + assert_eq!( + get_first_expression("const 🍣 = 1; assert.ok(🍣)".into(), 23), + "assert.ok(🍣)" + ); + } + + #[test] + fn valid_syntax_checks() { + assert!(is_valid_syntax("await foo".into())); + assert!(is_valid_syntax("{ value: 1 }".into())); + assert!(is_valid_syntax("foo + bar".into())); + assert!(is_valid_syntax("const x: number = 1".into())); + assert!(!is_valid_syntax("function foo(".into())); + } + + #[test] + fn recoverable_syntax_checks() { + assert!(is_recoverable_error("function foo() {".into())); + assert!(is_recoverable_error("{".into())); + assert!(is_recoverable_error("`template".into())); + assert!(is_recoverable_error("/* comment".into())); + assert!(is_recoverable_error("\"continued\\\n".into())); + assert!(!is_recoverable_error("const x: number = 1".into())); + assert!(!is_recoverable_error("{ value: 1 }".into())); + assert!(!is_recoverable_error("{}".into())); + assert!(!is_recoverable_error("2e".into())); + assert!(!is_recoverable_error("\"unterminated".into())); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9f96115fa65..0f746da7f082 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -290,6 +290,12 @@ importers: specifier: ^0.7.8 version: 0.7.8 + bindings/binding_nodejs_support_wasm: + devDependencies: + '@rstest/core': + specifier: ^0.7.8 + version: 0.7.8 + bindings/binding_typescript_wasm: devDependencies: '@rstest/core': diff --git a/scripts/publish.sh b/scripts/publish.sh index c65f0d69684f..ef0c03a51258 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -17,7 +17,7 @@ echo "Publishing $version with swc_core $swc_core_version" (cd ./packages/html && npm version "$version" --no-git-tag-version --allow-same-version || true) (cd ./packages/minifier && npm version "$version" --no-git-tag-version --allow-same-version || true) (cd ./packages/react-compiler && npm version "$version" --no-git-tag-version --allow-same-version || true) -cargo set-version $version -p binding_core_wasm -p binding_minifier_wasm -p binding_typescript_wasm -p binding_html_wasm -p binding_es_ast_viewer +cargo set-version $version -p binding_core_wasm -p binding_minifier_wasm -p binding_typescript_wasm -p binding_nodejs_support_wasm -p binding_html_wasm -p binding_es_ast_viewer cargo set-version --bump patch -p swc_cli