diff --git a/src/runtime/cli/repl.rs b/src/runtime/cli/repl.rs index 553d90cf2ba1..a5d5d33d39a9 100644 --- a/src/runtime/cli/repl.rs +++ b/src/runtime/cli/repl.rs @@ -62,6 +62,8 @@ unsafe extern "C" { const MAX_HISTORY_SIZE: usize = 1000; const HISTORY_FILENAME: &[u8] = b".bun_repl_history"; +/// Replaces an entry's inner newlines in the one-entry-per-line file (entries never contain CR). +const HISTORY_STORED_NEWLINE: &[u8] = b"\r"; // ANSI escape codes const CSI: &str = concat!("\x1b", "["); @@ -204,9 +206,13 @@ impl History { }; for line in strings::split(&content, b"\n") { - if !line.is_empty() { - self.entries.push(Box::<[u8]>::from(line)); + // A CRLF line ending; never entry content, since add() gets newline-trimmed input. + let line = strings::trim_suffix(line, b"\r"); + if line.is_empty() { + continue; } + let entry = strings::replace_owned(line, HISTORY_STORED_NEWLINE, b"\n"); + self.entries.push(entry.into_boxed_slice()); } // Trim to max size @@ -235,7 +241,8 @@ impl History { let mut content: Vec = Vec::new(); for entry in &self.entries[start..] { - content.extend_from_slice(entry); + let stored = strings::replace_owned(entry, b"\n", HISTORY_STORED_NEWLINE); + content.extend_from_slice(&stored); content.push(b'\n'); } diff --git a/test/js/bun/repl/repl.test.ts b/test/js/bun/repl/repl.test.ts index 403342009aa3..583df232bcee 100644 --- a/test/js/bun/repl/repl.test.ts +++ b/test/js/bun/repl/repl.test.ts @@ -1266,6 +1266,144 @@ describe.skipIf(isWindows)("REPL history file permissions", () => { }); }); +describe.concurrent("REPL history file", () => { + // $HOME/.bun_repl_history holds one entry per line; the newlines inside a + // multi-line entry are written as '\r' so the entry survives a restart. + const ARROW_UP = "\x1b[A"; + + async function runReplWithHome(home: string, input: string[]) { + const { stdout, stderr, exitCode } = await runRepl(input, { env: { HOME: home, USERPROFILE: home } }); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + const out = stripAnsi(stdout); + // Everything after the banner. With piped stdin the input is not echoed; + // the output is a "> " prompt each time the REPL is back at top level + // plus whatever each input printed. + return out.slice(out.indexOf("> ")); + } + + // The physical lines of the history file (the last one is the "" after the + // final newline). + async function savedLines(home: string) { + const content = await Bun.file(path.join(home, ".bun_repl_history")).text(); + return content.split("\n"); + } + + test("multi-line entry is saved on one line and reloads as one entry", async () => { + using dir = tempDir("repl-history-multiline", {}); + const home = String(dir); + + await runReplWithHome(home, ["function f() {", " return 1", "}", ".exit"]); + + expect(await savedLines(home)).toEqual(["function f() {\r return 1\r}", ""]); + + // Next session: recall the entry with the up arrow and run it, then list + // the history. The recalled entry defines f, and re-running it does not + // add a duplicate entry. + const session = await runReplWithHome(home, [ARROW_UP, "f()", ".history", ".exit"]); + expect(session).toMatchInlineSnapshot(` + "> + undefined + > + 1 + > + + Command History: + 1 function f() { + return 1 + } + 2 f() + + > + " + `); + }); + + test("entries with blank lines round-trip in order", async () => { + using dir = tempDir("repl-history-mixed", {}); + const home = String(dir); + + await runReplWithHome(home, ["const a = 1", "function g() {", "", " return 2", "}", "const b = 3", ".exit"]); + + expect(await savedLines(home)).toEqual(["const a = 1", "function g() {\r\r return 2\r}", "const b = 3", ""]); + + const session = await runReplWithHome(home, [".history", ".exit"]); + expect(session).toMatchInlineSnapshot(` + "> + + Command History: + 1 const a = 1 + 2 function g() { + + return 2 + } + 3 const b = 3 + + > + " + `); + }); + + test("loads a CRLF history file without keeping the line endings", async () => { + // A history file re-saved by a CRLF editor: the '\r' before each '\n' is + // part of the line ending, while the ones inside the second entry are its + // stored newlines. + using dir = tempDir("repl-history-crlf", { + ".bun_repl_history": "1 + 1\r\nfunction h() {\r return 3\r}\r\n", + }); + const home = String(dir); + + const session = await runReplWithHome(home, [".history", "2 + 2", ".exit"]); + // Snapshot matching tolerates CRLF, so check for a leaked '\r' explicitly. + expect(session).not.toContain("\r"); + expect(session).toMatchInlineSnapshot(` + "> + + Command History: + 1 1 + 1 + 2 function h() { + return 3 + } + + > + 4 + > + " + `); + + // Re-saved with plain '\n' line endings; the stored newlines inside the + // multi-line entry are kept. + expect(await savedLines(home)).toEqual(["1 + 1", "function h() {\r return 3\r}", "2 + 2", ""]); + }); + + test("a CRLF file of single-line entries loads and is re-saved without the CRs", async () => { + // A history file written on Windows, or edited in a CRLF editor: every '\r' + // is part of a line ending, so none of them may end up in an entry, where + // it would show in recall and .history and be written back on every save. + using dir = tempDir("repl-history-crlf-single-line", { + ".bun_repl_history": "old_one\r\nold_two\r\n", + }); + const home = String(dir); + + const session = await runReplWithHome(home, [".history", "2 + 2", ".exit"]); + expect(session).not.toContain("\r"); + expect(session).toMatchInlineSnapshot(` + "> + + Command History: + 1 old_one + 2 old_two + + > + 4 + > + " + `); + + expect(await savedLines(home)).toEqual(["old_one", "old_two", "2 + 2", ""]); + }); +}); + // `bun --interactive` boots the full node:repl + readline + acorn stack; on a // debug+asan build that is ~4–5s per spawn, so the 5s default is too tight. const interactiveTimeout = 20_000;