feat(transpiler): add replMode option for REPL transforms (#26246)

## Summary

Add a new `replMode` option to `Bun.Transpiler` that transforms code for
interactive REPL evaluation. This enables building a Node.js-compatible
REPL using `Bun.Transpiler` with `vm.runInContext` for persistent
variable scope.

## Features

- **Expression result capture**: Wraps the last expression in `{
__proto__: null, value: expr }` for result capture
- **IIFE wrappers**: Uses sync/async IIFE wrappers to avoid extra
parentheses around object literals in output
- **Variable hoisting**: Hoists `var`/`let`/`const` declarations outside
the IIFE for persistence across REPL lines
- **const → let conversion**: Converts `const` to `let` for REPL
mutability (allows re-declaration)
- **Function hoisting**: Hoists function declarations with
`this.funcName = funcName` assignment for vm context persistence
- **Class hoisting**: Hoists class declarations with `var` for vm
context persistence
- **Object literal detection**: Auto-detects object literals (code
starting with `{` without trailing `;`) and wraps them in parentheses

## Usage

```typescript
import vm from "node:vm";

const transpiler = new Bun.Transpiler({
  loader: "tsx",
  replMode: true,
});

const context = vm.createContext({ console, Promise });

async function repl(code: string) {
  const transformed = transpiler.transformSync(code);
  const result = await vm.runInContext(transformed, context);
  return result.value;
}

// Example REPL session
await repl("var x = 10");        // 10
await repl("x + 5");             // 15
await repl("class Counter {}"); // [class Counter]
await repl("new Counter()");    // Counter {}
await repl("{a: 1, b: 2}");     // {a: 1, b: 2} (auto-detected object literal)
await repl("await Promise.resolve(42)"); // 42
```

## Transform Examples

| Input | Output |
|-------|--------|
| `42` | `(() => { return { __proto__: null, value: 42 }; })()` |
| `var x = 10` | `var x; (() => { return { __proto__: null, value: x =
10 }; })()` |
| `await fetch()` | `(async () => { return { __proto__: null, value:
await fetch() }; })()` |
| `{a: 1}` | `(() => { return { __proto__: null, value: ({a: 1}) };
})()` |
| `class Foo {}` | `var Foo; (() => { return { __proto__: null, value:
Foo = class Foo {} }; })()` |

## Files Changed

- `src/ast/repl_transforms.zig`: New module containing REPL transform
logic
- `src/ast/P.zig`: Calls REPL transforms after parsing in REPL mode
- `src/bun.js/api/JSTranspiler.zig`: Adds `replMode` config option and
object literal detection
- `src/options.zig`, `src/runtime.zig`, `src/transpiler.zig`: Propagate
`repl_mode` flag
- `packages/bun-types/bun.d.ts`: TypeScript type definitions
- `test/js/bun/transpiler/repl-transform.test.ts`: Test cases

## Testing

```bash
bun bd test test/js/bun/transpiler/repl-transform.test.ts
```

34 tests covering:
- Basic transform output
- REPL session with node:vm
- Variable persistence across lines
- Object literal detection
- Edge cases (empty input, comments, TypeScript, etc.)
- No-transform cases (await inside async functions)

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Jarred Sumner
2026-01-21 13:39:25 -08:00
committed by GitHub
parent dc203e853a
commit 37c41137f8
9 changed files with 741 additions and 2 deletions

View File

@@ -1745,6 +1745,17 @@ declare module "bun" {
* @default "warn"
*/
logLevel?: "verbose" | "debug" | "info" | "warn" | "error";
/**
* Enable REPL mode transforms:
* - Wraps top-level inputs that appear to be object literals (inputs starting with '{' without trailing ';') in parentheses
* - Hoists all declarations as var for REPL persistence across vm.runInContext calls
* - Wraps last expression in { __proto__: null, value: expr } for result capture
* - Wraps code in sync/async IIFE to avoid parentheses around object literals
*
* @default false
*/
replMode?: boolean;
}
/**

View File

@@ -6467,6 +6467,11 @@ pub fn NewParser_(
parts.items[0].stmts = top_level_stmts;
}
// REPL mode transforms
if (p.options.repl_mode) {
try repl_transforms.ReplTransforms(P).apply(p, parts, allocator);
}
var top_level_symbols_to_parts = js_ast.Ast.TopLevelSymbolToParts{};
var top_level = &top_level_symbols_to_parts;
@@ -6762,6 +6767,8 @@ var falseValueExpr = Expr.Data{ .e_boolean = E.Boolean{ .value = false } };
const string = []const u8;
const repl_transforms = @import("./repl_transforms.zig");
const Define = @import("../defines.zig").Define;
const DefineData = @import("../defines.zig").DefineData;

View File

@@ -39,6 +39,13 @@ pub const Parser = struct {
/// able to customize what import sources are used.
framework: ?*bun.bake.Framework = null,
/// REPL mode: transforms code for interactive evaluation
/// - Wraps lone object literals `{...}` in parentheses
/// - Hoists variable declarations for REPL persistence
/// - Wraps last expression in { value: expr } for result capture
/// - Wraps code with await in async IIFE
repl_mode: bool = false,
pub fn hashForRuntimeTranspiler(this: *const Options, hasher: *std.hash.Wyhash, did_use_jsx: bool) void {
bun.assert(!this.bundle);

365
src/ast/repl_transforms.zig Normal file
View File

@@ -0,0 +1,365 @@
/// REPL Transform module - transforms code for interactive REPL evaluation
///
/// This module provides transformations for REPL mode:
/// - Wraps the last expression in { value: expr } for result capture
/// - Wraps code with await in async IIFE with variable hoisting
/// - Hoists declarations for variable persistence across REPL lines
pub fn ReplTransforms(comptime P: type) type {
return struct {
const Self = @This();
/// Apply REPL-mode transforms to the AST.
/// This transforms code for interactive evaluation:
/// - Wraps the last expression in { value: expr } for result capture
/// - Wraps code with await in async IIFE with variable hoisting
pub fn apply(p: *P, parts: *ListManaged(js_ast.Part), allocator: Allocator) !void {
// Skip transform if there's a top-level return (indicates module pattern)
if (p.has_top_level_return) {
return;
}
// Collect all statements
var total_stmts_count: usize = 0;
for (parts.items) |part| {
total_stmts_count += part.stmts.len;
}
if (total_stmts_count == 0) {
return;
}
// Check if there's top-level await
const has_top_level_await = p.top_level_await_keyword.len > 0;
// Collect all statements into a single array
var all_stmts = bun.handleOom(allocator.alloc(Stmt, total_stmts_count));
var stmt_idx: usize = 0;
for (parts.items) |part| {
for (part.stmts) |stmt| {
all_stmts[stmt_idx] = stmt;
stmt_idx += 1;
}
}
// Apply transform with is_async based on presence of top-level await
try transformWithHoisting(p, parts, all_stmts, allocator, has_top_level_await);
}
/// Transform code with hoisting and IIFE wrapper
/// @param is_async: true for async IIFE (when top-level await present), false for sync IIFE
fn transformWithHoisting(
p: *P,
parts: *ListManaged(js_ast.Part),
all_stmts: []Stmt,
allocator: Allocator,
is_async: bool,
) !void {
if (all_stmts.len == 0) return;
// Lists for hoisted declarations and inner statements
var hoisted_stmts = ListManaged(Stmt).init(allocator);
var inner_stmts = ListManaged(Stmt).init(allocator);
try hoisted_stmts.ensureTotalCapacity(all_stmts.len);
try inner_stmts.ensureTotalCapacity(all_stmts.len);
// Process each statement - hoist all declarations for REPL persistence
for (all_stmts) |stmt| {
switch (stmt.data) {
.s_local => |local| {
// Hoist all declarations as var so they become context properties
// In sloppy mode, var at top level becomes a property of the global/context object
// This is essential for REPL variable persistence across vm.runInContext calls
const kind: S.Local.Kind = .k_var;
// Extract individual identifiers from binding patterns for hoisting
var hoisted_decl_list = ListManaged(G.Decl).init(allocator);
for (local.decls.slice()) |decl| {
try extractIdentifiersFromBinding(p, decl.binding, &hoisted_decl_list);
}
if (hoisted_decl_list.items.len > 0) {
try hoisted_stmts.append(p.s(S.Local{
.kind = kind,
.decls = Decl.List.fromOwnedSlice(hoisted_decl_list.items),
}, stmt.loc));
}
// Create assignment expressions for the inner statements
for (local.decls.slice()) |decl| {
if (decl.value) |value| {
// Create assignment expression: binding = value
const assign_expr = createBindingAssignment(p, decl.binding, value, allocator);
try inner_stmts.append(p.s(S.SExpr{ .value = assign_expr }, stmt.loc));
}
}
},
.s_function => |func| {
// For function declarations:
// Hoist as: var funcName;
// Inner: this.funcName = funcName; function funcName() {}
if (func.func.name) |name_loc| {
try hoisted_stmts.append(p.s(S.Local{
.kind = .k_var,
.decls = Decl.List.fromOwnedSlice(bun.handleOom(allocator.dupe(G.Decl, &.{
G.Decl{
.binding = p.b(B.Identifier{ .ref = name_loc.ref.? }, name_loc.loc),
.value = null,
},
}))),
}, stmt.loc));
// Add this.funcName = funcName assignment
const this_expr = p.newExpr(E.This{}, stmt.loc);
const this_dot = p.newExpr(E.Dot{
.target = this_expr,
.name = p.symbols.items[name_loc.ref.?.innerIndex()].original_name,
.name_loc = name_loc.loc,
}, stmt.loc);
const func_id = p.newExpr(E.Identifier{ .ref = name_loc.ref.? }, name_loc.loc);
const assign = p.newExpr(E.Binary{
.op = .bin_assign,
.left = this_dot,
.right = func_id,
}, stmt.loc);
try inner_stmts.append(p.s(S.SExpr{ .value = assign }, stmt.loc));
}
// Add the function declaration itself
try inner_stmts.append(stmt);
},
.s_class => |class| {
// For class declarations:
// Hoist as: var ClassName; (use var so it persists to vm context)
// Inner: ClassName = class ClassName {}
if (class.class.class_name) |name_loc| {
try hoisted_stmts.append(p.s(S.Local{
.kind = .k_var,
.decls = Decl.List.fromOwnedSlice(bun.handleOom(allocator.dupe(G.Decl, &.{
G.Decl{
.binding = p.b(B.Identifier{ .ref = name_loc.ref.? }, name_loc.loc),
.value = null,
},
}))),
}, stmt.loc));
// Convert class declaration to assignment: ClassName = class ClassName {}
const class_expr = p.newExpr(class.class, stmt.loc);
const class_id = p.newExpr(E.Identifier{ .ref = name_loc.ref.? }, name_loc.loc);
const assign = p.newExpr(E.Binary{
.op = .bin_assign,
.left = class_id,
.right = class_expr,
}, stmt.loc);
try inner_stmts.append(p.s(S.SExpr{ .value = assign }, stmt.loc));
} else {
try inner_stmts.append(stmt);
}
},
.s_directive => |directive| {
// In REPL mode, treat directives (string literals) as expressions
const str_expr = p.newExpr(E.String{ .data = directive.value }, stmt.loc);
try inner_stmts.append(p.s(S.SExpr{ .value = str_expr }, stmt.loc));
},
else => {
try inner_stmts.append(stmt);
},
}
}
// Wrap the last expression in return { value: expr }
wrapLastExpressionWithReturn(p, &inner_stmts, allocator);
// Create the IIFE: (() => { ...inner_stmts... })() or (async () => { ... })()
const arrow = p.newExpr(E.Arrow{
.args = &.{},
.body = .{ .loc = logger.Loc.Empty, .stmts = inner_stmts.items },
.is_async = is_async,
}, logger.Loc.Empty);
const iife = p.newExpr(E.Call{
.target = arrow,
.args = ExprNodeList{},
}, logger.Loc.Empty);
// Final output: hoisted declarations + IIFE call
const final_stmts_count = hoisted_stmts.items.len + 1;
var final_stmts = bun.handleOom(allocator.alloc(Stmt, final_stmts_count));
for (hoisted_stmts.items, 0..) |stmt, j| {
final_stmts[j] = stmt;
}
final_stmts[hoisted_stmts.items.len] = p.s(S.SExpr{ .value = iife }, logger.Loc.Empty);
// Update parts
if (parts.items.len > 0) {
parts.items[0].stmts = final_stmts;
parts.items.len = 1;
}
}
/// Wrap the last expression in return { value: expr }
fn wrapLastExpressionWithReturn(p: *P, inner_stmts: *ListManaged(Stmt), allocator: Allocator) void {
if (inner_stmts.items.len > 0) {
var last_idx: usize = inner_stmts.items.len;
while (last_idx > 0) {
last_idx -= 1;
const last_stmt = inner_stmts.items[last_idx];
switch (last_stmt.data) {
.s_empty, .s_comment => continue,
.s_expr => |expr_data| {
// Wrap in return { value: expr }
const wrapped = wrapExprInValueObject(p, expr_data.value, allocator);
inner_stmts.items[last_idx] = p.s(S.Return{ .value = wrapped }, last_stmt.loc);
break;
},
else => break,
}
}
}
}
/// Extract individual identifiers from a binding pattern for hoisting
fn extractIdentifiersFromBinding(p: *P, binding: Binding, decls: *ListManaged(G.Decl)) !void {
switch (binding.data) {
.b_identifier => |ident| {
try decls.append(G.Decl{
.binding = p.b(B.Identifier{ .ref = ident.ref }, binding.loc),
.value = null,
});
},
.b_array => |arr| {
for (arr.items) |item| {
try extractIdentifiersFromBinding(p, item.binding, decls);
}
},
.b_object => |obj| {
for (obj.properties) |prop| {
try extractIdentifiersFromBinding(p, prop.value, decls);
}
},
.b_missing => {},
}
}
/// Create { __proto__: null, value: expr } wrapper object
/// Uses null prototype to create a clean data object
fn wrapExprInValueObject(p: *P, expr: Expr, allocator: Allocator) Expr {
var properties = bun.handleOom(allocator.alloc(G.Property, 2));
// __proto__: null - creates null-prototype object
properties[0] = G.Property{
.key = p.newExpr(E.String{ .data = "__proto__" }, expr.loc),
.value = p.newExpr(E.Null{}, expr.loc),
};
// value: expr - the actual result value
properties[1] = G.Property{
.key = p.newExpr(E.String{ .data = "value" }, expr.loc),
.value = expr,
};
return p.newExpr(E.Object{
.properties = G.Property.List.fromOwnedSlice(properties),
}, expr.loc);
}
/// Create assignment expression from binding pattern
fn createBindingAssignment(p: *P, binding: Binding, value: Expr, allocator: Allocator) Expr {
switch (binding.data) {
.b_identifier => |ident| {
return p.newExpr(E.Binary{
.op = .bin_assign,
.left = p.newExpr(E.Identifier{ .ref = ident.ref }, binding.loc),
.right = value,
}, binding.loc);
},
.b_array => {
// For array destructuring, create: [a, b] = value
return p.newExpr(E.Binary{
.op = .bin_assign,
.left = convertBindingToExpr(p, binding, allocator),
.right = value,
}, binding.loc);
},
.b_object => {
// For object destructuring, create: {a, b} = value
return p.newExpr(E.Binary{
.op = .bin_assign,
.left = convertBindingToExpr(p, binding, allocator),
.right = value,
}, binding.loc);
},
.b_missing => {
// Return Missing expression to match convertBindingToExpr
return p.newExpr(E.Missing{}, binding.loc);
},
}
}
/// Convert a binding pattern to an expression (for assignment targets)
/// Handles spread/rest patterns in arrays and objects to match Binding.toExpr behavior
fn convertBindingToExpr(p: *P, binding: Binding, allocator: Allocator) Expr {
switch (binding.data) {
.b_identifier => |ident| {
return p.newExpr(E.Identifier{ .ref = ident.ref }, binding.loc);
},
.b_array => |arr| {
var items = bun.handleOom(allocator.alloc(Expr, arr.items.len));
for (arr.items, 0..) |item, i| {
const expr = convertBindingToExpr(p, item.binding, allocator);
// Check for spread pattern: if has_spread and this is the last element
if (arr.has_spread and i == arr.items.len - 1) {
items[i] = p.newExpr(E.Spread{ .value = expr }, expr.loc);
} else if (item.default_value) |default_val| {
items[i] = p.newExpr(E.Binary{
.op = .bin_assign,
.left = expr,
.right = default_val,
}, item.binding.loc);
} else {
items[i] = expr;
}
}
return p.newExpr(E.Array{
.items = ExprNodeList.fromOwnedSlice(items),
.is_single_line = arr.is_single_line,
}, binding.loc);
},
.b_object => |obj| {
var properties = bun.handleOom(allocator.alloc(G.Property, obj.properties.len));
for (obj.properties, 0..) |prop, i| {
properties[i] = G.Property{
.flags = prop.flags,
.key = prop.key,
// Set kind to .spread if the property has spread flag
.kind = if (prop.flags.contains(.is_spread)) .spread else .normal,
.value = convertBindingToExpr(p, prop.value, allocator),
.initializer = prop.default_value,
};
}
return p.newExpr(E.Object{
.properties = G.Property.List.fromOwnedSlice(properties),
.is_single_line = obj.is_single_line,
}, binding.loc);
},
.b_missing => {
return p.newExpr(E.Missing{}, binding.loc);
},
}
}
};
}
const std = @import("std");
const Allocator = std.mem.Allocator;
const ListManaged = std.array_list.Managed;
const bun = @import("bun");
const logger = bun.logger;
const js_ast = bun.ast;
const B = js_ast.B;
const Binding = js_ast.Binding;
const E = js_ast.E;
const Expr = js_ast.Expr;
const ExprNodeList = js_ast.ExprNodeList;
const S = js_ast.S;
const Stmt = js_ast.Stmt;
const G = js_ast.G;
const Decl = G.Decl;

View File

@@ -42,6 +42,7 @@ pub const Config = struct {
minify_identifiers: bool = false,
minify_syntax: bool = false,
no_macros: bool = false,
repl_mode: bool = false,
pub fn fromJS(this: *Config, globalThis: *jsc.JSGlobalObject, object: jsc.JSValue, allocator: std.mem.Allocator) bun.JSError!void {
if (object.isUndefinedOrNull()) {
@@ -245,6 +246,10 @@ pub const Config = struct {
this.dead_code_elimination = flag;
}
if (try object.getBooleanLoose(globalThis, "replMode")) |flag| {
this.repl_mode = flag;
}
if (try object.getTruthy(globalThis, "minify")) |minify| {
if (minify.isBoolean()) {
this.minify_whitespace = minify.toBoolean();
@@ -701,7 +706,8 @@ pub fn constructor(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) b
transpiler.options.macro_remap = config.macro_map;
}
transpiler.options.dead_code_elimination = config.dead_code_elimination;
// REPL mode disables DCE to preserve expressions like `42`
transpiler.options.dead_code_elimination = config.dead_code_elimination and !config.repl_mode;
transpiler.options.minify_whitespace = config.minify_whitespace;
// Keep defaults for these
@@ -720,6 +726,7 @@ pub fn constructor(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) b
transpiler.options.inlining = config.runtime.inlining;
transpiler.options.hot_module_reloading = config.runtime.hot_module_reloading;
transpiler.options.react_fast_refresh = false;
transpiler.options.repl_mode = config.repl_mode;
return this;
}
@@ -741,9 +748,47 @@ pub fn deinit(this: *JSTranspiler) void {
bun.destroy(this);
}
/// Check if code looks like an object literal that would be misinterpreted as a block
/// Returns true if code starts with { (after whitespace) and doesn't end with ;
/// This matches Node.js REPL behavior for object literal disambiguation
fn isLikelyObjectLiteral(code: []const u8) bool {
// Skip leading whitespace
var start: usize = 0;
while (start < code.len and (code[start] == ' ' or code[start] == '\t' or code[start] == '\n' or code[start] == '\r')) {
start += 1;
}
// Check if starts with {
if (start >= code.len or code[start] != '{') {
return false;
}
// Skip trailing whitespace
var end: usize = code.len;
while (end > 0 and (code[end - 1] == ' ' or code[end - 1] == '\t' or code[end - 1] == '\n' or code[end - 1] == '\r')) {
end -= 1;
}
// Check if ends with semicolon - if so, it's likely a block statement
if (end > 0 and code[end - 1] == ';') {
return false;
}
return true;
}
fn getParseResult(this: *JSTranspiler, allocator: std.mem.Allocator, code: []const u8, loader: ?Loader, macro_js_ctx: Transpiler.MacroJSValueType) ?Transpiler.ParseResult {
const name = this.config.default_loader.stdinName();
const source = &logger.Source.initPathString(name, code);
// In REPL mode, wrap potential object literals in parentheses
// If code starts with { and doesn't end with ; it might be an object literal
// that would otherwise be parsed as a block statement
const processed_code: []const u8 = if (this.config.repl_mode and isLikelyObjectLiteral(code))
std.fmt.allocPrint(allocator, "({s})", .{code}) catch code
else
code;
const source = &logger.Source.initPathString(name, processed_code);
const jsx = if (this.config.tsconfig != null)
this.config.tsconfig.?.mergeJSX(this.transpiler.options.jsx)

View File

@@ -1802,6 +1802,10 @@ pub const BundleOptions = struct {
minify_identifiers: bool = false,
keep_names: bool = false,
dead_code_elimination: bool = true,
/// REPL mode: transforms code for interactive evaluation with vm.runInContext.
/// Hoists declarations as var for persistence, wraps code in IIFE, and
/// captures the last expression in { value: expr } for result extraction.
repl_mode: bool = false,
css_chunking: bool,
ignore_dce_annotations: bool = false,

View File

@@ -215,6 +215,13 @@ pub const Runtime = struct {
/// When `feature("FLAG_NAME")` is called, it returns true if FLAG_NAME is in this set.
bundler_feature_flags: *const bun.StringSet = &empty_bundler_feature_flags,
/// REPL mode: transforms code for interactive evaluation
/// - Wraps lone object literals `{...}` in parentheses
/// - Hoists variable declarations for REPL persistence
/// - Wraps last expression in { value: expr } for result capture
/// - Assigns functions to context for persistence
repl_mode: bool = false,
pub const empty_bundler_feature_flags: bun.StringSet = bun.StringSet.initComptime();
/// Initialize bundler feature flags for dead-code elimination via `import { feature } from "bun:bundle"`.

View File

@@ -1122,6 +1122,8 @@ pub const Transpiler = struct {
opts.features.dead_code_elimination = transpiler.options.dead_code_elimination;
opts.features.remove_cjs_module_wrapper = this_parse.remove_cjs_module_wrapper;
opts.features.bundler_feature_flags = transpiler.options.bundler_feature_flags;
opts.features.repl_mode = transpiler.options.repl_mode;
opts.repl_mode = transpiler.options.repl_mode;
if (transpiler.macro_context == null) {
transpiler.macro_context = js_ast.Macro.MacroContext.init(transpiler);

View File

@@ -0,0 +1,291 @@
import { describe, expect, test } from "bun:test";
import vm from "node:vm";
describe("Bun.Transpiler replMode", () => {
describe("basic transform output", () => {
const transpiler = new Bun.Transpiler({ loader: "tsx", replMode: true });
test("simple expression wrapped in value object", () => {
const result = transpiler.transformSync("42");
// Should contain value wrapper
expect(result).toContain("value:");
});
test("variable declaration with await", () => {
const result = transpiler.transformSync("var x = await 1");
// Should hoist var declaration
expect(result).toContain("var x");
// Should have async wrapper
expect(result).toContain("async");
});
test("const becomes var with await", () => {
const result = transpiler.transformSync("const x = await 1");
// const should become var for REPL persistence (becomes context property)
expect(result).toContain("var x");
expect(result).not.toContain("const x");
});
test("let becomes var with await", () => {
const result = transpiler.transformSync("let x = await 1");
// let should become var for REPL persistence (becomes context property)
expect(result).toContain("var x");
expect(result).not.toContain("let x");
expect(result).toContain("async");
});
test("no async wrapper when no await", () => {
const result = transpiler.transformSync("var x = 1; x + 5");
// Should still have value wrapper for the last expression
expect(result).toContain("value:");
// Should not wrap in async when no await
expect(result).not.toMatch(/\(\s*async\s*\(\s*\)\s*=>/);
});
test("function declaration with await", () => {
const result = transpiler.transformSync("await 1; function foo() { return 42; }");
// Should hoist function declaration
expect(result).toContain("var foo");
expect(result).toContain("async");
});
test("class declaration with await", () => {
const result = transpiler.transformSync("await 1; class Bar { }");
// Should hoist class declaration with var (not let) for vm context persistence
expect(result).toContain("var Bar");
expect(result).toContain("async");
});
});
describe("REPL session with node:vm", () => {
const transpiler = new Bun.Transpiler({ loader: "tsx", replMode: true });
async function runRepl(code: string, context?: object) {
const ctx = vm.createContext(context ?? { console, Promise });
const transformed = transpiler.transformSync(code);
return await vm.runInContext(transformed, ctx);
}
test("simple expression returns value object", async () => {
const result = await runRepl("42");
expect(result).toEqual({ value: 42 });
});
test("arithmetic expression", async () => {
const result = await runRepl("2 + 3 * 4");
expect(result).toEqual({ value: 14 });
});
test("string expression", async () => {
const result = await runRepl('"hello world"');
expect(result).toEqual({ value: "hello world" });
});
test("object literal (auto-detected)", async () => {
// Object literals don't need parentheses - the transpiler auto-detects them
const result = await runRepl("{a: 1, b: 2}");
expect(result).toEqual({ value: { a: 1, b: 2 } });
});
test("array literal", async () => {
const result = await runRepl("[1, 2, 3]");
expect(result).toEqual({ value: [1, 2, 3] });
});
test("await expression", async () => {
const result = await runRepl("await Promise.resolve(100)");
expect(result).toEqual({ value: 100 });
});
test("await with variable", async () => {
const ctx = vm.createContext({ Promise });
const code1 = transpiler.transformSync("var x = await Promise.resolve(10)");
await vm.runInContext(code1, ctx);
expect(ctx.x).toBe(10);
const code2 = transpiler.transformSync("x * 2");
const result = await vm.runInContext(code2, ctx);
expect(result).toEqual({ value: 20 });
});
});
describe("variable persistence across lines", () => {
const transpiler = new Bun.Transpiler({ loader: "tsx", replMode: true });
async function runReplSession(lines: string[]) {
const ctx = vm.createContext({ console, Promise });
const results: any[] = [];
for (const line of lines) {
const transformed = transpiler.transformSync(line);
const result = await vm.runInContext(transformed, ctx);
results.push(result?.value ?? result);
}
return { results, context: ctx };
}
test("var persists across lines", async () => {
const { results, context } = await runReplSession(["var x = 10", "x + 5", "x = 20", "x"]);
expect(results[1]).toBe(15);
expect(results[3]).toBe(20);
expect(context.x).toBe(20);
});
test("let persists with await", async () => {
const { results } = await runReplSession(["let y = await Promise.resolve(100)", "y * 2"]);
expect(results[1]).toBe(200);
});
test("function declarations persist", async () => {
const { results, context } = await runReplSession(["await 1; function add(a, b) { return a + b; }", "add(2, 3)"]);
expect(results[1]).toBe(5);
expect(typeof context.add).toBe("function");
});
test("class declarations persist to vm context", async () => {
// Class declarations use 'var' hoisting so they persist to vm context
const { results, context } = await runReplSession([
"await 1; class Counter { constructor() { this.count = 0; } inc() { this.count++; } }",
"new Counter()",
]);
// The class is returned in the result's value
expect(typeof results[0]).toBe("function");
expect(results[0].name).toBe("Counter");
// The class should be accessible in subsequent REPL lines
expect(results[1]).toBeInstanceOf(context.Counter);
expect(typeof context.Counter).toBe("function");
});
});
describe("object literal detection", () => {
const transpiler = new Bun.Transpiler({ loader: "tsx", replMode: true });
async function runRepl(code: string, context?: object) {
const ctx = vm.createContext(context ?? { console, Promise });
const transformed = transpiler.transformSync(code);
return await vm.runInContext(transformed, ctx);
}
test("{a: 1} parsed as object literal, not block", async () => {
const result = await runRepl("{a: 1}");
expect(result.value).toEqual({ a: 1 });
});
test("{a: 1, b: 2} parsed as object literal", async () => {
const result = await runRepl("{a: 1, b: 2}");
expect(result.value).toEqual({ a: 1, b: 2 });
});
test("{foo: await bar()} parsed as object literal", async () => {
const ctx = vm.createContext({
bar: async () => 42,
});
const code = transpiler.transformSync("{foo: await bar()}");
const result = await vm.runInContext(code, ctx);
expect(result.value).toEqual({ foo: 42 });
});
test("{x: 1}; is NOT wrapped (has trailing semicolon)", async () => {
// With semicolon, it's explicitly a block statement
const code = transpiler.transformSync("{x: 1};");
// The output should NOT treat this as an object literal
// It should be a block with a labeled statement, no value wrapper
expect(code).not.toContain("value:");
expect(code).toContain("x:");
});
test("whitespace around object literal is handled", async () => {
const result = await runRepl(" { a: 1 } ");
expect(result.value).toEqual({ a: 1 });
});
});
describe("edge cases", () => {
const transpiler = new Bun.Transpiler({ loader: "tsx", replMode: true });
test("empty input", () => {
const result = transpiler.transformSync("");
expect(result).toBe("");
});
test("whitespace only", () => {
const result = transpiler.transformSync(" \n\t ");
expect(result.trim()).toBe("");
});
test("comment only produces empty output", () => {
// Comments are stripped by the transpiler
const result = transpiler.transformSync("// just a comment");
expect(result.trim()).toBe("");
});
test("TypeScript types stripped", () => {
const result = transpiler.transformSync("const x: number = await Promise.resolve(42)");
expect(result).not.toContain(": number");
});
test("multiple await expressions", async () => {
const ctx = vm.createContext({ Promise });
const code = transpiler.transformSync("await 1; await 2; await 3");
const result = await vm.runInContext(code, ctx);
// Last expression should be wrapped
expect(result).toEqual({ value: 3 });
});
test("destructuring assignment persists", async () => {
const ctx = vm.createContext({ Promise });
const code = transpiler.transformSync("var { a, b } = await Promise.resolve({ a: 1, b: 2 })");
await vm.runInContext(code, ctx);
expect(ctx.a).toBe(1);
expect(ctx.b).toBe(2);
});
test("array destructuring persists", async () => {
const ctx = vm.createContext({ Promise });
const code = transpiler.transformSync("var [x, y, z] = await Promise.resolve([10, 20, 30])");
await vm.runInContext(code, ctx);
expect(ctx.x).toBe(10);
expect(ctx.y).toBe(20);
expect(ctx.z).toBe(30);
});
});
describe("no transform cases", () => {
const transpiler = new Bun.Transpiler({ loader: "tsx", replMode: true });
test("async function expression - no async wrapper", () => {
const result = transpiler.transformSync("async function foo() { await 1; }");
// await inside async function doesn't trigger TLA transform
// The top level has no await
expect(result).not.toMatch(/^\(async/);
});
test("arrow async function - no async wrapper", () => {
const result = transpiler.transformSync("const fn = async () => await 1");
// await inside arrow function doesn't trigger TLA transform
expect(result).not.toMatch(/^\(async\s*\(\)/);
});
});
describe("replMode option", () => {
test("replMode false by default", () => {
const transpiler = new Bun.Transpiler({ loader: "tsx" });
const result = transpiler.transformSync("42");
// Without replMode, no value wrapper
expect(result).not.toContain("value:");
});
test("replMode true adds transforms", () => {
const transpiler = new Bun.Transpiler({ loader: "tsx", replMode: true });
const result = transpiler.transformSync("42");
// With replMode, value wrapper should be present
expect(result).toContain("value:");
});
});
});