diff --git a/packages/metro-source-map/src/LineIndexedMappings.js b/packages/metro-source-map/src/LineIndexedMappings.js new file mode 100644 index 0000000000..89e4d0b658 --- /dev/null +++ b/packages/metro-source-map/src/LineIndexedMappings.js @@ -0,0 +1,211 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +/* eslint-disable no-bitwise */ + +// Base64 digit lookup for the VLQ decoder below. Indexed by char code; -1 +// marks a non-base64 character (never hit inside a well-formed segment). +const BASE64_VALUES: Int16Array = (() => { + const table = new Int16Array(128).fill(-1); + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + for (let i = 0; i < chars.length; i++) { + table[chars.charCodeAt(i)] = i; + } + return table; +})(); + +const VLQ_SEMICOLON = 59; // ';' generated-line separator +const VLQ_COMMA = 44; // ',' segment separator + +// Consume one base64-VLQ value from `mappings` starting at `cursor[0]`, +// advancing `cursor[0]` past it. A one-element array is used as a mutable +// cursor so the scan can share this helper without per-call closures. +function readVlq(mappings: string, cursor: [number]): number { + let shift = 0; + let value = 0; + let continuation; + let i = cursor[0]; + do { + const digit = BASE64_VALUES[mappings.charCodeAt(i++)]; + continuation = digit & 32; + value = value + ((digit & 31) << shift); + shift = shift + 5; + } while (continuation); + cursor[0] = i; + const shouldNegate = value & 1; + value = value >>> 1; + return shouldNegate ? -value : value; +} + +// True if another segment field (not a ';'/',' separator) begins at `index`. +function isFieldAhead( + mappings: string, + index: number, + length: number, +): boolean { + return ( + index < length && + mappings.charCodeAt(index) !== VLQ_SEMICOLON && + mappings.charCodeAt(index) !== VLQ_COMMA + ); +} + +/** + * A compact, lazily-decoded view of one module's VLQ `mappings` for the + * generated -> original position lookup that `/symbolicate` performs. + * + * Construction builds a per-line index: one entry per generated line holding the + * byte offset into `mappings` where that line's segments begin and the + * source-line/source-column delta accumulators as they stand entering the line. + * A lookup jumps to the target line and decodes only that line's segments in + * place over the retained `mappings` string, allocating nothing. + * + * VLQ deltas are cumulative across the whole string, so the index snapshots the + * accumulator state at each line start to make any line independently + * decodable. The index holds O(lines) integers; generated columns reset per + * line and are not stored. + */ +export default class LineIndexedMappings { + #mappings: string; + // One entry per generated line, indexed by (generatedLine1Based - 1). + #lineByteOffset: Int32Array; // start of the line's segments in `#mappings` + #srcLineAtLineStart: Int32Array; // originalLine accumulator (1-based) entering the line + #srcColAtLineStart: Int32Array; // originalColumn accumulator (0-based) entering the line + #lineCount: number; + + constructor(mappings: string) { + const length = mappings.length; + + // One generated line per ';' separator, plus one; an empty string is a + // single empty line. + let lineCount = 1; + for (let i = 0; i < length; i++) { + if (mappings.charCodeAt(i) === VLQ_SEMICOLON) { + lineCount++; + } + } + + const lineByteOffset = new Int32Array(lineCount); + const srcLineAtLineStart = new Int32Array(lineCount); + const srcColAtLineStart = new Int32Array(lineCount); + + // Record the offset and source-line/source-column accumulators at each + // line's start. Source lines are 1-based, columns 0-based. Segment fields + // are decoded only far enough to advance the accumulators; generated + // columns and source/name indices are dropped. + let originalLine = 1; + let originalColumn = 0; + let line = 0; + const cursor: [number] = [0]; + + lineByteOffset[0] = 0; + srcLineAtLineStart[0] = originalLine; + srcColAtLineStart[0] = originalColumn; + + while (cursor[0] < length) { + const c = mappings.charCodeAt(cursor[0]); + if (c === VLQ_SEMICOLON) { + cursor[0]++; + line++; + lineByteOffset[line] = cursor[0]; + srcLineAtLineStart[line] = originalLine; + srcColAtLineStart[line] = originalColumn; + continue; + } + if (c === VLQ_COMMA) { + cursor[0]++; + continue; + } + + // Segment: [genCol, srcIndex, srcLine, srcCol(, name)]. genCol resets per + // line and doesn't feed the accumulators, so skip its digits. + readVlq(mappings, cursor); // generated column delta (unused) + if (isFieldAhead(mappings, cursor[0], length)) { + readVlq(mappings, cursor); // sourceIndex delta (unused) + originalLine = originalLine + readVlq(mappings, cursor); + originalColumn = originalColumn + readVlq(mappings, cursor); + if (isFieldAhead(mappings, cursor[0], length)) { + readVlq(mappings, cursor); // nameIndex delta (unused) + } + } + } + + this.#mappings = mappings; + this.#lineByteOffset = lineByteOffset; + this.#srcLineAtLineStart = srcLineAtLineStart; + this.#srcColAtLineStart = srcColAtLineStart; + this.#lineCount = lineCount; + } + + originalPositionFor( + generatedLine1Based: number, + generatedColumn0Based: number, + ): ?{line1Based: number, column0Based: number} { + if (generatedLine1Based < 1 || generatedLine1Based > this.#lineCount) { + return null; + } + const lineIndex = generatedLine1Based - 1; + const mappings = this.#mappings; + const length = mappings.length; + + const cursor: [number] = [this.#lineByteOffset[lineIndex]]; + let originalLine = this.#srcLineAtLineStart[lineIndex]; + let originalColumn = this.#srcColAtLineStart[lineIndex]; + let generatedColumn = 0; + + // Segments are sorted by generated column, so the last one with column <= + // the target is the match; stop as soon as one overshoots. A generated-only + // match carries no source position and resolves to null. + let found = false; + let foundHasSource = false; + let foundLine = 0; + let foundColumn = 0; + + while (cursor[0] < length) { + const c = mappings.charCodeAt(cursor[0]); + if (c === VLQ_SEMICOLON) { + break; // end of this generated line + } + if (c === VLQ_COMMA) { + cursor[0]++; + continue; + } + + generatedColumn = generatedColumn + readVlq(mappings, cursor); + if (generatedColumn > generatedColumn0Based) { + break; // this and all later segments on the line exceed the target + } + + if (isFieldAhead(mappings, cursor[0], length)) { + readVlq(mappings, cursor); // sourceIndex delta (unused) + originalLine = originalLine + readVlq(mappings, cursor); + originalColumn = originalColumn + readVlq(mappings, cursor); + if (isFieldAhead(mappings, cursor[0], length)) { + readVlq(mappings, cursor); // nameIndex delta (unused) + } + found = true; + foundHasSource = true; + foundLine = originalLine; + foundColumn = originalColumn; + } else { + // Generated-only mapping: no original position. + found = true; + foundHasSource = false; + } + } + + if (!found || !foundHasSource) { + return null; + } + return {line1Based: foundLine, column0Based: foundColumn}; + } +} diff --git a/packages/metro-source-map/src/__tests__/source-map-test.js b/packages/metro-source-map/src/__tests__/source-map-test.js index 29817e6834..130b9295c1 100644 --- a/packages/metro-source-map/src/__tests__/source-map-test.js +++ b/packages/metro-source-map/src/__tests__/source-map-test.js @@ -16,7 +16,9 @@ import type { MixedSourceMap, } from '../source-map'; +import {greatestLowerBound} from '../Consumer/search'; import Generator from '../Generator'; +import LineIndexedMappings from '../LineIndexedMappings'; import { fromRawMappings, fromRawMappingsIndexed, @@ -494,3 +496,104 @@ describe('vlqMapFromBabelDecodedMap', () => { ); }); }); + +describe('LineIndexedMappings', () => { + // Reference lookup: decode to tuples via toBabelSegments + toSegmentTuple, + // then greatestLowerBound over (generatedLine, generatedColumn), returning the + // original position only when the matched segment is on the target line and + // carries source info. + const referenceOriginalPositionFor = ( + tuples: Array, + line1Based: number, + column0Based: number, + ) => { + const index = greatestLowerBound( + tuples, + {line1Based, column0Based}, + (target, candidate) => + target.line1Based === candidate[0] + ? target.column0Based - candidate[1] + : target.line1Based - candidate[0], + ); + if (index == null) { + return null; + } + const mapping = tuples[index]; + if (mapping[0] !== line1Based || mapping.length < 4) { + return null; + } + return { + // $FlowFixMe[invalid-tuple-index]: Length checks do not refine tuple unions. + line1Based: mapping[2], + // $FlowFixMe[invalid-tuple-index]: Length checks do not refine tuple unions. + column0Based: mapping[3], + }; + }; + + const cases: {[string]: Array} = { + 'single segment': [[1, 0, 10, 4]], + 'multiple segments on one line': [ + [1, 0, 10, 4], + [1, 8, 10, 12, 'greet'], + [2, 0, 11, 0], + ], + 'generated-only segments (no source)': [ + [1, 0], + [1, 5, 3, 2], + [2, 0], + [3, 0, 4, 0, 'x'], + ], + 'gap in generated lines (blank line -> ";;")': [ + [1, 0, 1, 0], + [3, 4, 3, 2, 'bar'], + [5, 0], + ], + 'large multi-line map with names': [ + [1, 2], + [3, 4, 5, 6, 'apples'], + [7, 8, 9, 10], + [11, 12, 13, 14, 'pears'], + [11, 20, 30, 40], + ], + }; + + for (const name of Object.keys(cases)) { + test(`matches the tuple path exactly across a position grid: ${name}`, () => { + const tuples = cases[name]; + const vlqMap = vlqMapFromTuples(tuples); + // The exact tuples the old path would have produced from this VLQ map. + const reference = toBabelSegments({ + version: 3, + sources: [''], + names: [...vlqMap.names], + mappings: vlqMap.mappings, + }).map(toSegmentTuple); + + const decoded = new LineIndexedMappings(vlqMap.mappings); + + const maxLine = Math.max(...tuples.map(t => t[0])) + 1; + for (let line = 1; line <= maxLine; line++) { + for (let column = 0; column <= 24; column++) { + expect(decoded.originalPositionFor(line, column)).toEqual( + referenceOriginalPositionFor(reference, line, column), + ); + } + } + }); + } + + test('empty mappings never resolves a position', () => { + const decoded = new LineIndexedMappings(''); + expect(decoded.originalPositionFor(1, 0)).toBeNull(); + expect(decoded.originalPositionFor(5, 3)).toBeNull(); + }); + + test('out-of-range generated lines resolve to null', () => { + const decoded = new LineIndexedMappings( + vlqMapFromTuples([[1, 0, 10, 4]]).mappings, + ); + expect(decoded.originalPositionFor(0, 0)).toBeNull(); + expect(decoded.originalPositionFor(-1, 0)).toBeNull(); + expect(decoded.originalPositionFor(99, 0)).toBeNull(); + }); +}); diff --git a/packages/metro-source-map/types/LineIndexedMappings.d.ts b/packages/metro-source-map/types/LineIndexedMappings.d.ts new file mode 100644 index 0000000000..a4dc09be9e --- /dev/null +++ b/packages/metro-source-map/types/LineIndexedMappings.d.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @noformat + * @oncall react_native + * @generated SignedSource<> + * + * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js + * Original file: packages/metro-source-map/src/LineIndexedMappings.js + * To regenerate, run: + * js1 build metro-ts-defs (internal) OR + * yarn run build-ts-defs (OSS) + */ + +/** + * A compact, lazily-decoded view of one module's VLQ `mappings` for the + * generated -> original position lookup that `/symbolicate` performs. + * + * Construction builds a per-line index: one entry per generated line holding the + * byte offset into `mappings` where that line's segments begin and the + * source-line/source-column delta accumulators as they stand entering the line. + * A lookup jumps to the target line and decodes only that line's segments in + * place over the retained `mappings` string, allocating nothing. + * + * VLQ deltas are cumulative across the whole string, so the index snapshots the + * accumulator state at each line start to make any line independently + * decodable. The index holds O(lines) integers; generated columns reset per + * line and are not stored. + */ +declare class LineIndexedMappings { + constructor(mappings: string); + originalPositionFor( + generatedLine1Based: number, + generatedColumn0Based: number, + ): null | undefined | {line1Based: number; column0Based: number}; +} +export default LineIndexedMappings; diff --git a/packages/metro/src/Server/symbolicate.js b/packages/metro/src/Server/symbolicate.js index 9452367dca..c8aaf34f23 100644 --- a/packages/metro/src/Server/symbolicate.js +++ b/packages/metro/src/Server/symbolicate.js @@ -17,8 +17,8 @@ import type { import type {ExplodedSourceMap} from '../DeltaBundler/Serializers/getExplodedSourceMap'; import type {ConfigT} from 'metro-config'; -import {toBabelSegments, toSegmentTuple} from 'metro-source-map'; import {greatestLowerBound} from 'metro-source-map/private/Consumer/search'; +import LineIndexedMappings from 'metro-source-map/private/LineIndexedMappings'; import {SourceMetadataMapConsumer} from 'metro-symbolicate/private/Symbolication'; export type StackFrameInput = { @@ -37,24 +37,66 @@ export type StackFrameOutput = Readonly; type ExplodedSourceMapModule = ExplodedSourceMap[number]; type Position = {readonly line1Based: number, column0Based: number}; -function ensureDecodedMap( +// Resolve a generated (line, column) within a module to its original position. +// Tuple-backed modules keep their decoded segments, so we search them directly; +// VLQ-backed modules go through a compact per-line `LineIndexedMappings` (cached +// for the request) that decodes only the target line. Byte-identical either way. +function originalPositionInModule( map: Array | VlqMap, - decodedMapCache: Map>, -): Array { + generatedLine1Based: number, + generatedColumn0Based: number, + decodedMapCache: Map, +): ?Position { if (Array.isArray(map)) { - return map; + return originalPositionInTuples( + map, + generatedLine1Based, + generatedColumn0Based, + ); } let decoded = decodedMapCache.get(map); if (decoded == null) { - decoded = toBabelSegments({ - version: 3, - sources: [''], - names: [...map.names], - mappings: map.mappings, - }).map(toSegmentTuple); + decoded = new LineIndexedMappings(map.mappings); decodedMapCache.set(map, decoded); } - return decoded; + return decoded.originalPositionFor( + generatedLine1Based, + generatedColumn0Based, + ); +} + +// greatestLowerBound over pre-decoded tuples, ordered by (line, column). +function originalPositionInTuples( + mappings: Array, + generatedLine1Based: number, + generatedColumn0Based: number, +): ?Position { + const target = { + line1Based: generatedLine1Based, + column0Based: generatedColumn0Based, + }; + const mappingIndex = greatestLowerBound(mappings, target, (t, candidate) => { + if (t.line1Based === candidate[0]) { + return t.column0Based - candidate[1]; + } + return t.line1Based - candidate[0]; + }); + if (mappingIndex == null) { + return null; + } + const mapping = mappings[mappingIndex]; + if ( + mapping[0] !== target.line1Based || + mapping.length < 4 /* no source line/column info */ + ) { + return null; + } + return { + // $FlowFixMe[invalid-tuple-index]: Length checks do not refine tuple unions. + line1Based: mapping[2], + // $FlowFixMe[invalid-tuple-index]: Length checks do not refine tuple unions. + column0Based: mapping[3], + }; } function createFunctionNameGetter( @@ -98,12 +140,10 @@ export default async function symbolicate( (Position) => ?string, >(); - // Decoded VLQ maps are cached only for the duration of this request, then - // discarded. The cache dedupes decoding across frames that resolve to the - // same module, while keeping the (large) decoded tuples short-lived — the - // VlqMaps themselves are retained by the long-lived module graph, so caching - // beyond request scope would defeat the memory savings of storing them as VLQ. - const decodedMapCache = new Map>(); + // Dedupes VLQ decoding across frames that resolve to the same module. Scoped + // to this request so decoded maps are released once it completes, rather than + // being retained alongside the long-lived module graph. + const decodedMapCache = new Map(); function findModule(frame: StackFrameInput): ?ExplodedSourceMapModule { const map = mapsByUrl.get(frame.file); @@ -130,37 +170,12 @@ export default async function symbolicate( if (module.map == null || lineNumber == null || column == null) { return null; } - const decodedMap = ensureDecodedMap(module.map, decodedMapCache); - const generatedPosInModule = { - line1Based: lineNumber - module.firstLine1Based + 1, - column0Based: column, - }; - const mappingIndex = greatestLowerBound( - decodedMap, - generatedPosInModule, - (target, candidate) => { - if (target.line1Based === candidate[0]) { - return target.column0Based - candidate[1]; - } - return target.line1Based - candidate[0]; - }, + return originalPositionInModule( + module.map, + lineNumber - module.firstLine1Based + 1, + column, + decodedMapCache, ); - if (mappingIndex == null) { - return null; - } - const mapping = decodedMap[mappingIndex]; - if ( - mapping[0] !== generatedPosInModule.line1Based || - mapping.length < 4 /* no source line/column info */ - ) { - return null; - } - return { - // $FlowFixMe[invalid-tuple-index]: Length checks do not refine tuple unions. - line1Based: mapping[2], - // $FlowFixMe[invalid-tuple-index]: Length checks do not refine tuple unions. - column0Based: mapping[3], - }; } function findFunctionName(