mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
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:
11
packages/bun-types/bun.d.ts
vendored
11
packages/bun-types/bun.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
365
src/ast/repl_transforms.zig
Normal 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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`.
|
||||
|
||||
@@ -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);
|
||||
|
||||
291
test/js/bun/transpiler/repl-transform.test.ts
Normal file
291
test/js/bun/transpiler/repl-transform.test.ts
Normal 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:");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user