feat(repl): implement native Zig REPL with full TUI support

This commit introduces a native Zig implementation of `bun repl` that
provides a modern, feature-rich interactive JavaScript environment.

Key features:
- Syntax highlighting using QuickAndDirtySyntaxHighlighter
- Full line editing with Emacs-style keybindings (Ctrl+A/E/K/U/W, etc.)
- Persistent history saved to ~/.bun_repl_history
- Tab completion for global properties
- Multi-line input detection (unclosed brackets/strings)
- REPL commands: .help, .exit, .clear, .load, .save, .editor, .break
- Raw terminal mode for smooth character-by-character input
- Result formatting via util.inspect integration
- Special REPL variables: _ (last result), _error (last error)

The implementation consists of:
- src/repl.zig: ~1500 line REPL implementation
  - LineEditor: cursor movement, editing, clipboard
  - History: load/save from file, navigation
  - Syntax highlighting with ANSI colors
  - JavaScript evaluation via C++ bindings
- src/cli/repl_command.zig: CLI integration and VM setup
- C++ bindings in bindings.cpp for JSC evaluation and completion

The native REPL provides faster startup and better integration with
Bun's toolkit compared to the previous TypeScript implementation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2026-01-20 18:58:00 +00:00
parent 43f5df2596
commit 974fb9a272
4 changed files with 1771 additions and 64 deletions

View File

@@ -6109,6 +6109,164 @@ CPP_DECL [[ZIG_EXPORT(nothrow)]] unsigned int Bun__CallFrame__getLineNumber(JSC:
return lineColumn.line;
}
// REPL evaluation function - evaluates JavaScript code in the global scope
// Returns the result value, or undefined if an exception was thrown
// If an exception is thrown, the exception value is stored in *exception
extern "C" JSC::EncodedJSValue Bun__REPL__evaluate(
JSC::JSGlobalObject* globalObject,
const unsigned char* sourcePtr,
size_t sourceLen,
const unsigned char* filenamePtr,
size_t filenameLen,
JSC::EncodedJSValue* exception)
{
auto& vm = JSC::getVM(globalObject);
auto scope = DECLARE_CATCH_SCOPE(vm);
WTF::String source = WTF::String::fromUTF8(std::span { sourcePtr, sourceLen });
WTF::String filename = filenameLen > 0
? WTF::String::fromUTF8(std::span { filenamePtr, filenameLen })
: "[repl]"_s;
JSC::SourceCode sourceCode = JSC::makeSource(
source,
JSC::SourceOrigin {},
JSC::SourceTaintedOrigin::Untainted,
filename,
WTF::TextPosition(),
JSC::SourceProviderSourceType::Program);
WTF::NakedPtr<JSC::Exception> evalException;
JSC::JSValue result = JSC::evaluate(globalObject, sourceCode, globalObject->globalThis(), evalException);
if (evalException) {
*exception = JSC::JSValue::encode(evalException->value());
// Set _error to the exception
globalObject->globalThis()->putDirect(vm, JSC::Identifier::fromString(vm, "_error"_s), evalException->value());
scope.clearException();
return JSC::JSValue::encode(JSC::jsUndefined());
}
if (scope.exception()) {
*exception = JSC::JSValue::encode(scope.exception()->value());
// Set _error to the exception
globalObject->globalThis()->putDirect(vm, JSC::Identifier::fromString(vm, "_error"_s), scope.exception()->value());
scope.clearException();
return JSC::JSValue::encode(JSC::jsUndefined());
}
// Set _ to the last result (only if it's not undefined)
if (!result.isUndefined()) {
globalObject->globalThis()->putDirect(vm, JSC::Identifier::fromString(vm, "_"_s), result);
}
return JSC::JSValue::encode(result);
}
// REPL completion function - gets completions for a partial property access
// Returns an array of completion strings, or undefined if no completions
extern "C" JSC::EncodedJSValue Bun__REPL__getCompletions(
JSC::JSGlobalObject* globalObject,
JSC::EncodedJSValue targetValue,
const unsigned char* prefixPtr,
size_t prefixLen)
{
auto& vm = JSC::getVM(globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);
JSC::JSValue target = JSC::JSValue::decode(targetValue);
if (!target || target.isUndefined() || target.isNull()) {
target = globalObject->globalThis();
}
if (!target.isObject()) {
return JSC::JSValue::encode(JSC::jsUndefined());
}
WTF::String prefix = prefixLen > 0
? WTF::String::fromUTF8(std::span { prefixPtr, prefixLen })
: WTF::String();
JSC::JSObject* object = target.getObject();
JSC::PropertyNameArrayBuilder propertyNames(vm, JSC::PropertyNameMode::Strings, JSC::PrivateSymbolMode::Exclude);
object->getPropertyNames(globalObject, propertyNames, DontEnumPropertiesMode::Include);
RETURN_IF_EXCEPTION(scope, JSC::JSValue::encode(JSC::jsUndefined()));
JSC::JSArray* completions = JSC::constructEmptyArray(globalObject, nullptr, 0);
RETURN_IF_EXCEPTION(scope, JSC::JSValue::encode(JSC::jsUndefined()));
unsigned completionIndex = 0;
for (const auto& propertyName : propertyNames) {
WTF::String name = propertyName.string();
if (prefix.isEmpty() || name.startsWith(prefix)) {
completions->putDirectIndex(globalObject, completionIndex++, JSC::jsString(vm, name));
RETURN_IF_EXCEPTION(scope, JSC::JSValue::encode(JSC::jsUndefined()));
}
}
// Also check the prototype chain
JSC::JSValue proto = object->getPrototype(globalObject);
RETURN_IF_EXCEPTION(scope, JSC::JSValue::encode(completions));
while (proto && proto.isObject()) {
JSC::JSObject* protoObj = proto.getObject();
JSC::PropertyNameArrayBuilder protoNames(vm, JSC::PropertyNameMode::Strings, JSC::PrivateSymbolMode::Exclude);
protoObj->getPropertyNames(globalObject, protoNames, DontEnumPropertiesMode::Include);
RETURN_IF_EXCEPTION(scope, JSC::JSValue::encode(completions));
for (const auto& propertyName : protoNames) {
WTF::String name = propertyName.string();
if (prefix.isEmpty() || name.startsWith(prefix)) {
completions->putDirectIndex(globalObject, completionIndex++, JSC::jsString(vm, name));
RETURN_IF_EXCEPTION(scope, JSC::JSValue::encode(completions));
}
}
proto = protoObj->getPrototype(globalObject);
RETURN_IF_EXCEPTION(scope, JSC::JSValue::encode(completions));
}
return JSC::JSValue::encode(completions);
}
// Format a value for REPL output using util.inspect style
extern "C" JSC::EncodedJSValue Bun__REPL__formatValue(
JSC::JSGlobalObject* globalObject,
JSC::EncodedJSValue valueEncoded,
int32_t depth,
bool colors)
{
auto& vm = JSC::getVM(globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);
// Get the util.inspect function from the global object
auto* bunGlobal = jsCast<Zig::GlobalObject*>(globalObject);
JSC::JSValue inspectFn = bunGlobal->utilInspectFunction();
if (!inspectFn || !inspectFn.isCallable()) {
// Fallback to toString if util.inspect is not available
JSC::JSValue value = JSC::JSValue::decode(valueEncoded);
return JSC::JSValue::encode(value.toString(globalObject));
}
// Create options object
JSC::JSObject* options = JSC::constructEmptyObject(globalObject);
options->putDirect(vm, JSC::Identifier::fromString(vm, "depth"_s), JSC::jsNumber(depth));
options->putDirect(vm, JSC::Identifier::fromString(vm, "colors"_s), JSC::jsBoolean(colors));
options->putDirect(vm, JSC::Identifier::fromString(vm, "maxArrayLength"_s), JSC::jsNumber(100));
options->putDirect(vm, JSC::Identifier::fromString(vm, "maxStringLength"_s), JSC::jsNumber(10000));
options->putDirect(vm, JSC::Identifier::fromString(vm, "breakLength"_s), JSC::jsNumber(80));
JSC::MarkedArgumentBuffer args;
args.append(JSC::JSValue::decode(valueEncoded));
args.append(options);
JSC::JSValue result = JSC::call(globalObject, inspectFn, JSC::ArgList(args), "util.inspect"_s);
RETURN_IF_EXCEPTION(scope, JSC::JSValue::encode(JSC::jsUndefined()));
return JSC::JSValue::encode(result);
}
extern "C" void JSC__ArrayBuffer__ref(JSC::ArrayBuffer* self) { self->ref(); }
extern "C" void JSC__ArrayBuffer__deref(JSC::ArrayBuffer* self) { self->deref(); }
extern "C" void JSC__ArrayBuffer__asBunArrayBuffer(JSC::ArrayBuffer* self, Bun__ArrayBuffer* out)

View File

@@ -168,6 +168,12 @@ CPP_DECL uint32_t JSC__JSInternalPromise__status(const JSC::JSInternalPromise* a
CPP_DECL void JSC__JSFunction__optimizeSoon(JSC::EncodedJSValue JSValue0);
#pragma mark - REPL Functions
CPP_DECL JSC::EncodedJSValue Bun__REPL__evaluate(JSC::JSGlobalObject* globalObject, const unsigned char* sourcePtr, size_t sourceLen, const unsigned char* filenamePtr, size_t filenameLen, JSC::EncodedJSValue* exception);
CPP_DECL JSC::EncodedJSValue Bun__REPL__getCompletions(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue targetValue, const unsigned char* prefixPtr, size_t prefixLen);
CPP_DECL JSC::EncodedJSValue Bun__REPL__formatValue(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue valueEncoded, int32_t depth, bool colors);
#pragma mark - JSC::JSGlobalObject
CPP_DECL VirtualMachine* JSC__JSGlobalObject__bunVM(JSC::JSGlobalObject* arg0);

View File

@@ -1,87 +1,160 @@
//! Bun REPL Command - Native Zig REPL with full TUI support
//!
//! This is the entry point for `bun repl` which provides an interactive
//! JavaScript REPL with:
//! - Syntax highlighting using QuickAndDirtySyntaxHighlighter
//! - Full line editing with Emacs-style keybindings
//! - Persistent history
//! - Tab completion
//! - Multi-line input support
//! - REPL commands (.help, .exit, .clear, .load, .save, .editor)
pub const ReplCommand = struct {
pub fn exec(ctx: Command.Context) !void {
@branchHint(.cold);
// Embed the REPL script
const repl_script = @embedFile("../js/eval/repl.ts");
// Initialize the Zig REPL
var repl = Repl.init(ctx.allocator);
defer repl.deinit();
// Get platform-specific temp directory
const temp_dir = bun.fs.FileSystem.RealFS.platformTempDir();
// Boot the JavaScript VM for the REPL
try bootReplVM(ctx, &repl);
}
// Create unique temp file name with PID to avoid collisions
const pid = if (bun.Environment.isWindows)
std.os.windows.GetCurrentProcessId()
else
std.c.getpid();
fn bootReplVM(ctx: Command.Context, repl: *Repl) !void {
// Load bunfig if not already loaded
if (!ctx.debug.loaded_bunfig) {
try bun.cli.Arguments.loadConfigPath(ctx.allocator, true, "bunfig.toml", ctx, .RunCommand);
}
// Format the filename with PID (null-terminated for syscalls)
var filename_buf: [64:0]u8 = undefined;
const filename = std.fmt.bufPrintZ(&filename_buf, "bun-repl-{d}.ts", .{pid}) catch {
Output.prettyErrorln("<r><red>error<r>: Could not create temp file name", .{});
// Initialize JSC
bun.jsc.initialize(true); // true for eval mode
js_ast.Expr.Data.Store.create();
js_ast.Stmt.Data.Store.create();
const arena = Arena.init();
// Create a virtual path for REPL evaluation
const repl_path = "[repl]";
// Initialize the VM
const vm = try jsc.VirtualMachine.init(.{
.allocator = arena.allocator(),
.log = ctx.log,
.args = ctx.args,
.store_fd = false,
.smol = ctx.runtime_options.smol,
.eval = true,
.debugger = ctx.runtime_options.debugger,
.dns_result_order = DNSResolver.Order.fromStringOrDie(ctx.runtime_options.dns_result_order),
.is_main_thread = true,
});
var b = &vm.transpiler;
vm.preload = ctx.preloads;
vm.argv = ctx.passthrough;
vm.arena = @constCast(&arena);
vm.allocator = vm.arena.allocator();
// Configure bundler options
b.options.install = ctx.install;
b.resolver.opts.install = ctx.install;
b.resolver.opts.global_cache = ctx.debug.global_cache;
b.resolver.opts.prefer_offline_install = (ctx.debug.offline_mode_setting orelse .online) == .offline;
b.resolver.opts.prefer_latest_install = (ctx.debug.offline_mode_setting orelse .online) == .latest;
b.options.global_cache = b.resolver.opts.global_cache;
b.options.prefer_offline_install = b.resolver.opts.prefer_offline_install;
b.options.prefer_latest_install = b.resolver.opts.prefer_latest_install;
b.resolver.env_loader = b.env;
b.options.env.behavior = .load_all_without_inlining;
b.options.dead_code_elimination = false; // REPL needs all code
b.configureDefines() catch {
dumpBuildError(vm);
Global.exit(1);
};
// Join temp_dir and filename using platform-aware path joining
var temp_path_buf: [bun.MAX_PATH_BYTES]u8 = undefined;
const temp_path = bun.path.joinAbsStringBufZ(temp_dir, &temp_path_buf, &.{filename}, .auto);
bun.http.AsyncHTTP.loadEnv(vm.allocator, vm.log, b.env);
vm.loadExtraEnvAndSourceCodePrinter();
// Open temp directory for openat/unlinkat operations
const temp_dir_z = std.posix.toPosixPath(temp_dir) catch {
Output.prettyErrorln("<r><red>error<r>: Temp directory path too long", .{});
Global.exit(1);
};
const temp_dir_fd = switch (bun.sys.open(&temp_dir_z, bun.O.DIRECTORY | bun.O.RDONLY, 0)) {
.result => |fd| fd,
.err => {
Output.prettyErrorln("<r><red>error<r>: Could not access temp directory", .{});
Global.exit(1);
},
};
defer temp_dir_fd.close();
vm.is_main_thread = true;
jsc.VirtualMachine.is_main_thread_vm = true;
const temp_file_fd = switch (bun.sys.openat(temp_dir_fd, filename, bun.O.CREAT | bun.O.WRONLY | bun.O.TRUNC, 0o644)) {
.result => |fd| fd,
.err => {
Output.prettyErrorln("<r><red>error<r>: Could not create temp file", .{});
Global.exit(1);
},
// Store VM reference in REPL (safe - no JS allocation)
repl.vm = vm;
repl.global = vm.global;
// Create the ReplRunner and execute within the API lock
// NOTE: JS-allocating operations like ExposeNodeModuleGlobals must
// be done inside the API lock callback, not before
var runner = ReplRunner{
.repl = repl,
.vm = vm,
.arena = arena,
.entry_path = repl_path,
};
// Write the script to the temp file, handling partial writes
var offset: usize = 0;
while (offset < repl_script.len) {
switch (bun.sys.write(temp_file_fd, repl_script[offset..])) {
.err => {
Output.prettyErrorln("<r><red>error<r>: Could not write temp file", .{});
temp_file_fd.close();
Global.exit(1);
},
.result => |written| {
if (written == 0) {
Output.prettyErrorln("<r><red>error<r>: Could not write temp file: write returned 0 bytes", .{});
temp_file_fd.close();
Global.exit(1);
}
offset += written;
},
}
}
temp_file_fd.close();
const callback = jsc.OpaqueWrap(ReplRunner, ReplRunner.start);
vm.global.vm().holdAPILock(&runner, callback);
}
// Ensure cleanup on exit - unlink temp file after Run.boot returns
defer {
_ = bun.sys.unlinkat(temp_dir_fd, filename);
}
// Run the temp file
try Run.boot(ctx, temp_path, null);
fn dumpBuildError(vm: *jsc.VirtualMachine) void {
Output.flush();
const writer = Output.errorWriterBuffered();
defer Output.flush();
vm.log.print(writer) catch {};
}
};
const std = @import("std");
/// Runs the REPL within the VM's API lock
const ReplRunner = struct {
repl: *Repl,
vm: *jsc.VirtualMachine,
arena: bun.allocators.MimallocArena,
entry_path: []const u8,
pub fn start(this: *ReplRunner) void {
const vm = this.vm;
// Set up the REPL environment (now inside API lock)
this.setupReplEnvironment();
// Run the REPL loop
this.repl.runWithVM(vm) catch |err| {
Output.prettyErrorln("<r><red>REPL error: {s}<r>", .{@errorName(err)});
};
// Clean up
vm.onExit();
Global.exit(vm.exit_handler.exit_code);
}
fn setupReplEnvironment(this: *ReplRunner) void {
const vm = this.vm;
// Expose Node.js module globals (__dirname, __filename, require, etc.)
// This must be done inside the API lock as it allocates JS objects
bun.cpp.Bun__ExposeNodeModuleGlobals(vm.global);
// Set timezone if specified
if (vm.transpiler.env.get("TZ")) |tz| {
if (tz.len > 0) {
_ = vm.global.setTimeZone(&jsc.ZigString.init(tz));
}
}
vm.transpiler.env.loadTracy();
}
};
const Repl = @import("../repl.zig");
const std = @import("std");
const bun = @import("bun");
const jsc = bun.jsc;
const js_ast = bun.ast;
const Global = bun.Global;
const Output = bun.Output;
const Command = bun.cli.Command;
const Run = bun.bun_js.Run;
const Arena = bun.allocators.MimallocArena;
const DNSResolver = bun.api.dns.Resolver;

1470
src/repl.zig Normal file

File diff suppressed because it is too large Load Diff