From 0b0beb86263ee1e6707e2c229bce720916615231 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:00:20 +0000 Subject: [PATCH] bake: align the error overlay underline with a windowed line excerpt Location::init_or_null sends the dev server a ~120 byte window of a long source line as line_text, but column still counts from the start of the whole line. The overlay drew the underline column - 1 characters into the window, so for any error more than 40 bytes into a long line it ended up far to the right of the token, or past the end of the shown text. Serialize Location.line_text_column_offset (the width of the part of the line the window dropped) after the location length, read it back on the client, and start the underline at column - 1 - lineTextColumnOffset. column itself is unchanged so it keeps referring to the whole line, like the logger's "at file:line:col" suffix does. --- .../bake/client/error-serialization.ts | 11 +- src/runtime/bake/client/overlay.ts | 6 +- .../bake/dev_server/serialized_failure.rs | 6 +- test/bake/dev/bundle.test.ts | 27 +++++ test/cli/inspect/BunFrontendDevServer.test.ts | 109 ++++++++++++++++++ 5 files changed, 156 insertions(+), 3 deletions(-) diff --git a/src/runtime/bake/client/error-serialization.ts b/src/runtime/bake/client/error-serialization.ts index 2c0f84482d1f..746d18541206 100644 --- a/src/runtime/bake/client/error-serialization.ts +++ b/src/runtime/bake/client/error-serialization.ts @@ -19,10 +19,17 @@ export interface BundlerMessage { export interface BundlerMessageLocation { /** One-based */ line: number; - /** One-based */ + /** One-based, relative to the whole source line (not to `lineText`) */ column: number; /** Byte length */ length: number; + /** + * Number of columns of the source line that precede `lineText`. Non-zero + * when `lineText` is only a window of a long line, so the highlight starts + * `column - 1 - lineTextColumnOffset` characters into `lineText`. + */ + lineTextColumnOffset: number; + /** The source line, or a window of it around the highlighted range. */ lineText: string; } @@ -71,12 +78,14 @@ function readBundlerMessageLocationOrNull(r: DataViewReader): BundlerMessageLoca const column = r.u32(); const length = r.u32(); + const lineTextColumnOffset = r.u32(); const lineText = r.string32(); return { line, column, length, + lineTextColumnOffset, lineText, }; } diff --git a/src/runtime/bake/client/overlay.ts b/src/runtime/bake/client/overlay.ts index 64757a8f84c2..7ecdfda079d1 100644 --- a/src/runtime/bake/client/overlay.ts +++ b/src/runtime/bake/client/overlay.ts @@ -624,7 +624,11 @@ function renderCodeLine(location: BundlerMessageLocation, level: BundlerMessageL elem("div", { class: "view" }, [ mapCodePreviewLine(syntaxHighlight(location.lineText)), elem("div", { class: "highlight-wrap log-" + bundleLogLevelToName[level] }, [ - elemText("span", { class: "space" }, "_".repeat(location.column - 1)), + elemText( + "span", + { class: "space" }, + "_".repeat(Math.max(0, location.column - 1 - location.lineTextColumnOffset)), + ), elemText("span", { class: "line" }, "_".repeat(location.length)), ]), ]), diff --git a/src/runtime/bake/dev_server/serialized_failure.rs b/src/runtime/bake/dev_server/serialized_failure.rs index f1e457b2add3..552a65b7bda8 100644 --- a/src/runtime/bake/dev_server/serialized_failure.rs +++ b/src/runtime/bake/dev_server/serialized_failure.rs @@ -198,7 +198,7 @@ pub enum ErrorKind { JsAggregate, } -// All "write" functions get a corresponding "read" function in ./client/error.ts +// All "write" functions get a corresponding "read" function in ../client/error-serialization.ts type Writer = Vec; fn write_log_msg(msg: &bun_ast::Msg, w: &mut Writer) { @@ -231,6 +231,10 @@ fn write_log_data(data: &bun_ast::Data, w: &mut Writer) { _ = w.write_int_le::(u32::try_from(loc.column).expect("int cast")); _ = w.write_int_le::(u32::try_from(loc.length).expect("int cast")); + // `line_text` may be a window of a long line (`Location::init_or_null`) + // while `column` is relative to the whole line; the client needs the + // width of the dropped prefix to put the underline under the window. + _ = w.write_int_le::(loc.line_text_column_offset); // TODO: syntax highlighted line text + give more context lines write_string32(loc.line_text.as_deref().unwrap_or(b""), w); diff --git a/test/bake/dev/bundle.test.ts b/test/bake/dev/bundle.test.ts index 6c6d3657029c..4153ce536588 100644 --- a/test/bake/dev/bundle.test.ts +++ b/test/bake/dev/bundle.test.ts @@ -450,6 +450,33 @@ devTest("importing bun on the client", { }); }, }); +devTest("error overlay underlines an error deep inside a long line", { + files: { + "index.html": emptyHtmlFile({ + styles: [], + scripts: ["index.ts"], + }), + "index.ts": `let ok = 1;\n${"a".repeat(100)}]${"b".repeat(100)}`, + }, + async test(dev) { + // The `]` is at column 101 of a 201 character line. The overlay only shows + // a window of the line that starts 40 characters before the error, so the + // underline (what the `:41` below is measured from) has to start 40 + // characters in rather than 100. + await using c = await dev.client("/", { + errors: ['index.ts:2:41: error: Expected ";" but found "]"'], + }); + const { excerpt, underlineStart } = await c.js<{ excerpt: string; underlineStart: number }>`{ + const msg = document.querySelector("bun-hmr").shadowRoot.querySelector(".b-msg"); + return { + excerpt: msg.querySelector(".view > pre").textContent, + underlineStart: msg.querySelector(".highlight-wrap > .space").textContent.length, + }; + }`; + expect(excerpt).toBe(`${"a".repeat(40)}]${"b".repeat(79)}`); + expect(excerpt[underlineStart]).toBe("]"); + }, +}); devTest("import.meta.main", { files: { "index.html": emptyHtmlFile({ diff --git a/test/cli/inspect/BunFrontendDevServer.test.ts b/test/cli/inspect/BunFrontendDevServer.test.ts index af083513f938..de8469a58ea4 100644 --- a/test/cli/inspect/BunFrontendDevServer.test.ts +++ b/test/cli/inspect/BunFrontendDevServer.test.ts @@ -443,6 +443,7 @@ describe.if(isPosix)("BunFrontendDevServer inspector protocol", () => { "length": 1, "line": 3, "lineText": " export function brokenFunction(name: string {", + "lineTextColumnOffset": 0, }, "message": "Expected ")" but found "{"", "notes": [], @@ -455,6 +456,7 @@ describe.if(isPosix)("BunFrontendDevServer inspector protocol", () => { "length": 1, "line": 5, "lineText": " }", + "lineTextColumnOffset": 0, }, "message": "Unexpected }", "notes": [], @@ -477,6 +479,113 @@ describe.if(isPosix)("BunFrontendDevServer inspector protocol", () => { ); }); + test("bundleFailed locations on a long line carry the column offset of the lineText window", async () => { + // For an error deep inside a long line, `lineText` is only a window of the + // line (40 bytes in front of the error, 80 after it) while `column` still + // counts from the start of the line. `lineTextColumnOffset` is the width of + // the part the window dropped, so the overlay underlines `lineText` at + // `column - 1 - lineTextColumnOffset`. Like `column`, it counts UTF-16 code + // units (JS string indices), not bytes: 170 bytes of the second line are + // dropped but they are only 91 characters. + const asciiLine = `let a = 1;/*${"-".repeat(96)}*/let a = 2;/*${"-".repeat(96)}*/`; + const unicodeLine = `let b = 1;/*${"é".repeat(96)}*/let b = 2;/*${"é".repeat(96)}*/`; + const secondDeclaration = asciiLine.lastIndexOf("let ") + "let ".length; // 114, same on both lines + + const bundleFailedPromise = session.waitForEvent("BunFrontendDevServer.bundleFailed"); + // The file is already part of the graph, so the watcher rebundles it on its own. + fs.writeFileSync(join(tempdir, "utils.ts"), `${asciiLine}\n${unicodeLine}\n`); + + const { buildErrorsPayloadBase64 } = await bundleFailedPromise; + const buffer = Uint8Array.from(atob(buildErrorsPayloadBase64), c => c.charCodeAt(0)); + const reader = new DataViewReader(new DataView(buffer.buffer), 0); + const failures: Array> = []; + while (reader.hasMoreData()) { + failures.push(decodeAndAppendServerError(reader)); + } + + expect(failures.map(({ file, messages }) => ({ file, messages }))).toEqual([ + { + file: "utils.ts", + messages: [ + { + kind: "bundler", + level: 0, + message: '"a" has already been declared', + location: { + line: 1, + column: secondDeclaration + 1, + length: 1, + lineTextColumnOffset: secondDeclaration - 40, + lineText: asciiLine.slice(secondDeclaration - 40, secondDeclaration + 80), + }, + notes: [ + { + message: '"a" was originally declared here', + location: { + line: 1, + column: 5, + length: 1, + lineTextColumnOffset: 0, + lineText: asciiLine.slice(0, 4 + 80), + }, + }, + ], + }, + { + kind: "bundler", + level: 0, + message: '"b" has already been declared', + location: { + line: 2, + column: secondDeclaration + 1, + length: 1, + // The 40 bytes kept in front of `b` are "*/let " plus 17 two-byte + // "é", so the window drops "let b = 1;/*" and the other 79 "é". + lineTextColumnOffset: "let b = 1;/*".length + 79, + lineText: `${"é".repeat(17)}*/let b = 2;/*${"é".repeat(36)}`, + }, + notes: [ + { + message: '"b" was originally declared here', + location: { + line: 2, + column: 5, + length: 1, + lineTextColumnOffset: 0, + lineText: `let b = 1;/*${"é".repeat(36)}`, + }, + }, + ], + }, + ], + }, + ]); + + // What the overlay relies on: the offset is where `lineText` starts in the + // line, in string indices, so the underline lands on the declared name. + const [aMessage, bMessage] = failures[0].messages; + for (const [name, line, message] of [ + ["a", asciiLine, aMessage], + ["b", unicodeLine, bMessage], + ] as const) { + for (const { location } of [message, ...message.notes]) { + expect(line.indexOf(location.lineText)).toBe(location.lineTextColumnOffset); + expect(location.lineText[location.column - 1 - location.lineTextColumnOffset]).toBe(name); + } + } + + // Fix the file so subsequent tests don't fail + fs.writeFileSync( + join(tempdir, "utils.ts"), + ` + // Fixed utility module + export function greet(name: string) { + return \`Hello, \${name}!\`; + } + `, + ); + }); + test("should notify on clientNavigated events", async () => { await fetch(serverUrl.href).then(r => r.blob());