diff --git a/src/sourcemap/Chunk.rs b/src/sourcemap/Chunk.rs index 899e5671c01f..79e167d9dca3 100644 --- a/src/sourcemap/Chunk.rs +++ b/src/sourcemap/Chunk.rs @@ -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 diff --git a/test/bundler/bundler_comments.test.ts b/test/bundler/bundler_comments.test.ts index c05527343bf9..30fc55b72126 100644 --- a/test/bundler/bundler_comments.test.ts +++ b/test/bundler/bundler_comments.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "bun:test"; +import { readFileSync } from "node:fs"; import { SourceMap } from "node:module"; import { itBundled } from "./expectBundled"; @@ -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", { diff --git a/test/js/bun/sourcemap/internal-sourcemap.test.ts b/test/js/bun/sourcemap/internal-sourcemap.test.ts index b8efdae7770b..3934cefa9419 100644 --- a/test/js/bun/sourcemap/internal-sourcemap.test.ts +++ b/test/js/bun/sourcemap/internal-sourcemap.test.ts @@ -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