From c7b5cc58f5f98e7e4d8ec46ccc19813a60cfc762 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:14:20 +0000 Subject: [PATCH 1/3] strings: include unterminated final line in index_of_line_ranges When the source text has no trailing newline and the target line is the last (unterminated) line, index_of_line_ranges would exit its scan loop without ever pushing that line's range. remap_zig_exception assumes the last returned range is the target line and numbers backward from there, so the code frame showed the wrong line numbers and omitted the line that actually threw. --- src/bun_core/string/immutable.rs | 19 +++++++--- test/cli/run/run-eval.test.ts | 60 +++++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/bun_core/string/immutable.rs b/src/bun_core/string/immutable.rs index 13c5214c91eb..ea4fffc00daf 100644 --- a/src/bun_core/string/immutable.rs +++ b/src/bun_core/string/immutable.rs @@ -1725,10 +1725,21 @@ pub(crate) fn index_of_line_ranges( current_line += 1; } - if ranges.len() == LINE_RANGE_COUNT && current_line <= target_line { - let mut new_ranges = BoundedArray::::default(); - let _ = new_ranges.extend_from_slice(&ranges.as_slice()[1..]); // OOM/capacity: fire-and-forget - ranges = new_ranges; + // The loop exits here when no more newlines/non-ASCII remain, so the tail + // `text[prev_end+1..]` is the unterminated final line (index `current_line`, + // which is always `<= target_line` at this point). Push it so callers that + // assume the last returned range is `target_line` label the right content. + if (prev_end as usize) + 1 < text.len() { + if ranges.len() == LINE_RANGE_COUNT { + let mut new_ranges = BoundedArray::::default(); + let _ = new_ranges.extend_from_slice(&ranges.as_slice()[1..]); // OOM/capacity: fire-and-forget + ranges = new_ranges; + } + let _ = ranges.push(LineRange { + start: prev_end, + // Wrapping cast. + end: text.len() as u32, + }); // OOM/capacity: fire-and-forget } ranges diff --git a/test/cli/run/run-eval.test.ts b/test/cli/run/run-eval.test.ts index 9b86234fdadf..a487ccc4be89 100644 --- a/test/cli/run/run-eval.test.ts +++ b/test/cli/run/run-eval.test.ts @@ -1,7 +1,7 @@ import { SyncSubprocess } from "bun"; import { describe, expect, test } from "bun:test"; import { rmSync, writeFileSync } from "fs"; -import { bunEnv, bunExe, isWindows, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir, tmpdirSync } from "harness"; import { tmpdir } from "os"; import { join, sep } from "path"; @@ -44,6 +44,23 @@ for (const flag of ["-e", "--print"]) { expect(stderr.toString("utf8")).toInclude("Unexpected throw"); }); + test("error code frame when last line throws and input has no trailing newline", async () => { + const code = 'const a = 1;\nconst b = 2;\nthrow new Error("x");'; + expect(code.endsWith("\n")).toBe(false); + + const { stderr, exitCode } = Bun.spawnSync({ + cmd: [bunExe(), flag, code], + env: bunEnv, + stderr: "pipe", + }); + const text = stderr.toString("utf8"); + expect(text).toContain("1 | const a = 1;"); + expect(text).toContain("2 | const b = 2;"); + expect(text).toContain('3 | throw new Error("x");'); + expect(text).toContain("[eval]:3:"); + expect(exitCode).toBe(1); + }); + test("process.argv", async () => { function testProcessArgv(args: string[], expected: string[]) { const input = flag === "--print" ? "process.argv" : "console.log(process.argv)"; @@ -108,6 +125,47 @@ for (const flag of ["-e", "--print"]) { }); } +describe("error code frame when source has no trailing newline", () => { + test.concurrent("uncaught error from a file", async () => { + using dir = tempDir("no-trailing-nl", { + "entry.js": 'const a = 1;\nconst b = 2;\nthrow new Error("x");', + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain("1 | const a = 1;"); + expect(stderr).toContain("2 | const b = 2;"); + expect(stderr).toContain('3 | throw new Error("x");'); + expect(stderr).toContain("entry.js:3:"); + expect(exitCode).toBe(1); + }); + + test.concurrent("Bun.inspect(new Error(...)) on the last line", async () => { + using dir = tempDir("no-trailing-nl-inspect", { + "entry.js": 'const a = 1;\nconst b = 2;\nconsole.log(Bun.inspect(new Error("x")));', + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toContain("1 | const a = 1;"); + expect(stdout).toContain("2 | const b = 2;"); + expect(stdout).toContain('3 | console.log(Bun.inspect(new Error("x")));'); + expect(stdout).toContain("entry.js:3:"); + expect(exitCode).toBe(0); + }); +}); + describe("--print for cjs/esm", () => { test("eval result between esm imports", async () => { let cwd = tmpdirSync(); From 4d49d534ddd6cd3edff2c942af494c2180c101c8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:41:13 +0000 Subject: [PATCH 2/3] test: drain stdout in the file code-frame test --- test/cli/run/run-eval.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/cli/run/run-eval.test.ts b/test/cli/run/run-eval.test.ts index a487ccc4be89..56cc352b367c 100644 --- a/test/cli/run/run-eval.test.ts +++ b/test/cli/run/run-eval.test.ts @@ -137,7 +137,8 @@ describe("error code frame when source has no trailing newline", () => { stderr: "pipe", stdout: "pipe", }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe(""); expect(stderr).toContain("1 | const a = 1;"); expect(stderr).toContain("2 | const b = 2;"); expect(stderr).toContain('3 | throw new Error("x");'); From 29fd56585ab279c29e1e44d02e7dc18952342193 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:39:03 +0000 Subject: [PATCH 3/3] address review: bare-CR prev_end, shorter comment, ordered assertions, window-full test The bare-CR arm of the scan loop pushed a range but left prev_end stale (and computed the range end from the advanced cursor, not the CR position). Update prev_end and the end index so the tail-push guard does not re-push an overlapping range for sources that mix LF and bare CR. Tests now assert the code-frame lines as one ordered block and assert empty stdout, and a new 8-line case covers the full-window shift branch. --- src/bun_core/string/immutable.rs | 13 +++++----- test/cli/run/run-eval.test.ts | 43 ++++++++++++++++++++++++-------- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/src/bun_core/string/immutable.rs b/src/bun_core/string/immutable.rs index ea4fffc00daf..277bc16b8a4c 100644 --- a/src/bun_core/string/immutable.rs +++ b/src/bun_core/string/immutable.rs @@ -1702,10 +1702,12 @@ pub(crate) fn index_of_line_ranges( prev_end = cursor.i; r } else { - LineRange { + let r = LineRange { start: prev_end, - end: cursor.i + 1, - } + end: current_end + 1, + }; + prev_end = current_end; + r } } _ => continue, @@ -1725,10 +1727,7 @@ pub(crate) fn index_of_line_ranges( current_line += 1; } - // The loop exits here when no more newlines/non-ASCII remain, so the tail - // `text[prev_end+1..]` is the unterminated final line (index `current_line`, - // which is always `<= target_line` at this point). Push it so callers that - // assume the last returned range is `target_line` label the right content. + // Tail after the last newline is the unterminated final line. if (prev_end as usize) + 1 < text.len() { if ranges.len() == LINE_RANGE_COUNT { let mut new_ranges = BoundedArray::::default(); diff --git a/test/cli/run/run-eval.test.ts b/test/cli/run/run-eval.test.ts index 56cc352b367c..1bc5354b2ab5 100644 --- a/test/cli/run/run-eval.test.ts +++ b/test/cli/run/run-eval.test.ts @@ -48,15 +48,15 @@ for (const flag of ["-e", "--print"]) { const code = 'const a = 1;\nconst b = 2;\nthrow new Error("x");'; expect(code.endsWith("\n")).toBe(false); - const { stderr, exitCode } = Bun.spawnSync({ + const { stdout, stderr, exitCode } = Bun.spawnSync({ cmd: [bunExe(), flag, code], env: bunEnv, + stdout: "pipe", stderr: "pipe", }); const text = stderr.toString("utf8"); - expect(text).toContain("1 | const a = 1;"); - expect(text).toContain("2 | const b = 2;"); - expect(text).toContain('3 | throw new Error("x");'); + expect(stdout.toString("utf8")).toBe(""); + expect(text).toContain('1 | const a = 1;\n2 | const b = 2;\n3 | throw new Error("x");\n'); expect(text).toContain("[eval]:3:"); expect(exitCode).toBe(1); }); @@ -139,13 +139,38 @@ describe("error code frame when source has no trailing newline", () => { }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stdout).toBe(""); - expect(stderr).toContain("1 | const a = 1;"); - expect(stderr).toContain("2 | const b = 2;"); - expect(stderr).toContain('3 | throw new Error("x");'); + expect(stderr).toContain('1 | const a = 1;\n2 | const b = 2;\n3 | throw new Error("x");\n'); expect(stderr).toContain("entry.js:3:"); expect(exitCode).toBe(1); }); + test.concurrent("uncaught error from a file longer than the preview window", async () => { + const lines = Array.from({ length: 7 }, (_, i) => `const a${i + 1} = ${i + 1};`); + lines.push('throw new Error("x");'); + using dir = tempDir("no-trailing-nl-long", { + "entry.js": lines.join("\n"), + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe(""); + expect(stderr).toContain( + "3 | const a3 = 3;\n" + + "4 | const a4 = 4;\n" + + "5 | const a5 = 5;\n" + + "6 | const a6 = 6;\n" + + "7 | const a7 = 7;\n" + + '8 | throw new Error("x");\n', + ); + expect(stderr).toContain("entry.js:8:"); + expect(exitCode).toBe(1); + }); + test.concurrent("Bun.inspect(new Error(...)) on the last line", async () => { using dir = tempDir("no-trailing-nl-inspect", { "entry.js": 'const a = 1;\nconst b = 2;\nconsole.log(Bun.inspect(new Error("x")));', @@ -159,9 +184,7 @@ describe("error code frame when source has no trailing newline", () => { }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); - expect(stdout).toContain("1 | const a = 1;"); - expect(stdout).toContain("2 | const b = 2;"); - expect(stdout).toContain('3 | console.log(Bun.inspect(new Error("x")));'); + expect(stdout).toContain('1 | const a = 1;\n2 | const b = 2;\n3 | console.log(Bun.inspect(new Error("x")));\n'); expect(stdout).toContain("entry.js:3:"); expect(exitCode).toBe(0); });