From 96f29e8555842dda4cf2e07bd82b4dedb6019dbb Mon Sep 17 00:00:00 2001 From: dave caruso Date: Tue, 28 May 2024 16:51:35 -0700 Subject: [PATCH] fix(bundler): some sourcemap generation bugs (#11344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: paperdave Co-authored-by: Meghan Denny Co-authored-by: nektro Co-authored-by: Jarred Sumner Co-authored-by: Le Michel <95184938+Ptitet@users.noreply.github.com> Co-authored-by: Дмитрий Заводской Co-authored-by: Dylan Conway <35280289+dylan-conway@users.noreply.github.com> Co-authored-by: HUMORCE Co-authored-by: huseeiin <122984423+huseeiin@users.noreply.github.com> --- src/StringJoiner.zig | 5 +- src/bun.js/WebKit | 2 +- src/bun.js/javascript.zig | 2 + src/bundler/bundle_v2.zig | 6 +- src/js_printer.zig | 3 - src/sourcemap/sourcemap.zig | 103 ++++++++------ test/bundler/bundler_edgecase.test.ts | 31 +++- test/bundler/bundler_npm.test.ts | 32 ++++- test/bundler/expectBundled.ts | 195 ++++++++++++++++++++++++-- 9 files changed, 313 insertions(+), 66 deletions(-) diff --git a/src/StringJoiner.zig b/src/StringJoiner.zig index a1385b998f..3691090616 100644 --- a/src/StringJoiner.zig +++ b/src/StringJoiner.zig @@ -56,6 +56,7 @@ pub fn pushStatic(this: *StringJoiner, data: []const u8) void { /// `data` is cloned pub fn pushCloned(this: *StringJoiner, data: []const u8) void { + if (data.len == 0) return; this.push( this.allocator.dupe(u8, data) catch bun.outOfMemory(), this.allocator, @@ -63,6 +64,7 @@ pub fn pushCloned(this: *StringJoiner, data: []const u8) void { } pub fn push(this: *StringJoiner, data: []const u8, allocator: ?Allocator) void { + if (data.len == 0) return; this.len += data.len; const new_tail = Node.init(this.allocator, data, allocator); @@ -142,7 +144,8 @@ pub fn doneWithEnd(this: *StringJoiner, allocator: Allocator, end: []const u8) ! pub fn lastByte(this: *const StringJoiner) u8 { const slice = (this.tail orelse return 0).slice; - return if (slice.len > 0) slice[slice.len - 1] else 0; + assert(slice.len > 0); + return slice[slice.len - 1]; } pub fn ensureNewlineAtEnd(this: *StringJoiner) void { diff --git a/src/bun.js/WebKit b/src/bun.js/WebKit index ddc47cfa03..2c4f31e109 160000 --- a/src/bun.js/WebKit +++ b/src/bun.js/WebKit @@ -1 +1 @@ -Subproject commit ddc47cfa03b87d48217079b7aaa64d23ebd3c3a2 +Subproject commit 2c4f31e10974404bc8316a70d491ec0f400c880d diff --git a/src/bun.js/javascript.zig b/src/bun.js/javascript.zig index fdd8711cc2..754a177c14 100644 --- a/src/bun.js/javascript.zig +++ b/src/bun.js/javascript.zig @@ -190,8 +190,10 @@ pub const SavedSourceMap = struct { pub const MissingSourceMapNoteInfo = struct { pub var storage: bun.PathBuffer = undefined; pub var path: ?[]const u8 = null; + pub var seen_invalid = false; pub fn print() void { + if (seen_invalid) return; if (path) |note| { Output.note( "missing sourcemaps for {s}", diff --git a/src/bundler/bundle_v2.zig b/src/bundler/bundle_v2.zig index 0dd0a7f769..f32b0ae74d 100644 --- a/src/bundler/bundle_v2.zig +++ b/src/bundler/bundle_v2.zig @@ -6903,18 +6903,18 @@ const LinkerContext = struct { line_offset.advance(compile_result.code()); j.push(compile_result.code(), bun.default_allocator); } else { - const generated_offset = line_offset; j.push(compile_result.code(), bun.default_allocator); if (compile_result.source_map_chunk()) |source_map_chunk| { - line_offset.reset(); if (c.options.source_maps != .none) { try compile_results_for_source_map.append(worker.allocator, CompileResultForSourceMap{ .source_map_chunk = source_map_chunk, - .generated_offset = generated_offset.value, + .generated_offset = line_offset.value, .source_index = compile_result.sourceIndex(), }); } + + line_offset.reset(); } else { line_offset.advance(compile_result.code()); } diff --git a/src/js_printer.zig b/src/js_printer.zig index 42fffe91d8..cfe0cd2530 100644 --- a/src/js_printer.zig +++ b/src/js_printer.zig @@ -1381,7 +1381,6 @@ fn NewPrinter( if (comptime !generate_source_map) { return; } - printer.source_map_builder.addSourceMapping(location, printer.writer.slice()); } @@ -1389,8 +1388,6 @@ fn NewPrinter( if (comptime !generate_source_map) { return; } - - // TODO: printer.addSourceMapping(location); } diff --git a/src/sourcemap/sourcemap.zig b/src/sourcemap/sourcemap.zig index e46c136eae..1e59b552ad 100644 --- a/src/sourcemap/sourcemap.zig +++ b/src/sourcemap/sourcemap.zig @@ -484,11 +484,6 @@ pub const Mapping = struct { } remain = remain[source_index_delta.start..]; - // // "AAAA" is extremely common - // if (strings.hasPrefixComptime(remain, "AAAA;")) { - - // } - // Read the original line const original_line_delta = decodeVLQ(remain, 0); if (original_line_delta.start == 0) { @@ -713,7 +708,15 @@ pub const SourceProviderMap = opaque { arena.allocator(), found_url.slice(), result, - ) catch return null, + ) catch |err| { + bun.Output.warn("Could not decode sourcemap in '{s}': {s}", .{ + source_filename, + @errorName(err), + }); + // Disable the "try using --sourcemap=external" hint + bun.JSC.SavedSourceMap.MissingSourceMapNoteInfo.seen_invalid = true; + return null; + }, }; } @@ -725,11 +728,8 @@ pub const SourceProviderMap = opaque { @memcpy(load_path_buf[0..source_filename.len], source_filename); @memcpy(load_path_buf[source_filename.len..][0..4], ".map"); - const data = switch (bun.sys.File.readFrom( - std.fs.cwd(), - load_path_buf[0 .. source_filename.len + 4], - arena.allocator(), - )) { + const load_path = load_path_buf[0 .. source_filename.len + 4]; + const data = switch (bun.sys.File.readFrom(std.fs.cwd(), load_path, arena.allocator())) { .err => break :try_external, .result => |data| data, }; @@ -741,7 +741,15 @@ pub const SourceProviderMap = opaque { arena.allocator(), data, result, - ) catch return null, + ) catch |err| { + bun.Output.warn("Could not decode sourcemap '{s}': {s}", .{ + source_filename, + @errorName(err), + }); + // Disable the "try using --sourcemap=external" hint + bun.JSC.SavedSourceMap.MissingSourceMapNoteInfo.seen_invalid = true; + return null; + }, }; } @@ -766,14 +774,14 @@ pub const LineColumnOffset = struct { pub fn advance(this: *Optional, input: []const u8) void { switch (this.*) { .null => {}, - .value => this.value.advance(input), + .value => |*v| v.advance(input), } } pub fn reset(this: *Optional) void { switch (this.*) { .null => {}, - .value => this.value = .{}, + .value => this.* = .{ .value = .{} }, } } }; @@ -787,9 +795,13 @@ pub const LineColumnOffset = struct { } } - pub fn advance(this: *LineColumnOffset, input: []const u8) void { - var columns = this.columns; - defer this.columns = columns; + pub fn advance(this_ptr: *LineColumnOffset, input: []const u8) void { + // Instead of mutating `this_ptr` directly, copy the state to the stack and do + // all the work here, then move it back to the input pointer. When sourcemaps + // are enabled, this function is extremely hot. + var this = this_ptr.*; + defer this_ptr.* = this; + var offset: u32 = 0; while (strings.indexOfNewlineOrNonASCII(input, offset)) |i| { assert(i >= offset); @@ -803,7 +815,7 @@ pub const LineColumnOffset = struct { // This can lead to integer overflow, crashes, or hangs. // https://github.com/oven-sh/bun/issues/10624 if (cursor.width == 0) { - columns += 1; + this.columns += 1; offset = i + 1; continue; } @@ -814,22 +826,32 @@ pub const LineColumnOffset = struct { '\r', '\n', 0x2028, 0x2029 => { // Handle Windows-specific "\r\n" newlines if (cursor.c == '\r' and input.len > i + 1 and input[i + 1] == '\n') { - columns += 1; + this.columns += 1; continue; } this.lines += 1; - columns = 0; + this.columns = 0; }, else => |c| { // Mozilla's "source-map" library counts columns using UTF-16 code units - columns += switch (c) { + this.columns += switch (c) { 0...0xFFFF => 1, else => 2, }; }, } } + + const remain = input[offset..]; + + if (bun.Environment.allow_assert) { + assert(bun.strings.isAllASCII(remain)); + assert(!bun.strings.containsChar(remain, '\n')); + assert(!bun.strings.containsChar(remain, '\r')); + } + + this.columns += @intCast(remain.len); } pub fn comesBefore(a: LineColumnOffset, b: LineColumnOffset) bool { @@ -977,43 +999,44 @@ pub fn appendSourceMapChunk(j: *StringJoiner, allocator: std.mem.Allocator, prev var prev_end_state = prev_end_state_; var start_state = start_state_; // Handle line breaks in between this mapping and the previous one - if (start_state.generated_line > 0) { + if (start_state.generated_line != 0) { j.push(try strings.repeatingAlloc(allocator, @intCast(start_state.generated_line), ';'), allocator); prev_end_state.generated_column = 0; } + // Skip past any leading semicolons, which indicate line breaks var source_map = source_map_; if (strings.indexOfNotChar(source_map, ';')) |semicolons| { - j.pushStatic(source_map[0..semicolons]); - source_map = source_map[semicolons..]; - prev_end_state.generated_column = 0; - start_state.generated_column = 0; + if (semicolons > 0) { + j.pushStatic(source_map[0..semicolons]); + source_map = source_map[semicolons..]; + prev_end_state.generated_column = 0; + start_state.generated_column = 0; + } } // Strip off the first mapping from the buffer. The first mapping should be // for the start of the original file (the printer always generates one for // the start of the file). - // - // Bun has a 24-byte header for source map meta-data var i: usize = 0; - const generated_column_ = decodeVLQAssumeValid(source_map, i); - i = generated_column_.start; - const source_index_ = decodeVLQAssumeValid(source_map, i); - i = source_index_.start; - const original_line_ = decodeVLQAssumeValid(source_map, i); - i = original_line_.start; - const original_column_ = decodeVLQAssumeValid(source_map, i); - i = original_column_.start; + const generated_column = decodeVLQAssumeValid(source_map, i); + i = generated_column.start; + const source_index = decodeVLQAssumeValid(source_map, i); + i = source_index.start; + const original_line = decodeVLQAssumeValid(source_map, i); + i = original_line.start; + const original_column = decodeVLQAssumeValid(source_map, i); + i = original_column.start; source_map = source_map[i..]; // Rewrite the first mapping to be relative to the end state of the previous // chunk. We now know what the end state is because we're in the second pass // where all chunks have already been generated. - start_state.source_index += source_index_.value; - start_state.generated_column += generated_column_.value; - start_state.original_line += original_line_.value; - start_state.original_column += original_column_.value; + start_state.source_index += source_index.value; + start_state.generated_column += generated_column.value; + start_state.original_line += original_line.value; + start_state.original_column += original_column.value; j.push( appendMappingToBuffer( diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index 2676f55703..56ef384136 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -574,7 +574,7 @@ describe("bundler", () => { break; } console.log(a); - + var x = 123, y = 45; switch (console) { case 456: @@ -582,14 +582,14 @@ describe("bundler", () => { } var y = 67; console.log(x, y); - + var z = 123; switch (console) { default: var z = typeof z; } console.log(z); - + var A = 1, B = 2; switch (A) { case A: @@ -1079,6 +1079,31 @@ describe("bundler", () => { minifyWhitespace: true, splitting: true, }); + // chunk-concat weaved mappings together incorrectly causing the `console` + // token to be -2, thus breaking the rest of the mappings in the file + itBundled("edgecase/EmitInvalidSourceMap2", { + files: { + "/entry.js": ` + import * as react from "react"; + console.log(react); + `, + "/node_modules/react/index.js": ` + var _ = module; + sideEffect(() => {}); + `, + }, + outdir: "/out", + sourceMap: "external", + minifySyntax: true, + minifyIdentifiers: true, + minifyWhitespace: true, + snapshotSourceMap: { + "entry.js.map": { + files: ["../node_modules/react/index.js", "../entry.js"], + mappingsExactMatch: "uYACA,WAAW,IAAQ,EAAE,ICDrB,eACA,QAAQ,IAAI,CAAK", + }, + }, + }); // TODO(@paperdave): test every case of this. I had already tested it manually, but it may break later const requireTranspilationListESM = [ diff --git a/test/bundler/bundler_npm.test.ts b/test/bundler/bundler_npm.test.ts index b3661ce0e2..de2032597b 100644 --- a/test/bundler/bundler_npm.test.ts +++ b/test/bundler/bundler_npm.test.ts @@ -4,7 +4,7 @@ var { describe, test, expect } = testForFile(import.meta.path); describe("bundler", () => { itBundled("npm/ReactSSR", { todo: process.platform === "win32", // TODO(@paperdave) - install: ["react@next", "react-dom@next"], + install: ["react@18.3.1", "react-dom@18.3.1"], files: { "/entry.tsx": /* tsx */ ` import React from "react"; @@ -37,8 +37,36 @@ describe("bundler", () => { console.log(await res.text()); `, }, + // this test serves two purposes + // - does react work when bundled + // - do sourcemaps on a real-world library work + sourceMap: "external", + outdir: "out/", + minifySyntax: true, + minifyWhitespace: true, + minifyIdentifiers: true, + snapshotSourceMap: { + "entry.js.map": { + files: [ + "../node_modules/react/cjs/react.development.js", + "../node_modules/react/cjs/react-jsx-dev-runtime.development.js", + "../node_modules/react-dom/cjs/react-dom-server-legacy.browser.development.js", + "../node_modules/react-dom/cjs/react-dom-server.browser.development.js", + "../node_modules/react-dom/server.browser.js", + "../entry.tsx", + ], + mappings: [ + ["react.development.js:524:'getContextName'", "1:5404:r1"], + ["react.development.js:2495:'actScopeDepth'", "1:26072:GJ++"], + ["react.development.js:696:''Component'", '1:7470:\'Component "%s"'], + ["entry.tsx:6:'\"Content-Type\"'", '1:221674:"Content-Type"'], + ["entry.tsx:11:''", "1:221930:void"], + ["entry.tsx:23:'await'", "1:222035:await"], + ], + }, + }, run: { - stdout: "

Hello World

This is an example.

", + stdout: "

Hello World

This is an example.

", }, }); itBundled("npm/LodashES", { diff --git a/test/bundler/expectBundled.ts b/test/bundler/expectBundled.ts index a1b26b388c..e320eb4542 100644 --- a/test/bundler/expectBundled.ts +++ b/test/bundler/expectBundled.ts @@ -1,9 +1,9 @@ /** * See `./expectBundled.md` for how this works. */ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, readdirSync } from "fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, readdirSync, realpathSync } from "fs"; import path from "path"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, joinP } from "harness"; import { tmpdir } from "os"; import { callerSourceOrigin } from "bun:jsc"; import { BuildConfig, BunPlugin, fileURLToPath } from "bun"; @@ -105,7 +105,7 @@ const HIDE_SKIP = process.env.BUN_BUNDLER_TEST_HIDE_SKIP; const BUN_EXE = (process.env.BUN_EXE && Bun.which(process.env.BUN_EXE)) ?? bunExe(); export const RUN_UNCHECKED_TESTS = false; -const tempDirectoryTemplate = path.join(tmpdir(), "bun-build-tests", `${ESBUILD ? "esbuild" : "bun"}-`); +const tempDirectoryTemplate = path.join(realpathSync(tmpdir()), "bun-build-tests", `${ESBUILD ? "esbuild" : "bun"}-`); if (!existsSync(path.dirname(tempDirectoryTemplate))) mkdirSync(path.dirname(tempDirectoryTemplate), { recursive: true }); const tempDirectory = mkdtempSync(tempDirectoryTemplate); @@ -263,8 +263,35 @@ export interface BundlerTestInput { /* TODO: remove this from the tests after this is implemented */ skipIfWeDidNotImplementWildcardSideEffects?: boolean; + + snapshotSourceMap?: Record; } +export interface SourceMapTests { + /** Should be verbaitim equal to the input */ + files: string[]; + /** + * some tests do not use bun snapshots because they are huge, and doing byte + * for byte snapshots will not be sustainable. Instead, we will sample a few mappings to make sure + * the map is correct. This can be used to test for a single mapping. + */ + mappings: MappingSnapshot[]; + /** For small files it is acceptable to inline all of the mappings. */ + mappingsExactMatch: string; +} + +/** Keep in mind this is an array/tuple, NOT AN OBJECT. This keeps things more consise */ +export type MappingSnapshot = [ + // format a string like "file:line:col", for example + // "index.ts:5:2" + // If column is left out, it is the first non-whitespace character + // "index.ts:5" + // If column is quoted text, find the token and use the column of it + // "index.ts:5:'abc'" + source_code: string, + generated_mapping: string, +]; + export interface BundlerTestBundleAPI { root: string; outfile: string; @@ -364,6 +391,7 @@ function expectBundled( chunkNaming, cjs2esm, compile, + conditions, dce, dceKeepMarkerCount, define, @@ -387,26 +415,26 @@ function expectBundled( minifyIdentifiers, minifySyntax, minifyWhitespace, - todo: notImplemented, onAfterBundle, - root: outbase, outdir, outfile, outputPaths, plugins, publicPath, + root: outbase, run, runtimeFiles, serverComponents = false, skipOnEsbuild, + snapshotSourceMap, sourceMap, splitting, target, + todo: notImplemented, treeShaking, unsupportedCSSFeatures, unsupportedJSFeatures, useDefineForClassFields, - conditions, // @ts-expect-error _referenceFn, ...unknownProps @@ -487,7 +515,9 @@ function expectBundled( backend = plugins !== undefined ? "api" : "cli"; } - const root = path.join(tempDirectory, id); + let root = path.join(tempDirectory, id); + mkdirSync(root, { recursive: true }); + root = realpathSync(root); if (DEBUG) console.log("root:", root); const entryPaths = entryPoints.map(file => path.join(root, file)); @@ -502,7 +532,7 @@ function expectBundled( outputPaths = ( outputPaths ? outputPaths.map(file => path.join(root, file)) - : entryPaths.map(file => path.join(outdir || "", path.basename(file))) + : entryPaths.map(file => path.join(outdir || "", path.basename(file).replace(/\.[jt]sx?$/, ".js"))) ).map(x => x.replace(/\.ts$/, ".js")); if (cjs2esm && !outfile && !minifySyntax && !minifyWhitespace) { @@ -1081,7 +1111,7 @@ for (const [key, blob] of build.outputs) { if (dce && typeof dceKeepMarkerCount !== "number" && dceKeepMarkerCount !== false) { for (const file of Object.entries(files)) { keepMarkers[outfile ? outfile : path.join(outdir!, file[0]).slice(root.length).replace(/\.ts$/, ".js")] ??= [ - ...file[1].matchAll(/KEEP/gi), + ...String(file[1]).matchAll(/KEEP/gi), ].length; } } @@ -1248,19 +1278,67 @@ for (const [key, blob] of build.outputs) { } } - // Check that all source maps are valid JSON + // Check that all source maps are valid if (opts.sourceMap === "external" && outdir) { - for (const file of readdirSync(outdir, { recursive: true })) { + for (const file_input of readdirSync(outdir, { recursive: true })) { + const file = file_input.toString("utf8"); // type bug? `file_input` is `Buffer|string` if (file.endsWith(".map")) { const parsed = await Bun.file(path.join(outdir, file)).json(); + const mappedLocations = new Map(); await SourceMapConsumer.with(parsed, null, async map => { map.eachMapping(m => { expect(m.source).toBeDefined(); - expect(m.generatedLine).toBeGreaterThanOrEqual(0); + expect(m.generatedLine).toBeGreaterThanOrEqual(1); expect(m.generatedColumn).toBeGreaterThanOrEqual(0); - expect(m.originalLine).toBeGreaterThanOrEqual(0); + expect(m.originalLine).toBeGreaterThanOrEqual(1); expect(m.originalColumn).toBeGreaterThanOrEqual(0); + + const loc_key = `${m.generatedLine}:${m.generatedColumn}`; + if (mappedLocations.has(loc_key)) { + const fmtLoc = (loc: any) => + `${loc.generatedLine}:${m.generatedColumn} -> ${m.originalLine}:${m.originalColumn} [${m.source.replaceAll(/^(\.\.\/)+/g, "/").replace(root, "")}]`; + + const a = fmtLoc(mappedLocations.get(loc_key)); + const b = fmtLoc(m); + + // We only care about duplicates that point to + // multiple source locations. + if (a !== b) throw new Error("Duplicate mapping in source-map for " + loc_key + "\n" + a + "\n" + b); + } + mappedLocations.set(loc_key, { ...m }); }); + const map_tests = snapshotSourceMap?.[path.basename(file)]; + if (map_tests) { + expect(parsed.sources).toEqual(map_tests.files); + for (let i = 0; i < parsed.sources; i++) { + const source = parsed.sources[i]; + const sourcemap_content = parsed.sourceContent[i]; + const actual_content = readFileSync(path.resolve(path.join(outdir!, file), source), "utf-8"); + expect(sourcemap_content).toBe(actual_content); + } + + const generated_code = await Bun.file(path.join(outdir!, file.replace(".map", ""))).text(); + + if (map_tests.mappings) + for (const mapping of map_tests.mappings) { + const src = parseSourceMapStrSource(outdir!, parsed, mapping[0]); + const dest = parseSourceMapStrGenerated(generated_code, mapping[1]); + const pos = map.generatedPositionFor(src); + if (!dest.matched) { + const real_generated = generated_code + .split("\n") + [pos.line! - 1].slice(pos.column!) + .slice(0, dest.expected!.length); + expect(`${pos.line}:${pos.column}:${real_generated}`).toBe(mapping[1]); + throw new Error("Not matched"); + } + expect(pos.line === dest.line); + expect(pos.column === dest.column); + } + if (map_tests.mappingsExactMatch) { + expect(parsed.mappings).toBe(map_tests.mappingsExactMatch); + } + } }); } } @@ -1457,3 +1535,94 @@ function formatError(err: ErrorMeta) { function filterMatches(id: string) { return FILTER === id || FILTER + "Dev" === id || FILTER + "Prod" === id; } + +interface SourceMapDecodedLocation { + line: number; + column: number; + source: string; +} + +interface SourceMap { + sourcesContent: string[]; + sources: string[]; +} + +function parseSourceMapStrSource(root: string, source_map: SourceMap, string: string) { + const split = string.split(":"); + if (split.length < 2) + throw new Error("Test is invalid; Invalid source location. See MappingSnapshot typedef for more info."); + const [file, line_raw, col_raw] = split; + const source_id = source_map.sources.findIndex(x => x.endsWith(file)); + if (source_id === -1) + throw new Error("Test is invalid; Invalid file " + file + ". See MappingSnapshot typedef for more info."); + + const line = Number(line_raw); + if (!Number.isInteger(line)) + throw new Error( + "Test is invalid; Invalid source line " + + JSON.stringify(line_raw) + + ". See MappingSnapshot typedef for more info.", + ); + + let col = Number(col_raw); + if (!Number.isInteger(col)) { + const text = source_map.sourcesContent[source_id].split("\n")[line - 1]; + if (col_raw === "") { + col = text.split("").findIndex(x => x != " " && x != "\t"); + } else if (col_raw[0] == "'" && col_raw[col_raw.length - 1] == "'") { + col = text.indexOf(col_raw.slice(1, -1)); + if (col == -1) { + throw new Error( + `Test is invalid; String "${col_raw.slice(1, -1)}" is not present on line ${line} of ${path.join(root, source_map.sources[source_id])}`, + ); + } + } else { + throw new Error( + "Test is invalid; Invalid source column " + + JSON.stringify(col_raw) + + ". See MappingSnapshot typedef for more info.", + ); + } + if (col > text.length) { + throw new Error( + `Test is invalid; Line ${line} is only ${text.length} columns long, snapshot points to column ${col}`, + ); + } + } + + return { line, column: col, source: source_map.sources[source_id] }; +} + +function parseSourceMapStrGenerated(source_code: string, string: string) { + const split = string.split(":"); + if (split.length != 3) + throw new Error("Test is invalid; Invalid generated location. See MappingSnapshot typedef for more info."); + const [line_raw, col_raw, ...match] = split; + const line = Number(line_raw); + if (!Number.isInteger(line)) + throw new Error( + "Test is invalid; Invalid generated line " + + JSON.stringify(line_raw) + + ". See MappingSnapshot typedef for more info.", + ); + + let column = Number(col_raw); + if (!Number.isInteger(column)) { + throw new Error( + "Test is invalid; Invalid generated column " + + JSON.stringify(col_raw) + + ". See MappingSnapshot typedef for more info.", + ); + } + + if (match.length > 0) { + let str = match.join(":"); + const text = source_code.split("\n")[line - 1]; + const actual = text.slice(column, column + str.length); + if (actual !== str) { + return { matched: false, line, column, actual, expected: str }; + } + } + + return { matched: true, line, column }; +}