From baf1bbda8b63a9f1d47ce85b873dac36ffb29eff Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Tue, 20 Jan 2026 20:35:13 +0000 Subject: [PATCH] feat(transpiler): add replMode option for REPL transforms Cherry-pick replMode feature from jarred/repl-mode branch: - Add `replMode` option to Bun.Transpiler for REPL transforms - Wraps expressions in { value: expr } for result capture - Hoists var/let/const declarations for persistence across REPL lines - Hoists function/class declarations with var for vm context persistence - Auto-detects object literals (starting with { without trailing ;) - Uses sync/async IIFE wrappers based on top-level await presence Co-Authored-By: Claude Opus 4.5 --- packages/bun-types/bun.d.ts | 11 + src/ast/P.zig | 6 + src/ast/Parser.zig | 7 + src/ast/repl_transforms.zig | 364 ++++++++++++++++++ src/bun.js/api/JSTranspiler.zig | 49 ++- src/options.zig | 4 + src/runtime.zig | 7 + src/transpiler.zig | 2 + test/js/bun/transpiler/repl-transform.test.ts | 291 ++++++++++++++ 9 files changed, 739 insertions(+), 2 deletions(-) create mode 100644 src/ast/repl_transforms.zig create mode 100644 test/js/bun/transpiler/repl-transform.test.ts diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 543d973cc8..ef1fdad5a1 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -1654,6 +1654,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; } /** diff --git a/src/ast/P.zig b/src/ast/P.zig index 718dc2cf93..266e7eab0e 100644 --- a/src/ast/P.zig +++ b/src/ast/P.zig @@ -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,7 @@ const string = []const u8; const Define = @import("../defines.zig").Define; const DefineData = @import("../defines.zig").DefineData; +const repl_transforms = @import("repl_transforms.zig"); const bun = @import("bun"); const Environment = bun.Environment; diff --git a/src/ast/Parser.zig b/src/ast/Parser.zig index bc882d7ac1..a95fa54d7b 100644 --- a/src/ast/Parser.zig +++ b/src/ast/Parser.zig @@ -38,6 +38,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); diff --git a/src/ast/repl_transforms.zig b/src/ast/repl_transforms.zig new file mode 100644 index 0000000000..1ac23f8ac2 --- /dev/null +++ b/src/ast/repl_transforms.zig @@ -0,0 +1,364 @@ +const std = @import("std"); +const bun = @import("bun"); +const logger = bun.logger; +const js_ast = bun.ast; +const Allocator = std.mem.Allocator; + +const G = js_ast.G; +const B = js_ast.B; +const E = js_ast.E; +const S = js_ast.S; +const Expr = js_ast.Expr; +const Stmt = js_ast.Stmt; +const Binding = js_ast.Binding; +const Decl = G.Decl; +const ExprNodeList = js_ast.ExprNodeList; + +const ListManaged = std.array_list.Managed; + +/// 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); + }, + } + } + }; +} diff --git a/src/bun.js/api/JSTranspiler.zig b/src/bun.js/api/JSTranspiler.zig index 2524468d1a..47dc3e0ac8 100644 --- a/src/bun.js/api/JSTranspiler.zig +++ b/src/bun.js/api/JSTranspiler.zig @@ -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(); @@ -698,7 +703,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 @@ -717,6 +723,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; } @@ -738,9 +745,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) diff --git a/src/options.zig b/src/options.zig index b6e42b2806..27fd53b017 100644 --- a/src/options.zig +++ b/src/options.zig @@ -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, diff --git a/src/runtime.zig b/src/runtime.zig index ecb33e4f97..f463d21e85 100644 --- a/src/runtime.zig +++ b/src/runtime.zig @@ -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"`. diff --git a/src/transpiler.zig b/src/transpiler.zig index 0c058e0f77..298eb9d772 100644 --- a/src/transpiler.zig +++ b/src/transpiler.zig @@ -1115,6 +1115,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); diff --git a/test/js/bun/transpiler/repl-transform.test.ts b/test/js/bun/transpiler/repl-transform.test.ts new file mode 100644 index 0000000000..1fc3725a4f --- /dev/null +++ b/test/js/bun/transpiler/repl-transform.test.ts @@ -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:"); + }); + }); +});