Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/runtime/repl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,19 @@ Type `.help` at the prompt to see all available REPL commands.
| `.break` | Cancel the current multi-line input |
| `.history` | Print the command history |

The REPL reads the first word of a line that starts with `.` as a command name. A line that starts with a number, such as `.5 + 1`, or with a spread, such as `...args`, is JavaScript.

In a multi-line input, a line that starts with `.` is a command only when its first word is a full command name: `.break`, or `.save ./out.txt`. Every other line is JavaScript, such as `.sort()`, `.clear()` or `...args`:

```txt
> ([3, 1, 2]
... .sort()
... .reverse())
[ 3, 2, 1 ]
```

The REPL runs a complete line when you press `Enter`, so the next line cannot continue it. To continue an expression on the next line, open a parenthesis first, as above, or use `.editor`. To leave a multi-line input, type `.break` or press `Ctrl+C`.

---

## Keybindings
Expand Down
56 changes: 37 additions & 19 deletions src/runtime/cli/repl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,12 @@ struct ReplCommand {
handler: fn(&mut Repl, &[u8]) -> ReplResult,
}

enum InputLine<'a> {
Code,
Command(&'static ReplCommand, &'a [u8]),
UnknownCommand(&'a [u8]),
}

impl ReplCommand {
const ALL: [ReplCommand; 9] = [
ReplCommand {
Expand Down Expand Up @@ -576,12 +582,33 @@ impl ReplCommand {
},
];

fn find(name: &[u8]) -> Option<&'static ReplCommand> {
/// `.5` is a number and `..` starts a spread: lines that are JavaScript, as in node's REPL.
fn may_name(line: &[u8]) -> bool {
line.first() == Some(&b'.') && !matches!(line.get(1), Some(b'0'..=b'9' | b'.'))
}

/// A line of a pending input can be a member access (`.b`), so only a full name is a command there.
fn find(name: &[u8], mode: InputMode) -> Option<&'static ReplCommand> {
let prefixes = mode == InputMode::Normal;
Self::ALL.iter().find(|&cmd| {
strings::eql_long(cmd.name, name, true)
|| (name.len() > 1 && cmd.name.starts_with(name))
|| (prefixes && name.len() > 1 && cmd.name.starts_with(name))
})
}

fn classify(line: &[u8], mode: InputMode) -> InputLine<'_> {
// `.editor` mode stores every line as text. Only Tab asks about a line there.
if mode == InputMode::Editor || !Self::may_name(line) {
return InputLine::Code;
}
let (name, args) = strings::split_once_char(line, b' ').unwrap_or((line, b""));
match Self::find(name, mode) {
Some(cmd) => InputLine::Command(cmd, args),
// `.then(f)` under `(promise` continues the expression.
None if mode == InputMode::Multiline => InputLine::Code,
None => InputLine::UnknownCommand(name),
}
}
}

#[derive(Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -1491,7 +1518,7 @@ impl<'a> Repl<'a> {
if self.line_editor.cursor != line.len() {
return;
}
if line.is_empty() || line[0] == b'.' {
if line.is_empty() || ReplCommand::may_name(&line) {
return; // skip REPL dot-commands
}

Expand Down Expand Up @@ -2532,20 +2559,8 @@ impl<'a> Repl<'a> {
}

// Check for REPL commands
if !line.is_empty() && line[0] == b'.' {
let space_idx = strings::index_of_char(&line, b' ');
let cmd_name = if let Some(idx) = space_idx {
&line[..idx as usize]
} else {
&line[..]
};
let args = if let Some(idx) = space_idx {
&line[idx as usize + 1..]
} else {
&b""[..]
};

if let Some(cmd) = ReplCommand::find(cmd_name) {
match ReplCommand::classify(&line, self.input_mode) {
InputLine::Command(cmd, args) => {
let result = (cmd.handler)(self, args);
match result {
ReplResult::ExitRepl => {
Expand All @@ -2559,7 +2574,8 @@ impl<'a> Repl<'a> {
return Ok(());
}
}
} else {
}
InputLine::UnknownCommand(cmd_name) => {
self.print_error(format_args!("Unknown command: {}\n", BStr::new(cmd_name)));
self.print(format_args!(
"Type {}.help{} for available commands\n",
Expand All @@ -2570,6 +2586,7 @@ impl<'a> Repl<'a> {
self.refresh_line();
return Ok(());
}
InputLine::Code => {}
}

// Handle empty line
Expand Down Expand Up @@ -2675,7 +2692,8 @@ impl<'a> Repl<'a> {
let line: Vec<u8> = self.line_editor.get_line().to_vec();

// Complete REPL commands
if !line.is_empty() && line[0] == b'.' {
let kind = ReplCommand::classify(&line, self.input_mode);
if !matches!(kind, InputLine::Code) {
let mut matches: Vec<&'static [u8]> = Vec::new();

for cmd in &ReplCommand::ALL {
Expand Down
235 changes: 235 additions & 0 deletions test/js/bun/repl/repl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,160 @@ describe.concurrent("Bun REPL", () => {
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});

const unknownCommand = (name: string) => `Unknown command: ${name}\nType .help for available commands`;

test("at the prompt, a line that starts with a number or a spread is code", async () => {
const { outputs, stderr, exitCode } = await runRepl([
".5 + 1",
".5.toFixed(2)",
".1e3",
".0",
"...[1]",
"..",
// Not JavaScript either way: these still name a command that does not exist.
".",
". 5",
".e1",
"._5",
".HELP",
".exit()",
".exit",
]);
expect(outputs).toEqual([
"1.5",
'"0.50"',
"100",
"0",
"SyntaxError: Unexpected token '...'",
"SyntaxError: Unexpected token '.'",
unknownCommand("."),
unknownCommand("."),
unknownCommand(".e1"),
unknownCommand("._5"),
unknownCommand(".HELP"),
unknownCommand(".exit()"),
]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});

test("a line of a pending input that starts with a dot is code", async () => {
const { outputs, stderr, exitCode } = await runRepl([
"[1,",
".5]",
"const o = { b: 7 }",
"(o",
".b)",
"(o",
".",
"b)",
"[",
"...[1, 2],",
"...[3]",
"]",
"(1",
".nope",
")",
"await (Promise.resolve([3, 1, 2])",
".then(a => a.sort())",
".then(a => a.slice(1))",
")",
"`",
".env",
".DS_Store",
"`",
// A mistyped `.break` is code too. The error comes when the input is complete, and nothing runs.
"function foo() {",
".brek",
"}",
"typeof foo",
".exit",
]);
expect(outputs).toEqual([
"[ 1, 0.5 ]",
"{\n b: 7,\n}",
"7",
"7",
"[ 1, 2, 3 ]",
"undefined",
"[ 2, 3 ]",
'"\\n.env\\n.DS_Store\\n"',
"SyntaxError: Unexpected token '.'",
'"undefined"',
]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});

test("tab-indented lines of a pending input keep a line that starts with a spread", async () => {
// A Tab on an empty line inserts nothing, so `...defaults,` is the start of its line.
const { outputs, stderr, exitCode } = await runRepl([
"const defaults = { retries: 3 }",
"const cfg = {",
"\t...defaults,",
"\ttimeout: 50,",
"}",
"cfg",
".exit",
]);
expect(outputs.at(-1)).toBe("{\n retries: 3,\n timeout: 50,\n}");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});

test("a command name cut short is a command at the prompt and code in a pending input", async () => {
const { outputs, stderr, exitCode } = await runRepl([
".sa",
"const rgb = { b: 3 }",
"(rgb",
".b",
")",
"const ev = { e: 4 }",
"(ev",
".e",
")",
"(ev",
".ex",
")",
".exit",
]);
expect(outputs).toEqual(["Usage: .save <filename>", "{\n b: 3,\n}", "3", "{\n e: 4,\n}", "4", "undefined"]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});

test("a full command name still runs in a pending input, as the first word of a line", async () => {
const { outputs, stderr, exitCode } = await runRepl([
"(1,",
".save",
"2)",
// `.clear())` is one word, and no command has that name.
"const m = new Map([[1, 2]])",
"(m",
".clear())",
"m.size",
"[1,",
".exit",
"2]",
"'not reached'",
]);
expect(outputs).toEqual(["Usage: .save <filename>\n\n2", "Map(1) {\n 1: 2,\n}", "undefined", "0"]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});

test("an assignment to a property in a pending input does not run .save", async () => {
using dir = tempDir("repl-dot-line", {});
const { outputs, stderr, exitCode } = await runRepl(["const o = { s: 0 }", "(o", ".s = 1)", "o.s", ".exit"], {
cwd: String(dir),
});
// As `.save`, the line would write the session to a file named `= 1)`.
const files = [...new Bun.Glob("*").scanSync({ cwd: String(dir), dot: true, onlyFiles: false })];
expect({ outputs, files }).toEqual({ outputs: ["{\n s: 0,\n}", "1", "1"], files: [] });
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
});

describe(".copy command", () => {
Expand Down Expand Up @@ -1151,6 +1305,69 @@ describe.todoIf(isWindows).concurrent("Bun REPL (Terminal)", () => {
});
});

test("tab completes a property on a line that starts with a number, and none on a `.help`, `.load` or unknown-command line", async () => {
await withTerminalRepl(async ({ send, waitFor, waitForScreen }) => {
// `Math.ma` has one completion, `max`. The result is not in the echoed input.
send(".5 + Math.ma\t(3, 7)\n");
await waitFor("7.5");

// Enter takes each of the next lines as a command, so Tab completes no property on them: the
// second Tab lists nothing after `.help `, `proc` does not become `process`, and `ma` stays.
const top = (await waitForScreen(s => s.rows.at(-1) === "> ")).rows.length - 1;
send(".he\t\t\x15.load ./proc\t!");
let screen = await waitForScreen(s => s.rows.at(-1) === "> .load ./proc!");
expect(screen.rows.slice(top)).toEqual(["> .load ./proc!"]);
send("\x15.x Math.ma\t!");
screen = await waitForScreen(s => s.rows.at(-1) === "> .x Math.ma!");
expect(screen.rows.slice(top)).toEqual(["> .x Math.ma!"]);

// A lone `.` names no command yet: Tab lists them all.
const names = [".help", ".exit", ".clear", ".copy", ".load", ".save", ".editor", ".break", ".history"];
send("\x15.\t");
screen = await waitForScreen(s => s.rows.length === top + names.length + 2 && s.rows.at(-1) === "> .");
expect(screen.rows.slice(top)).toEqual(["> .", ...names.map(name => ` ${name}`), "> ."]);
});
});

test("tab completes a property on a line of a pending input that starts with a dot", async () => {
await withTerminalRepl(async ({ send, waitFor }) => {
send("([41, 1]\n");
await waitFor("...");
// `Math.ma` has one completion, `max`. The result is not in the echoed input.
send(".map(n => Math.ma\t(n, 2))\n");
send(".reduce((a, b) => a + b) - 1)\n");
await waitFor(/\b42\b/);
});
});

test("tab does not complete a command name cut short in a pending input", async () => {
await withTerminalRepl(async ({ send, waitFor }) => {
send("globalThis.__rgb = { b: 3 }; 'rgb' + 'Ready'\n");
await waitFor("rgbReady");
send("(__rgb\n");
await waitFor("...");
// Not `.break `: Tab indents, and the line stays a member access.
send(".b\t\n");
send(") * 14\n");
await waitFor(/\b42\b/);
});
});

test("tab does not complete a command name in .editor mode", async () => {
await withTerminalRepl(async ({ send, waitFor }) => {
send("globalThis.__o = { b: 6 }; 'o' + 'Ready'\n");
await waitFor("oReady");
send(".editor\n");
await waitFor(/editor mode/i);
send("__o\n");
// Not `.break `: Tab indents, and the line stays a member access.
send(".b\t\n");
send("* 7\n");
send("\x04"); // Ctrl+D to finish editor mode
await waitFor(/\b42\b/);
});
});

describe("inline suggestions", () => {
// Ghost text is rendered as: ESC[2m<remainder>ESC[0m after the typed text.
// The feature requires colors, so override bunEnv's NO_COLOR for these.
Expand All @@ -1170,6 +1387,24 @@ describe.todoIf(isWindows).concurrent("Bun REPL (Terminal)", () => {
);
});

test("suggests on a line that starts with a number, and not on a command line", async () => {
await withTerminalRepl(
async ({ send, waitFor, waitForScreen }) => {
send(".5 + Mat");
await waitFor(`${DIM}h`);
send("\x1b[C"); // Right arrow accepts the ghost text
// The result is not in the echoed input.
send(".max(3, 7)\n");
await waitFor("7.5");

// `proc` would get the ghost `ess` on a line of code. Here the right arrow has nothing to accept.
send(".load ./proc\x1b[C!");
await waitForScreen(s => s.rows.at(-1) === "\u276f .load ./proc!");
},
{ env: colorEnv },
);
});

test("right arrow accepts the suggestion", async () => {
await withTerminalRepl(
async ({ send, waitFor }) => {
Expand Down
Loading