diff --git a/src/bun_core/string/immutable.rs b/src/bun_core/string/immutable.rs index 13c5214c91eb..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,18 @@ 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; + // 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(); + 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..1bc5354b2ab5 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 { stdout, stderr, exitCode } = Bun.spawnSync({ + cmd: [bunExe(), flag, code], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const text = stderr.toString("utf8"); + 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); + }); + test("process.argv", async () => { function testProcessArgv(args: string[], expected: string[]) { const input = flag === "--print" ? "process.argv" : "console.log(process.argv)"; @@ -108,6 +125,71 @@ 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 [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe(""); + 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")));', + }); + 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;\n2 | const b = 2;\n3 | console.log(Bun.inspect(new Error("x")));\n'); + 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();