Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
01f8c7c
logger: indent the caret relative to the windowed line excerpt
robobun Sep 6, 2026
85fb611
logger: keep the full line for tail errors, port caret tests, fix lin…
robobun Sep 7, 2026
0f236ab
logger: trim comments
robobun Sep 7, 2026
eb8100d
logger: shorten comments
robobun Sep 7, 2026
0e8105a
test: split install stderr on CRLF too
robobun Sep 7, 2026
bb6ecf2
Merge branch 'main' into robobun/9216d528/caret-under-windowed-excerpt
Jarred-Sumner Sep 7, 2026
d825771
logger: bound the excerpt printed for an error on a long line
robobun Sep 17, 2026
86c3429
logger: print a redacted line whole
robobun Sep 18, 2026
e8f7683
test: assert the exit code in the excerpt tests
robobun Sep 18, 2026
239fad0
logger: shorten comments
robobun Sep 18, 2026
4add19d
ci: retrigger
robobun Sep 18, 2026
62cfbe5
Merge remote-tracking branch 'origin/main' into robobun/9216d528/care…
robobun Sep 19, 2026
c0d24b3
logger: keep Location at 96 bytes
robobun Sep 19, 2026
aff16d5
logger: keep Location.length in place
robobun Sep 19, 2026
7be9837
ci: retrigger
robobun Sep 20, 2026
2d5e976
Merge branch 'main' into robobun/9216d528/caret-under-windowed-excerpt
robobun Oct 25, 2026
48f179d
test: pin the excerpt window at its two limits
robobun Oct 25, 2026
8657159
Merge branch 'main' into robobun/097de884/bound-printed-line-excerpt
robobun Oct 25, 2026
0091de2
logger: bun lint, bun check and bun fmt print their lines uncut
robobun Oct 25, 2026
5df8b47
logger: trim the end of the excerpt
robobun Oct 25, 2026
b3bfbd6
Merge branch 'robobun/9216d528/caret-under-windowed-excerpt' (#41658)…
robobun Oct 26, 2026
c363aa1
logger: keep an excerpt of a line of more than 1000 bytes wherever th…
robobun Oct 26, 2026
5bbf963
logger: name the rule that the 1000-byte limit follows
robobun Oct 26, 2026
4e978ba
logger: count a CSS or plugin column in its own line, and test the ex…
robobun Oct 26, 2026
2aa8789
Merge branch 'main' into robobun/9216d528/caret-under-windowed-excerpt
robobun Oct 27, 2026
0438b03
Merge branch 'robobun/9216d528/caret-under-windowed-excerpt' (#41658)…
robobun Oct 27, 2026
b80f198
Merge remote-tracking branch 'origin/main' into robobun/097de884/boun…
robobun Oct 28, 2026
4e4600d
Fit the two readers of Location.length that main added to its u32
robobun Oct 28, 2026
e961622
Merge remote-tracking branch 'origin/main' into robobun/097de884/boun…
robobun Nov 8, 2026
072af93
logger: never cut the line of a redacted message
robobun Nov 8, 2026
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
61 changes: 56 additions & 5 deletions src/ast/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -978,11 +978,19 @@ impl Data {

if let Some(location) = &self.location {
if let Some(line_text_) = location.line_text.as_deref() {
let line_text_right_trimmed = bun_core::trim_right(line_text_, b" \r\n\t");
let line_text = bun_core::trim_left(line_text_right_trimmed, b"\n\r");
if location.column > 0 && !line_text.is_empty() {
let mut line_offset_for_second_line: usize =
usize::try_from(location.column - 1).expect("int cast");
let line_text = bun_core::trim_left(line_text_, b"\n\r");
if location.column > 0 && !bun_core::trim_right(line_text, b" \r\n\t").is_empty() {
let (line_text, caret_column) = excerpt_around_column(
line_text,
usize::try_from(location.column - 1).expect("int cast"),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// A cut can drop the key that marks a secret, so a redacted line prints whole.
if redact_sensitive_information {
usize::MAX
} else {
MAX_EXCERPT_LEN
},
);
let mut line_offset_for_second_line: usize = caret_column;

if location.line > -1 {
let bold = matches!(kind, Kind::Err | Kind::Warn);
Expand Down Expand Up @@ -1071,6 +1079,49 @@ fn write_n_bytes(to: &mut impl fmt::Write, b: u8, n: usize) -> fmt::Result {
Ok(())
}

/// `Data::write_format` cuts a line longer than this many bytes, unless it redacts the line.
const MAX_EXCERPT_LEN: usize = 120;
/// The excerpt starts this many bytes before the caret, or earlier when the line ends first.
const EXCERPT_LEN_BEFORE_CARET: usize = 40;

/// Walks `text` for up to `columns` UTF-16 code units. Returns the bytes and the code units walked.
fn advance_utf16_columns(text: &[u8], columns: usize) -> (usize, usize) {
use bun_core::strings::{CodepointIterator, Cursor};
let iter = CodepointIterator::init(text);
let mut cursor = Cursor::default();
let mut walked: usize = 0;
while walked < columns && iter.next(&mut cursor) {
walked += 1 + (cursor.c > 0xFFFF) as usize;
}
(cursor.i as usize + cursor.width as usize, walked)
}

/// The part of `line_text` to print for a caret at the 0-based `column`, and the caret's column in it.
fn excerpt_around_column(line_text: &[u8], column: usize, max_len: usize) -> (&[u8], usize) {
debug_assert!(max_len >= EXCERPT_LEN_BEFORE_CARET);
let (caret, caret_column) = advance_utf16_columns(line_text, column);
let visible = bun_core::trim_right(line_text, b" \r\n\t");
if line_text.len() <= max_len {
return (visible, caret_column);
}
let caret = caret.min(visible.len());
let mut hi = (caret.saturating_sub(EXCERPT_LEN_BEFORE_CARET) + max_len).min(visible.len());
let mut lo = hi.saturating_sub(max_len);
// A UTF-8 sequence has at most 3 continuation bytes.
for _ in 0..3 {
if !bun_core::strings::is_on_char_boundary(visible, lo) {
lo -= 1;
}
if !bun_core::strings::is_on_char_boundary(visible, hi) {
hi += 1;
}
}
(
bun_core::trim_right(&visible[lo..hi], b" \r\n\t"),
advance_utf16_columns(&visible[lo..caret], usize::MAX).1,
)
}

// ───────────────────────────────────────────────────────────────────────────
// BabyString
// ───────────────────────────────────────────────────────────────────────────
Expand Down
47 changes: 47 additions & 0 deletions test/cli/install/redacted-config-logs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,53 @@ test("bunfig password value is masked in config error output", async () => {
expect(coloredExit).toBe(1);
});

test("bunfig token is masked when its key is further from the error than one printed excerpt", async () => {
// The logger prints about 120 bytes around the caret of a long line. The
// `token` key is 154 bytes to the left of the caret here, and the redaction
// has to see that key to know that the string is a secret.
const secret = Buffer.alloc(143, "secretvalue").toString();
const masked = Buffer.alloc(143, "*").toString();
using dir = tempDir("redacted-bunfig-long-line", {
"bunfig.toml": `[install]\ntoken = "${secret}" ]\n`,
"package.json": "{}",
});

await using plain = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
env: { ...bunEnv, NO_COLOR: "1" },
stdout: "pipe",
stderr: "pipe",
});

const [plainOut, plainErr, plainExit] = await Promise.all([plain.stdout.text(), plain.stderr.text(), plain.exited]);

expect(plainOut).not.toContain("secretvalue");
expect(plainErr).not.toContain("secretvalue");
expect(plainErr).toContain(`2 | token = "${masked}" ]`);

await using colored = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
env: { ...bunEnv, NO_COLOR: undefined, FORCE_COLOR: "1" },
stdout: "pipe",
stderr: "pipe",
});

const [coloredOut, coloredErr, coloredExit] = await Promise.all([
colored.stdout.text(),
colored.stderr.text(),
colored.exited,
]);

expect(coloredOut).not.toContain("secretvalue");
expect(coloredErr).not.toContain("secretvalue");
expect(coloredErr).toContain(`"${masked}"`);

expect(plainExit).toBe(1);
expect(coloredExit).toBe(1);
});

describe.concurrent("redact", async () => {
const tests = [
{
Expand Down
169 changes: 165 additions & 4 deletions test/js/bun/transpiler/parse-error-column.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, test } from "bun:test";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import { join } from "node:path";

Expand Down Expand Up @@ -142,8 +142,8 @@ test.concurrent("long non-ASCII line's lineText window does not split a UTF-8 se
});

test.concurrent("CLI caret stays under the token for an error at the end of a long line", async () => {
// `write_format` offsets the caret by `column - 1` with no knowledge of any
// left-trim, so the window gate must not left-trim this case.
// The location keeps the whole 151-byte line. The printer shows its last 120
// bytes and indents the caret by what it printed, not by the column.
using dir = tempDir("parse-col-caret", {
"long.js": Buffer.alloc(150, "a").toString() + "]",
});
Expand All @@ -158,5 +158,166 @@ test.concurrent("CLI caret stays under the token for an error at the end of a lo
const lines = stderr.split("\n");
const textLine = lines.find(l => l.includes("]"))!;
const caretLine = lines.find(l => l.trimEnd().endsWith("^"))!;
expect({ token: textLine.indexOf("]"), caret: caretLine.indexOf("^") }).toEqual({ token: 154, caret: 154 });
expect({ token: textLine.indexOf("]"), caret: caretLine.indexOf("^") }).toEqual({ token: 123, caret: 123 });
});

const fill = (count: number, char: string) => Buffer.alloc(count, char).toString();

/**
* Runs `bun <args>` in a directory holding `files`. Returns the exit code,
* stderr and every source excerpt in it: a `N | text` line and the index of
* the `^` under it.
*/
async function printedExcerpts(args: string[], files: Record<string, string>) {
using dir = tempDir("parse-col-excerpt", files);
await using proc = Bun.spawn({
cmd: [bunExe(), ...args],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stderr, , exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]);
const lines = stderr.split(/\r?\n/);
const excerpts: { excerpt: string; caret: number }[] = [];
for (let i = 0; i + 1 < lines.length; i++) {
if (/^\d+ \| /.test(lines[i]) && /^ *\^$/.test(lines[i + 1])) {
excerpts.push({ excerpt: lines[i], caret: lines[i + 1].indexOf("^") });
}
}
return { stderr, excerpts, exitCode };
}

// The logger prints at most about 120 bytes of a line: 40 before the caret and
// 80 after it, or the last 120 when the line ends sooner. It indents the caret
// by what it printed. A location can hold far more than 120 bytes: the parsers
// keep the whole line for an error in its last 80 bytes, and CSS always does.
const longArray = "[" + fill(80_000, "1,") + "]";

describe.each([["build"], ["run"]])("bun %s prints a bounded excerpt of a very long line", subcommand => {
test.concurrent("for an error at its start", async () => {
const source = `var b = (; var a = ${longArray};`;
const { stderr, excerpts, exitCode } = await printedExcerpts([subcommand, "long.js"], { "long.js": source });
expect(excerpts).toEqual([{ excerpt: "1 | " + source.slice(0, 9 + 80), caret: 4 + 9 }]);
expect(stderr.length).toBeLessThan(1024);
expect(exitCode).toBe(1);
});

test.concurrent("for an error in its middle", async () => {
const source = `var a = ${longArray}; var b = (; var c = ${longArray};`;
const at = source.indexOf("(;") + 1;
const { stderr, excerpts, exitCode } = await printedExcerpts([subcommand, "long.js"], { "long.js": source });
expect(excerpts.map(e => e.excerpt)).toEqual(["1 | " + source.slice(at - 40, at + 80)]);
// This location does not say how much of the line it dropped on the left,
// so only the length of the caret line is checked: it was `at` spaces long.
expect(excerpts[0].caret).toBeLessThanOrEqual(excerpts[0].excerpt.length);
expect(stderr.length).toBeLessThan(1024);
expect(exitCode).toBe(1);
});

test.concurrent("for an error at its end", async () => {
const source = `var a = ${longArray}; var b = (;`;
const { stderr, excerpts, exitCode } = await printedExcerpts([subcommand, "long.js"], { "long.js": source });
expect(excerpts).toEqual([{ excerpt: "1 | " + source.slice(-120), caret: 4 + 119 }]);
expect(stderr.length).toBeLessThan(1024);
expect(exitCode).toBe(1);
});
});

test.concurrent("an error in the trailing whitespace of a very long line prints the end of its text", async () => {
// "Unexpected end of file" at the last of 200 spaces. Those 200 bytes are
// not the excerpt, and the caret goes right after the `(`.
const text = `var a = ${longArray}; var b = (`;
const { stderr, excerpts, exitCode } = await printedExcerpts(["build", "long.js"], {
"long.js": text + fill(200, " "),
});
expect(excerpts).toEqual([{ excerpt: "1 | " + text.slice(-120), caret: 4 + 120 }]);
expect(stderr.length).toBeLessThan(1024);
expect(exitCode).toBe(1);
});

test.concurrent("an error in the trailing whitespace of a short line keeps its caret at the column", async () => {
const { excerpts, exitCode } = await printedExcerpts(["build", "short.js"], {
"short.js": "var b = (" + fill(20, " "),
});
expect(excerpts).toEqual([{ excerpt: "1 | var b = (", caret: 4 + 28 }]);
expect(exitCode).toBe(1);
});

test.concurrent("an error and its note on one very long line both print a bounded excerpt", async () => {
const source = `const x = 1; var a = ${longArray}; const x = 2;`;
const { stderr, excerpts, exitCode } = await printedExcerpts(["build", "long.js"], { "long.js": source });
expect(excerpts).toEqual([
{ excerpt: "1 | " + source.slice(-120), caret: 4 + 120 - "x = 2;".length },
{ excerpt: "1 | " + source.slice(0, 6 + 80), caret: 4 + 6 },
]);
expect(stderr.length).toBeLessThan(1024);
expect(exitCode).toBe(1);
});

test.concurrent.each([
// The last 120 bytes start one byte into an `é` and three bytes into a `𐐀`.
["2-byte", fill(600, "\u00E9") + "]", "1 | " + fill(120, "\u00E9") + "]", 4 + 60],
["4-byte", fill(400, "\u{10400}") + "xy]", "1 | " + fill(120, "\u{10400}") + "xy]", 4 + 60 + 2],
])(
"bounded excerpt of a line of %s characters starts on a character and counts UTF-16 units",
async (_, source, excerpt, caret) => {
const { excerpts, exitCode } = await printedExcerpts(["build", "long.js"], { "long.js": source });
expect(excerpts).toEqual([{ excerpt, caret }]);
expect(exitCode).toBe(1);
},
);

describe("bun build prints a bounded excerpt of a very long CSS line", () => {
// A stray `}` after a rule is an error at the `}`.
const rules = fill(60_000, "a{color:red}");

test.concurrent("for an error at its start", async () => {
const source = "a{color:red}}" + rules;
const { stderr, excerpts, exitCode } = await printedExcerpts(["build", "long.css"], { "long.css": source });
expect(excerpts).toEqual([{ excerpt: "1 | " + source.slice(0, 120), caret: 4 + 12 }]);
expect(stderr.length).toBeLessThan(1024);
expect(exitCode).toBe(1);
});

test.concurrent("for an error in its middle", async () => {
const source = rules + "}" + rules;
const { stderr, excerpts, exitCode } = await printedExcerpts(["build", "long.css"], { "long.css": source });
expect(excerpts).toEqual([{ excerpt: "1 | " + source.slice(60_000 - 40, 60_000 + 80), caret: 4 + 40 }]);
expect(stderr.length).toBeLessThan(1024);
expect(exitCode).toBe(1);
});

test.concurrent("for an error at its end", async () => {
// "Unexpected end of input": the caret is one column past the last `}`.
const source = rules + "}";
const { stderr, excerpts, exitCode } = await printedExcerpts(["build", "long.css"], { "long.css": source });
expect(excerpts).toEqual([{ excerpt: "1 | " + source.slice(-120), caret: 4 + 120 }]);
expect(stderr.length).toBeLessThan(1024);
expect(exitCode).toBe(1);
});
});

test.concurrent("bounded excerpt of a CSS line starts and ends on a character", async () => {
// 40 bytes before the stray `}` is one byte into a `€`, and 80 bytes after
// it is two bytes into one.
const comment = "/*" + fill(60_000, "\u20AC") + "*/";
const source = comment + "a{color:red}}" + comment + "a{color:red}";
const { stderr, excerpts, exitCode } = await printedExcerpts(["build", "long.css"], { "long.css": source });
expect(excerpts).toEqual([
{
excerpt: "1 | " + fill(27, "\u20AC") + "*/a{color:red}}/*" + fill(78, "\u20AC"),
caret: 4 + 9 + "*/a{color:red}".length,
},
]);
expect(stderr.length).toBeLessThan(1024);
expect(exitCode).toBe(1);
});

test.concurrent("bun install prints a bounded excerpt of a one-line package.json that is cut short", async () => {
const source = `{"name":"x","version":"1.0.0","files":[` + fill(80_000, '"a",') + `"a"]`;
const { stderr, excerpts, exitCode } = await printedExcerpts(["install"], { "package.json": source });
expect(excerpts).toEqual([{ excerpt: "1 | " + source.slice(-120), caret: 4 + 119 }]);
expect(stderr.length).toBeLessThan(1024);
expect(exitCode).toBe(1);
});
Loading