Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
8e62f7940d fix(repl): preserve const/let semantics in REPL evaluation
The REPL transform was unconditionally converting all const/let
declarations to var for persistence across evaluations. This broke
const semantics: const variables could be reassigned and redeclared.

Split the REPL transform into two paths:
- Sync path (no top-level await): emits declarations directly at top
  level without IIFE wrapping. const stays const (JSC's global lexical
  environment enforces no-reassign/no-redeclare), let becomes var
  (allows redeclaration like Node.js REPL).
- Async path (top-level await/imports): keeps existing IIFE-based
  approach with var hoisting, matching Node.js behavior where await
  invalidates const/let scoping.

Closes #27675

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-01 14:31:04 +00:00
4 changed files with 263 additions and 22 deletions

View File

@@ -2,8 +2,8 @@
///
/// 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
/// - For async code: wraps in async IIFE with variable hoisting (const/let → var)
/// - For sync code: preserves const/let semantics at top level
pub fn ReplTransforms(comptime P: type) type {
return struct {
const Self = @This();
@@ -11,7 +11,8 @@ pub fn ReplTransforms(comptime P: type) type {
/// 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
/// - For async code (top-level await/imports): wraps in async IIFE with var hoisting
/// - For sync code: emits declarations directly at top level, preserving const semantics
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) {
@@ -49,11 +50,99 @@ pub fn ReplTransforms(comptime P: type) type {
}
}
// Apply transform with is_async based on presence of top-level await
try transformWithHoisting(p, parts, all_stmts, allocator, has_top_level_await);
if (has_top_level_await) {
// Async path: use IIFE with var hoisting (const/let semantics lost, matches Node.js
// behavior where "await in REPL invalidates const/let scoping")
try transformWithHoisting(p, parts, all_stmts, allocator, true);
} else {
// Sync path: emit declarations directly at top level, preserving const semantics.
// - const stays const (enforces no-reassign, no-redeclare via global lexical env)
// - let becomes var (allows redeclaration across REPL lines, like Node.js REPL)
// - var/function/class stay at top level naturally
try transformDirect(p, parts, all_stmts, allocator);
}
}
/// Transform code with hoisting and IIFE wrapper
/// Transform code directly at top level (sync path, no IIFE).
/// Preserves const semantics by keeping const declarations at the top level where
/// JSC's global lexical environment enforces no-reassign and no-redeclare.
/// let is converted to var for redeclarability across REPL lines (matching Node.js REPL).
fn transformDirect(
p: *P,
parts: *ListManaged(js_ast.Part),
all_stmts: []Stmt,
allocator: Allocator,
) !void {
if (all_stmts.len == 0) return;
var result_stmts = ListManaged(Stmt).init(allocator);
try result_stmts.ensureTotalCapacity(all_stmts.len);
for (all_stmts) |stmt| {
switch (stmt.data) {
.s_local => |local| {
if (local.kind == .k_const) {
// Keep const as-is: top-level const in JSC's global lexical environment
// enforces no-reassign (TypeError) and no-redeclare (SyntaxError)
try result_stmts.append(stmt);
} else if (local.kind == .k_let) {
// Convert let → var for redeclarability across REPL lines
// (Node.js REPL also allows let redeclaration via V8's REPL mode)
try result_stmts.append(p.s(S.Local{
.kind = .k_var,
.decls = local.decls,
.is_export = local.is_export,
.was_ts_import_equals = local.was_ts_import_equals,
.was_commonjs_export = local.was_commonjs_export,
}, stmt.loc));
} else {
// var, using, await using: keep as-is
try result_stmts.append(stmt);
}
},
.s_class => |class| {
// Convert class declarations to var assignment for redeclarability:
// class Foo {} → var Foo = class Foo {};
if (class.class.class_name) |name_loc| {
const class_expr = p.newExpr(class.class, stmt.loc);
try result_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 = class_expr,
},
}))),
}, stmt.loc));
} else {
// Anonymous class expression — keep as-is
try result_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 result_stmts.append(p.s(S.SExpr{ .value = str_expr }, stmt.loc));
},
else => {
// function declarations, expression statements, etc: keep as-is
try result_stmts.append(stmt);
},
}
}
// Wrap the last expression statement in { __proto__: null, value: expr }
// so the REPL can extract the result value.
wrapLastExpressionInPlace(p, &result_stmts, allocator);
// Update parts
if (parts.items.len > 0) {
parts.items[0].stmts = result_stmts.items;
parts.items.len = 1;
}
}
/// Transform code with hoisting and IIFE wrapper (async path)
/// @param is_async: true for async IIFE (when top-level await present), false for sync IIFE
fn transformWithHoisting(
p: *P,
@@ -361,6 +450,29 @@ pub fn ReplTransforms(comptime P: type) type {
}
}
/// Wrap the last expression in { __proto__: null, value: expr } as an expression statement.
/// Used by the sync (direct) path where there's no IIFE, so we use an expression
/// statement instead of a return statement.
fn wrapLastExpressionInPlace(p: *P, stmts: *ListManaged(Stmt), allocator: Allocator) void {
if (stmts.items.len > 0) {
var last_idx: usize = stmts.items.len;
while (last_idx > 0) {
last_idx -= 1;
const last_stmt = stmts.items[last_idx];
switch (last_stmt.data) {
.s_empty, .s_comment => continue,
.s_expr => |expr_data| {
// Wrap in expression statement: ({ __proto__: null, value: expr })
const wrapped = wrapExprInValueObject(p, expr_data.value, allocator);
stmts.items[last_idx] = p.s(S.SExpr{ .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) {

View File

@@ -610,11 +610,26 @@ describe.concurrent("Bun REPL", () => {
expect(exitCode).toBe(0);
});
test("const can be redeclared across lines", async () => {
// REPL hoists const -> var so redeclaration works like Node's REPL.
const { stdout, stderr, exitCode } = await runRepl(["const x = 1", "const x = 2", "x", ".exit"]);
test("const cannot be redeclared across lines", async () => {
// const should preserve its semantics: no redeclaration allowed
const { stdout, stderr, exitCode } = await runRepl(["const x = 1", "const x = 2", ".exit"]);
const output = stripAnsi(stdout + stderr);
expect(output).not.toMatch(/already.*declared|redeclar/i);
expect(output).toMatch(/duplicate variable|already.*declared/i);
expect(exitCode).toBe(0);
});
test("const cannot be reassigned", async () => {
const { stdout, stderr, exitCode } = await runRepl(["const x = 42", "x = 99", ".exit"]);
const output = stripAnsi(stdout + stderr);
expect(output).toMatch(/readonly|constant/i);
expect(exitCode).toBe(0);
});
test("let can be redeclared across lines", async () => {
// let is converted to var in REPL for redeclarability (like Node.js REPL)
const { stdout, stderr, exitCode } = await runRepl(["let x = 1", "let x = 2", "x", ".exit"]);
const output = stripAnsi(stdout + stderr);
expect(output).not.toMatch(/already.*declared|redeclar|duplicate/i);
expect(output).toContain("2");
expect(exitCode).toBe(0);
});

View File

@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
import vm from "node:vm";
describe("Bun.Transpiler replMode", () => {
describe("basic transform output", () => {
describe("sync path (no top-level await)", () => {
const transpiler = new Bun.Transpiler({ loader: "tsx", replMode: true });
test("simple expression wrapped in value object", () => {
@@ -11,6 +11,52 @@ describe("Bun.Transpiler replMode", () => {
expect(result).toContain("value:");
});
test("const preserved as const (not converted to var)", () => {
const result = transpiler.transformSync("const x = 42");
// const should stay as const for proper semantics (no-reassign, no-redeclare)
expect(result).toContain("const x");
expect(result).not.toContain("var x");
});
test("let becomes var for redeclarability", () => {
const result = transpiler.transformSync("let x = 10");
// let should become var to allow redeclaration across REPL lines (like Node.js REPL)
expect(result).toContain("var x");
expect(result).not.toContain("let x");
});
test("var stays as var", () => {
const result = transpiler.transformSync("var x = 5");
expect(result).toContain("var x");
});
test("no IIFE 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*=>/);
// Should not wrap in sync IIFE either (direct path)
expect(result).not.toMatch(/\(\s*\(\s*\)\s*=>/);
});
test("function declaration preserved as-is", () => {
const result = transpiler.transformSync("function foo() { return 42; }");
expect(result).toContain("function foo()");
// No var hoisting needed for sync path
expect(result).not.toContain("var foo");
});
test("class declaration becomes var for redeclarability", () => {
const result = transpiler.transformSync("class Bar { }");
// Class becomes var assignment so it can be redeclared across REPL lines
expect(result).toContain("var Bar");
});
});
describe("async path (top-level await)", () => {
const transpiler = new Bun.Transpiler({ loader: "tsx", replMode: true });
test("variable declaration with await", () => {
const result = transpiler.transformSync("var x = await 1");
// Should hoist var declaration
@@ -21,27 +67,19 @@ describe("Bun.Transpiler replMode", () => {
test("const becomes var with await", () => {
const result = transpiler.transformSync("const x = await 1");
// const should become var for REPL persistence (becomes context property)
// In async path, const becomes var (same as Node.js: await invalidates const/let scoping)
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)
// In async path, let becomes var
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
@@ -51,7 +89,7 @@ describe("Bun.Transpiler replMode", () => {
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
// Should hoist class declaration with var for vm context persistence
expect(result).toContain("var Bar");
expect(result).toContain("async");
});

View File

@@ -0,0 +1,76 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// Helper to run REPL with piped stdin and capture output
async function runRepl(input: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const inputStr = input.join("\n") + "\n";
await using proc = Bun.spawn({
cmd: [bunExe(), "repl"],
stdin: Buffer.from(inputStr),
stdout: "pipe",
stderr: "pipe",
env: {
...bunEnv,
TERM: "dumb",
NO_COLOR: "1",
},
});
const exitCode = await proc.exited;
const stdout = Bun.stripANSI(await new Response(proc.stdout).text());
const stderr = Bun.stripANSI(await new Response(proc.stderr).text());
return { stdout, stderr, exitCode };
}
describe("issue #27675 - REPL const/let semantics", () => {
test("const cannot be reassigned", async () => {
const { stdout, stderr, exitCode } = await runRepl(["const a = 42", "a = 43", ".exit"]);
const output = stdout + stderr;
// Should throw TypeError on assignment to const
expect(output).toMatch(/TypeError|readonly|constant/i);
expect(exitCode).toBe(0);
});
test("const cannot be redeclared", async () => {
const { stdout, stderr, exitCode } = await runRepl(["const a = 42", "const a = 44", ".exit"]);
const output = stdout + stderr;
// Should throw SyntaxError on redeclaration
expect(output).toMatch(/SyntaxError|duplicate.*variable|already.*declared/i);
expect(exitCode).toBe(0);
});
test("const value persists across lines", async () => {
const { stdout, exitCode } = await runRepl(["const a = 42", "a", ".exit"]);
expect(stdout).toContain("42");
expect(exitCode).toBe(0);
});
test("const declaration returns undefined", async () => {
const { stdout, exitCode } = await runRepl(["const a = 42", ".exit"]);
expect(stdout).toContain("undefined");
expect(exitCode).toBe(0);
});
test("let can be reassigned", async () => {
const { stdout, exitCode } = await runRepl(["let b = 1", "b = 2", "b", ".exit"]);
expect(stdout).toContain("2");
expect(exitCode).toBe(0);
});
test("let can be redeclared across lines", async () => {
const { stdout, stderr, exitCode } = await runRepl(["let b = 1", "let b = 2", "b", ".exit"]);
const output = stdout + stderr;
// Should NOT throw - let redeclaration is allowed in REPL (like Node.js)
expect(output).not.toMatch(/SyntaxError|duplicate.*variable|already.*declared/i);
expect(output).toContain("2");
expect(exitCode).toBe(0);
});
test("var can be reassigned and redeclared", async () => {
const { stdout, exitCode } = await runRepl(["var c = 1", "c = 2", "var c = 3", "c", ".exit"]);
expect(stdout).toContain("3");
expect(exitCode).toBe(0);
});
});