diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 82662920a933..7fd6319d23a0 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -6774,7 +6774,12 @@ extern "C" JSC::EncodedJSValue Bun__REPL__getCompletions( size_t prefixLen) { auto& vm = JSC::getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); + // The Rust caller (repl.rs) has no exception scope, so nothing may escape. + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + auto clearAndEncode = [&](JSC::JSValue v) { + scope.clearException(); + return JSC::JSValue::encode(v); + }; JSC::JSValue target = JSC::JSValue::decode(targetValue); if (!target || target.isUndefined() || target.isNull()) { @@ -6783,7 +6788,8 @@ extern "C" JSC::EncodedJSValue Bun__REPL__getCompletions( if (!target.isObject()) { JSObject* boxed = target.toObject(globalObject); - RETURN_IF_EXCEPTION(scope, JSC::JSValue::encode(JSC::jsUndefined())); + if (scope.exception()) [[unlikely]] + return clearAndEncode(JSC::jsUndefined()); target = boxed; } @@ -6791,46 +6797,58 @@ extern "C" JSC::EncodedJSValue Bun__REPL__getCompletions( ? WTF::String::fromUTF8(std::span { prefixPtr, prefixLen }) : WTF::String(); + // getPropertyNames already walks (and dedups) the prototype chain, throwing past maximumPrototypeChainDepth. 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())); + if (scope.exception()) [[unlikely]] + return clearAndEncode(JSC::jsUndefined()); JSC::JSArray* completions = JSC::constructEmptyArray(globalObject, nullptr, 0); - RETURN_IF_EXCEPTION(scope, JSC::JSValue::encode(JSC::jsUndefined())); + if (scope.exception()) [[unlikely]] + return clearAndEncode(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())); + if (scope.exception()) [[unlikely]] + return clearAndEncode(JSC::jsUndefined()); } } - // Also check the prototype chain - JSC::JSValue proto = object->getPrototype(globalObject); - RETURN_IF_EXCEPTION(scope, JSC::JSValue::encode(completions)); + return 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)); +// One `base.name` step of a completion chain: ordinary property semantics (primitives boxed, prototype chain, getters run), UTF-8 name; a miss or a throwing getter yields undefined. +extern "C" JSC::EncodedJSValue Bun__REPL__getProperty( + JSC::JSGlobalObject* globalObject, + JSC::EncodedJSValue baseValue, + const unsigned char* namePtr, + size_t nameLen) +{ + auto& vm = JSC::getVM(globalObject); + // As in Bun__REPL__getCompletions: the Rust caller has no exception scope. + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - 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)); - } - } + JSC::JSValue base = JSC::JSValue::decode(baseValue); + WTF::String name = WTF::String::fromUTF8(std::span { namePtr, nameLen }); + if (!base || base.isUndefinedOrNull() || name.isNull()) + return JSC::JSValue::encode(JSC::jsUndefined()); - proto = protoObj->getPrototype(globalObject); - RETURN_IF_EXCEPTION(scope, JSC::JSValue::encode(completions)); + JSC::JSObject* object = base.toObject(globalObject); + if (scope.exception()) [[unlikely]] { + scope.clearException(); + return JSC::JSValue::encode(JSC::jsUndefined()); } - return JSC::JSValue::encode(completions); + JSC::JSValue result = object->getIfPropertyExists(globalObject, JSC::Identifier::fromString(vm, name)); + if (scope.exception()) [[unlikely]] { + scope.clearException(); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + return JSC::JSValue::encode(result ? result : JSC::jsUndefined()); } // Format a value for REPL output using util.inspect style diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index b7c0c524653d..5c1acc4bf937 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -167,6 +167,7 @@ CPP_DECL void JSC__JSFunction__optimizeSoon(JSC::EncodedJSValue JSValue0); 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__getProperty(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue baseValue, const unsigned char* namePtr, size_t nameLen); CPP_DECL JSC::EncodedJSValue Bun__REPL__formatValue(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue valueEncoded, int32_t depth, bool colors); #pragma mark - JSC::JSGlobalObject diff --git a/src/runtime/cli/repl.rs b/src/runtime/cli/repl.rs index 553d90cf2ba1..a4a3131a01a5 100644 --- a/src/runtime/cli/repl.rs +++ b/src/runtime/cli/repl.rs @@ -22,7 +22,7 @@ use bun_collections::VecExt; use bun_core::strings; #[cfg(unix)] use bun_core::tty; -use bun_core::{Environment, Output, env_var, fmt}; +use bun_core::{Environment, Output, env_var, fmt, identifier}; use bun_jsc::js_promise::Status as PromiseStatus; use bun_jsc::virtual_machine::VirtualMachine; use bun_jsc::{self as jsc, JSGlobalObject, JSValue, JsResult, ProtectedJSValue}; @@ -54,6 +54,13 @@ unsafe extern "C" { prefixPtr: *const u8, prefixLen: usize, ) -> JSValue; + + fn Bun__REPL__getProperty( + globalObject: *const JSGlobalObject, + baseValue: JSValue, + namePtr: *const u8, + nameLen: usize, + ) -> JSValue; } // ============================================================================ @@ -74,6 +81,7 @@ struct Cursor; impl Cursor { const HOME: &'static str = concat!("\x1b", "[", "H"); const CLEAR_LINE: &'static str = concat!("\x1b", "[", "2K"); + const CLEAR_TO_END: &'static str = concat!("\x1b", "[", "0K"); const CLEAR_SCREEN: &'static str = concat!("\x1b", "[", "2J"); const CLEAR_SCROLLBACK: &'static str = concat!("\x1b", "[", "3J"); } @@ -656,7 +664,12 @@ fn cmd_help(repl: &mut Repl, _: &[u8]) -> ReplResult { Color::RESET )); repl.print(format_args!( - " {}Tab{} Auto-complete\n", + " {}Tab{} Auto-complete / accept suggestion\n", + Color::CYAN, + Color::RESET + )); + repl.print(format_args!( + " {}Right/End{} Accept inline suggestion\n", Color::CYAN, Color::RESET )); @@ -797,6 +810,7 @@ fn cmd_editor(repl: &mut Repl, _: &[u8]) -> ReplResult { fn cmd_break(repl: &mut Repl, _: &[u8]) -> ReplResult { repl.line_editor.clear(); repl.multiline_buffer.clear(); + repl.suggestion.clear(); repl.input_mode = InputMode::Normal; ReplResult::SkipEval } @@ -842,6 +856,8 @@ pub(super) struct Repl<'a> { history: History, multiline_buffer: Vec, editor_buffer: Vec, + /// Remainder of the current inline completion (not the full word); empty when none. + suggestion: Vec, // State input_mode: InputMode, @@ -882,6 +898,7 @@ impl<'a> Repl<'a> { history: History::init(), multiline_buffer: Vec::new(), editor_buffer: Vec::new(), + suggestion: Vec::new(), input_mode: InputMode::Normal, running: false, is_tty: false, @@ -1242,6 +1259,16 @@ impl<'a> Repl<'a> { self.write(line); } + // Ghost text; update_suggestion() already guarantees it fits on this row. + if !self.suggestion.is_empty() + && self.use_colors + && self.line_editor.cursor == self.line_editor.buffer.len() + { + self.write(Color::DIM.as_bytes()); + self.write(&self.suggestion); + self.write(Color::RESET.as_bytes()); + } + // Position cursor. The cursor is a byte offset, but the terminal column // is the display width of the text before it, so multi-byte UTF-8 and // wide (e.g. CJK) characters advance the column correctly. @@ -1263,6 +1290,168 @@ impl<'a> Repl<'a> { Output::flush(); } + // ======================================================================== + // Inline Suggestions (ghost text) + // ======================================================================== + + /// Walks `a.b.c` via property gets, not the evaluator (so `_` is untouched). + fn resolve_object_expr(&self, expr: &[u8]) -> JSValue { + let Some(global) = self.global else { + return JSValue::UNDEFINED; + }; + if expr.is_empty() { + return JSValue::UNDEFINED; + } + + let mut current = global.to_js_value(); + for (index, part) in strings::split(expr, b".").enumerate() { + // Top-level `this` evaluates to globalThis in the REPL, which is where the walk starts. + if index == 0 && part == b"this" { + continue; + } + if !identifier::is_identifier(part) || current.is_undefined_or_null() { + return JSValue::UNDEFINED; + } + // SAFETY: `global` is a live opaque `JSGlobalObject` handle; `part` borrows + // from `expr`, which outlives the call. + current = unsafe { Bun__REPL__getProperty(global, current, part.as_ptr(), part.len()) }; + } + current + } + + fn update_suggestion(&mut self) { + self.suggestion.clear(); + + if !self.is_tty || !self.use_colors { + return; + } + if self.input_mode != InputMode::Normal { + return; + } + + let line: Vec = self.line_editor.get_line().to_vec(); + if self.line_editor.cursor != line.len() { + return; + } + if line.is_empty() || line[0] == b'.' { + return; // skip REPL dot-commands + } + + let Some(ctx) = parse_completion_context(&line, self.line_editor.cursor) else { + return; + }; + if (ctx.prefix.is_empty() && ctx.object_expr.is_empty()) || ends_inside_string(&[&line]) { + return; + } + + let Some(global) = self.global else { + return; + }; + + let mut target = JSValue::UNDEFINED; + if !ctx.object_expr.is_empty() { + target = self.resolve_object_expr(ctx.object_expr); + if target.is_undefined_or_null() { + return; + } + } + + // SAFETY: `global` is a live opaque `JSGlobalObject` handle; the prefix ptr/len + // pair is valid for the duration of the call. + let completions = unsafe { + Bun__REPL__getCompletions(global, target, ctx.prefix.as_ptr(), ctx.prefix.len()) + }; + + let mut best_len: usize = usize::MAX; + + if !completions.is_undefined_or_null() && completions.is_array() { + let len: u32 = match completions.get_length(global) { + Ok(n) => n as u32, + Err(_) => { + global.clear_exception(); + 0 + } + }; + for idx in 0..len { + let item = match completions.get_index(global, idx) { + Ok(v) => v, + Err(_) => { + global.clear_exception(); + continue; + } + }; + if !item.is_string() { + continue; + } + let slice = match item.to_slice(global) { + Ok(s) => s, + Err(_) => { + global.clear_exception(); + continue; + } + }; + let name = slice.slice(); + let Some(rest) = name.strip_prefix(ctx.prefix) else { + continue; + }; + // Skips keys like `"foo-bar"` or `"0"`, which can't follow a `.`. + if rest.is_empty() || !identifier::is_identifier(name) { + continue; + } + if name.len() < best_len { + best_len = name.len(); + self.suggestion.clear(); + self.suggestion.extend_from_slice(rest); + if ctx.prefix.is_empty() { + break; + } + } + } + } + + if self.suggestion.is_empty() && ctx.object_expr.is_empty() && !ctx.prefix.is_empty() { + for &kw in JS_KEYWORDS { + if kw.len() > ctx.prefix.len() && kw.starts_with(ctx.prefix) && kw.len() < best_len + { + best_len = kw.len(); + self.suggestion.clear(); + self.suggestion.extend_from_slice(&kw[ctx.prefix.len()..]); + } + } + } + + // Dropped here, not at render, so accept can never apply more than was drawn. + if !self.suggestion.is_empty() { + let line_width = strings::visible::width::exclude_ansi_colors::utf8(&line); + let suggestion_width = + strings::visible::width::exclude_ansi_colors::utf8(&self.suggestion); + if self.get_prompt_length() + line_width + suggestion_width + >= self.terminal_width as usize + { + self.suggestion.clear(); + } + } + } + + fn accept_suggestion(&mut self) -> bool { + if self.suggestion.is_empty() { + return false; + } + let sugg = core::mem::take(&mut self.suggestion); + let ok = self.line_editor.insert_slice(&sugg).is_ok(); + self.suggestion = sugg; + self.suggestion.clear(); + ok + } + + /// Clear the suggestion and wipe any ghost text already drawn past the cursor. + fn erase_suggestion(&mut self) { + if !self.suggestion.is_empty() { + self.write(Cursor::CLEAR_TO_END.as_bytes()); + self.suggestion.clear(); + } + } + fn write_highlighted(&self, text: &[u8]) { let writer = Output::writer(); let highlighter = fmt::QuickAndDirtyJavaScriptSyntaxHighlighter { @@ -2065,6 +2254,7 @@ impl<'a> Repl<'a> { } _ => { self.line_editor.delete_char(); + self.update_suggestion(); self.refresh_line(); } }, @@ -2075,54 +2265,73 @@ impl<'a> Repl<'a> { } Key::CtrlA => { self.line_editor.move_to_start(); + self.suggestion.clear(); self.refresh_line(); } Key::CtrlE => { - self.line_editor.move_to_end(); + if !self.accept_suggestion() { + self.line_editor.move_to_end(); + } + self.update_suggestion(); self.refresh_line(); } Key::CtrlB | Key::ArrowLeft => { self.line_editor.move_left(); + self.suggestion.clear(); self.refresh_line(); } Key::CtrlF | Key::ArrowRight => { - self.line_editor.move_right(); + if self.line_editor.cursor != self.line_editor.buffer.len() + || !self.accept_suggestion() + { + self.line_editor.move_right(); + } + self.update_suggestion(); self.refresh_line(); } Key::AltB | Key::AltLeft => { self.line_editor.move_word_left(); + self.suggestion.clear(); self.refresh_line(); } Key::AltF | Key::AltRight => { self.line_editor.move_word_right(); + self.update_suggestion(); self.refresh_line(); } Key::CtrlU => { self.line_editor.delete_to_start(); + self.update_suggestion(); self.refresh_line(); } Key::CtrlK => { self.line_editor.delete_to_end(); + self.update_suggestion(); self.refresh_line(); } Key::CtrlW | Key::AltBackspace => { self.line_editor.backspace_word(); + self.update_suggestion(); self.refresh_line(); } Key::AltD => { self.line_editor.delete_word(); + self.update_suggestion(); self.refresh_line(); } Key::CtrlT => { self.line_editor.swap(); + self.update_suggestion(); self.refresh_line(); } Key::Backspace => { self.line_editor.backspace(); + self.update_suggestion(); self.refresh_line(); } Key::Delete => { self.line_editor.delete_char(); + self.update_suggestion(); self.refresh_line(); } Key::ArrowUp | Key::CtrlP => { @@ -2131,6 +2340,7 @@ impl<'a> Repl<'a> { if let Some(prev_line) = self.history.prev(&cur) { let prev_line = prev_line.to_vec(); let _ = self.line_editor.set(&prev_line); + self.suggestion.clear(); self.refresh_line(); } } @@ -2141,23 +2351,30 @@ impl<'a> Repl<'a> { } else { self.line_editor.clear(); } + self.suggestion.clear(); self.refresh_line(); } Key::Tab => self.handle_tab(), Key::Home => { self.line_editor.move_to_start(); + self.suggestion.clear(); self.refresh_line(); } Key::End => { - self.line_editor.move_to_end(); + if !self.accept_suggestion() { + self.line_editor.move_to_end(); + } + self.update_suggestion(); self.refresh_line(); } Key::Char(c) => { let _ = self.line_editor.insert(c); + self.update_suggestion(); self.refresh_line(); } Key::Text(bytes, len) => { let _ = self.line_editor.insert_slice(&bytes[..len]); + self.update_suggestion(); self.refresh_line(); } _ => {} @@ -2170,6 +2387,7 @@ impl<'a> Repl<'a> { } fn handle_enter(&mut self) -> Result<(), crate::Error> { + self.erase_suggestion(); self.print(format_args!("\n")); // Note: reshaped for borrowck — copy line out so we can call &mut self methods @@ -2275,6 +2493,7 @@ impl<'a> Repl<'a> { } fn handle_ctrl_c(&mut self) { + self.erase_suggestion(); match self.input_mode { InputMode::Editor => { self.print(format_args!( @@ -2313,7 +2532,20 @@ impl<'a> Repl<'a> { self.refresh_line(); } + /// Tab with nothing to complete indents instead. + fn insert_tab_spaces(&mut self) { + let _ = self.line_editor.insert_slice(b" "); + self.refresh_line(); + } + fn handle_tab(&mut self) { + if !self.suggestion.is_empty() && self.line_editor.cursor == self.line_editor.buffer.len() { + self.accept_suggestion(); + self.update_suggestion(); + self.refresh_line(); + return; + } + // Note: reshaped for borrowck — copy line out let line: Vec = self.line_editor.get_line().to_vec(); @@ -2348,36 +2580,51 @@ impl<'a> Repl<'a> { // Property completion using JSC let Some(global) = self.global else { - // No VM, just insert spaces - let _ = self.line_editor.insert(b' '); - let _ = self.line_editor.insert(b' '); - self.refresh_line(); + self.insert_tab_spaces(); return; }; - // Find the word being completed - let mut word_start: usize = line.len(); - while word_start > 0 { - let c = line[word_start - 1]; - if !c.is_ascii_alphanumeric() && c != b'_' && c != b'$' { - break; - } - word_start -= 1; + let cursor = self.line_editor.cursor; + // A template literal may have been opened on an earlier line of this input. + let earlier_lines: &[u8] = match self.input_mode { + InputMode::Normal => b"", + InputMode::Multiline => &self.multiline_buffer, + InputMode::Editor => &self.editor_buffer, + }; + if ends_inside_string(&[earlier_lines, &line[..cursor]]) { + self.insert_tab_spaces(); + return; } - let prefix = &line[word_start..]; + // Mid-identifier (`con|sole`): completing would duplicate the suffix. + if cursor < line.len() && is_word_byte(line[cursor]) { + self.refresh_line(); + return; + } + + let Some(ctx) = parse_completion_context(&line, cursor) else { + self.insert_tab_spaces(); + return; + }; + let word_start = ctx.prefix_start; + let prefix = ctx.prefix; + + let mut target = JSValue::UNDEFINED; + if !ctx.object_expr.is_empty() { + target = self.resolve_object_expr(ctx.object_expr); + if target.is_undefined_or_null() { + self.insert_tab_spaces(); + return; + } + } - // Get completions from global object // SAFETY: `global` is a live opaque `JSGlobalObject` handle; `prefix` ptr/len // are valid for the duration of the call. - let completions = unsafe { - Bun__REPL__getCompletions(global, JSValue::UNDEFINED, prefix.as_ptr(), prefix.len()) - }; + let completions = + unsafe { Bun__REPL__getCompletions(global, target, prefix.as_ptr(), prefix.len()) }; if completions.is_undefined() || !completions.is_array() { - let _ = self.line_editor.insert(b' '); - let _ = self.line_editor.insert(b' '); - self.refresh_line(); + self.insert_tab_spaces(); return; } @@ -2390,9 +2637,7 @@ impl<'a> Repl<'a> { } }; if len == 0 { - let _ = self.line_editor.insert(b' '); - let _ = self.line_editor.insert(b' '); - self.refresh_line(); + self.insert_tab_spaces(); return; } @@ -2414,11 +2659,12 @@ impl<'a> Repl<'a> { } }; let completion = slice.slice(); - // Replace the prefix with the completion - while self.line_editor.cursor > word_start { - self.line_editor.backspace(); + if identifier::is_identifier(completion) { + while self.line_editor.cursor > word_start { + self.line_editor.backspace(); + } + let _ = self.line_editor.insert_slice(completion); } - let _ = self.line_editor.insert_slice(completion); self.refresh_line(); } } else if len <= 50 { @@ -2491,6 +2737,154 @@ extern "C" fn sigint_handler(_: c_int) { } } +// ============================================================================ +// Inline Suggestions (ghost text) +// ============================================================================ + +/// Word bytes for the prefix/chain scan; non-ASCII is taken wholesale (a junk prefix matches nothing). +#[inline] +fn is_word_byte(c: u8) -> bool { + c.is_ascii_alphanumeric() || c == b'_' || c == b'$' || c >= 0x80 +} + +/// Fallback suggestions when no global matches the prefix. +const JS_KEYWORDS: &[&[u8]] = &[ + b"async", + b"await", + b"break", + b"case", + b"catch", + b"class", + b"const", + b"continue", + b"debugger", + b"default", + b"delete", + b"else", + b"export", + b"extends", + b"false", + b"finally", + b"for", + b"function", + b"import", + b"instanceof", + b"let", + b"new", + b"null", + b"return", + b"static", + b"super", + b"switch", + b"this", + b"throw", + b"true", + b"try", + b"typeof", + b"undefined", + b"var", + b"void", + b"while", + b"yield", +]; + +/// Text ends inside string/template content, where completing is noise; `${…}` holes hold code. +fn ends_inside_string(parts: &[&[u8]]) -> bool { + let mut quote = 0u8; + // Unclosed-brace count of each `${` hole being scanned, innermost last. + let mut holes: Vec = Vec::new(); + for part in parts { + let mut i = 0; + while i < part.len() { + let c = part[i]; + i += 1; + if quote != 0 { + match c { + b'\\' => i += 1, + b'$' if quote == b'`' && part.get(i) == Some(&b'{') => { + i += 1; + holes.push(1); + quote = 0; + } + _ if c == quote => quote = 0, + _ => {} + } + } else { + match c { + b'"' | b'\'' | b'`' => quote = c, + b'{' => { + if let Some(depth) = holes.last_mut() { + *depth += 1; + } + } + b'}' => { + if let Some(depth) = holes.last_mut() { + *depth -= 1; + if *depth == 0 { + holes.pop(); + quote = b'`'; + } + } + } + _ => {} + } + } + } + } + quote != 0 +} + +/// `console.lo|` → `object_expr = "console"`, `prefix = "lo"`; empty `object_expr` = globalThis. +struct CompletionContext<'a> { + object_expr: &'a [u8], + prefix: &'a [u8], + prefix_start: usize, +} + +/// `None` for e.g. `foo().th|`: a property name follows the `.`, so globals/keywords don't apply. +fn parse_completion_context(line: &[u8], cursor: usize) -> Option> { + let mut i = cursor; + while i > 0 && is_word_byte(line[i - 1]) { + i -= 1; + } + let prefix_start = i; + let prefix = &line[prefix_start..cursor]; + + // A `..` ending at `end` is the tail of a spread (`[...args`, `[...a.b`), not member access. + let member_dot_ends_at = + |end: usize| end >= 1 && line[end - 1] == b'.' && (end < 2 || line[end - 2] != b'.'); + + if !member_dot_ends_at(i) { + return Some(CompletionContext { + object_expr: b"", + prefix, + prefix_start, + }); + } + i -= 1; // skip the `.` + let chain_end = i; + + loop { + let ident_end = i; + while i > 0 && is_word_byte(line[i - 1]) { + i -= 1; + } + if i == ident_end { + return None; + } + if !member_dot_ends_at(i) { + break; + } + i -= 1; + } + + Some(CompletionContext { + object_expr: &line[i..chain_end], + prefix, + prefix_start, + }) +} + fn is_incomplete_code(code: &[u8]) -> bool { let mut brace_count: i32 = 0; let mut bracket_count: i32 = 0; diff --git a/test/js/bun/repl/repl.test.ts b/test/js/bun/repl/repl.test.ts index 403342009aa3..83443329adce 100644 --- a/test/js/bun/repl/repl.test.ts +++ b/test/js/bun/repl/repl.test.ts @@ -46,6 +46,7 @@ async function withTerminalRepl( waitFor: (pattern: string | RegExp, timeoutMs?: number) => Promise; allOutput: () => string; }) => Promise, + options: { env?: Record } = {}, ) { const received: string[] = []; let cursor = 0; @@ -70,6 +71,7 @@ async function withTerminalRepl( env: { ...bunEnv, TERM: "xterm-256color", + ...options.env, }, }); @@ -106,8 +108,10 @@ async function withTerminalRepl( await fn({ terminal, proc, send, waitFor, allOutput }); - // Clean exit - send(".exit\n"); + // Clean exit. Ctrl+U first discards whatever the test left on the line, so + // `.exit` is not appended to it (which would leave the REPL running until + // the kill below). + send("\x15.exit\n"); await Promise.race([proc.exited, Bun.sleep(2000)]); if (!proc.killed) proc.kill(); } @@ -1056,6 +1060,315 @@ describe.todoIf(isWindows)("Bun REPL (Terminal)", () => { }); }); + describe("inline suggestions", () => { + // Ghost text is rendered as: ESC[2mESC[0m after the typed text. + // The feature requires colors, so override bunEnv's NO_COLOR for these. + const DIM = "\x1b[2m"; + const colorEnv = { NO_COLOR: undefined, FORCE_COLOR: "1" }; + + test("suggests global completion while typing", async () => { + await withTerminalRepl( + async ({ send, waitFor }) => { + // "cons" should suggest "ole" (-> console). There are longer globals + // like `constructor` on the prototype chain, but the REPL picks the + // shortest match. + send("cons"); + await waitFor(`${DIM}ole`); + }, + { env: colorEnv }, + ); + }); + + test("right arrow accepts the suggestion", async () => { + await withTerminalRepl( + async ({ send, waitFor }) => { + send("JSO"); + await waitFor(`${DIM}N`); + send("\x1b[C"); // Right arrow accepts the ghost text + // After acceptance the input is `JSON`; extend it and evaluate. + // The result "81" never appears in the echoed input, so this only + // matches once the expression actually evaluates, proving the ghost + // was accepted (otherwise `JSO.stringify` is a ReferenceError). + send(".stringify(9*9)\n"); + await waitFor('"81"'); + }, + { env: colorEnv }, + ); + }); + + test("suggests property completion after a dot", async () => { + await withTerminalRepl( + async ({ send, waitFor }) => { + // Build up `console.` by accepting the global suggestion first. + send("cons"); + await waitFor(`${DIM}ole`); + send("\x1b[C"); // accept -> "console" + send(".l"); + // console.l -> "log" is the shortest property starting with "l" + await waitFor(`${DIM}og`); + }, + { env: colorEnv }, + ); + }); + + test("tab accepts the visible suggestion", async () => { + await withTerminalRepl( + async ({ send, waitFor }) => { + send("JSON.str"); + await waitFor(`${DIM}ingify`, 10000); + send("\t"); // Tab accepts the ghost suggestion -> `JSON.stringify` + // Result "81" cannot occur in the echoed input, so it only matches + // if Tab really completed to `stringify` and the call succeeded. + send("(9*9)\n"); + await waitFor('"81"'); + }, + { env: colorEnv }, + ); + }, 15000); + + test("end key accepts the suggestion", async () => { + await withTerminalRepl( + async ({ send, waitFor }) => { + send("Mat"); + await waitFor(`${DIM}h`); + send("\x1b[F"); // End accepts the suggestion -> "Math" + // Result 63 cannot occur in the echoed input; if End didn't accept, + // `Mat.max(...)` would throw instead of producing it. + send(".max(4,7)*9\n"); + await waitFor("63"); + }, + { env: colorEnv }, + ); + }); + + test("suggests first property when prefix is empty after dot", async () => { + await withTerminalRepl( + async ({ send, waitFor }) => { + // Define an object with a single distinctive property so the + // suggestion is deterministic regardless of prototype ordering. + send("globalThis.__sgObj = Object.create(null); __sgObj.onlyProp = 1\n"); + await waitFor("1"); + send("__sgObj."); + await waitFor(`${DIM}onlyProp`); + }, + { env: colorEnv }, + ); + }); + + test("falls back to JS keywords when no global matches", async () => { + await withTerminalRepl( + async ({ send, waitFor }) => { + // No global starts with "instan", but the keyword `instanceof` does. + send("x instan"); + await waitFor(`${DIM}ceof`); + }, + { env: colorEnv }, + ); + }); + + test("no global or keyword suggestion for a property of an unresolvable expression", async () => { + await withTerminalRepl( + async ({ send, waitFor }) => { + send('globalThis.__o = () => ({ th: "no" + "Ghost", this: "had" + "Ghost" }); "o" + "Ready"\n'); + await waitFor("oReady"); + // `.th` names a property of the call result, so the keyword fallback + // (`this`) must not kick in. Right arrow would accept such a ghost, + // turning the line into `__o().this`. + send("__o().th\x1b[C\n"); + await waitFor("noGhost"); + }, + { env: colorEnv }, + ); + }); + + test("suggests non-ASCII property names that are valid identifiers", async () => { + await withTerminalRepl( + async ({ send, waitFor }) => { + // Both keys start with "caf" and have the same byte length, and `caf→` + // comes first; only `cafés` can follow a `.`, so the ghost must be its + // remainder. + send( + 'globalThis.__u = { "caf\\u2192": 1, "caf\\u00e9s": 2 }; globalThis["caf\\u00e9"] = { latte: 1 }; "u" + "Ready"\n', + ); + await waitFor("uReady"); + send("__u.caf"); + await waitFor(`${DIM}és`); + // The typed prefix itself may contain non-ASCII characters. + send("é"); + await waitFor(`${DIM}s`); + // So may the object being completed: the chain segment is looked up as + // UTF-8, not byte-per-character. + send("\x15café."); + await waitFor(`${DIM}latte`); + }, + { env: colorEnv }, + ); + }); + + test("resolves chain segments inherited from Object.prototype", async () => { + await withTerminalRepl( + async ({ send, waitFor }) => { + // `constructor` comes from Object.prototype; it must resolve to `Object` + // like a real property access would, not stop at the object's own keys. + send('globalThis.__plain = {}; "plain" + "Ready"\n'); + await waitFor("plainReady"); + send("__plain.constructor.getOwnPropertyNa"); + await waitFor(`${DIM}mes`); + }, + { env: colorEnv }, + ); + }); + + test("resolves chains through primitive values", async () => { + await withTerminalRepl( + async ({ send, waitFor }) => { + // `process.version` is a string and `.length` a number; both get boxed + // the way a real property access would, ending on Number.prototype. + send("process.version.length.toF"); + await waitFor(`${DIM}ixed`); + }, + { env: colorEnv }, + ); + }); + + test("spread dots do not turn the word into a property access", async () => { + await withTerminalRepl( + async ({ send, waitFor }) => { + send("[...cons"); + await waitFor(`${DIM}ole`); + // Nor do they swallow the chain that follows them. + send("\x15[...console.l"); + await waitFor(`${DIM}og`); + }, + { env: colorEnv }, + ); + }); + + test("a chain starting with `this` completes against the global object", async () => { + await withTerminalRepl( + async ({ send, waitFor }) => { + send("this.cons"); + await waitFor(`${DIM}ole`); + }, + { env: colorEnv }, + ); + }); + + test("no suggestions inside a string literal", async () => { + await withTerminalRepl( + async ({ send, waitFor }) => { + // `st` would otherwise match globals such as `structuredClone`, and the + // right arrow would accept that ghost, changing the evaluated string. + send('"st\x1b[C" + "x"\n'); + await waitFor('"stx"'); + }, + { env: colorEnv }, + ); + }); + + test("tab inside a string literal indents instead of completing", async () => { + await withTerminalRepl(async ({ send, waitFor }) => { + // `JSON.pars` has exactly one completion, which Tab would otherwise splice + // into the string; indenting adds two spaces, so the length is 9 + 2. + send('("JSON.pars\t").length\n'); + await waitFor(/\b11\b/); + }); + }); + + test("suggests inside a template hole but not in the template text around it", async () => { + await withTerminalRepl( + async ({ send, waitFor }) => { + send("`${JSO"); + await waitFor(`${DIM}N`); + // Back in template text after the `}`: "st" must get no ghost, or the + // right arrow would accept it. `${JSON}` stringifies to the 13-char + // "[object JSON]", plus " st", so the length is 16. + send("N} st\x1b[C`.length === 16\n"); + await waitFor("true"); + }, + { env: colorEnv }, + ); + }); + + test("tab on a continuation line of a template literal indents", async () => { + await withTerminalRepl(async ({ send, waitFor }) => { + send("globalThis.__tpl = `\n"); + await waitFor("..."); + // The backtick was opened on the previous line. Without that context + // Tab would complete `JSON.pars` to `parse` inside the template. + send("JSON.pars\t`\n"); + await waitFor(/\u276f|> /); + // "\n" + "JSON.pars" + two spaces. + send("__tpl.length\n"); + await waitFor(/\b12\b/); + }); + }); + + test("tab on a continuation line outside a string still completes", async () => { + await withTerminalRepl(async ({ send, waitFor }) => { + send("function __cont() {\n"); + await waitFor("..."); + send("return JSON.pars\t\n"); + send("}\n"); + await waitFor(/\u276f|> /); + send("__cont() === JSON.parse\n"); + await waitFor("true"); + }); + }); + + test("completion on a Proxy with a misbehaving getPrototypeOf trap does not hang", async () => { + await withTerminalRepl( + async ({ send, waitFor }) => { + // The trap alternates between ending the chain and pointing back at + // the proxy, so any completer that walks the chain itself never finishes. + send( + 'globalThis.__flipN = 0; globalThis.__flip = new Proxy({}, { getPrototypeOf: () => (__flipN++ % 2 ? __flip : null) }); "flip" + "Ready"\n', + ); + await waitFor("flipReady"); + // Typing the `.` computes completions for `__flip`; Ctrl+C then + // discards the line and the REPL must still evaluate the next one. + send("__flip.\x03"); + send('"still" + "Alive"\n'); + await waitFor("stillAlive"); + }, + { env: colorEnv }, + ); + }); + + test("tab completes properties on an object (no ghost)", async () => { + // Tab completion resolves `obj.prefix` chains even when ghost text is + // disabled (NO_COLOR), so this covers parse_completion_context + resolve. + await withTerminalRepl(async ({ send, waitFor }) => { + // Store the marker as two halves so it never appears in the echoed + // input; it only shows up once the completed property is evaluated. + send("globalThis.__tcObj = { uniqueLongName: 'tcMAR' + 'KER' }; 0\n"); + await waitFor(/\b0\b/); + send("__tcObj.uni"); + send("\t"); + // After tab, the full property should be in the input; evaluate it. + send("\n"); + await waitFor("tcMARKER"); + }); + }); + + test("suggestion is not evaluated on enter", async () => { + await withTerminalRepl( + async ({ send, waitFor }) => { + send("globalThis.zzGhostMarker = 1\n"); + await waitFor("1"); + // Type a prefix that triggers a suggestion but don't accept it. + send("zz"); + await waitFor(`${DIM}GhostMarker`); + // Hit Enter without accepting: the ghost text must not be part of + // the evaluated input, so `zz` alone is a ReferenceError. + send("\n"); + await waitFor(/ReferenceError|not defined/); + }, + { env: colorEnv }, + ); + }); + }); + test(".editor mode collects lines until Ctrl+D", async () => { await withTerminalRepl(async ({ send, waitFor }) => { send(".editor\n");