//! Node.js-specific helpers exposed by Amaro.
//!
//! These functions intentionally expose only the compact operations Node needs
//! from Rust and avoid serializing SWC ASTs into JavaScript.
use serde::Serialize;
use swc_common::{comments::SingleThreadedComments, sync::Lrc, FileName, SourceMap, Span, Spanned};
use swc_ecma_ast::{
CallExpr, Callee, Decl, DefaultDecl, EsVersion, ExportSpecifier, Expr, ImportDecl,
ImportSpecifier, MetaPropExpr, MetaPropKind, Module, ModuleDecl, ModuleExportName, ModuleItem,
NamedExport, ObjectLit,
};
use swc_ecma_parser::{
error::{Error as ParseError, SyntaxError},
lexer::Lexer,
unstable::{Token, TokenAndSpan},
EsSyntax, Parser, StringInput, Syntax, TsSyntax,
};
use swc_ecma_visit::{Visit, VisitWith};
mod awaits;
mod position;
mod tokenize;
#[cfg(test)]
mod fixtures;
pub use self::{awaits::find_top_level_awaits, tokenize::tokenize};
const DYNAMIC_IMPORT_NAME: &str = "__nodeREPLDynamicImport";
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModuleSyntaxTransformOutput {
pub code: String,
pub had_module_syntax: bool,
}
/// Rewrites Node REPL module syntax into script syntax.
///
/// The transform follows Node's edit-list approach: static imports become
/// awaited dynamic imports, dynamic `import(...)` is routed through Node's REPL
/// helper, and exports are unwrapped or dropped depending on whether they have
/// runtime declarations.
pub fn transform_module_syntax(code: String) -> ModuleSyntaxTransformOutput {
if !code.contains("import") && !code.contains("export") {
return ModuleSyntaxTransformOutput {
code,
had_module_syntax: false,
};
}
let Some(module) = parse_module(&code) else {
return ModuleSyntaxTransformOutput {
code,
had_module_syntax: false,
};
};
let (edits, hoisted) = {
let mut transform = ModuleSyntaxTransform::new(&code);
for item in &module.body {
transform.collect_module_item_edit(item);
}
let replaced_ranges = transform
.edits
.iter()
.map(|edit| (edit.start, edit.end))
.collect::<Vec<_>>();
module.visit_with(&mut ModuleFeatureCollector {
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,
};
}
(transform.edits, transform.hoisted)
};
ModuleSyntaxTransformOutput {
code: prepend_hoisted_imports(apply_edits(code, edits), hoisted),
had_module_syntax: true,
}
}
/// Extracts the first source expression associated with a V8 error column.
///
/// `start_column` is interpreted as a JavaScript UTF-16 code-unit column to
/// match Node/V8 source locations. The implementation uses only lexer tokens
/// because the source line can be incomplete JavaScript.
pub fn get_first_expression(code: String, start_column: u32) -> String {
let start_byte = utf16_column_to_byte_pos(&code, start_column);
let mut last_token = None;
let mut potential_expression_start_token = None;
let mut first_member_access_name_token = None;
let mut pending_optional_chain_name_token = None;
let mut terminating_byte = None;
let mut paren_level = 0u32;
let mut member_bracket_depth = 0u32;
for token in lex_tokens(&code) {
if token.token == Token::Eof {
break;
}
let Some((token_start, token_end)) = span_range(token.span) else {
continue;
};
if token_start < start_byte {
if token.token == Token::Semi {
potential_expression_start_token = None;
first_member_access_name_token = None;
pending_optional_chain_name_token = None;
member_bracket_depth = 0;
last_token = None;
continue;
}
if member_bracket_depth > 0 {
match token.token {
Token::LBracket => member_bracket_depth += 1,
Token::RBracket => {
member_bracket_depth = member_bracket_depth.saturating_sub(1);
}
_ => {}
}
last_token = Some(token);
continue;
}
if is_member_identifier_token(&code, &token)
&& first_member_access_name_token.is_none()
&& potential_expression_start_token.is_none()
{
potential_expression_start_token = Some(token);
}
if token.token == Token::QuestionMark
&& last_token
.as_ref()
.is_some_and(|last: &TokenAndSpan| is_member_identifier_token(&code, last))
{
pending_optional_chain_name_token = last_token;
last_token = Some(token);
continue;
}
if token.token == Token::Dot
&& last_token
.as_ref()
.is_some_and(|last: &TokenAndSpan| last.token == Token::QuestionMark)
{
if let Some(name_token) = pending_optional_chain_name_token.take() {
first_member_access_name_token.get_or_insert(name_token);
last_token = Some(token);
continue;
}
} else if pending_optional_chain_name_token.take().is_some() {
first_member_access_name_token = None;
}
if token.token == Token::LBracket
&& last_token
.as_ref()
.is_some_and(|last: &TokenAndSpan| is_member_identifier_token(&code, last))
{
first_member_access_name_token
.get_or_insert(potential_expression_start_token.unwrap_or(last_token.unwrap()));
member_bracket_depth = 1;
last_token = Some(token);
continue;
}
if is_member_access_token(token.token) {
if last_token
.as_ref()
.is_some_and(|last: &TokenAndSpan| is_member_identifier_token(&code, last))
{
first_member_access_name_token.get_or_insert(
potential_expression_start_token.unwrap_or(last_token.unwrap()),
);
last_token = Some(token);
continue;
}
} else if token.token != Token::LParen && !is_member_name_token(&code, &token) {
potential_expression_start_token = None;
first_member_access_name_token = None;
}
if token.token == Token::LParen && first_member_access_name_token.is_some() {
paren_level += 1;
last_token = Some(token);
continue;
}
if token.token == Token::LParen
&& last_token
.as_ref()
.is_some_and(|last: &TokenAndSpan| is_member_identifier_token(&code, last))
{
first_member_access_name_token
.get_or_insert(potential_expression_start_token.unwrap_or(last_token.unwrap()));
paren_level += 1;
last_token = Some(token);
continue;
}
last_token = Some(token);
continue;
}
if token.token == Token::LParen
&& last_token
.as_ref()
.is_some_and(|last: &TokenAndSpan| is_member_identifier_token(&code, last))
{
first_member_access_name_token
.get_or_insert(potential_expression_start_token.unwrap_or(last_token.unwrap()));
}
match token.token {
Token::LParen => {
paren_level += 1;
}
Token::RParen => {
paren_level = paren_level.saturating_sub(1);
if paren_level == 0 {
terminating_byte = Some(token_end);
break;
}
}
Token::Semi => {
terminating_byte = Some(token_start);
break;
}
_ => {}
}
}
let start = first_member_access_name_token
.and_then(|token| span_range(token.span).map(|(start, _)| start))
.unwrap_or(start_byte);
let end = terminating_byte.unwrap_or(code.len());
if start >= code.len()
|| start > end
|| !code.is_char_boundary(start)
|| !code.is_char_boundary(end)
{
return String::new();
}
code[start..end].to_string()
}
/// Returns true when the source parses as a program or expression.
pub fn is_valid_syntax(code: String) -> bool {
parses_as_program(&code) || parses_as_program(&format!("_={code}"))
}
/// Returns true for parser errors that should keep a Node REPL input open.
pub fn is_recoverable_error(code: String) -> bool {
match parse_program_result(&code) {
ParseOutcome::Valid => false,
ParseOutcome::Invalid(errors) => {
if errors
.iter()
.any(|error| is_recoverable_parse_error(&code, error))
{
return true;
}
code.trim_start().starts_with('{') && is_recoverable_error(format!("({code}"))
}
}
}
struct ModuleSyntaxTransform<'a> {
code: &'a str,
edits: Vec<Edit>,
hoisted: Vec<String>,
}
impl<'a> ModuleSyntaxTransform<'a> {
fn new(code: &'a str) -> Self {
Self {
code,
edits: Vec::new(),
hoisted: Vec::new(),
}
}
fn collect_module_item_edit(&mut self, item: &ModuleItem) {
let ModuleItem::ModuleDecl(decl) = item else {
return;
};
match decl {
ModuleDecl::Import(import) => {
self.delete_span(import.span);
if !import.type_only {
if let Some(script) = self.import_decl_to_script(import) {
self.hoisted.push(script);
}
}
}
ModuleDecl::ExportDecl(export) => {
if is_type_only_decl(&export.decl) {
self.delete_span(export.span);
} else if let (Some((start, _)), Some((decl_start, _))) =
(span_range(export.span), span_range(export.decl.span()))
{
push_range_edit(&mut self.edits, self.code, start, decl_start, String::new());
}
}
ModuleDecl::ExportNamed(export) => {
self.delete_span(export.span);
if let Some(src) = &export.src {
if let Some(required_exports) = named_export_source_imports(export) {
self.hoist_validated_import(
&src.value.to_string_lossy(),
export.with.as_deref(),
&required_exports,
);
}
}
}
ModuleDecl::ExportDefaultDecl(export) => {
self.unwrap_default_declaration(export.span, export.decl.span(), &export.decl);
}
ModuleDecl::ExportDefaultExpr(export) => {
self.unwrap_default_expression(
export.span,
export.expr.span(),
matches!(&*export.expr, Expr::Object(..)),
);
}
ModuleDecl::ExportAll(export) => {
self.delete_span(export.span);
if !export.type_only {
self.hoist_import(&export.src.value.to_string_lossy(), export.with.as_deref());
}
}
ModuleDecl::TsImportEquals(import) => {
if import.is_type_only {
self.delete_span(import.span);
}
}
ModuleDecl::TsNamespaceExport(export) => {
self.delete_span(export.span);
}
ModuleDecl::TsExportAssignment(..) => {}
}
}
fn delete_span(&mut self, span: Span) {
let replacement = if needs_statement_separator(self.code, span) {
";"
} else {
""
};
self.replace_span(span, replacement.to_string());
}
fn replace_span(&mut self, span: Span, text: String) {
push_span_edit(&mut self.edits, self.code, span, text);
}
fn hoist_import(&mut self, specifier: &str, with: Option<&ObjectLit>) {
self.hoisted.push(format!(
"await {};",
await_import(self.code, specifier, with, &[])
));
}
fn hoist_validated_import(
&mut self,
specifier: &str,
with: Option<&ObjectLit>,
required_exports: &[String],
) {
self.hoisted.push(format!(
"await {};",
await_import(self.code, specifier, with, required_exports)
));
}
fn import_decl_to_script(&self, node: &ImportDecl) -> Option<String> {
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 import = await_import(self.code, &specifier, with, &required_exports);
if let Some(namespace_name) = namespace_name {
let mut out = format!("const {namespace_name} = await {import};");
if let Some(default_name) = default_name {
out.push_str(&format!(
" const {default_name} = {namespace_name}.default;"
));
}
return Some(out);
}
// Declare the user's bindings so subsequent REPL inputs can access them.
// Like Node's REPL transform, these capture the initial export values;
// namespace imports still refer to the live module namespace object.
let mut bindings = Vec::with_capacity(named.len() + usize::from(default_name.is_some()));
if let Some(default_name) = default_name {
bindings.push(format!("default: {default_name}"));
}
for (local, imported) in named {
// Quote arbitrary export names while keeping ordinary names readable.
let key = if is_identifier_name(&imported) {
imported
} else {
json_string(&imported)
};
bindings.push(format!("{key}: {local}"));
}
Some(format!(
"const {{ {} }} = await {import};",
bindings.join(", ")
))
}
fn unwrap_default_declaration(
&mut self,
export_span: Span,
decl_span: Span,
decl: &DefaultDecl,
) {
if matches!(decl, DefaultDecl::TsInterfaceDecl(..)) {
self.delete_span(export_span);
return;
}
let (Some((export_start, _)), Some((decl_start, decl_end))) =
(span_range(export_span), span_range(decl_span))
else {
return;
};
push_range_edit(
&mut self.edits,
self.code,
export_start,
decl_start,
String::new(),
);
if default_declaration_needs_parentheses(decl) {
push_range_edit(
&mut self.edits,
self.code,
decl_start,
decl_start,
"(".to_string(),
);
push_range_edit(
&mut self.edits,
self.code,
decl_end,
decl_end,
");".to_string(),
);
}
}
fn unwrap_default_expression(
&mut self,
export_span: Span,
expr_span: Span,
needs_parentheses: bool,
) {
let (Some((export_start, export_end)), Some((expr_start, expr_end))) =
(span_range(export_span), span_range(expr_span))
else {
return;
};
push_range_edit(
&mut self.edits,
self.code,
export_start,
expr_start,
String::new(),
);
if needs_parentheses {
push_range_edit(
&mut self.edits,
self.code,
expr_start,
expr_start,
"(".to_string(),
);
}
let has_separator = self
.code
.get(expr_end..export_end)
.is_some_and(|tail| tail.contains(';'));
if needs_parentheses || !has_separator {
let mut suffix = String::new();
if needs_parentheses {
suffix.push(')');
}
if !has_separator {
suffix.push(';');
}
push_range_edit(&mut self.edits, self.code, expr_end, expr_end, suffix);
}
}
}
fn await_import(
code: &str,
specifier: &str,
with: Option<&ObjectLit>,
required_exports: &[String],
) -> String {
let mut out = format!("{DYNAMIC_IMPORT_NAME}({}", json_string(specifier));
if let Some(options) = import_options_to_dynamic_options(code, with) {
out.push_str(", ");
out.push_str(&options);
}
out.push(')');
if !required_exports.is_empty() {
out.push_str(".then((m) => { ");
for export_name in required_exports {
out.push_str("if (!(");
out.push_str(&json_string(export_name));
out.push_str(" in m)) throw new SyntaxError(");
out.push_str(&json_string(&format!(
"The requested module '{specifier}' does not provide an export named \
'{export_name}'"
)));
out.push_str("); ");
}
out.push_str("return m; })");
}
out
}
fn import_options_to_dynamic_options(code: &str, with: Option<&ObjectLit>) -> Option<String> {
let attributes = slice_span(code, with?.span)?;
Some(format!("{{ with: {attributes} }}"))
}
fn default_declaration_needs_parentheses(decl: &DefaultDecl) -> bool {
matches!(decl, DefaultDecl::Class(class) if class.ident.is_none())
|| matches!(decl, DefaultDecl::Fn(function) if function.ident.is_none())
}
fn is_type_only_decl(decl: &Decl) -> bool {
matches!(decl, Decl::TsInterface(..) | Decl::TsTypeAlias(..))
}
fn named_export_source_imports(export: &NamedExport) -> Option<Vec<String>> {
if export.type_only {
return None;
}
let mut loads_source = export.specifiers.is_empty();
let mut required_exports = Vec::new();
for specifier in &export.specifiers {
match specifier {
ExportSpecifier::Named(named) => {
if named.is_type_only {
continue;
}
loads_source = true;
push_required_export(&mut required_exports, module_export_name(&named.orig));
}
ExportSpecifier::Default(..) => {
loads_source = true;
push_required_export(&mut required_exports, "default".to_string());
}
ExportSpecifier::Namespace(..) => {
loads_source = true;
}
}
}
loads_source.then_some(required_exports)
}
fn push_required_export(required_exports: &mut Vec<String>, export_name: String) {
if !required_exports
.iter()
.any(|required| required == &export_name)
{
required_exports.push(export_name);
}
}
fn module_export_name(name: &ModuleExportName) -> String {
match name {
ModuleExportName::Ident(ident) => ident.sym.to_string(),
ModuleExportName::Str(string) => string.value.to_string_lossy().into_owned(),
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct Edit {
start: usize,
end: usize,
text: String,
}
fn push_span_edit(edits: &mut Vec<Edit>, code: &str, span: Span, text: String) {
if let Some((start, end)) = span_range(span) {
push_range_edit(edits, code, start, end, text);
}
}
fn push_range_edit(edits: &mut Vec<Edit>, code: &str, start: usize, end: usize, text: String) {
if start <= end && end <= code.len() {
edits.push(Edit { start, end, text });
}
}
fn apply_edits(mut code: String, mut edits: Vec<Edit>) -> String {
edits.sort_by(|a, b| b.start.cmp(&a.start).then_with(|| b.end.cmp(&a.end)));
for edit in edits {
if edit.start <= edit.end
&& edit.end <= code.len()
&& code.is_char_boundary(edit.start)
&& code.is_char_boundary(edit.end)
{
code.replace_range(edit.start..edit.end, &edit.text);
}
}
code
}
fn needs_statement_separator(code: &str, span: Span) -> bool {
let Some((start, end)) = span_range(span) else {
return false;
};
code[..start].chars().any(|ch| !ch.is_whitespace())
&& code[end..].chars().any(|ch| !ch.is_whitespace())
}
fn prepend_hoisted_imports(code: String, hoisted: Vec<String>) -> String {
if hoisted.is_empty() {
return code;
}
let imports = hoisted.join("\n");
if code.starts_with("#!") {
if let Some(line_end) = code.find('\n') {
let insert_at = line_end + 1;
let (hashbang, rest) = code.split_at(insert_at);
if rest.is_empty() || rest.starts_with('\n') {
format!("{hashbang}{imports}{rest}")
} else {
format!("{hashbang}{imports}\n{rest}")
}
} else {
format!("{code}\n{imports}")
}
} else if code.is_empty() {
imports
} else if code.starts_with('\n') || code.starts_with("\r\n") {
format!("{imports}{code}")
} else {
format!("{imports}\n{code}")
}
}
struct ModuleFeatureCollector<'a, 'b> {
code: &'a str,
edits: &'b mut Vec<Edit>,
replaced_ranges: &'b [(usize, usize)],
}
impl Visit for ModuleFeatureCollector<'_, '_> {
fn visit_call_expr(&mut self, node: &CallExpr) {
if matches!(node.callee, Callee::Import(..)) {
if let Some((start, end)) = span_range(node.span) {
if !range_is_replaced(self.replaced_ranges, start, end) {
let original = &self.code[start..end];
if original.starts_with("import") {
self.edits.push(Edit {
start,
end: start + "import".len(),
text: DYNAMIC_IMPORT_NAME.to_string(),
});
}
}
}
}
node.visit_children_with(self);
}
fn visit_meta_prop_expr(&mut self, node: &MetaPropExpr) {
if node.kind == MetaPropKind::ImportMeta {
if let Some((start, end)) = span_range(node.span) {
if !range_is_replaced(self.replaced_ranges, start, end) {
self.edits.push(Edit {
start,
end,
text: import_meta_error_expression(),
});
}
}
}
}
}
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_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 {
Valid,
Invalid(Vec<ParseError>),
}
fn parse_program_result(code: &str) -> ParseOutcome {
let es = parse_program_result_with_syntax(code, Syntax::Es(es_syntax()));
if matches!(es, ParseOutcome::Valid) {
return es;
}
parse_program_result_with_syntax(code, Syntax::Typescript(ts_syntax()))
}
fn parse_program_result_with_syntax(code: &str, syntax: Syntax) -> ParseOutcome {
let cm = Lrc::new(SourceMap::default());
let fm = cm.new_source_file(FileName::Anon.into(), code.to_string());
let comments = SingleThreadedComments::default();
let lexer = Lexer::new(
syntax,
EsVersion::latest(),
StringInput::from(&*fm),
Some(&comments),
);
let mut parser = Parser::new_from(lexer);
match parser.parse_program() {
Ok(..) => {
let errors = parser.take_errors();
if errors.is_empty() {
ParseOutcome::Valid
} else {
ParseOutcome::Invalid(errors)
}
}
Err(error) => {
let mut errors = vec![error];
errors.extend(parser.take_errors());
ParseOutcome::Invalid(errors)
}
}
}
fn parse_module(code: &str) -> Option<Module> {
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<Module> {
let cm = Lrc::new(SourceMap::default());
let fm = cm.new_source_file(FileName::Anon.into(), code.to_string());
let comments = SingleThreadedComments::default();
let lexer = Lexer::new(
syntax,
EsVersion::latest(),
StringInput::from(&*fm),
Some(&comments),
);
let mut parser = Parser::new_from(lexer);
let module = parser.parse_module().ok()?;
if parser.take_errors().is_empty() {
Some(module)
} else {
None
}
}
fn parses_as_program(code: &str) -> bool {
matches!(parse_program_result(code), ParseOutcome::Valid)
}
fn lex_tokens(code: &str) -> Vec<TokenAndSpan> {
let cm = Lrc::new(SourceMap::default());
let fm = cm.new_source_file(FileName::Anon.into(), code.to_string());
let comments = SingleThreadedComments::default();
Lexer::new(
Syntax::Es(es_syntax()),
EsVersion::latest(),
StringInput::from(&*fm),
Some(&comments),
)
.collect()
}
fn es_syntax() -> EsSyntax {
EsSyntax {
auto_accessors: true,
explicit_resource_management: true,
import_attributes: true,
..Default::default()
}
}
fn ts_syntax() -> TsSyntax {
TsSyntax {
decorators: true,
..Default::default()
}
}
fn is_recoverable_parse_error(code: &str, error: &ParseError) -> bool {
match error.kind() {
SyntaxError::Eof | SyntaxError::UnterminatedTpl | SyntaxError::UnterminatedBlockComment => {
true
}
SyntaxError::UnterminatedStrLit => has_trailing_line_continuation(code),
SyntaxError::Expected(_, got) | SyntaxError::Unexpected { got, .. } => got == "<eof>",
_ => false,
}
}
fn has_trailing_line_continuation(code: &str) -> bool {
code.ends_with("\\\n")
|| code.ends_with("\\\r")
|| code.ends_with("\\\r\n")
|| code.ends_with("\\\u{2028}")
|| code.ends_with("\\\u{2029}")
}
fn is_member_access_token(token: Token) -> bool {
matches!(
token,
Token::Dot | Token::OptionalChain | Token::LBracket | Token::RBracket
)
}
fn is_member_name_token(code: &str, token: &TokenAndSpan) -> bool {
matches!(token.token, Token::Ident | Token::Str | Token::Num)
|| token.token.is_known_ident()
|| token_text_is_identifier(code, token)
}
fn is_member_identifier_token(code: &str, token: &TokenAndSpan) -> bool {
token.token == Token::Ident
|| token.token.is_known_ident()
|| token_text_is_identifier(code, token)
}
fn token_text_is_identifier(code: &str, token: &TokenAndSpan) -> bool {
slice_span(code, token.span).is_some_and(is_identifier_name)
}
fn slice_span(code: &str, span: Span) -> Option<&str> {
let (start, end) = span_range(span)?;
code.get(start..end)
}
fn span_range(span: Span) -> Option<(usize, usize)> {
if span.lo.is_dummy() || span.hi.is_dummy() {
return None;
}
let start = span.lo.0.checked_sub(1)? as usize;
let end = span.hi.0.checked_sub(1)? as usize;
Some((start, end))
}
fn utf16_column_to_byte_pos(code: &str, column: u32) -> usize {
let mut utf16_pos = 0u32;
for (byte_pos, ch) in code.char_indices() {
if utf16_pos >= column {
return byte_pos;
}
utf16_pos += ch.len_utf16() as u32;
if utf16_pos > column {
return byte_pos;
}
}
code.len()
}
fn json_string(value: &str) -> String {
serde_json::to_string(value).expect("serializing a string should not fail")
}
#[cfg(test)]
mod tests {
use super::*;
fn validated_import(specifier: &str, required_exports: &[&str]) -> String {
validated_import_with_options(specifier, None, required_exports)
}
fn validated_import_with_options(
specifier: &str,
options: Option<&str>,
required_exports: &[&str],
) -> String {
let mut out = format!("{DYNAMIC_IMPORT_NAME}({}", json_string(specifier));
if let Some(options) = options {
out.push_str(", ");
out.push_str(options);
}
out.push(')');
if !required_exports.is_empty() {
out.push_str(".then((m) => { ");
for export_name in required_exports {
out.push_str("if (!(");
out.push_str(&json_string(export_name));
out.push_str(" in m)) throw new SyntaxError(");
out.push_str(&json_string(&format!(
"The requested module '{specifier}' does not provide an export named \
'{export_name}'"
)));
out.push_str("); ");
}
out.push_str("return m; })");
}
out
}
#[test]
fn transform_import_forms() {
assert_eq!(
transform_module_syntax("import \"node:fs\";".into()).code,
"await __nodeREPLDynamicImport(\"node:fs\");"
);
assert_eq!(
transform_module_syntax("import fs from \"node:fs\";".into()).code,
format!(
"const {{ default: fs }} = 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 {};",
validated_import("node:fs", &["readFile"])
)
);
assert_eq!(
transform_module_syntax("import { readFile as rf } from \"node:fs\";\nrf();".into())
.code,
format!(
"const {{ readFile: rf }} = await {};\nrf();",
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;",
validated_import("mod", &["default"])
)
);
assert_eq!(
transform_module_syntax("import def, * as ns from \"mod\";\ndef; ns;".into()).code,
format!(
"const ns = await {}; const def = ns.default;\ndef; 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 {};",
validated_import("mod", &["a-b"])
)
);
assert_eq!(
transform_module_syntax("import { \"a-b\" as c } from \"mod\";\nc;".into()).code,
format!(
"const {{ \"a-b\": c }} = await {};\nc;",
validated_import("mod", &["a-b"])
)
);
assert_eq!(
transform_module_syntax(
"import data from \"./data.json\" with { type: \"json\" };".into()
)
.code,
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,
format!(
"const {{ default: fs }} = await {};\nconsole.log(typeof fs);\n",
validated_import("node:fs", &["default"])
)
);
}
#[test]
fn transform_export_forms() {
assert_eq!(
transform_module_syntax("export const x = 1;".into()).code,
"const x = 1;"
);
assert_eq!(
transform_module_syntax("export default value;".into()).code,
"value;"
);
assert_eq!(
transform_module_syntax("export default foo; doSomething();".into()).code,
"foo; doSomething();"
);
assert_eq!(
transform_module_syntax("export default { ...config };".into()).code,
"({ ...config });"
);
assert_eq!(
transform_module_syntax("export default function () {}".into()).code,
"(function () {});"
);
assert_eq!(
transform_module_syntax(
"export default function f() { return import(\"node:fs\"); }".into()
)
.code,
"function f() { return __nodeREPLDynamicImport(\"node:fs\"); }"
);
assert_eq!(
transform_module_syntax(
"export default function () { return import(\"node:fs\"); }".into()
)
.code,
"(function () { return __nodeREPLDynamicImport(\"node:fs\"); });"
);
assert_eq!(
transform_module_syntax("export default class {}".into()).code,
"(class {});"
);
assert_eq!(transform_module_syntax("export { x };".into()).code, "");
assert_eq!(
transform_module_syntax("export * from \"mod\";".into()).code,
"await __nodeREPLDynamicImport(\"mod\");"
);
assert_eq!(
transform_module_syntax("console.log(1);\nexport { value } from \"mod\";".into()).code,
format!(
"await {};\nconsole.log(1);\n",
validated_import("mod", &["value"])
)
);
}
#[test]
fn transform_preserves_missing_export_failures() {
let missing_default = transform_module_syntax("import missing from \"mod\";".into());
assert!(missing_default.code.contains("\"default\" in m"));
assert!(missing_default.code.contains("export named 'default'"));
let missing_named = transform_module_syntax("import { missing, ok } from \"mod\";".into());
assert!(missing_named.code.contains("\"missing\" in m"));
assert!(missing_named.code.contains("\"ok\" in m"));
let re_export = transform_module_syntax("export { missing } from \"mod\";".into());
assert!(re_export.code.contains("\"missing\" in m"));
}
#[test]
fn transform_preserves_import_binding_semantics() {
assert_eq!(
transform_module_syntax("import { spec } from \"m\";\nimport(spec);".into()).code,
format!(
"const {{ spec: spec }} = await {};\n__nodeREPLDynamicImport(spec);",
validated_import("m", &["spec"])
)
);
assert_eq!(
transform_module_syntax(
"import { x } from \"m\";\nfunction f(x) { return x; }\nx;".into()
)
.code,
format!(
"const {{ x: x }} = await {};\nfunction f(x) {{ return x; }}\nx;",
validated_import("m", &["x"])
)
);
assert_eq!(
transform_module_syntax("import { x } from \"m\";\n{ const x = 1; x; }\nx;".into())
.code,
format!(
"const {{ x: x }} = await {};\n{{ const x = 1; x; }}\nx;",
validated_import("m", &["x"])
)
);
assert_eq!(
transform_module_syntax("import { x } from \"m\";\ntry {} catch (x) { x; }\nx;".into())
.code,
format!(
"const {{ x: x }} = await {};\ntry {{}} catch (x) {{ x; }}\nx;",
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 {{ x: x }} = await {};\nfunction f(){{ if (ok) {{ var x = 1; }} return x; \
}}\nx;",
validated_import("m", &["x"])
)
);
assert_eq!(
transform_module_syntax("import { readFile } from \"node:fs\";\nreadFile = 1;".into())
.code,
format!(
"const {{ readFile: readFile }} = await {};\nreadFile = 1;",
validated_import("node:fs", &["readFile"])
)
);
assert_eq!(
transform_module_syntax(
"import { readFile } from \"node:fs\";\n({ readFile } = obj);".into()
)
.code,
format!(
"const {{ readFile: readFile }} = await {};\n({{ readFile }} = obj);",
validated_import("node:fs", &["readFile"])
)
);
assert_eq!(
transform_module_syntax("import { readFile } from \"node:fs\";\nreadFile++;".into())
.code,
format!(
"const {{ readFile: readFile }} = await {};\nreadFile++;",
validated_import("node:fs", &["readFile"])
)
);
}
#[test]
fn transform_dynamic_import_and_noops() {
assert_eq!(
transform_module_syntax("const mod = import(\"node:fs\");".into()).code,
"const mod = __nodeREPLDynamicImport(\"node:fs\");"
);
let no_module_syntax = transform_module_syntax("const importName = \"import\";".into());
assert!(!no_module_syntax.had_module_syntax);
assert_eq!(no_module_syntax.code, "const importName = \"import\";");
let incomplete = transform_module_syntax("import {".into());
assert!(!incomplete.had_module_syntax);
assert_eq!(incomplete.code, "import {");
let import_meta = transform_module_syntax("console.log(import.meta.url);".into());
assert!(import_meta.had_module_syntax);
assert!(import_meta
.code
.contains("Cannot use import.meta outside a module"));
}
#[test]
fn transform_preserves_statement_boundaries() {
let hashbang = transform_module_syntax(
"#!/usr/bin/env node\nimport fs from \"node:fs\";\nfs.readFile;".into(),
)
.code;
assert!(hashbang.starts_with("#!/usr/bin/env node\nconst { default: fs } = await "));
assert!(hashbang.contains("\nfs.readFile;"));
assert_eq!(
transform_module_syntax("foo()\nimport \"x\";\n[1].forEach(bar)".into()).code,
"await __nodeREPLDynamicImport(\"x\");\nfoo()\n;\n[1].forEach(bar)"
);
}
#[test]
fn transform_typescript_module_syntax() {
assert_eq!(
transform_module_syntax("import fs from \"node:fs\";\nconst x: number = 1;".into())
.code,
format!(
"const {{ default: fs }} = await {};\nconst x: number = 1;",
validated_import("node:fs", &["default"])
)
);
let type_only = transform_module_syntax(
"import type { Foo } from \"types\";\nexport type { Foo };\nconst x: number = 1;"
.into(),
);
assert!(type_only.had_module_syntax);
assert!(!type_only.code.contains("__nodeREPLDynamicImport"));
assert!(!type_only.code.contains("import type"));
assert!(!type_only.code.contains("export type"));
let re_export_type =
transform_module_syntax("export { type Foo } from \"types\";\nconst x = 1;".into());
assert!(!re_export_type.code.contains("__nodeREPLDynamicImport"));
assert_eq!(re_export_type.code, "\nconst x = 1;");
let import_equals =
transform_module_syntax("import type Foo = require(\"foo\");\nconst x = 1;".into());
assert!(import_equals.had_module_syntax);
assert_eq!(import_equals.code, "\nconst x = 1;");
let namespace_export =
transform_module_syntax("export as namespace Foo;\nconst x = 1;".into());
assert!(namespace_export.had_module_syntax);
assert_eq!(namespace_export.code, "\nconst x = 1;");
}
#[test]
fn first_expression_from_error_column() {
assert_eq!(
get_first_expression("assert(value)".into(), 6),
"assert(value)"
);
assert_eq!(get_first_expression("ok(value)".into(), 3), "ok(value)");
assert_eq!(
get_first_expression("assert.ok(value)".into(), 9),
"assert.ok(value)"
);
assert_eq!(
get_first_expression("assert['ok'](value)".into(), 12),
"assert['ok'](value)"
);
assert_eq!(
get_first_expression("assert[method + suffix](value)".into(), 23),
"assert[method + suffix](value)"
);
assert_eq!(
get_first_expression("assert.deepEqual(foo(), 1)".into(), 17),
"assert.deepEqual(foo(), 1)"
);
assert_eq!(
get_first_expression("assert?.ok(value)".into(), 10),
"assert?.ok(value)"
);
assert_eq!(
get_first_expression("a(); assert.ok(value); b()".into(), 13),
"assert.ok(value)"
);
assert_eq!(
get_first_expression("const 🍣 = 1; assert.ok(🍣)".into(), 23),
"assert.ok(🍣)"
);
}
#[test]
fn valid_syntax_checks() {
assert!(is_valid_syntax("await foo".into()));
assert!(is_valid_syntax("{ value: 1 }".into()));
assert!(is_valid_syntax("foo + bar".into()));
assert!(is_valid_syntax("const x: number = 1".into()));
assert!(!is_valid_syntax("function foo(".into()));
}
#[test]
fn recoverable_syntax_checks() {
assert!(is_recoverable_error("function foo() {".into()));
assert!(is_recoverable_error("{".into()));
assert!(is_recoverable_error("`template".into()));
assert!(is_recoverable_error("/* comment".into()));
assert!(is_recoverable_error("\"continued\\\n".into()));
assert!(!is_recoverable_error("const x: number = 1".into()));
assert!(!is_recoverable_error("{ value: 1 }".into()));
assert!(!is_recoverable_error("{}".into()));
assert!(!is_recoverable_error("2e".into()));
assert!(!is_recoverable_error("\"unterminated".into()));
}
}