From 4cea0b7a1f4f54a207f7a474349e22801d3b8ee0 Mon Sep 17 00:00:00 2001 From: DongYun Kang Date: Wed, 1 Jul 2026 10:09:39 +0900 Subject: [PATCH 1/9] feat(wasm-typescript): add nodejs helpers --- .changeset/nodejs-wasm-typescript-api.md | 6 + Cargo.lock | 4 + bindings/binding_typescript_wasm/Cargo.toml | 4 + .../__tests__/transform.js | 59 ++ .../binding_typescript_wasm/scripts/esm.mjs | 17 + .../binding_typescript_wasm/scripts/patch.mjs | 17 +- bindings/binding_typescript_wasm/src/lib.rs | 72 +- .../binding_typescript_wasm/src/nodejs.rs | 637 ++++++++++++++++++ 8 files changed, 800 insertions(+), 16 deletions(-) create mode 100644 .changeset/nodejs-wasm-typescript-api.md create mode 100644 bindings/binding_typescript_wasm/src/nodejs.rs diff --git a/.changeset/nodejs-wasm-typescript-api.md b/.changeset/nodejs-wasm-typescript-api.md new file mode 100644 index 000000000000..733b5a58acdc --- /dev/null +++ b/.changeset/nodejs-wasm-typescript-api.md @@ -0,0 +1,6 @@ +--- +"@swc/wasm-typescript": minor +"@swc/wasm-typescript-esm": minor +--- + +feat(wasm-typescript): add Node.js-specific parser helpers diff --git a/Cargo.lock b/Cargo.lock index 27cdee1d6607..01d77df50253 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -543,7 +543,11 @@ dependencies = [ "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", diff --git a/bindings/binding_typescript_wasm/Cargo.toml b/bindings/binding_typescript_wasm/Cargo.toml index 922980b92e50..d49ce7a8a3ee 100644 --- a/bindings/binding_typescript_wasm/Cargo.toml +++ b/bindings/binding_typescript_wasm/Cargo.toml @@ -22,7 +22,11 @@ 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", diff --git a/bindings/binding_typescript_wasm/__tests__/transform.js b/bindings/binding_typescript_wasm/__tests__/transform.js index ebe097fec991..6a12876a2ea1 100644 --- a/bindings/binding_typescript_wasm/__tests__/transform.js +++ b/bindings/binding_typescript_wasm/__tests__/transform.js @@ -1,4 +1,6 @@ const swc = require("../pkg"); +const fs = require("node:fs"); +const path = require("node:path"); it("properly reports error", () => { expect(() => { @@ -218,3 +220,60 @@ if(!(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((( }) }); }); + +describe("nodejs namespace", () => { + it("exposes Node.js-specific helpers only through the namespace", () => { + expect(swc.nodejs).toBeDefined(); + expect(typeof swc.nodejs.transformModuleSyntax).toBe("function"); + expect(typeof swc.__nodejsTransformModuleSyntax).toBe("undefined"); + }); + + it("transforms module syntax without exposing an AST", () => { + expect( + swc.nodejs.transformModuleSyntax(`import fs from "node:fs";`), + ).toEqual({ + code: `const { default: fs } = await __nodeREPLDynamicImport("node:fs");`, + hadModuleSyntax: true, + }); + + expect( + swc.nodejs.transformModuleSyntax(`const importName = "import";`), + ).toEqual({ + code: `const importName = "import";`, + hadModuleSyntax: false, + }); + }); + + it("supports Uint8Array input", () => { + const input = new TextEncoder().encode(`import "node:fs";`); + + expect(swc.nodejs.transformModuleSyntax(input)).toEqual({ + code: `await __nodeREPLDynamicImport("node:fs");`, + hadModuleSyntax: true, + }); + }); + + it("extracts the first expression for assertion locations", () => { + expect(swc.nodejs.getFirstExpression("assert.ok(value)", 9)).toBe( + "assert.ok(value)", + ); + expect(swc.nodejs.getFirstExpression("a(); assert.ok(value); b()", 13)).toBe( + "assert.ok(value)", + ); + }); + + it("checks syntax validity and recoverability", () => { + expect(swc.nodejs.isValidSyntax("await value")).toBe(true); + expect(swc.nodejs.isValidSyntax("function foo(")).toBe(false); + expect(swc.nodejs.isRecoverableError("function foo() {")).toBe(true); + expect(swc.nodejs.isRecoverableError("2e")).toBe(false); + }); + + it("declares the public namespace in generated types", () => { + const types = fs.readFileSync(path.join(__dirname, "../pkg/wasm.d.ts"), "utf8"); + + expect(types).toContain("export declare namespace nodejs"); + expect(types).toContain("function transformModuleSyntax"); + expect(types).not.toContain("__nodejsTransformModuleSyntax"); + }); +}); diff --git a/bindings/binding_typescript_wasm/scripts/esm.mjs b/bindings/binding_typescript_wasm/scripts/esm.mjs index fef6b686e8a5..f3d233873fff 100755 --- a/bindings/binding_typescript_wasm/scripts/esm.mjs +++ b/bindings/binding_typescript_wasm/scripts/esm.mjs @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; const pkgJsonFile = await fs.readFile("esm/package.json", "utf8"); +const wasmJsFile = await fs.readFile("esm/wasm.js", "utf8"); const pkgJson = JSON.parse(pkgJsonFile); pkgJson.name = '@swc/wasm-typescript-esm'; pkgJson.exports = { @@ -9,7 +10,23 @@ pkgJson.exports = { default: "./wasm.js", }; +const patchedWasmJsFile = wasmJsFile + .replace("export function __nodejsTransformModuleSyntax(", "function __nodejsTransformModuleSyntax(") + .replace("export function __nodejsGetFirstExpression(", "function __nodejsGetFirstExpression(") + .replace("export function __nodejsIsValidSyntax(", "function __nodejsIsValidSyntax(") + .replace("export function __nodejsIsRecoverableError(", "function __nodejsIsRecoverableError(") + + ` + +export const nodejs = Object.freeze({ + transformModuleSyntax: __nodejsTransformModuleSyntax, + getFirstExpression: __nodejsGetFirstExpression, + isValidSyntax: __nodejsIsValidSyntax, + isRecoverableError: __nodejsIsRecoverableError, +}); +`; + await Promise.all([ fs.cp("src/wasm-node.js", "esm/wasm-node.js"), + fs.writeFile("esm/wasm.js", patchedWasmJsFile), fs.writeFile("esm/package.json", JSON.stringify(pkgJson, null, 2)), ]); diff --git a/bindings/binding_typescript_wasm/scripts/patch.mjs b/bindings/binding_typescript_wasm/scripts/patch.mjs index ca7634ca492a..5b946e40fabd 100755 --- a/bindings/binding_typescript_wasm/scripts/patch.mjs +++ b/bindings/binding_typescript_wasm/scripts/patch.mjs @@ -11,7 +11,20 @@ const patchedJsFile = origJsFile .replace(', fatal: true', '') .replace(`const bytes = require('fs').readFileSync(path);`, ` const { Buffer } = require('node:buffer'); -const bytes = Buffer.from('${base64}', 'base64');`) +const bytes = Buffer.from('${base64}', 'base64');`) + ` + +const __swcNodejs = Object.freeze({ + transformModuleSyntax: module.exports.__nodejsTransformModuleSyntax, + getFirstExpression: module.exports.__nodejsGetFirstExpression, + isValidSyntax: module.exports.__nodejsIsValidSyntax, + isRecoverableError: module.exports.__nodejsIsRecoverableError, +}); +delete module.exports.__nodejsTransformModuleSyntax; +delete module.exports.__nodejsGetFirstExpression; +delete module.exports.__nodejsIsValidSyntax; +delete module.exports.__nodejsIsRecoverableError; +module.exports.nodejs = __swcNodejs; +`; await fs.writeFile('pkg/wasm.js', patchedJsFile); @@ -23,4 +36,4 @@ const pkgJsonFile = await fs.readFile('pkg/package.json', 'utf8'); const pkgJson = JSON.parse(pkgJsonFile); pkgJson.name = '@swc/wasm-typescript'; pkgJson.files = pkgJson.files.filter(file => file !== 'wasm_bg.wasm'); -await fs.writeFile('pkg/package.json', JSON.stringify(pkgJson, null, 2)); \ No newline at end of file +await fs.writeFile('pkg/package.json', JSON.stringify(pkgJson, null, 2)); diff --git a/bindings/binding_typescript_wasm/src/lib.rs b/bindings/binding_typescript_wasm/src/lib.rs index ffc93e641dd5..c83c9b0eb006 100644 --- a/bindings/binding_typescript_wasm/src/lib.rs +++ b/bindings/binding_typescript_wasm/src/lib.rs @@ -20,6 +20,7 @@ use wasm_bindgen::prelude::*; use wasm_bindgen_futures::{future_to_promise, js_sys::Promise}; mod error_reporter; +mod nodejs; /// Custom interface definitions for the @swc/wasm's public interface instead of /// auto generated one, which is not reflecting most of types in detail. @@ -27,6 +28,17 @@ mod error_reporter; 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 namespace nodejs { + function transformModuleSyntax(src: string | Uint8Array): ModuleSyntaxTransformOutput; + function getFirstExpression(src: string | Uint8Array, startColumn: number): string; + function isValidSyntax(src: string | Uint8Array): boolean; + function isRecoverableError(src: string | Uint8Array): boolean; + + interface ModuleSyntaxTransformOutput { + code: string; + hadModuleSyntax: boolean; + } +} export type { Options, TransformOutput }; "#; @@ -43,20 +55,7 @@ pub fn transform_sync(input: JsValue, options: JsValue) -> Result input, - None => { - if input.is_instance_of::() { - let input = input.unchecked_into::(); - match input.to_string().as_string() { - Some(input) => input, - None => return Err(JsValue::from_str("Input Uint8Array is not valid utf-8")), - } - } else { - return Err(JsValue::from_str("Input is not a string or Uint8Array")); - } - } - }; + let input = coerce_input(input)?; let result = GLOBALS.set(&Default::default(), || operate(input, options)); @@ -66,6 +65,51 @@ pub fn transform_sync(input: JsValue, options: JsValue) -> Result Result { + let input = coerce_input(input)?; + let output = nodejs::transform_module_syntax(input); + + Ok(serde_wasm_bindgen::to_value(&output)?) +} + +#[wasm_bindgen(js_name = "__nodejsGetFirstExpression", skip_typescript)] +pub fn nodejs_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 = "__nodejsIsValidSyntax", skip_typescript)] +pub fn nodejs_is_valid_syntax(input: JsValue) -> Result { + let input = coerce_input(input)?; + + Ok(nodejs::is_valid_syntax(input)) +} + +#[wasm_bindgen(js_name = "__nodejsIsRecoverableError", skip_typescript)] +pub fn nodejs_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()); diff --git a/bindings/binding_typescript_wasm/src/nodejs.rs b/bindings/binding_typescript_wasm/src/nodejs.rs new file mode 100644 index 000000000000..3e810741fdeb --- /dev/null +++ b/bindings/binding_typescript_wasm/src/nodejs.rs @@ -0,0 +1,637 @@ +//! Node.js-specific helpers for `@swc/wasm-typescript`. +//! +//! These functions intentionally expose only the compact operations Node needs +//! from Rust. They avoid serializing SWC ASTs into JavaScript while keeping the +//! public package API under the `nodejs` namespace. + +use serde::Serialize; +use swc_common::{comments::SingleThreadedComments, sync::Lrc, FileName, SourceMap, Span, Spanned}; +use swc_ecma_ast::{ + CallExpr, Callee, EsVersion, ExportDefaultDecl, ImportDecl, ImportSpecifier, Module, + ModuleDecl, ModuleExportName, ModuleItem, +}; +use swc_ecma_parser::{ + error::{Error as ParseError, SyntaxError}, + lexer::Lexer, + unstable::{Token, TokenAndSpan}, + EsSyntax, Parser, StringInput, Syntax, +}; +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 mut edits = Vec::new(); + + for item in &module.body { + collect_module_item_edit(&code, item, &mut edits); + } + + let replaced_ranges = edits + .iter() + .map(|edit| (edit.start, edit.end)) + .collect::>(); + module.visit_with(&mut DynamicImportCollector { + code: &code, + edits: &mut edits, + replaced_ranges: &replaced_ranges, + }); + + if edits.is_empty() { + return ModuleSyntaxTransformOutput { + code, + had_module_syntax: false, + }; + } + + ModuleSyntaxTransformOutput { + code: apply_edits(code, edits), + 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 first_member_access_name_token = None; + let mut pending_optional_chain_name_token = None; + let mut terminating_byte = None; + let mut paren_level = 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 { + first_member_access_name_token = None; + pending_optional_chain_name_token = None; + last_token = None; + continue; + } + + if token.token == Token::QuestionMark + && last_token + .as_ref() + .is_some_and(|last: &TokenAndSpan| is_member_identifier_token(last.token)) + { + 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 is_member_access_token(token.token) { + if last_token + .as_ref() + .is_some_and(|last: &TokenAndSpan| is_member_identifier_token(last.token)) + { + first_member_access_name_token.get_or_insert(last_token.unwrap()); + last_token = Some(token); + continue; + } + } else if !is_member_name_token(token.token) { + first_member_access_name_token = None; + } + + last_token = Some(token); + continue; + } + + 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 { + if code.trim_start().starts_with('{') && is_recoverable_error(format!("({code}")) { + return true; + } + + match parse_program_result(&code) { + ParseOutcome::Valid => false, + ParseOutcome::Invalid(errors) => errors + .iter() + .any(|error| is_recoverable_parse_error(&code, error)), + } +} + +fn collect_module_item_edit(code: &str, item: &ModuleItem, edits: &mut Vec) { + let ModuleItem::ModuleDecl(decl) = item else { + return; + }; + + match decl { + ModuleDecl::Import(import) => { + push_span_edit(edits, code, import.span, import_decl_to_script(import)); + } + ModuleDecl::ExportDecl(export) => { + if let Some(text) = slice_span(code, export.decl.span()) { + push_span_edit(edits, code, export.span, text.to_string()); + } + } + ModuleDecl::ExportNamed(export) => { + push_span_edit(edits, code, export.span, String::new()); + } + ModuleDecl::ExportDefaultDecl(export) => { + push_span_edit( + edits, + code, + export.span, + default_decl_to_script(code, export), + ); + } + ModuleDecl::ExportDefaultExpr(export) => { + if let Some(text) = slice_span(code, export.expr.span()) { + push_span_edit(edits, code, export.span, text.to_string()); + } + } + ModuleDecl::ExportAll(export) => { + push_span_edit(edits, code, export.span, String::new()); + } + ModuleDecl::TsImportEquals(..) + | ModuleDecl::TsExportAssignment(..) + | ModuleDecl::TsNamespaceExport(..) => {} + } +} + +fn await_import(specifier: &str) -> String { + format!("{DYNAMIC_IMPORT_NAME}({})", json_string(specifier)) +} + +fn import_decl_to_script(node: &ImportDecl) -> String { + let specifier = node.src.value.to_string_lossy(); + + if node.specifiers.is_empty() { + return format!("await {};", await_import(&specifier)); + } + + let mut namespace_name = None; + let mut default_name = None; + let mut named = 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()); + } + ImportSpecifier::Named(specifier) => { + let local = specifier.local.sym.to_string(); + let imported = specifier + .imported + .as_ref() + .map(module_export_name) + .unwrap_or_else(|| local.clone()); + + if imported == local { + named.push(imported); + } else { + named.push(format!("{}: {local}", json_string(&imported))); + } + } + } + } + + if let Some(namespace_name) = namespace_name { + let mut out = format!( + "const {namespace_name} = await {};", + await_import(&specifier) + ); + if let Some(default_name) = default_name { + out.push_str(&format!( + " const {default_name} = {namespace_name}.default;" + )); + } + return out; + } + + if let Some(default_name) = default_name { + named.push(format!("default: {default_name}")); + } + + format!( + "const {{ {} }} = await {};", + named.join(", "), + await_import(&specifier) + ) +} + +fn default_decl_to_script(code: &str, export: &ExportDefaultDecl) -> String { + slice_span(code, export.decl.span()) + .unwrap_or_default() + .to_string() +} + +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, +} + +fn push_span_edit(edits: &mut Vec, code: &str, span: Span, text: String) { + if let Some((start, end)) = span_range(span) { + 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 +} + +struct DynamicImportCollector<'a, 'b> { + code: &'a str, + edits: &'b mut Vec, + replaced_ranges: &'b [(usize, usize)], +} + +impl Visit for DynamicImportCollector<'_, '_> { + fn visit_call_expr(&mut self, node: &CallExpr) { + if matches!(node.callee, Callee::Import(..)) { + if let Some((start, end)) = span_range(node.span) { + if !self + .replaced_ranges + .iter() + .any(|(replace_start, replace_end)| { + start >= *replace_start && end <= *replace_end + }) + { + let original = &self.code[start..end]; + if let Some(rest) = original.strip_prefix("import") { + self.edits.push(Edit { + start, + end, + text: format!("{DYNAMIC_IMPORT_NAME}{rest}"), + }); + } + } + } + } + + node.visit_children_with(self); + } +} + +enum ParseOutcome { + Valid, + Invalid(Vec), +} + +fn parse_program_result(code: &str) -> 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::Es(es_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 { + 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::Es(es_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 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(token: Token) -> bool { + matches!(token, Token::Ident | Token::Str | Token::Num) || token.is_known_ident() +} + +fn is_member_identifier_token(token: Token) -> bool { + token == Token::Ident || token.is_known_ident() +} + +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::*; + + #[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, + "const { default: fs } = await __nodeREPLDynamicImport(\"node:fs\");" + ); + assert_eq!( + transform_module_syntax("import { readFile as rf } from \"node:fs\";".into()).code, + "const { \"readFile\": rf } = await __nodeREPLDynamicImport(\"node:fs\");" + ); + assert_eq!( + transform_module_syntax("import def, * as ns from \"mod\";".into()).code, + "const ns = await __nodeREPLDynamicImport(\"mod\"); const def = ns.default;" + ); + assert_eq!( + transform_module_syntax("import { \"a-b\" as c } from \"mod\";".into()).code, + "const { \"a-b\": c } = await __nodeREPLDynamicImport(\"mod\");" + ); + } + + #[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 { x };".into()).code, ""); + assert_eq!( + transform_module_syntax("export * from \"mod\";".into()).code, + "" + ); + } + + #[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 {"); + } + + #[test] + fn first_expression_from_error_column() { + 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?.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("function foo(".into())); + } + + #[test] + fn recoverable_syntax_checks() { + assert!(is_recoverable_error("function foo() {".into())); + assert!(is_recoverable_error("`template".into())); + assert!(is_recoverable_error("/* comment".into())); + assert!(is_recoverable_error("\"continued\\\n".into())); + assert!(!is_recoverable_error("2e".into())); + assert!(!is_recoverable_error("\"unterminated".into())); + } +} From b768cce092ff9633e64390b66a335e373dd6b84d Mon Sep 17 00:00:00 2001 From: DongYun Kang Date: Wed, 1 Jul 2026 15:30:38 +0900 Subject: [PATCH 2/9] fix(wasm-typescript): handle nodejs module edge cases --- .../binding_typescript_wasm/src/nodejs.rs | 349 ++++++++++++++---- 1 file changed, 278 insertions(+), 71 deletions(-) diff --git a/bindings/binding_typescript_wasm/src/nodejs.rs b/bindings/binding_typescript_wasm/src/nodejs.rs index 3e810741fdeb..a9f14755599b 100644 --- a/bindings/binding_typescript_wasm/src/nodejs.rs +++ b/bindings/binding_typescript_wasm/src/nodejs.rs @@ -7,14 +7,14 @@ use serde::Serialize; use swc_common::{comments::SingleThreadedComments, sync::Lrc, FileName, SourceMap, Span, Spanned}; use swc_ecma_ast::{ - CallExpr, Callee, EsVersion, ExportDefaultDecl, ImportDecl, ImportSpecifier, Module, - ModuleDecl, ModuleExportName, ModuleItem, + CallExpr, Callee, Decl, DefaultDecl, EsVersion, ExportDefaultDecl, ExportSpecifier, ImportDecl, + ImportSpecifier, Module, ModuleDecl, ModuleExportName, ModuleItem, NamedExport, ObjectLit, }; use swc_ecma_parser::{ error::{Error as ParseError, SyntaxError}, lexer::Lexer, unstable::{Token, TokenAndSpan}, - EsSyntax, Parser, StringInput, Syntax, + EsSyntax, Parser, StringInput, Syntax, TsSyntax, }; use swc_ecma_visit::{Visit, VisitWith}; @@ -48,31 +48,36 @@ pub fn transform_module_syntax(code: String) -> ModuleSyntaxTransformOutput { }; }; - let mut edits = Vec::new(); + let (edits, hoisted) = { + let mut transform = ModuleSyntaxTransform::new(&code); - for item in &module.body { - collect_module_item_edit(&code, item, &mut edits); - } + for item in &module.body { + transform.collect_module_item_edit(item); + } - let replaced_ranges = edits - .iter() - .map(|edit| (edit.start, edit.end)) - .collect::>(); - module.visit_with(&mut DynamicImportCollector { - code: &code, - edits: &mut edits, - replaced_ranges: &replaced_ranges, - }); + let replaced_ranges = transform + .edits + .iter() + .map(|edit| (edit.start, edit.end)) + .collect::>(); + module.visit_with(&mut DynamicImportCollector { + code: &code, + edits: &mut transform.edits, + replaced_ranges: &replaced_ranges, + }); + + if transform.edits.is_empty() && transform.hoisted.is_empty() { + return ModuleSyntaxTransformOutput { + code, + had_module_syntax: false, + }; + } - if edits.is_empty() { - return ModuleSyntaxTransformOutput { - code, - had_module_syntax: false, - }; - } + (transform.edits, transform.hoisted) + }; ModuleSyntaxTransformOutput { - code: apply_edits(code, edits), + code: prepend_hoisted_imports(apply_edits(code, edits), hoisted), had_module_syntax: true, } } @@ -202,54 +207,132 @@ pub fn is_recoverable_error(code: String) -> bool { } } -fn collect_module_item_edit(code: &str, item: &ModuleItem, edits: &mut Vec) { - let ModuleItem::ModuleDecl(decl) = item else { - return; - }; +struct ModuleSyntaxTransform<'a> { + code: &'a str, + edits: Vec, + hoisted: Vec, +} - match decl { - ModuleDecl::Import(import) => { - push_span_edit(edits, code, import.span, import_decl_to_script(import)); +impl<'a> ModuleSyntaxTransform<'a> { + fn new(code: &'a str) -> Self { + Self { + code, + edits: Vec::new(), + hoisted: Vec::new(), } - ModuleDecl::ExportDecl(export) => { - if let Some(text) = slice_span(code, export.decl.span()) { - push_span_edit(edits, code, export.span, text.to_string()); + } + + 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) = import_decl_to_script(self.code, import) { + self.hoisted.push(script); + } + } } - } - ModuleDecl::ExportNamed(export) => { - push_span_edit(edits, code, export.span, String::new()); - } - ModuleDecl::ExportDefaultDecl(export) => { - push_span_edit( - edits, - code, - export.span, - default_decl_to_script(code, export), - ); - } - ModuleDecl::ExportDefaultExpr(export) => { - if let Some(text) = slice_span(code, export.expr.span()) { - push_span_edit(edits, code, export.span, text.to_string()); + 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 named_export_loads_source(export) { + self.hoist_import(&src.value.to_string_lossy(), export.with.as_deref()); + } + } + } + ModuleDecl::ExportDefaultDecl(export) => { + self.replace_span(export.span, default_decl_to_script(self.code, export)); + } + ModuleDecl::ExportDefaultExpr(export) => { + self.unwrap_default_expression(export.span, export.expr.span()); + } + 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(..) + | ModuleDecl::TsExportAssignment(..) + | ModuleDecl::TsNamespaceExport(..) => {} } - ModuleDecl::ExportAll(export) => { - push_span_edit(edits, code, export.span, String::new()); + } + + fn delete_span(&mut self, span: Span) { + self.replace_span(span, String::new()); + } + + 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 unwrap_default_expression(&mut self, export_span: Span, expr_span: Span) { + 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(), + ); + + let has_separator = self + .code + .get(expr_end..export_end) + .is_some_and(|tail| tail.contains(';')); + if !has_separator { + push_range_edit( + &mut self.edits, + self.code, + expr_end, + expr_end, + ";".to_string(), + ); } - ModuleDecl::TsImportEquals(..) - | ModuleDecl::TsExportAssignment(..) - | ModuleDecl::TsNamespaceExport(..) => {} } } -fn await_import(specifier: &str) -> String { - format!("{DYNAMIC_IMPORT_NAME}({})", json_string(specifier)) +fn await_import(code: &str, specifier: &str, with: Option<&ObjectLit>) -> 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(')'); + out } -fn import_decl_to_script(node: &ImportDecl) -> String { +fn import_decl_to_script(code: &str, node: &ImportDecl) -> Option { let specifier = node.src.value.to_string_lossy(); + let with = node.with.as_deref(); if node.specifiers.is_empty() { - return format!("await {};", await_import(&specifier)); + return Some(format!("await {};", await_import(code, &specifier, with))); } let mut namespace_name = None; @@ -265,6 +348,10 @@ fn import_decl_to_script(node: &ImportDecl) -> String { default_name = Some(specifier.local.sym.to_string()); } ImportSpecifier::Named(specifier) => { + if specifier.is_type_only { + continue; + } + let local = specifier.local.sym.to_string(); let imported = specifier .imported @@ -284,31 +371,66 @@ fn import_decl_to_script(node: &ImportDecl) -> String { if let Some(namespace_name) = namespace_name { let mut out = format!( "const {namespace_name} = await {};", - await_import(&specifier) + await_import(code, &specifier, with) ); if let Some(default_name) = default_name { out.push_str(&format!( " const {default_name} = {namespace_name}.default;" )); } - return out; + return Some(out); } if let Some(default_name) = default_name { named.push(format!("default: {default_name}")); } - format!( + if named.is_empty() { + return None; + } + + Some(format!( "const {{ {} }} = await {};", named.join(", "), - await_import(&specifier) - ) + await_import(code, &specifier, with) + )) } fn default_decl_to_script(code: &str, export: &ExportDefaultDecl) -> String { - slice_span(code, export.decl.span()) - .unwrap_or_default() - .to_string() + let Some(text) = slice_span(code, export.decl.span()) else { + return String::new(); + }; + + match &export.decl { + DefaultDecl::Class(class) if class.ident.is_none() => format!("({text});"), + DefaultDecl::Fn(function) if function.ident.is_none() => format!("({text});"), + DefaultDecl::TsInterfaceDecl(..) => String::new(), + _ => text.to_string(), + } +} + +fn import_options_to_dynamic_options(code: &str, with: Option<&ObjectLit>) -> Option { + let attributes = slice_span(code, with?.span)?; + Some(format!("{{ with: {attributes} }}")) +} + +fn is_type_only_decl(decl: &Decl) -> bool { + matches!(decl, Decl::TsInterface(..) | Decl::TsTypeAlias(..)) +} + +fn named_export_loads_source(export: &NamedExport) -> bool { + if export.type_only { + return false; + } + + if export.specifiers.is_empty() { + return true; + } + + export.specifiers.iter().any(|specifier| match specifier { + ExportSpecifier::Named(named) => !named.is_type_only, + ExportSpecifier::Namespace(..) | ExportSpecifier::Default(..) => true, + }) } fn module_export_name(name: &ModuleExportName) -> String { @@ -327,9 +449,13 @@ struct Edit { fn push_span_edit(edits: &mut Vec, code: &str, span: Span, text: String) { if let Some((start, end)) = span_range(span) { - if start <= end && end <= code.len() { - edits.push(Edit { start, end, text }); - } + 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 }); } } @@ -349,6 +475,21 @@ fn apply_edits(mut code: String, mut edits: Vec) -> String { code } +fn prepend_hoisted_imports(code: String, hoisted: Vec) -> String { + if hoisted.is_empty() { + return code; + } + + let imports = hoisted.join("\n"); + 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 DynamicImportCollector<'a, 'b> { code: &'a str, edits: &'b mut Vec, @@ -417,11 +558,16 @@ fn parse_program_result(code: &str) -> ParseOutcome { } 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::Es(es_syntax()), + syntax, EsVersion::latest(), StringInput::from(&*fm), Some(&comments), @@ -462,6 +608,13 @@ fn es_syntax() -> EsSyntax { } } +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 => { @@ -558,6 +711,20 @@ mod tests { transform_module_syntax("import { \"a-b\" as c } from \"mod\";".into()).code, "const { \"a-b\": c } = await __nodeREPLDynamicImport(\"mod\");" ); + assert_eq!( + transform_module_syntax( + "import data from \"./data.json\" with { type: \"json\" };".into() + ) + .code, + "const { default: data } = await __nodeREPLDynamicImport(\"./data.json\", { with: { \ + type: \"json\" } });" + ); + assert_eq!( + transform_module_syntax("console.log(typeof fs);\nimport fs from \"node:fs\";".into()) + .code, + "const { default: fs } = await \ + __nodeREPLDynamicImport(\"node:fs\");\nconsole.log(typeof fs);\n" + ); } #[test] @@ -568,12 +735,28 @@ mod tests { ); assert_eq!( transform_module_syntax("export default value;".into()).code, - "value" + "value;" + ); + assert_eq!( + transform_module_syntax("export default foo; doSomething();".into()).code, + "foo; doSomething();" + ); + assert_eq!( + transform_module_syntax("export default function () {}".into()).code, + "(function () {});" + ); + 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, + "await __nodeREPLDynamicImport(\"mod\");\nconsole.log(1);\n" ); } @@ -593,6 +776,30 @@ mod tests { assert_eq!(incomplete.code, "import {"); } + #[test] + fn transform_typescript_module_syntax() { + assert_eq!( + transform_module_syntax("import fs from \"node:fs\";\nconst x: number = 1;".into()) + .code, + "const { default: fs } = await __nodeREPLDynamicImport(\"node:fs\");\nconst x: number \ + = 1;" + ); + + 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;"); + } + #[test] fn first_expression_from_error_column() { assert_eq!( From 48364c0d9226b919d2143a8d1988496d3fc428f5 Mon Sep 17 00:00:00 2001 From: DongYun Kang Date: Wed, 1 Jul 2026 15:30:42 +0900 Subject: [PATCH 3/9] chore: update anyhow for rustsec advisory --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 01d77df50253..fe5c5e771e5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -168,9 +168,9 @@ checksum = "70033777eb8b5124a81a1889416543dddef2de240019b674c81285a2635a7e1e" [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "approx" From 6889ea84403adbf9cbbfdf62b7b573ef9816bf7b Mon Sep 17 00:00:00 2001 From: DongYun Kang Date: Fri, 3 Jul 2026 10:54:30 +0900 Subject: [PATCH 4/9] fix(wasm-typescript): tighten nodejs helper semantics --- .../__tests__/transform.js | 22 +- .../binding_typescript_wasm/src/nodejs.rs | 350 +++++++++++++++--- 2 files changed, 319 insertions(+), 53 deletions(-) diff --git a/bindings/binding_typescript_wasm/__tests__/transform.js b/bindings/binding_typescript_wasm/__tests__/transform.js index 6a12876a2ea1..dbc567ae14f7 100644 --- a/bindings/binding_typescript_wasm/__tests__/transform.js +++ b/bindings/binding_typescript_wasm/__tests__/transform.js @@ -2,6 +2,21 @@ const swc = require("../pkg"); const fs = require("node:fs"); const path = require("node:path"); +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; +} + it("properly reports error", () => { expect(() => { swc.transformSync("Foo {}", {}); @@ -232,7 +247,7 @@ describe("nodejs namespace", () => { expect( swc.nodejs.transformModuleSyntax(`import fs from "node:fs";`), ).toEqual({ - code: `const { default: fs } = await __nodeREPLDynamicImport("node:fs");`, + code: `const { default: fs } = await ${validatedImport("node:fs", ["default"])};`, hadModuleSyntax: true, }); @@ -257,6 +272,9 @@ describe("nodejs namespace", () => { expect(swc.nodejs.getFirstExpression("assert.ok(value)", 9)).toBe( "assert.ok(value)", ); + expect(swc.nodejs.getFirstExpression("assert(value)", 6)).toBe( + "assert(value)", + ); expect(swc.nodejs.getFirstExpression("a(); assert.ok(value); b()", 13)).toBe( "assert.ok(value)", ); @@ -264,8 +282,10 @@ describe("nodejs namespace", () => { it("checks syntax validity and recoverability", () => { expect(swc.nodejs.isValidSyntax("await value")).toBe(true); + expect(swc.nodejs.isValidSyntax("const x: number = 1")).toBe(true); expect(swc.nodejs.isValidSyntax("function foo(")).toBe(false); expect(swc.nodejs.isRecoverableError("function foo() {")).toBe(true); + expect(swc.nodejs.isRecoverableError("const x: number = 1")).toBe(false); expect(swc.nodejs.isRecoverableError("2e")).toBe(false); }); diff --git a/bindings/binding_typescript_wasm/src/nodejs.rs b/bindings/binding_typescript_wasm/src/nodejs.rs index a9f14755599b..0a9aee927b61 100644 --- a/bindings/binding_typescript_wasm/src/nodejs.rs +++ b/bindings/binding_typescript_wasm/src/nodejs.rs @@ -7,7 +7,7 @@ use serde::Serialize; use swc_common::{comments::SingleThreadedComments, sync::Lrc, FileName, SourceMap, Span, Spanned}; use swc_ecma_ast::{ - CallExpr, Callee, Decl, DefaultDecl, EsVersion, ExportDefaultDecl, ExportSpecifier, ImportDecl, + CallExpr, Callee, Decl, DefaultDecl, EsVersion, ExportSpecifier, Expr, ImportDecl, ImportSpecifier, Module, ModuleDecl, ModuleExportName, ModuleItem, NamedExport, ObjectLit, }; use swc_ecma_parser::{ @@ -149,10 +149,28 @@ pub fn get_first_expression(code: String, start_column: u32) -> String { first_member_access_name_token = None; } + if token.token == Token::LParen + && last_token + .as_ref() + .is_some_and(|last: &TokenAndSpan| is_member_identifier_token(last.token)) + { + first_member_access_name_token.get_or_insert(last_token.unwrap()); + 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(last.token)) + { + first_member_access_name_token.get_or_insert(last_token.unwrap()); + } + match token.token { Token::LParen => { paren_level += 1; @@ -248,16 +266,24 @@ impl<'a> ModuleSyntaxTransform<'a> { ModuleDecl::ExportNamed(export) => { self.delete_span(export.span); if let Some(src) = &export.src { - if named_export_loads_source(export) { - self.hoist_import(&src.value.to_string_lossy(), export.with.as_deref()); + 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.replace_span(export.span, default_decl_to_script(self.code, export)); + self.unwrap_default_declaration(export.span, export.decl.span(), &export.decl); } ModuleDecl::ExportDefaultExpr(export) => { - self.unwrap_default_expression(export.span, export.expr.span()); + self.unwrap_default_expression( + export.span, + export.expr.span(), + matches!(&*export.expr, Expr::Object(..)), + ); } ModuleDecl::ExportAll(export) => { self.delete_span(export.span); @@ -282,11 +308,71 @@ impl<'a> ModuleSyntaxTransform<'a> { fn hoist_import(&mut self, specifier: &str, with: Option<&ObjectLit>) { self.hoisted.push(format!( "await {};", - await_import(self.code, specifier, with) + 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 unwrap_default_expression(&mut self, export_span: Span, expr_span: Span) { + 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 { @@ -301,29 +387,59 @@ impl<'a> ModuleSyntaxTransform<'a> { String::new(), ); - let has_separator = self - .code - .get(expr_end..export_end) - .is_some_and(|tail| tail.contains(';')); - if !has_separator { + if needs_parentheses { push_range_edit( &mut self.edits, self.code, - expr_end, - expr_end, - ";".to_string(), + 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>) -> String { +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 } @@ -332,12 +448,16 @@ fn import_decl_to_script(code: &str, node: &ImportDecl) -> Option { let with = node.with.as_deref(); if node.specifiers.is_empty() { - return Some(format!("await {};", await_import(code, &specifier, with))); + return Some(format!( + "await {};", + await_import(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 { @@ -346,6 +466,7 @@ fn import_decl_to_script(code: &str, node: &ImportDecl) -> Option { } 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 { @@ -358,6 +479,7 @@ fn import_decl_to_script(code: &str, node: &ImportDecl) -> Option { .as_ref() .map(module_export_name) .unwrap_or_else(|| local.clone()); + push_required_export(&mut required_exports, imported.clone()); if imported == local { named.push(imported); @@ -371,7 +493,7 @@ fn import_decl_to_script(code: &str, node: &ImportDecl) -> Option { if let Some(namespace_name) = namespace_name { let mut out = format!( "const {namespace_name} = await {};", - await_import(code, &specifier, with) + await_import(code, &specifier, with, &required_exports) ); if let Some(default_name) = default_name { out.push_str(&format!( @@ -392,45 +514,62 @@ fn import_decl_to_script(code: &str, node: &ImportDecl) -> Option { Some(format!( "const {{ {} }} = await {};", named.join(", "), - await_import(code, &specifier, with) + await_import(code, &specifier, with, &required_exports) )) } -fn default_decl_to_script(code: &str, export: &ExportDefaultDecl) -> String { - let Some(text) = slice_span(code, export.decl.span()) else { - return String::new(); - }; - - match &export.decl { - DefaultDecl::Class(class) if class.ident.is_none() => format!("({text});"), - DefaultDecl::Fn(function) if function.ident.is_none() => format!("({text});"), - DefaultDecl::TsInterfaceDecl(..) => String::new(), - _ => text.to_string(), - } -} - 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_loads_source(export: &NamedExport) -> bool { +fn named_export_source_imports(export: &NamedExport) -> Option> { if export.type_only { - return false; + return None; } - if export.specifiers.is_empty() { - return true; + 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; + } + } } - export.specifiers.iter().any(|specifier| match specifier { - ExportSpecifier::Named(named) => !named.is_type_only, - ExportSpecifier::Namespace(..) | ExportSpecifier::Default(..) => 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 { @@ -529,11 +668,20 @@ enum ParseOutcome { } 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::Es(es_syntax()), + syntax, EsVersion::latest(), StringInput::from(&*fm), Some(&comments), @@ -689,6 +837,40 @@ fn json_string(value: &str) -> String { 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!( @@ -697,33 +879,53 @@ mod tests { ); assert_eq!( transform_module_syntax("import fs from \"node:fs\";".into()).code, - "const { default: fs } = await __nodeREPLDynamicImport(\"node:fs\");" + format!( + "const {{ default: fs }} = await {};", + validated_import("node:fs", &["default"]) + ) ); assert_eq!( transform_module_syntax("import { readFile as rf } from \"node:fs\";".into()).code, - "const { \"readFile\": rf } = await __nodeREPLDynamicImport(\"node:fs\");" + format!( + "const {{ \"readFile\": rf }} = await {};", + validated_import("node:fs", &["readFile"]) + ) ); assert_eq!( transform_module_syntax("import def, * as ns from \"mod\";".into()).code, - "const ns = await __nodeREPLDynamicImport(\"mod\"); const def = ns.default;" + format!( + "const ns = await {}; const def = ns.default;", + validated_import("mod", &["default"]) + ) ); assert_eq!( transform_module_syntax("import { \"a-b\" as c } from \"mod\";".into()).code, - "const { \"a-b\": c } = await __nodeREPLDynamicImport(\"mod\");" + format!( + "const {{ \"a-b\": c }} = await {};", + validated_import("mod", &["a-b"]) + ) ); assert_eq!( transform_module_syntax( "import data from \"./data.json\" with { type: \"json\" };".into() ) .code, - "const { default: data } = await __nodeREPLDynamicImport(\"./data.json\", { with: { \ - type: \"json\" } });" + format!( + "const {{ default: data }} = 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, - "const { default: fs } = await \ - __nodeREPLDynamicImport(\"node:fs\");\nconsole.log(typeof fs);\n" + format!( + "const {{ default: fs }} = await {};\nconsole.log(typeof fs);\n", + validated_import("node:fs", &["default"]) + ) ); } @@ -741,10 +943,28 @@ mod tests { 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 {});" @@ -756,10 +976,27 @@ mod tests { ); assert_eq!( transform_module_syntax("console.log(1);\nexport { value } from \"mod\";".into()).code, - "await __nodeREPLDynamicImport(\"mod\");\nconsole.log(1);\n" + 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_dynamic_import_and_noops() { assert_eq!( @@ -781,8 +1018,10 @@ mod tests { assert_eq!( transform_module_syntax("import fs from \"node:fs\";\nconst x: number = 1;".into()) .code, - "const { default: fs } = await __nodeREPLDynamicImport(\"node:fs\");\nconst x: number \ - = 1;" + format!( + "const {{ default: fs }} = await {};\nconst x: number = 1;", + validated_import("node:fs", &["default"]) + ) ); let type_only = transform_module_syntax( @@ -802,6 +1041,11 @@ mod tests { #[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)" @@ -829,6 +1073,7 @@ mod tests { 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())); } @@ -838,6 +1083,7 @@ mod tests { 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("2e".into())); assert!(!is_recoverable_error("\"unterminated".into())); } From 420acf0a3e655310a12e07212a4eb44c01a0c7e1 Mon Sep 17 00:00:00 2001 From: DongYun Kang Date: Fri, 3 Jul 2026 11:22:40 +0900 Subject: [PATCH 5/9] fix(wasm-typescript): address nodejs helper review --- .../__tests__/transform.js | 26 +- .../binding_typescript_wasm/src/nodejs.rs | 456 ++++++++++++++---- 2 files changed, 378 insertions(+), 104 deletions(-) diff --git a/bindings/binding_typescript_wasm/__tests__/transform.js b/bindings/binding_typescript_wasm/__tests__/transform.js index dbc567ae14f7..d73157663c74 100644 --- a/bindings/binding_typescript_wasm/__tests__/transform.js +++ b/bindings/binding_typescript_wasm/__tests__/transform.js @@ -247,7 +247,23 @@ describe("nodejs namespace", () => { expect( swc.nodejs.transformModuleSyntax(`import fs from "node:fs";`), ).toEqual({ - code: `const { default: fs } = await ${validatedImport("node:fs", ["default"])};`, + code: `const __nodeREPLImport0 = await ${validatedImport("node:fs", ["default"])};`, + hadModuleSyntax: true, + }); + + expect( + swc.nodejs.transformModuleSyntax( + `import { readFile as rf } from "node:fs";\nrf();`, + ), + ).toEqual({ + code: `const __nodeREPLImport0 = await ${validatedImport("node:fs", ["readFile"])};\n__nodeREPLImport0.readFile();`, + hadModuleSyntax: true, + }); + + expect( + swc.nodejs.transformModuleSyntax(`console.log(import.meta.url);`), + ).toEqual({ + code: `console.log((() => { throw new SyntaxError("Cannot use import.meta outside a module"); })().url);`, hadModuleSyntax: true, }); @@ -278,6 +294,12 @@ describe("nodejs namespace", () => { expect(swc.nodejs.getFirstExpression("a(); assert.ok(value); b()", 13)).toBe( "assert.ok(value)", ); + expect( + swc.nodejs.getFirstExpression( + "assert[method + suffix](value)", + 23, + ), + ).toBe("assert[method + suffix](value)"); }); it("checks syntax validity and recoverability", () => { @@ -285,6 +307,8 @@ describe("nodejs namespace", () => { expect(swc.nodejs.isValidSyntax("const x: number = 1")).toBe(true); expect(swc.nodejs.isValidSyntax("function foo(")).toBe(false); expect(swc.nodejs.isRecoverableError("function foo() {")).toBe(true); + expect(swc.nodejs.isRecoverableError("{ value: 1 }")).toBe(false); + expect(swc.nodejs.isRecoverableError("{}")).toBe(false); expect(swc.nodejs.isRecoverableError("const x: number = 1")).toBe(false); expect(swc.nodejs.isRecoverableError("2e")).toBe(false); }); diff --git a/bindings/binding_typescript_wasm/src/nodejs.rs b/bindings/binding_typescript_wasm/src/nodejs.rs index 0a9aee927b61..4b3624185e60 100644 --- a/bindings/binding_typescript_wasm/src/nodejs.rs +++ b/bindings/binding_typescript_wasm/src/nodejs.rs @@ -8,7 +8,8 @@ use serde::Serialize; use swc_common::{comments::SingleThreadedComments, sync::Lrc, FileName, SourceMap, Span, Spanned}; use swc_ecma_ast::{ CallExpr, Callee, Decl, DefaultDecl, EsVersion, ExportSpecifier, Expr, ImportDecl, - ImportSpecifier, Module, ModuleDecl, ModuleExportName, ModuleItem, NamedExport, ObjectLit, + ImportSpecifier, MetaPropExpr, MetaPropKind, Module, ModuleDecl, ModuleExportName, ModuleItem, + NamedExport, ObjectLit, Prop, }; use swc_ecma_parser::{ error::{Error as ParseError, SyntaxError}, @@ -60,11 +61,17 @@ pub fn transform_module_syntax(code: String) -> ModuleSyntaxTransformOutput { .iter() .map(|edit| (edit.start, edit.end)) .collect::>(); - module.visit_with(&mut DynamicImportCollector { + 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, + }); if transform.edits.is_empty() && transform.hoisted.is_empty() { return ModuleSyntaxTransformOutput { @@ -94,6 +101,7 @@ pub fn get_first_expression(code: String, start_column: u32) -> String { 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 { @@ -108,10 +116,23 @@ pub fn get_first_expression(code: String, start_column: u32) -> String { if token.token == Token::Semi { 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 token.token == Token::QuestionMark && last_token .as_ref() @@ -136,6 +157,17 @@ pub fn get_first_expression(code: String, start_column: u32) -> String { first_member_access_name_token = None; } + if token.token == Token::LBracket + && last_token + .as_ref() + .is_some_and(|last: &TokenAndSpan| is_member_identifier_token(last.token)) + { + first_member_access_name_token.get_or_insert(last_token.unwrap()); + member_bracket_depth = 1; + last_token = Some(token); + continue; + } + if is_member_access_token(token.token) { if last_token .as_ref() @@ -149,6 +181,11 @@ pub fn get_first_expression(code: String, start_column: u32) -> String { first_member_access_name_token = None; } + if token.token == Token::LParen && first_member_access_name_token.is_some() { + last_token = Some(token); + continue; + } + if token.token == Token::LParen && last_token .as_ref() @@ -213,15 +250,18 @@ pub fn is_valid_syntax(code: String) -> bool { /// Returns true for parser errors that should keep a Node REPL input open. pub fn is_recoverable_error(code: String) -> bool { - if code.trim_start().starts_with('{') && is_recoverable_error(format!("({code}")) { - return true; - } - match parse_program_result(&code) { ParseOutcome::Valid => false, - ParseOutcome::Invalid(errors) => errors - .iter() - .any(|error| is_recoverable_parse_error(&code, error)), + 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}")) + } } } @@ -229,6 +269,8 @@ struct ModuleSyntaxTransform<'a> { code: &'a str, edits: Vec, hoisted: Vec, + import_bindings: Vec, + import_namespace_count: u32, } impl<'a> ModuleSyntaxTransform<'a> { @@ -237,6 +279,8 @@ impl<'a> ModuleSyntaxTransform<'a> { code, edits: Vec::new(), hoisted: Vec::new(), + import_bindings: Vec::new(), + import_namespace_count: 0, } } @@ -249,7 +293,7 @@ impl<'a> ModuleSyntaxTransform<'a> { ModuleDecl::Import(import) => { self.delete_span(import.span); if !import.type_only { - if let Some(script) = import_decl_to_script(self.code, import) { + if let Some(script) = self.import_decl_to_script(import) { self.hoisted.push(script); } } @@ -298,7 +342,12 @@ impl<'a> ModuleSyntaxTransform<'a> { } fn delete_span(&mut self, span: Span) { - self.replace_span(span, String::new()); + 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) { @@ -324,6 +373,88 @@ impl<'a> ModuleSyntaxTransform<'a> { )); } + 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, @@ -443,81 +574,6 @@ fn await_import( out } -fn import_decl_to_script(code: &str, 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(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()); - - if imported == local { - named.push(imported); - } else { - named.push(format!("{}: {local}", json_string(&imported))); - } - } - } - } - - if let Some(namespace_name) = namespace_name { - let mut out = format!( - "const {namespace_name} = await {};", - await_import(code, &specifier, with, &required_exports) - ); - if let Some(default_name) = default_name { - out.push_str(&format!( - " const {default_name} = {namespace_name}.default;" - )); - } - return Some(out); - } - - if let Some(default_name) = default_name { - named.push(format!("default: {default_name}")); - } - - if named.is_empty() { - return None; - } - - Some(format!( - "const {{ {} }} = await {};", - named.join(", "), - await_import(code, &specifier, with, &required_exports) - )) -} - fn import_options_to_dynamic_options(code: &str, with: Option<&ObjectLit>) -> Option { let attributes = slice_span(code, with?.span)?; Some(format!("{{ with: {attributes} }}")) @@ -586,6 +642,13 @@ struct Edit { 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); @@ -614,13 +677,34 @@ fn apply_edits(mut code: String, mut edits: Vec) -> String { 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.is_empty() { + 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}") @@ -629,23 +713,17 @@ fn prepend_hoisted_imports(code: String, hoisted: Vec) -> String { } } -struct DynamicImportCollector<'a, 'b> { +struct ModuleFeatureCollector<'a, 'b> { code: &'a str, edits: &'b mut Vec, replaced_ranges: &'b [(usize, usize)], } -impl Visit for DynamicImportCollector<'_, '_> { +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 !self - .replaced_ranges - .iter() - .any(|(replace_start, replace_end)| { - start >= *replace_start && end <= *replace_end - }) - { + if !range_is_replaced(self.replaced_ranges, start, end) { let original = &self.code[start..end]; if let Some(rest) = original.strip_prefix("import") { self.edits.push(Edit { @@ -660,6 +738,127 @@ impl Visit for DynamicImportCollector<'_, '_> { 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], +} + +impl Visit for ImportBindingReferenceCollector<'_, '_> { + 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(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(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 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 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 { @@ -880,28 +1079,50 @@ mod tests { assert_eq!( transform_module_syntax("import fs from \"node:fs\";".into()).code, format!( - "const {{ default: fs }} = await {};", + "const __nodeREPLImport0 = await {};", validated_import("node:fs", &["default"]) ) ); assert_eq!( transform_module_syntax("import { readFile as rf } from \"node:fs\";".into()).code, format!( - "const {{ \"readFile\": rf }} = await {};", + "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 {}; const def = ns.default;", + "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 {{ \"a-b\": c }} = await {};", + "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"]) ) ); @@ -911,7 +1132,7 @@ mod tests { ) .code, format!( - "const {{ default: data }} = await {};", + "const __nodeREPLImport0 = await {};", validated_import_with_options( "./data.json", Some("{ with: { type: \"json\" } }"), @@ -923,7 +1144,8 @@ mod tests { transform_module_syntax("console.log(typeof fs);\nimport fs from \"node:fs\";".into()) .code, format!( - "const {{ default: fs }} = await {};\nconsole.log(typeof fs);\n", + "const __nodeREPLImport0 = await {};\nconsole.log(typeof \ + __nodeREPLImport0.default);\n", validated_import("node:fs", &["default"]) ) ); @@ -1011,6 +1233,27 @@ mod tests { 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] @@ -1019,7 +1262,7 @@ mod tests { transform_module_syntax("import fs from \"node:fs\";\nconst x: number = 1;".into()) .code, format!( - "const {{ default: fs }} = await {};\nconst x: number = 1;", + "const __nodeREPLImport0 = await {};\nconst x: number = 1;", validated_import("node:fs", &["default"]) ) ); @@ -1054,6 +1297,10 @@ mod tests { 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?.ok(value)".into(), 10), "assert?.ok(value)" @@ -1080,10 +1327,13 @@ mod tests { #[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())); } From 28709ed22f3a0ee0fa88e7076a5d8af535bfe528 Mon Sep 17 00:00:00 2001 From: DongYun Kang Date: Fri, 3 Jul 2026 11:27:28 +0900 Subject: [PATCH 6/9] fix(swc_core): avoid duplicate fixture builds --- crates/swc_core/tests/integration.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/swc_core/tests/integration.rs b/crates/swc_core/tests/integration.rs index dc520ffc0fce..839fdc7b70e8 100644 --- a/crates/swc_core/tests/integration.rs +++ b/crates/swc_core/tests/integration.rs @@ -15,13 +15,9 @@ fn build_fixture_binary(dir: &Path, target: Option<&str>) -> Result<(), Error> { let mut cmd = Command::new("cargo"); cmd.current_dir(dir); cmd.args(args).stderr(Stdio::inherit()); - cmd.output()?; + let status = cmd.status()?; - if !cmd - .status() - .expect("Exit code should be available") - .success() - { + if !status.success() { return Err(anyhow!("Failed to build binary")); } From 1de0d8e0f090423587467063c6d073a805e3cfca Mon Sep 17 00:00:00 2001 From: DongYun Kang Date: Fri, 3 Jul 2026 12:11:39 +0900 Subject: [PATCH 7/9] fix(wasm-typescript): handle nodejs review cases --- .../binding_typescript_wasm/src/nodejs.rs | 462 +++++++++++++++++- 1 file changed, 436 insertions(+), 26 deletions(-) diff --git a/bindings/binding_typescript_wasm/src/nodejs.rs b/bindings/binding_typescript_wasm/src/nodejs.rs index 4b3624185e60..1133d981d09e 100644 --- a/bindings/binding_typescript_wasm/src/nodejs.rs +++ b/bindings/binding_typescript_wasm/src/nodejs.rs @@ -7,9 +7,11 @@ use serde::Serialize; use swc_common::{comments::SingleThreadedComments, sync::Lrc, FileName, SourceMap, Span, Spanned}; use swc_ecma_ast::{ - CallExpr, Callee, Decl, DefaultDecl, EsVersion, ExportSpecifier, Expr, ImportDecl, - ImportSpecifier, MetaPropExpr, MetaPropKind, Module, ModuleDecl, ModuleExportName, ModuleItem, - NamedExport, ObjectLit, Prop, + 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}, @@ -71,6 +73,7 @@ pub fn transform_module_syntax(code: String) -> ModuleSyntaxTransformOutput { 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() { @@ -97,6 +100,7 @@ pub fn transform_module_syntax(code: String) -> ModuleSyntaxTransformOutput { 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; @@ -111,9 +115,9 @@ pub fn get_first_expression(code: String, start_column: u32) -> String { 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; @@ -133,10 +137,17 @@ pub fn get_first_expression(code: String, start_column: u32) -> String { 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(last.token)) + .is_some_and(|last: &TokenAndSpan| is_member_identifier_token(&code, last)) { pending_optional_chain_name_token = last_token; last_token = Some(token); @@ -160,9 +171,10 @@ pub fn get_first_expression(code: String, start_column: u32) -> String { if token.token == Token::LBracket && last_token .as_ref() - .is_some_and(|last: &TokenAndSpan| is_member_identifier_token(last.token)) + .is_some_and(|last: &TokenAndSpan| is_member_identifier_token(&code, last)) { - first_member_access_name_token.get_or_insert(last_token.unwrap()); + 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; @@ -171,17 +183,21 @@ pub fn get_first_expression(code: String, start_column: u32) -> String { if is_member_access_token(token.token) { if last_token .as_ref() - .is_some_and(|last: &TokenAndSpan| is_member_identifier_token(last.token)) + .is_some_and(|last: &TokenAndSpan| is_member_identifier_token(&code, last)) { - first_member_access_name_token.get_or_insert(last_token.unwrap()); + first_member_access_name_token.get_or_insert( + potential_expression_start_token.unwrap_or(last_token.unwrap()), + ); last_token = Some(token); continue; } - } else if !is_member_name_token(token.token) { + } 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; } @@ -189,9 +205,11 @@ pub fn get_first_expression(code: String, start_column: u32) -> String { if token.token == Token::LParen && last_token .as_ref() - .is_some_and(|last: &TokenAndSpan| is_member_identifier_token(last.token)) + .is_some_and(|last: &TokenAndSpan| is_member_identifier_token(&code, last)) { - first_member_access_name_token.get_or_insert(last_token.unwrap()); + 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; } @@ -203,9 +221,10 @@ pub fn get_first_expression(code: String, start_column: u32) -> String { if token.token == Token::LParen && last_token .as_ref() - .is_some_and(|last: &TokenAndSpan| is_member_identifier_token(last.token)) + .is_some_and(|last: &TokenAndSpan| is_member_identifier_token(&code, last)) { - first_member_access_name_token.get_or_insert(last_token.unwrap()); + first_member_access_name_token + .get_or_insert(potential_expression_start_token.unwrap_or(last_token.unwrap())); } match token.token { @@ -335,9 +354,15 @@ impl<'a> ModuleSyntaxTransform<'a> { self.hoist_import(&export.src.value.to_string_lossy(), export.with.as_deref()); } } - ModuleDecl::TsImportEquals(..) - | ModuleDecl::TsExportAssignment(..) - | ModuleDecl::TsNamespaceExport(..) => {} + ModuleDecl::TsImportEquals(import) => { + if import.is_type_only { + self.delete_span(import.span); + } + } + ModuleDecl::TsNamespaceExport(export) => { + self.delete_span(export.span); + } + ModuleDecl::TsExportAssignment(..) => {} } } @@ -725,11 +750,11 @@ impl Visit for ModuleFeatureCollector<'_, '_> { 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 let Some(rest) = original.strip_prefix("import") { + if original.starts_with("import") { self.edits.push(Edit { start, - end, - text: format!("{DYNAMIC_IMPORT_NAME}{rest}"), + end: start + "import".len(), + text: DYNAMIC_IMPORT_NAME.to_string(), }); } } @@ -759,9 +784,54 @@ struct ImportBindingReferenceCollector<'a, 'b> { 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()); @@ -773,7 +843,7 @@ impl Visit for ImportBindingReferenceCollector<'_, '_> { fn visit_prop(&mut self, node: &Prop) { if let Prop::Shorthand(ident) = node { - if let Some(binding) = self.binding_for(ident.sym.as_ref()) { + 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); @@ -794,7 +864,7 @@ impl Visit for ImportBindingReferenceCollector<'_, '_> { impl ImportBindingReferenceCollector<'_, '_> { fn replace_identifier(&mut self, span: Span, local: &str) { - let Some(binding) = self.binding_for(local) else { + let Some(binding) = self.binding_for_unshadowed(local) else { return; }; @@ -820,6 +890,232 @@ impl ImportBindingReferenceCollector<'_, '_> { 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 { @@ -848,6 +1144,10 @@ 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 { @@ -988,12 +1288,20 @@ fn is_member_access_token(token: Token) -> bool { ) } -fn is_member_name_token(token: Token) -> bool { - matches!(token, Token::Ident | Token::Str | Token::Num) || token.is_known_ident() +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 is_member_identifier_token(token: Token) -> bool { - token == Token::Ident || token.is_known_ident() +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> { @@ -1219,6 +1527,94 @@ mod tests { 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!( @@ -1280,6 +1676,16 @@ mod tests { 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] @@ -1301,6 +1707,10 @@ mod tests { 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)" From 0a3f602f5fccaae020c2af24df24469fa0d95efb Mon Sep 17 00:00:00 2001 From: DongYun Kang Date: Mon, 13 Jul 2026 12:29:43 +0900 Subject: [PATCH 8/9] feat(wasm): add nodejs support package --- .changeset/nodejs-support-wasm.md | 5 + .changeset/nodejs-wasm-typescript-api.md | 6 - .github/workflows/CI.yml | 2 + .github/workflows/publish-npm-package.yml | 5 + bindings/binding_typescript_wasm/Cargo.toml | 16 +- .../README.nodejs-support.md | 24 +++ .../__tests__/nodejs-support.js | 166 ++++++++++++++++++ .../__tests__/transform.js | 103 ----------- .../scripts/build-nodejs-support.sh | 21 +++ .../binding_typescript_wasm/scripts/esm.mjs | 17 -- .../binding_typescript_wasm/scripts/patch.mjs | 59 ++++--- .../binding_typescript_wasm/scripts/test.sh | 4 +- bindings/binding_typescript_wasm/src/lib.rs | 39 ++-- 13 files changed, 293 insertions(+), 174 deletions(-) create mode 100644 .changeset/nodejs-support-wasm.md delete mode 100644 .changeset/nodejs-wasm-typescript-api.md create mode 100644 bindings/binding_typescript_wasm/README.nodejs-support.md create mode 100644 bindings/binding_typescript_wasm/__tests__/nodejs-support.js create mode 100755 bindings/binding_typescript_wasm/scripts/build-nodejs-support.sh 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/.changeset/nodejs-wasm-typescript-api.md b/.changeset/nodejs-wasm-typescript-api.md deleted file mode 100644 index 733b5a58acdc..000000000000 --- a/.changeset/nodejs-wasm-typescript-api.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@swc/wasm-typescript": minor -"@swc/wasm-typescript-esm": minor ---- - -feat(wasm-typescript): add Node.js-specific parser helpers diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 1b559df611cc..99de044ca242 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -63,6 +63,8 @@ jobs: - run: cargo clippy --all --all-targets -- -D warnings + - run: cargo clippy -p binding_typescript_wasm --all-targets --features nodejs-support -- -D warnings + cargo-deny: name: Check license of dependencies runs-on: ubuntu-latest diff --git a/.github/workflows/publish-npm-package.yml b/.github/workflows/publish-npm-package.yml index 2d978891335e..9f4a14f0b3ae 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_typescript_wasm" + npm: "@swc\\/nodejs-support-wasm" + build: "./scripts/build-nodejs-support.sh" + out: "nodejs-support" - package: "html" crate: "binding_html_wasm" npm: "@swc\\/html-wasm" diff --git a/bindings/binding_typescript_wasm/Cargo.toml b/bindings/binding_typescript_wasm/Cargo.toml index d49ce7a8a3ee..3e4e31e45e42 100644 --- a/bindings/binding_typescript_wasm/Cargo.toml +++ b/bindings/binding_typescript_wasm/Cargo.toml @@ -13,7 +13,13 @@ bench = false crate-type = ["cdylib"] [features] -nightly = ["swc_ts_fast_strip/nightly"] +nightly = ["swc_ts_fast_strip/nightly"] +nodejs-support = [ + "dep:serde_json", + "dep:swc_ecma_ast", + "dep:swc_ecma_parser", + "dep:swc_ecma_visit", +] [dependencies] anyhow = { workspace = true } @@ -22,11 +28,11 @@ miette = { workspace = true } owo-colors = { workspace = true } serde = { workspace = true, features = ["derive"] } serde-wasm-bindgen = { workspace = true } -serde_json = { workspace = true } +serde_json = { workspace = true, optional = 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_ecma_ast = { path = "../../crates/swc_ecma_ast", optional = true } +swc_ecma_parser = { path = "../../crates/swc_ecma_parser", optional = true } +swc_ecma_visit = { path = "../../crates/swc_ecma_visit", optional = true } swc_error_reporters = { path = "../../crates/swc_error_reporters" } swc_ts_fast_strip = { path = "../../crates/swc_ts_fast_strip", features = [ "wasm-bindgen", diff --git a/bindings/binding_typescript_wasm/README.nodejs-support.md b/bindings/binding_typescript_wasm/README.nodejs-support.md new file mode 100644 index 000000000000..4cbcd8f466a2 --- /dev/null +++ b/bindings/binding_typescript_wasm/README.nodejs-support.md @@ -0,0 +1,24 @@ +# @swc/nodejs-support-wasm + +This package provides WebAssembly helpers used by Node.js integrations. + +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_typescript_wasm/__tests__/nodejs-support.js b/bindings/binding_typescript_wasm/__tests__/nodejs-support.js new file mode 100644 index 000000000000..4539c303ad47 --- /dev/null +++ b/bindings/binding_typescript_wasm/__tests__/nodejs-support.js @@ -0,0 +1,166 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const typescriptWasm = require("../pkg"); +const nodejsSupport = require("../nodejs-support"); + +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("keeps Node.js-specific helpers out of @swc/wasm-typescript", () => { + expect(typescriptWasm.nodejs).toBeUndefined(); + expect(typescriptWasm.transformModuleSyntax).toBeUndefined(); + expect(typescriptWasm.__nodejsTransformModuleSyntax).toBeUndefined(); + }); + + 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 = await typescriptWasm.transform(source, {}); + + expect(nodejsSupport.transformSync(source, {})).toEqual( + typescriptWasm.transformSync(source, {}) + ); + await expect(nodejsSupport.transform(source, {})).resolves.toEqual( + expected + ); + + const bytes = new TextEncoder().encode(source); + expect(nodejsSupport.transformSync(bytes, {})).toEqual( + nodejsSupport.transformSync(source, {}) + ); + }); + + 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, "../nodejs-support"); + const packageJson = JSON.parse( + fs.readFileSync(path.join(packageDir, "package.json"), "utf8") + ); + const types = fs.readFileSync( + path.join(packageDir, "wasm.d.ts"), + "utf8" + ); + const legacyTypes = fs.readFileSync( + path.join(__dirname, "../pkg/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"); + expect(legacyTypes).not.toContain("transformModuleSyntax"); + }); +}); diff --git a/bindings/binding_typescript_wasm/__tests__/transform.js b/bindings/binding_typescript_wasm/__tests__/transform.js index d73157663c74..ebe097fec991 100644 --- a/bindings/binding_typescript_wasm/__tests__/transform.js +++ b/bindings/binding_typescript_wasm/__tests__/transform.js @@ -1,21 +1,4 @@ const swc = require("../pkg"); -const fs = require("node:fs"); -const path = require("node:path"); - -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; -} it("properly reports error", () => { expect(() => { @@ -235,89 +218,3 @@ if(!(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((( }) }); }); - -describe("nodejs namespace", () => { - it("exposes Node.js-specific helpers only through the namespace", () => { - expect(swc.nodejs).toBeDefined(); - expect(typeof swc.nodejs.transformModuleSyntax).toBe("function"); - expect(typeof swc.__nodejsTransformModuleSyntax).toBe("undefined"); - }); - - it("transforms module syntax without exposing an AST", () => { - expect( - swc.nodejs.transformModuleSyntax(`import fs from "node:fs";`), - ).toEqual({ - code: `const __nodeREPLImport0 = await ${validatedImport("node:fs", ["default"])};`, - hadModuleSyntax: true, - }); - - expect( - swc.nodejs.transformModuleSyntax( - `import { readFile as rf } from "node:fs";\nrf();`, - ), - ).toEqual({ - code: `const __nodeREPLImport0 = await ${validatedImport("node:fs", ["readFile"])};\n__nodeREPLImport0.readFile();`, - hadModuleSyntax: true, - }); - - expect( - swc.nodejs.transformModuleSyntax(`console.log(import.meta.url);`), - ).toEqual({ - code: `console.log((() => { throw new SyntaxError("Cannot use import.meta outside a module"); })().url);`, - hadModuleSyntax: true, - }); - - expect( - swc.nodejs.transformModuleSyntax(`const importName = "import";`), - ).toEqual({ - code: `const importName = "import";`, - hadModuleSyntax: false, - }); - }); - - it("supports Uint8Array input", () => { - const input = new TextEncoder().encode(`import "node:fs";`); - - expect(swc.nodejs.transformModuleSyntax(input)).toEqual({ - code: `await __nodeREPLDynamicImport("node:fs");`, - hadModuleSyntax: true, - }); - }); - - it("extracts the first expression for assertion locations", () => { - expect(swc.nodejs.getFirstExpression("assert.ok(value)", 9)).toBe( - "assert.ok(value)", - ); - expect(swc.nodejs.getFirstExpression("assert(value)", 6)).toBe( - "assert(value)", - ); - expect(swc.nodejs.getFirstExpression("a(); assert.ok(value); b()", 13)).toBe( - "assert.ok(value)", - ); - expect( - swc.nodejs.getFirstExpression( - "assert[method + suffix](value)", - 23, - ), - ).toBe("assert[method + suffix](value)"); - }); - - it("checks syntax validity and recoverability", () => { - expect(swc.nodejs.isValidSyntax("await value")).toBe(true); - expect(swc.nodejs.isValidSyntax("const x: number = 1")).toBe(true); - expect(swc.nodejs.isValidSyntax("function foo(")).toBe(false); - expect(swc.nodejs.isRecoverableError("function foo() {")).toBe(true); - expect(swc.nodejs.isRecoverableError("{ value: 1 }")).toBe(false); - expect(swc.nodejs.isRecoverableError("{}")).toBe(false); - expect(swc.nodejs.isRecoverableError("const x: number = 1")).toBe(false); - expect(swc.nodejs.isRecoverableError("2e")).toBe(false); - }); - - it("declares the public namespace in generated types", () => { - const types = fs.readFileSync(path.join(__dirname, "../pkg/wasm.d.ts"), "utf8"); - - expect(types).toContain("export declare namespace nodejs"); - expect(types).toContain("function transformModuleSyntax"); - expect(types).not.toContain("__nodejsTransformModuleSyntax"); - }); -}); diff --git a/bindings/binding_typescript_wasm/scripts/build-nodejs-support.sh b/bindings/binding_typescript_wasm/scripts/build-nodejs-support.sh new file mode 100755 index 000000000000..187821623d69 --- /dev/null +++ b/bindings/binding_typescript_wasm/scripts/build-nodejs-support.sh @@ -0,0 +1,21 @@ +#!/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 \ + --out-dir nodejs-support \ + --release \ + --scope=swc \ + --target nodejs \ + --features "${WASM_FEATURES:-nodejs-support}" \ + "$@" +ls -al ./nodejs-support + +node ./scripts/patch.mjs \ + nodejs-support \ + @swc/nodejs-support-wasm \ + "SWC WebAssembly helpers for Node.js" +cp ./README.nodejs-support.md ./nodejs-support/README.md diff --git a/bindings/binding_typescript_wasm/scripts/esm.mjs b/bindings/binding_typescript_wasm/scripts/esm.mjs index f3d233873fff..fef6b686e8a5 100755 --- a/bindings/binding_typescript_wasm/scripts/esm.mjs +++ b/bindings/binding_typescript_wasm/scripts/esm.mjs @@ -1,7 +1,6 @@ import fs from "node:fs/promises"; const pkgJsonFile = await fs.readFile("esm/package.json", "utf8"); -const wasmJsFile = await fs.readFile("esm/wasm.js", "utf8"); const pkgJson = JSON.parse(pkgJsonFile); pkgJson.name = '@swc/wasm-typescript-esm'; pkgJson.exports = { @@ -10,23 +9,7 @@ pkgJson.exports = { default: "./wasm.js", }; -const patchedWasmJsFile = wasmJsFile - .replace("export function __nodejsTransformModuleSyntax(", "function __nodejsTransformModuleSyntax(") - .replace("export function __nodejsGetFirstExpression(", "function __nodejsGetFirstExpression(") - .replace("export function __nodejsIsValidSyntax(", "function __nodejsIsValidSyntax(") - .replace("export function __nodejsIsRecoverableError(", "function __nodejsIsRecoverableError(") - + ` - -export const nodejs = Object.freeze({ - transformModuleSyntax: __nodejsTransformModuleSyntax, - getFirstExpression: __nodejsGetFirstExpression, - isValidSyntax: __nodejsIsValidSyntax, - isRecoverableError: __nodejsIsRecoverableError, -}); -`; - await Promise.all([ fs.cp("src/wasm-node.js", "esm/wasm-node.js"), - fs.writeFile("esm/wasm.js", patchedWasmJsFile), fs.writeFile("esm/package.json", JSON.stringify(pkgJson, null, 2)), ]); diff --git a/bindings/binding_typescript_wasm/scripts/patch.mjs b/bindings/binding_typescript_wasm/scripts/patch.mjs index 5b946e40fabd..c4480da85360 100755 --- a/bindings/binding_typescript_wasm/scripts/patch.mjs +++ b/bindings/binding_typescript_wasm/scripts/patch.mjs @@ -1,39 +1,44 @@ -import fs from 'node:fs/promises'; +import fs from "node:fs/promises"; +import path from "node:path"; +const [ + outDir = "pkg", + packageName = "@swc/wasm-typescript", + packageDescription, +] = process.argv.slice(2); -const rawWasmFile = await fs.readFile('pkg/wasm_bg.wasm'); -const origJsFile = await fs.readFile('pkg/wasm.js', 'utf8'); +const rawWasmPath = path.join(outDir, "wasm_bg.wasm"); +const wasmJsPath = path.join(outDir, "wasm.js"); +const packageJsonPath = path.join(outDir, "package.json"); +const rawWasmFile = await fs.readFile(rawWasmPath); +const origJsFile = await fs.readFile(wasmJsPath, "utf8"); -const base64 = rawWasmFile.toString('base64'); +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);`, ` + .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');`) + ` +const bytes = Buffer.from('${base64}', 'base64');` + ); -const __swcNodejs = Object.freeze({ - transformModuleSyntax: module.exports.__nodejsTransformModuleSyntax, - getFirstExpression: module.exports.__nodejsGetFirstExpression, - isValidSyntax: module.exports.__nodejsIsValidSyntax, - isRecoverableError: module.exports.__nodejsIsRecoverableError, -}); -delete module.exports.__nodejsTransformModuleSyntax; -delete module.exports.__nodejsGetFirstExpression; -delete module.exports.__nodejsIsValidSyntax; -delete module.exports.__nodejsIsRecoverableError; -module.exports.nodejs = __swcNodejs; -`; - -await fs.writeFile('pkg/wasm.js', patchedJsFile); +await fs.writeFile(wasmJsPath, patchedJsFile); // Remove wasm file -await fs.unlink('pkg/wasm_bg.wasm'); +await fs.unlink(rawWasmPath); // Remove wasm from .files section of package.json -const pkgJsonFile = await fs.readFile('pkg/package.json', 'utf8'); +const pkgJsonFile = await fs.readFile(packageJsonPath, "utf8"); const pkgJson = JSON.parse(pkgJsonFile); -pkgJson.name = '@swc/wasm-typescript'; -pkgJson.files = pkgJson.files.filter(file => file !== 'wasm_bg.wasm'); -await fs.writeFile('pkg/package.json', JSON.stringify(pkgJson, null, 2)); +pkgJson.name = packageName; +if (packageDescription) { + pkgJson.description = packageDescription; +} +pkgJson.files = pkgJson.files.filter((file) => file !== "wasm_bg.wasm"); +await fs.writeFile(packageJsonPath, JSON.stringify(pkgJson, null, 2)); diff --git a/bindings/binding_typescript_wasm/scripts/test.sh b/bindings/binding_typescript_wasm/scripts/test.sh index 4e6e79a0ddab..cc29d1b9cb8d 100755 --- a/bindings/binding_typescript_wasm/scripts/test.sh +++ b/bindings/binding_typescript_wasm/scripts/test.sh @@ -3,7 +3,9 @@ set -eu ./scripts/build.sh +./scripts/build-nodejs-support.sh npx rstest $@ ./scripts/build.sh --features nightly -npx rstest $@ \ No newline at end of file +WASM_FEATURES=nodejs-support,nightly ./scripts/build-nodejs-support.sh +npx rstest $@ diff --git a/bindings/binding_typescript_wasm/src/lib.rs b/bindings/binding_typescript_wasm/src/lib.rs index c83c9b0eb006..094e19a890b3 100644 --- a/bindings/binding_typescript_wasm/src/lib.rs +++ b/bindings/binding_typescript_wasm/src/lib.rs @@ -20,6 +20,7 @@ use wasm_bindgen::prelude::*; use wasm_bindgen_futures::{future_to_promise, js_sys::Promise}; mod error_reporter; +#[cfg(feature = "nodejs-support")] mod nodejs; /// Custom interface definitions for the @swc/wasm's public interface instead of @@ -28,20 +29,24 @@ mod nodejs; 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 namespace nodejs { - function transformModuleSyntax(src: string | Uint8Array): ModuleSyntaxTransformOutput; - function getFirstExpression(src: string | Uint8Array, startColumn: number): string; - function isValidSyntax(src: string | Uint8Array): boolean; - function isRecoverableError(src: string | Uint8Array): boolean; - - interface ModuleSyntaxTransformOutput { - code: string; - hadModuleSyntax: boolean; - } -} export type { Options, TransformOutput }; "#; +/// Node.js-specific helpers exposed only by `@swc/nodejs-support-wasm`. +#[cfg(feature = "nodejs-support")] +#[wasm_bindgen(typescript_custom_section)] +const NODEJS_SUPPORT_INTERFACE_DEFINITIONS: &'static str = r#" +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 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) }) @@ -65,7 +70,8 @@ pub fn transform_sync(input: JsValue, options: JsValue) -> Result Result { let input = coerce_input(input)?; let output = nodejs::transform_module_syntax(input); @@ -73,21 +79,24 @@ pub fn nodejs_transform_module_syntax(input: JsValue) -> Result Result { let input = coerce_input(input)?; Ok(nodejs::get_first_expression(input, start_column)) } -#[wasm_bindgen(js_name = "__nodejsIsValidSyntax", skip_typescript)] +#[cfg(feature = "nodejs-support")] +#[wasm_bindgen(js_name = "isValidSyntax", skip_typescript)] pub fn nodejs_is_valid_syntax(input: JsValue) -> Result { let input = coerce_input(input)?; Ok(nodejs::is_valid_syntax(input)) } -#[wasm_bindgen(js_name = "__nodejsIsRecoverableError", skip_typescript)] +#[cfg(feature = "nodejs-support")] +#[wasm_bindgen(js_name = "isRecoverableError", skip_typescript)] pub fn nodejs_is_recoverable_error(input: JsValue) -> Result { let input = coerce_input(input)?; From 199b340838140fade2a0eb7e74bfb23dfdd18fad Mon Sep 17 00:00:00 2001 From: DongYun Kang Date: Mon, 13 Jul 2026 13:28:59 +0900 Subject: [PATCH 9/9] refactor(wasm): split nodejs support binding --- .github/workflows/CI.yml | 3 +- .github/workflows/publish-npm-package.yml | 6 +- Cargo.lock | 31 +- .../.cargo/config.toml | 3 + .../binding_nodejs_support_wasm/Cargo.toml | 40 + bindings/binding_nodejs_support_wasm/LICENSE | 201 ++++ .../README.md} | 4 +- .../__tests__/nodejs-support.js | 37 +- .../binding_nodejs_support_wasm/package.json | 5 + .../rstest.config.mjs | 4 + .../scripts/build.sh | 16 + .../scripts/patch.mjs | 31 + .../scripts/test.sh | 9 + .../src/error_reporter.rs | 881 ++++++++++++++++++ .../binding_nodejs_support_wasm/src/lib.rs | 302 ++++++ .../src/nodejs.rs | 5 +- bindings/binding_typescript_wasm/Cargo.toml | 12 +- .../scripts/build-nodejs-support.sh | 21 - .../binding_typescript_wasm/scripts/patch.mjs | 46 +- .../binding_typescript_wasm/scripts/test.sh | 4 +- bindings/binding_typescript_wasm/src/lib.rs | 81 +- crates/swc_core/tests/integration.rs | 8 +- pnpm-lock.yaml | 6 + scripts/publish.sh | 2 +- 24 files changed, 1586 insertions(+), 172 deletions(-) create mode 100644 bindings/binding_nodejs_support_wasm/.cargo/config.toml create mode 100644 bindings/binding_nodejs_support_wasm/Cargo.toml create mode 100644 bindings/binding_nodejs_support_wasm/LICENSE rename bindings/{binding_typescript_wasm/README.nodejs-support.md => binding_nodejs_support_wasm/README.md} (72%) rename bindings/{binding_typescript_wasm => binding_nodejs_support_wasm}/__tests__/nodejs-support.js (86%) create mode 100644 bindings/binding_nodejs_support_wasm/package.json create mode 100644 bindings/binding_nodejs_support_wasm/rstest.config.mjs create mode 100755 bindings/binding_nodejs_support_wasm/scripts/build.sh create mode 100644 bindings/binding_nodejs_support_wasm/scripts/patch.mjs create mode 100755 bindings/binding_nodejs_support_wasm/scripts/test.sh create mode 100644 bindings/binding_nodejs_support_wasm/src/error_reporter.rs create mode 100644 bindings/binding_nodejs_support_wasm/src/lib.rs rename bindings/{binding_typescript_wasm => binding_nodejs_support_wasm}/src/nodejs.rs (99%) delete mode 100755 bindings/binding_typescript_wasm/scripts/build-nodejs-support.sh diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 99de044ca242..792650219d30 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -63,8 +63,6 @@ jobs: - run: cargo clippy --all --all-targets -- -D warnings - - run: cargo clippy -p binding_typescript_wasm --all-targets --features nodejs-support -- -D warnings - cargo-deny: name: Check license of dependencies runs-on: ubuntu-latest @@ -156,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 9f4a14f0b3ae..70b0e246d4bc 100644 --- a/.github/workflows/publish-npm-package.yml +++ b/.github/workflows/publish-npm-package.yml @@ -760,10 +760,10 @@ jobs: build: "./scripts/esm.sh" out: "esm" - package: "core" - crate: "binding_typescript_wasm" + crate: "binding_nodejs_support_wasm" npm: "@swc\\/nodejs-support-wasm" - build: "./scripts/build-nodejs-support.sh" - out: "nodejs-support" + 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 fe5c5e771e5c..dff6af306772 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -168,9 +168,9 @@ checksum = "70033777eb8b5124a81a1889416543dddef2de240019b674c81285a2635a7e1e" [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "approx" @@ -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" @@ -543,11 +566,7 @@ dependencies = [ "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", 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_typescript_wasm/README.nodejs-support.md b/bindings/binding_nodejs_support_wasm/README.md similarity index 72% rename from bindings/binding_typescript_wasm/README.nodejs-support.md rename to bindings/binding_nodejs_support_wasm/README.md index 4cbcd8f466a2..abd29b9bb046 100644 --- a/bindings/binding_typescript_wasm/README.nodejs-support.md +++ b/bindings/binding_nodejs_support_wasm/README.md @@ -1,6 +1,8 @@ # @swc/nodejs-support-wasm -This package provides WebAssembly helpers used by Node.js integrations. +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 diff --git a/bindings/binding_typescript_wasm/__tests__/nodejs-support.js b/bindings/binding_nodejs_support_wasm/__tests__/nodejs-support.js similarity index 86% rename from bindings/binding_typescript_wasm/__tests__/nodejs-support.js rename to bindings/binding_nodejs_support_wasm/__tests__/nodejs-support.js index 4539c303ad47..eb42c37b8a4f 100644 --- a/bindings/binding_typescript_wasm/__tests__/nodejs-support.js +++ b/bindings/binding_nodejs_support_wasm/__tests__/nodejs-support.js @@ -1,7 +1,6 @@ const fs = require("node:fs"); const path = require("node:path"); -const typescriptWasm = require("../pkg"); -const nodejsSupport = require("../nodejs-support"); +const nodejsSupport = require("../pkg"); function validatedImport(specifier, exports) { let out = `__nodeREPLDynamicImport(${JSON.stringify(specifier)})`; @@ -21,12 +20,6 @@ function validatedImport(specifier, exports) { } describe("@swc/nodejs-support-wasm", () => { - it("keeps Node.js-specific helpers out of @swc/wasm-typescript", () => { - expect(typescriptWasm.nodejs).toBeUndefined(); - expect(typescriptWasm.transformModuleSyntax).toBeUndefined(); - expect(typescriptWasm.__nodejsTransformModuleSyntax).toBeUndefined(); - }); - 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"); @@ -40,21 +33,30 @@ describe("@swc/nodejs-support-wasm", () => { it("preserves the existing TypeScript transform behavior", async () => { const source = "export const value: number = 1;"; - const expected = await typescriptWasm.transform(source, {}); + const expected = { + code: "export const value = 1;", + map: undefined, + }; - expect(nodejsSupport.transformSync(source, {})).toEqual( - typescriptWasm.transformSync(source, {}) - ); + 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( - nodejsSupport.transformSync(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";`) @@ -134,7 +136,7 @@ describe("@swc/nodejs-support-wasm", () => { }); it("generates package metadata and top-level TypeScript declarations", () => { - const packageDir = path.join(__dirname, "../nodejs-support"); + const packageDir = path.join(__dirname, "../pkg"); const packageJson = JSON.parse( fs.readFileSync(path.join(packageDir, "package.json"), "utf8") ); @@ -142,10 +144,6 @@ describe("@swc/nodejs-support-wasm", () => { path.join(packageDir, "wasm.d.ts"), "utf8" ); - const legacyTypes = fs.readFileSync( - path.join(__dirname, "../pkg/wasm.d.ts"), - "utf8" - ); expect(packageJson.name).toBe("@swc/nodejs-support-wasm"); expect(packageJson.description).toBe( @@ -161,6 +159,5 @@ describe("@swc/nodejs-support-wasm", () => { expect(types).toContain("export interface ModuleSyntaxTransformOutput"); expect(types).not.toContain("namespace nodejs"); expect(types).not.toContain("__nodejsTransformModuleSyntax"); - expect(legacyTypes).not.toContain("transformModuleSyntax"); }); }); 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_typescript_wasm/src/nodejs.rs b/bindings/binding_nodejs_support_wasm/src/nodejs.rs similarity index 99% rename from bindings/binding_typescript_wasm/src/nodejs.rs rename to bindings/binding_nodejs_support_wasm/src/nodejs.rs index 1133d981d09e..cef6bf21e655 100644 --- a/bindings/binding_typescript_wasm/src/nodejs.rs +++ b/bindings/binding_nodejs_support_wasm/src/nodejs.rs @@ -1,8 +1,7 @@ -//! Node.js-specific helpers for `@swc/wasm-typescript`. +//! Node.js-specific helpers for `@swc/nodejs-support-wasm`. //! //! These functions intentionally expose only the compact operations Node needs -//! from Rust. They avoid serializing SWC ASTs into JavaScript while keeping the -//! public package API under the `nodejs` namespace. +//! from Rust and avoid serializing SWC ASTs into JavaScript. use serde::Serialize; use swc_common::{comments::SingleThreadedComments, sync::Lrc, FileName, SourceMap, Span, Spanned}; diff --git a/bindings/binding_typescript_wasm/Cargo.toml b/bindings/binding_typescript_wasm/Cargo.toml index 3e4e31e45e42..922980b92e50 100644 --- a/bindings/binding_typescript_wasm/Cargo.toml +++ b/bindings/binding_typescript_wasm/Cargo.toml @@ -13,13 +13,7 @@ bench = false crate-type = ["cdylib"] [features] -nightly = ["swc_ts_fast_strip/nightly"] -nodejs-support = [ - "dep:serde_json", - "dep:swc_ecma_ast", - "dep:swc_ecma_parser", - "dep:swc_ecma_visit", -] +nightly = ["swc_ts_fast_strip/nightly"] [dependencies] anyhow = { workspace = true } @@ -28,11 +22,7 @@ miette = { workspace = true } owo-colors = { workspace = true } serde = { workspace = true, features = ["derive"] } serde-wasm-bindgen = { workspace = true } -serde_json = { workspace = true, optional = true } swc_common = { path = "../../crates/swc_common" } -swc_ecma_ast = { path = "../../crates/swc_ecma_ast", optional = true } -swc_ecma_parser = { path = "../../crates/swc_ecma_parser", optional = true } -swc_ecma_visit = { path = "../../crates/swc_ecma_visit", optional = true } swc_error_reporters = { path = "../../crates/swc_error_reporters" } swc_ts_fast_strip = { path = "../../crates/swc_ts_fast_strip", features = [ "wasm-bindgen", diff --git a/bindings/binding_typescript_wasm/scripts/build-nodejs-support.sh b/bindings/binding_typescript_wasm/scripts/build-nodejs-support.sh deleted file mode 100755 index 187821623d69..000000000000 --- a/bindings/binding_typescript_wasm/scripts/build-nodejs-support.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/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 \ - --out-dir nodejs-support \ - --release \ - --scope=swc \ - --target nodejs \ - --features "${WASM_FEATURES:-nodejs-support}" \ - "$@" -ls -al ./nodejs-support - -node ./scripts/patch.mjs \ - nodejs-support \ - @swc/nodejs-support-wasm \ - "SWC WebAssembly helpers for Node.js" -cp ./README.nodejs-support.md ./nodejs-support/README.md diff --git a/bindings/binding_typescript_wasm/scripts/patch.mjs b/bindings/binding_typescript_wasm/scripts/patch.mjs index c4480da85360..ca7634ca492a 100755 --- a/bindings/binding_typescript_wasm/scripts/patch.mjs +++ b/bindings/binding_typescript_wasm/scripts/patch.mjs @@ -1,44 +1,26 @@ -import fs from "node:fs/promises"; -import path from "node:path"; +import fs from 'node:fs/promises'; -const [ - outDir = "pkg", - packageName = "@swc/wasm-typescript", - packageDescription, -] = process.argv.slice(2); -const rawWasmPath = path.join(outDir, "wasm_bg.wasm"); -const wasmJsPath = path.join(outDir, "wasm.js"); -const packageJsonPath = path.join(outDir, "package.json"); -const rawWasmFile = await fs.readFile(rawWasmPath); -const origJsFile = await fs.readFile(wasmJsPath, "utf8"); +const rawWasmFile = await fs.readFile('pkg/wasm_bg.wasm'); +const origJsFile = await fs.readFile('pkg/wasm.js', 'utf8'); -const base64 = rawWasmFile.toString("base64"); +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);`, - ` + .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');` - ); +const bytes = Buffer.from('${base64}', 'base64');`) -await fs.writeFile(wasmJsPath, patchedJsFile); +await fs.writeFile('pkg/wasm.js', patchedJsFile); // Remove wasm file -await fs.unlink(rawWasmPath); +await fs.unlink('pkg/wasm_bg.wasm'); // Remove wasm from .files section of package.json -const pkgJsonFile = await fs.readFile(packageJsonPath, "utf8"); +const pkgJsonFile = await fs.readFile('pkg/package.json', 'utf8'); const pkgJson = JSON.parse(pkgJsonFile); -pkgJson.name = packageName; -if (packageDescription) { - pkgJson.description = packageDescription; -} -pkgJson.files = pkgJson.files.filter((file) => file !== "wasm_bg.wasm"); -await fs.writeFile(packageJsonPath, JSON.stringify(pkgJson, null, 2)); +pkgJson.name = '@swc/wasm-typescript'; +pkgJson.files = pkgJson.files.filter(file => file !== 'wasm_bg.wasm'); +await fs.writeFile('pkg/package.json', JSON.stringify(pkgJson, null, 2)); \ No newline at end of file diff --git a/bindings/binding_typescript_wasm/scripts/test.sh b/bindings/binding_typescript_wasm/scripts/test.sh index cc29d1b9cb8d..4e6e79a0ddab 100755 --- a/bindings/binding_typescript_wasm/scripts/test.sh +++ b/bindings/binding_typescript_wasm/scripts/test.sh @@ -3,9 +3,7 @@ set -eu ./scripts/build.sh -./scripts/build-nodejs-support.sh npx rstest $@ ./scripts/build.sh --features nightly -WASM_FEATURES=nodejs-support,nightly ./scripts/build-nodejs-support.sh -npx rstest $@ +npx rstest $@ \ No newline at end of file diff --git a/bindings/binding_typescript_wasm/src/lib.rs b/bindings/binding_typescript_wasm/src/lib.rs index 094e19a890b3..ffc93e641dd5 100644 --- a/bindings/binding_typescript_wasm/src/lib.rs +++ b/bindings/binding_typescript_wasm/src/lib.rs @@ -20,8 +20,6 @@ use wasm_bindgen::prelude::*; use wasm_bindgen_futures::{future_to_promise, js_sys::Promise}; mod error_reporter; -#[cfg(feature = "nodejs-support")] -mod nodejs; /// Custom interface definitions for the @swc/wasm's public interface instead of /// auto generated one, which is not reflecting most of types in detail. @@ -32,21 +30,6 @@ export declare function transformSync(src: string | Uint8Array, opts?: Options): export type { Options, TransformOutput }; "#; -/// Node.js-specific helpers exposed only by `@swc/nodejs-support-wasm`. -#[cfg(feature = "nodejs-support")] -#[wasm_bindgen(typescript_custom_section)] -const NODEJS_SUPPORT_INTERFACE_DEFINITIONS: &'static str = r#" -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 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) }) @@ -60,62 +43,26 @@ pub fn transform_sync(input: JsValue, options: JsValue) -> Result Ok(serde_wasm_bindgen::to_value(&v)?), - Err(errors) => Err(serde_wasm_bindgen::to_value(&errors[0])?), - } -} - -#[cfg(feature = "nodejs-support")] -#[wasm_bindgen(js_name = "transformModuleSyntax", skip_typescript)] -pub fn nodejs_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)?) -} - -#[cfg(feature = "nodejs-support")] -#[wasm_bindgen(js_name = "getFirstExpression", skip_typescript)] -pub fn nodejs_get_first_expression(input: JsValue, start_column: u32) -> Result { - let input = coerce_input(input)?; - - Ok(nodejs::get_first_expression(input, start_column)) -} - -#[cfg(feature = "nodejs-support")] -#[wasm_bindgen(js_name = "isValidSyntax", skip_typescript)] -pub fn nodejs_is_valid_syntax(input: JsValue) -> Result { - let input = coerce_input(input)?; - - Ok(nodejs::is_valid_syntax(input)) -} - -#[cfg(feature = "nodejs-support")] -#[wasm_bindgen(js_name = "isRecoverableError", skip_typescript)] -pub fn nodejs_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), + let input = match input.as_string() { + Some(input) => 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")) + match input.to_string().as_string() { + Some(input) => input, + None => return Err(JsValue::from_str("Input Uint8Array is not valid utf-8")), + } } else { - Err(JsValue::from_str("Input is not a string or Uint8Array")) + return Err(JsValue::from_str("Input is not a string or Uint8Array")); } } + }; + + 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])?), } } diff --git a/crates/swc_core/tests/integration.rs b/crates/swc_core/tests/integration.rs index 839fdc7b70e8..dc520ffc0fce 100644 --- a/crates/swc_core/tests/integration.rs +++ b/crates/swc_core/tests/integration.rs @@ -15,9 +15,13 @@ fn build_fixture_binary(dir: &Path, target: Option<&str>) -> Result<(), Error> { let mut cmd = Command::new("cargo"); cmd.current_dir(dir); cmd.args(args).stderr(Stdio::inherit()); - let status = cmd.status()?; + cmd.output()?; - if !status.success() { + if !cmd + .status() + .expect("Exit code should be available") + .success() + { return Err(anyhow!("Failed to build binary")); } 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