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
9 changes: 3 additions & 6 deletions src/sourcemap/Chunk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -568,12 +568,9 @@ impl NewBuilder<'_, VLQSourceMap> {
}
// '\r', '\n', U+2028, U+2029
0x0D | 0x0A | 0x2028 | 0x2029 => {
// windows newline
if c == 0x0D {
let newline_check = self.last_generated_update as usize + i + 1;
if newline_check < output.len() && output[newline_check] == b'\n' {
continue;
}
// windows newline: `i` already points at the byte after the '\r'
if c == 0x0D && i < n && slice[i] == b'\n' {
continue;
}

// If we're about to move to the next line and the previous line didn't have
Expand Down
52 changes: 52 additions & 0 deletions test/bundler/bundler_comments.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { SourceMap } from "node:module";
import { itBundled } from "./expectBundled";

Expand Down Expand Up @@ -391,6 +392,57 @@ describe("multi-line comments", () => {
},
});

// JS engines count a lone "\r" as a line terminator and "\r\n" as a single
// one, so the generated lines in the map have to be counted the same way for
// stack traces to remap. A legal comment is printed verbatim apart from its
// "\r\n"s becoming "\n", and an inlined enum member is followed by a comment
// holding its name; those are how these terminators end up in the output.
function lineOf(code: string, needle: string) {
const index = code.split(/\r\n|\r|\n/).findIndex(line => line.includes(needle));
expect(index).toBeGreaterThanOrEqual(0);
return index;
}
const terminatorCases = [
{
name: "lone CR in a legal comment starts a new generated line in the sourcemap",
file: "/entry.js",
// One character between the "\r" and the "\n" is the shape the builder used to miss.
entry: '/*! a\rb\nc */\nconsole.log("after comment");\nconsole.log("next line");\n',
printed: "/*! a\rb\nc */",
needles: ['"after comment"', '"next line"'],
},
{
name: "CRLF left in a legal comment counts as one generated line in the sourcemap",
file: "/entry.js",
entry: '/*! a\r\r\nb */\nconsole.log("after comment");\nconsole.log("next line");\n',
printed: "/*! a\r\nb */",
needles: ['"after comment"', '"next line"'],
},
{
name: "CRLF in an inlined enum member comment counts as one generated line in the sourcemap",
file: "/entry.ts",
entry: 'enum E { "a\\r\\nb" = 1 }\nconsole.log(E["a\\r\\nb"]);\nconsole.log("next line");\n',
printed: "1 /* a\r\nb */",
needles: ['"next line"'],
},
];
for (const { name, file, entry, printed, needles } of terminatorCases) {
itBundled(name, {
files: { [file]: entry },
sourceMap: "external",
onAfterBundle(api) {
// api.readFile() normalizes CRLF, so read the output directly.
const output = readFileSync(api.outfile, "utf8");
expect(output).toContain(printed);
const map = new SourceMap(JSON.parse(readFileSync(api.outfile + ".map", "utf8")));
// The generated line each needle ends up on must map back to the line it is on in the entry.
expect(needles.map(needle => [needle, map.findEntry(lineOf(output, needle), 0).originalLine])).toEqual(
needles.map(needle => [needle, lineOf(entry, needle)]),
);
},
});
}

// The lexer skips >=512-byte block comment bodies with SIMD; these verify
// large comments end-to-end (legal comment preservation, ASI, output code).
itBundled("large legal comment is preserved and does not corrupt the code after it", {
Expand Down
25 changes: 25 additions & 0 deletions test/js/bun/sourcemap/internal-sourcemap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,31 @@ describe("InternalSourceMap", () => {
expect(exited).toBe(0);
});

// The generated lines counted while building the map have to agree with what
// JSC reports: a lone "\r" is a line terminator and "\r\n" is a single one. A
// legal comment is printed verbatim apart from its "\r\n"s becoming "\n", so
// it is how either reaches the transpiled code ("\r\r\n" comes out as "\r\n").
describe("line terminators inside a legal comment", () => {
async function remappedLines(index: string) {
const { stdout, stderr, exited } = await run({ "index.ts": index });
expect(stderr).toBe("");
expect(exited).toBe(0);
return [...stdout.matchAll(/index\.ts:(\d+):\d+/g)].map(m => Number(m[1]));
}

test("a lone CR starts a new line", async () => {
// Lines: 1 "/*! a", 2 "b", 3 "c */", 4 the Error, 5 console.log.
const index = '/*! a\rb\nc */\nconst err = new Error("x");\nconsole.log(err.stack);\n';
expect(await remappedLines(index)).toEqual([4]);
});

test("a CRLF is a single line break", async () => {
// Lines: 1 "/*! a", 2 "", 3 "b */", 4 console.log("before"), 5 the Error, 6 console.log.
const index = '/*! a\r\r\nb */\nconsole.log("before");\nconst err = new Error("x");\nconsole.log(err.stack);\n';
expect(await remappedLines(index)).toEqual([5]);
});
});

test("FindCache eviction (>16 distinct windows in one stack)", async () => {
// 20 functions spread ~125 lines apart in a single file. With ~6 mappings
// per padding line that's ~750 mappings between calls -> each frame lands
Expand Down
Loading