Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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.
// 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