From cd9cabf31aa719f832a927f2af1ebd6b2604fcb6 Mon Sep 17 00:00:00 2001 From: Justin Ridgewell Date: Fri, 15 Apr 2022 14:24:54 -0400 Subject: [PATCH 1/6] Add generatedPositionFor API --- src/binary-search.ts | 39 ++++++++++-- src/by-source.ts | 71 +++++++++++++++++++++ src/sort.ts | 8 ++- src/sourcemap-segment.ts | 23 +++++++ src/trace-mapping.ts | 126 ++++++++++++++++++++++++++++++------- src/types.ts | 25 ++++---- test/setup.ts | 7 +++ test/trace-mapping.test.ts | 20 ++++++ 8 files changed, 276 insertions(+), 43 deletions(-) create mode 100644 src/by-source.ts create mode 100644 src/sourcemap-segment.ts diff --git a/src/binary-search.ts b/src/binary-search.ts index 49d334f..ce27fbe 100644 --- a/src/binary-search.ts +++ b/src/binary-search.ts @@ -1,11 +1,14 @@ -import type { SourceMapSegment } from './types'; +import type { SourceMapSegment, ReverseSegment } from './sourcemap-segment'; +import { COLUMN } from './sourcemap-segment'; -type MemoState = { +export type MemoState = { lastKey: number; lastNeedle: number; lastIndex: number; }; +export let found = false; + /** * A binary search implementation that returns the index if a match is found. * If no match is found, then the left-index (the index associated with the item that comes just @@ -23,16 +26,17 @@ type MemoState = { * ``` */ export function binarySearch( - haystack: SourceMapSegment[], + haystack: SourceMapSegment[] | ReverseSegment[], needle: number, low: number, high: number, ): number { while (low <= high) { const mid = low + ((high - low) >> 1); - const cmp = haystack[mid][0] - needle; + const cmp = haystack[mid][COLUMN] - needle; if (cmp === 0) { + found = true; return mid; } @@ -43,9 +47,32 @@ export function binarySearch( } } + found = false; return low - 1; } +export function upperBound( + haystack: SourceMapSegment[] | GeneratedSegment[], + needle: number, + index: number, +): number { + for (let i = index + 1; i < haystack.length; i++, index++) { + if (haystack[i][COLUMN] !== needle) break; + } + return index; +} + +export function lowerBound( + haystack: SourceMapSegment[] | GeneratedSegment[], + needle: number, + index: number, +): number { + for (let i = index - 1; i >= 0; i--, index--) { + if (haystack[i][COLUMN] !== needle) break; + } + return index; +} + export function memoizedState(): MemoState { return { lastKey: -1, @@ -59,7 +86,7 @@ export function memoizedState(): MemoState { * index, allowing us to skip a few tests if mappings are monotonically increasing. */ export function memoizedBinarySearch( - haystack: SourceMapSegment[], + haystack: SourceMapSegment[] | ReverseSegment[], needle: number, state: MemoState, key: number, @@ -75,7 +102,7 @@ export function memoizedBinarySearch( if (needle >= lastNeedle) { // lastIndex may be -1 if the previous needle was not found. - low = Math.max(lastIndex, 0); + low = lastIndex === -1 ? 0 : lastIndex; } else { high = lastIndex; } diff --git a/src/by-source.ts b/src/by-source.ts new file mode 100644 index 0000000..bb58eef --- /dev/null +++ b/src/by-source.ts @@ -0,0 +1,71 @@ +import { COLUMN, SOURCES_INDEX, SOURCE_LINE, SOURCE_COLUMN } from './sourcemap-segment'; +import { memoizedBinarySearch, memoizedState, upperBound } from './binary-search'; + +import type { ReverseSegment, SourceMapSegment } from './sourcemap-segment'; + +export type Source = { + __proto__: null; + [line: number]: Exclude[]; +}; + +// Rebuilds the original source files, with mappings that are ordered by source line/column instead +// of generated line/column. +export default function buildBySources( + sourceFiles: (string | null)[], + decoded: SourceMapSegment[][], +): Source[] { + const sources: Source[] = sourceFiles.map(buildNullArray); + + let lastLine: ReverseSegment[] = []; + const memo = memoizedState(); + for (let i = 0; i < decoded.length; i++) { + const line = decoded[i]; + for (let j = 0; j < line.length; j++) { + const seg = line[j]; + if (seg.length === 1) continue; + + const sourceColumn = seg[SOURCE_COLUMN]; + const originalSource = sources[seg[SOURCES_INDEX]]; + const originalLine = (originalSource[seg[SOURCE_LINE]] ||= []); + + // We really need two keys, the source index and the source line. But since that would cause a + // slowdown for the basic usecase, we instead directly manipulate the lastKey. By making it + // -1, and providing 0 as the key during memoized searches, we ensure that when the index/line + // changes, we will bust the cache and perform a real search. + if (lastLine !== originalLine) { + lastLine = originalLine; + memo.lastKey = -1; + } + + // The binary search either found a match, or it found the left-index just before where the + // segment should go. Either way, we want to insert after that. And there may be multiple + // generated segments associated with an original location, so there may need to move several + // indexes before we find where we need to insert. + const index = upperBound( + originalLine, + sourceColumn, + memoizedBinarySearch(originalLine, sourceColumn, memo, 0), + ); + + insert(originalLine, index + 1, [sourceColumn, i, seg[COLUMN]]); + } + } + + return sources; +} + +function insert(array: T[], index: number, value: T) { + for (let i = array.length; i > index; i--) { + array[i] = array[i - 1]; + } + array[index] = value; +} + +// Null arrays allow us to use ordered index keys without actually allocating contiguous memory like +// a real array. We use a null-prototype object to avoid prototype pollution and deoptimizations. +// Numeric properties on objects are magically sorted in ascending order by the engine regardless of +// the insertion order. So, by setting any numeric keys, even out of order, we'll get ascending +// order when iterating with for-in. +function buildNullArray(): T { + return { __proto__: null } as T; +} diff --git a/src/sort.ts b/src/sort.ts index e3ad374..61213c8 100644 --- a/src/sort.ts +++ b/src/sort.ts @@ -1,4 +1,6 @@ -import type { SourceMapSegment } from './types'; +import { COLUMN } from './sourcemap-segment'; + +import type { SourceMapSegment } from './sourcemap-segment'; export default function maybeSort( mappings: SourceMapSegment[][], @@ -26,7 +28,7 @@ function nextUnsortedSegmentLine(mappings: SourceMapSegment[][], start: number): function isSorted(line: SourceMapSegment[]): boolean { for (let j = 1; j < line.length; j++) { - if (line[j][0] < line[j - 1][0]) { + if (line[j][COLUMN] < line[j - 1][COLUMN]) { return false; } } @@ -39,5 +41,5 @@ function sortSegments(line: SourceMapSegment[], owned: boolean): SourceMapSegmen } function sortComparator(a: SourceMapSegment, b: SourceMapSegment): number { - return a[0] - b[0]; + return a[COLUMN] - b[COLUMN]; } diff --git a/src/sourcemap-segment.ts b/src/sourcemap-segment.ts new file mode 100644 index 0000000..94f1b6a --- /dev/null +++ b/src/sourcemap-segment.ts @@ -0,0 +1,23 @@ +type GeneratedColumn = number; +type SourcesIndex = number; +type SourceLine = number; +type SourceColumn = number; +type NamesIndex = number; + +type GeneratedLine = number; + +export type SourceMapSegment = + | [GeneratedColumn] + | [GeneratedColumn, SourcesIndex, SourceLine, SourceColumn] + | [GeneratedColumn, SourcesIndex, SourceLine, SourceColumn, NamesIndex]; + +export type ReverseSegment = [SourceColumn, GeneratedLine, GeneratedColumn]; + +export const COLUMN = 0; +export const SOURCES_INDEX = 1; +export const SOURCE_LINE = 2; +export const SOURCE_COLUMN = 3; +export const NAMES_INDEX = 4; + +export const REV_GENERATED_LINE = 1; +export const REV_GENERATED_COLUMN = 2; diff --git a/src/trace-mapping.ts b/src/trace-mapping.ts index f2a24d5..2dbdcf3 100644 --- a/src/trace-mapping.ts +++ b/src/trace-mapping.ts @@ -3,39 +3,72 @@ import { encode, decode } from '@jridgewell/sourcemap-codec'; import resolve from './resolve'; import stripFilename from './strip-filename'; import maybeSort from './sort'; -import { memoizedState, memoizedBinarySearch } from './binary-search'; +import buildBySources from './by-source'; +import { + found as bsFound, + upperBound, + lowerBound, + memoizedState, + memoizedBinarySearch, +} from './binary-search'; +import { + SOURCES_INDEX, + SOURCE_LINE, + SOURCE_COLUMN, + NAMES_INDEX, + REV_GENERATED_LINE, + REV_GENERATED_COLUMN, +} from './sourcemap-segment'; +import type { SourceMapSegment } from './sourcemap-segment'; import type { SourceMapV3, DecodedSourceMap, EncodedSourceMap, - InvalidMapping, + InvalidOriginalMapping, OriginalMapping, - SourceMapSegment, + InvalidGeneratedMapping, + GeneratedMapping, SourceMapInput, Needle, + SourceNeedle, SourceMap, EachMapping, } from './types'; +import type { Source } from './by-source'; +import type { MemoState } from './binary-search'; +export type { SourceMapSegment } from './sourcemap-segment'; export type { - SourceMapSegment, SourceMapInput, DecodedSourceMap, EncodedSourceMap, - InvalidMapping, + InvalidOriginalMapping, OriginalMapping as Mapping, OriginalMapping, + InvalidGeneratedMapping, + GeneratedMapping, EachMapping, } from './types'; -const INVALID_MAPPING: InvalidMapping = Object.freeze({ +const INVALID_ORIGINAL_MAPPING: InvalidOriginalMapping = Object.freeze({ source: null, line: null, column: null, name: null, }); +const INVALID_GENERATED_MAPPING: InvalidGeneratedMapping = Object.freeze({ + line: null, + column: null, +}); + +const LINE_GTR_ZERO = '`line` must be greater than 0 (lines start at line 1)'; +const COL_GTR_EQ_ZERO = '`column` must be greater than or equal to 0 (columns start at column 0)'; + +export const LEAST_UPPER_BOUND = -1; +export const GREATEST_LOWER_BOUND = 1; + /** * Returns the encoded (VLQ string) form of the SourceMap's mappings field. */ @@ -61,7 +94,22 @@ export let traceSegment: ( * (think, from a stack trace). Line is 1-based, but column is 0-based, due to legacy behavior in * `source-map` library. */ -export let originalPositionFor: (map: TraceMap, needle: Needle) => OriginalMapping | InvalidMapping; +export let originalPositionFor: ( + map: TraceMap, + needle: Needle, +) => OriginalMapping | InvalidOriginalMapping; + +/** + * Finds the source/line/column directly after the mapping returned by originalPositionFor, provided + * the found mapping is from the same source and line as the originalPositionFor mapping. + * + * Eg, in the code `let id = 1`, `originalPositionAfter` could find the mapping associated with `1` + * using the same needle that would return `id` when calling `originalPositionFor`. + */ +export let generatedPositionFor: ( + map: TraceMap, + needle: SourceNeedle, +) => GeneratedMapping | InvalidGeneratedMapping; /** * Iterates each mapping in generated position order. @@ -84,9 +132,12 @@ export class TraceMap implements SourceMap { declare resolvedSources: string[]; private declare _encoded: string | undefined; + private declare _decoded: SourceMapSegment[][]; + private _decodedSM = memoizedState(); - private _binarySearchMemo = memoizedState(); + private _bySources: Source[] | undefined = undefined; + private _bySourceMemos: MemoState[] | undefined = undefined; constructor(map: SourceMapInput, mapUrl?: string | null) { const isString = typeof map === 'string'; @@ -134,7 +185,7 @@ export class TraceMap implements SourceMap { if (line >= decoded.length) return null; const segments = decoded[line]; - const index = memoizedBinarySearch(segments, column, map._binarySearchMemo, line); + const index = memoizedBinarySearch(segments, column, map._decodedSM, line); // we come before any mapped segment if (index < 0) return null; @@ -142,27 +193,58 @@ export class TraceMap implements SourceMap { }; originalPositionFor = (map, { line, column }) => { - if (line < 1) throw new Error('`line` must be greater than 0 (lines start at line 1)'); - if (column < 0) { - throw new Error('`column` must be greater than or equal to 0 (columns start at column 0)'); - } + line--; + if (line < 0) throw new Error(LINE_GTR_ZERO); + if (column < 0) throw new Error(COL_GTR_EQ_ZERO); + + const segment = traceSegment(map, line, column); - const segment = traceSegment(map, line - 1, column); - if (segment == null) return INVALID_MAPPING; - if (segment.length == 1) return INVALID_MAPPING; + if (segment == null) return INVALID_ORIGINAL_MAPPING; + if (segment.length == 1) return INVALID_ORIGINAL_MAPPING; const { names, resolvedSources } = map; return { - source: resolvedSources[segment[1]], - line: segment[2] + 1, - column: segment[3], - name: segment.length === 5 ? names[segment[4]] : null, + source: resolvedSources[segment[SOURCES_INDEX]], + line: segment[SOURCE_LINE] + 1, + column: segment[SOURCE_COLUMN], + name: segment.length === 5 ? names[segment[NAMES_INDEX]] : null, + }; + }; + + generatedPositionFor = (map, { source, line, column, bias }) => { + line--; + if (line < 0) throw new Error(LINE_GTR_ZERO); + if (column < 0) throw new Error(COL_GTR_EQ_ZERO); + + const { sources, resolvedSources } = map; + let sourceIndex = sources.indexOf(source); + if (sourceIndex === -1) sourceIndex = resolvedSources.indexOf(source); + if (sourceIndex === -1) return INVALID_GENERATED_MAPPING; + + const generated = (map._bySources ||= buildBySources(sources, map._decoded)); + const memos = (map._bySourceMemos ||= sources.map(memoizedState)); + + const segments = generated[sourceIndex][line]; + + if (segments == null) return INVALID_GENERATED_MAPPING; + + let index = memoizedBinarySearch(segments, column, memos[sourceIndex], line); + + if (bsFound) { + index = (bias === LEAST_UPPER_BOUND ? upperBound : lowerBound)(segments, column, index); + } + + if (index < 0) return INVALID_GENERATED_MAPPING; + + const segment = segments[index]; + return { + line: segment[REV_GENERATED_LINE] + 1, + column: segment[REV_GENERATED_COLUMN], }; }; eachMapping = (map, cb) => { - const decoded = map._decoded; - const { names, resolvedSources } = map; + const { _decoded: decoded, names, resolvedSources } = map; for (let i = 0; i < decoded.length; i++) { const line = decoded[i]; diff --git a/src/types.ts b/src/types.ts index 44542df..8c1ddb7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import type { SourceMapSegment } from './sourcemap-segment'; + export interface SourceMapV3 { file?: string | null; names: string[]; @@ -7,17 +9,6 @@ export interface SourceMapV3 { version: 3; } -type Column = number; -type SourcesIndex = number; -type SourceLine = number; -type SourceColumn = number; -type NamesIndex = number; - -export type SourceMapSegment = - | [Column] - | [Column, SourcesIndex, SourceLine, SourceColumn] - | [Column, SourcesIndex, SourceLine, SourceColumn, NamesIndex]; - export interface EncodedSourceMap extends SourceMapV3 { mappings: string; } @@ -33,16 +24,26 @@ export type OriginalMapping = { name: string | null; }; -export type InvalidMapping = { +export type InvalidOriginalMapping = { source: null; line: null; column: null; name: null; }; +export type GeneratedMapping = { + line: number; + column: number; +}; +export type InvalidGeneratedMapping = { + line: null; + column: null; +}; + export type SourceMapInput = string | EncodedSourceMap | DecodedSourceMap; export type Needle = { line: number; column: number }; +export type SourceNeedle = { source: string; line: number; column: number; bias?: 1 | -1 }; export type EachMapping = | { diff --git a/test/setup.ts b/test/setup.ts index 298c704..88d8a36 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -32,5 +32,12 @@ test.macro = ava.macro; test.only = (label: string, fn: Implementation, ...args: Args) => { ava.only(getLabel(label), fn, ...args); }; +test.skip = function skip( + label: string, + fn: Implementation, + ...args: Args +): void { + ava.skip(getLabel(label), fn, ...args); +}; export { test, describe, describe as context }; diff --git a/test/trace-mapping.test.ts b/test/trace-mapping.test.ts index 46ca369..27b7954 100644 --- a/test/trace-mapping.test.ts +++ b/test/trace-mapping.test.ts @@ -9,6 +9,7 @@ import { decodedMappings, traceSegment, originalPositionFor, + generatedPositionFor, presortedDecodedMap, eachMapping, } from '../src/trace-mapping'; @@ -199,6 +200,25 @@ describe('TraceMap', () => { originalPositionFor(tracer, { line: 1, column: -1 }); }); }); + + test.only('generatedPositionFor', (t) => { + const tracer = new TraceMap(map); + + t.deepEqual(generatedPositionFor(tracer, { source: 'input.js', line: 1, column: 0 }), { + line: 1, + column: 0, + }); + + t.deepEqual(generatedPositionFor(tracer, { source: 'input.js', line: 1, column: 33 }), { + line: 1, + column: 18, + }); + + t.deepEqual(generatedPositionFor(tracer, { source: 'input.js', line: 4, column: 0 }), { + line: 5, + column: 0, + }); + }); }; } From 143e69e24c2d612098b4400e858ce7b2b743d3b5 Mon Sep 17 00:00:00 2001 From: Justin Ridgewell Date: Fri, 15 Apr 2022 14:29:08 -0400 Subject: [PATCH 2/6] Simplify algorithm --- src/binary-search.ts | 22 ---------------------- src/by-source.ts | 15 +++------------ src/trace-mapping.ts | 21 +++++---------------- src/types.ts | 2 +- 4 files changed, 9 insertions(+), 51 deletions(-) diff --git a/src/binary-search.ts b/src/binary-search.ts index ce27fbe..990a5ec 100644 --- a/src/binary-search.ts +++ b/src/binary-search.ts @@ -51,28 +51,6 @@ export function binarySearch( return low - 1; } -export function upperBound( - haystack: SourceMapSegment[] | GeneratedSegment[], - needle: number, - index: number, -): number { - for (let i = index + 1; i < haystack.length; i++, index++) { - if (haystack[i][COLUMN] !== needle) break; - } - return index; -} - -export function lowerBound( - haystack: SourceMapSegment[] | GeneratedSegment[], - needle: number, - index: number, -): number { - for (let i = index - 1; i >= 0; i--, index--) { - if (haystack[i][COLUMN] !== needle) break; - } - return index; -} - export function memoizedState(): MemoState { return { lastKey: -1, diff --git a/src/by-source.ts b/src/by-source.ts index bb58eef..270f7fd 100644 --- a/src/by-source.ts +++ b/src/by-source.ts @@ -1,5 +1,5 @@ import { COLUMN, SOURCES_INDEX, SOURCE_LINE, SOURCE_COLUMN } from './sourcemap-segment'; -import { memoizedBinarySearch, memoizedState, upperBound } from './binary-search'; +import { memoizedBinarySearch, memoizedState, found as bsFound } from './binary-search'; import type { ReverseSegment, SourceMapSegment } from './sourcemap-segment'; @@ -37,17 +37,8 @@ export default function buildBySources( memo.lastKey = -1; } - // The binary search either found a match, or it found the left-index just before where the - // segment should go. Either way, we want to insert after that. And there may be multiple - // generated segments associated with an original location, so there may need to move several - // indexes before we find where we need to insert. - const index = upperBound( - originalLine, - sourceColumn, - memoizedBinarySearch(originalLine, sourceColumn, memo, 0), - ); - - insert(originalLine, index + 1, [sourceColumn, i, seg[COLUMN]]); + const index = memoizedBinarySearch(originalLine, sourceColumn, memo, 0); + if (!bsFound) insert(originalLine, index + 1, [sourceColumn, i, seg[COLUMN]]); } } diff --git a/src/trace-mapping.ts b/src/trace-mapping.ts index 2dbdcf3..b9b3634 100644 --- a/src/trace-mapping.ts +++ b/src/trace-mapping.ts @@ -4,13 +4,7 @@ import resolve from './resolve'; import stripFilename from './strip-filename'; import maybeSort from './sort'; import buildBySources from './by-source'; -import { - found as bsFound, - upperBound, - lowerBound, - memoizedState, - memoizedBinarySearch, -} from './binary-search'; +import { memoizedState, memoizedBinarySearch } from './binary-search'; import { SOURCES_INDEX, SOURCE_LINE, @@ -134,7 +128,7 @@ export class TraceMap implements SourceMap { private declare _encoded: string | undefined; private declare _decoded: SourceMapSegment[][]; - private _decodedSM = memoizedState(); + private _decodedMemo = memoizedState(); private _bySources: Source[] | undefined = undefined; private _bySourceMemos: MemoState[] | undefined = undefined; @@ -185,7 +179,7 @@ export class TraceMap implements SourceMap { if (line >= decoded.length) return null; const segments = decoded[line]; - const index = memoizedBinarySearch(segments, column, map._decodedSM, line); + const index = memoizedBinarySearch(segments, column, map._decodedMemo, line); // we come before any mapped segment if (index < 0) return null; @@ -211,7 +205,7 @@ export class TraceMap implements SourceMap { }; }; - generatedPositionFor = (map, { source, line, column, bias }) => { + generatedPositionFor = (map, { source, line, column }) => { line--; if (line < 0) throw new Error(LINE_GTR_ZERO); if (column < 0) throw new Error(COL_GTR_EQ_ZERO); @@ -228,12 +222,7 @@ export class TraceMap implements SourceMap { if (segments == null) return INVALID_GENERATED_MAPPING; - let index = memoizedBinarySearch(segments, column, memos[sourceIndex], line); - - if (bsFound) { - index = (bias === LEAST_UPPER_BOUND ? upperBound : lowerBound)(segments, column, index); - } - + const index = memoizedBinarySearch(segments, column, memos[sourceIndex], line); if (index < 0) return INVALID_GENERATED_MAPPING; const segment = segments[index]; diff --git a/src/types.ts b/src/types.ts index 8c1ddb7..8239a03 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,7 +43,7 @@ export type InvalidGeneratedMapping = { export type SourceMapInput = string | EncodedSourceMap | DecodedSourceMap; export type Needle = { line: number; column: number }; -export type SourceNeedle = { source: string; line: number; column: number; bias?: 1 | -1 }; +export type SourceNeedle = { source: string; line: number; column: number }; export type EachMapping = | { From bccdda584386c038e992568f8d3649cd5d58005d Mon Sep 17 00:00:00 2001 From: Justin Ridgewell Date: Fri, 15 Apr 2022 14:36:40 -0400 Subject: [PATCH 3/6] Use N MemoStates during bySource building --- src/by-source.ts | 29 +++++++++++------------------ src/trace-mapping.ts | 7 +++++-- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/by-source.ts b/src/by-source.ts index 270f7fd..43a3067 100644 --- a/src/by-source.ts +++ b/src/by-source.ts @@ -1,7 +1,8 @@ import { COLUMN, SOURCES_INDEX, SOURCE_LINE, SOURCE_COLUMN } from './sourcemap-segment'; -import { memoizedBinarySearch, memoizedState, found as bsFound } from './binary-search'; +import { memoizedBinarySearch, found as bsFound } from './binary-search'; import type { ReverseSegment, SourceMapSegment } from './sourcemap-segment'; +import type { MemoState } from './binary-search'; export type Source = { __proto__: null; @@ -11,33 +12,25 @@ export type Source = { // Rebuilds the original source files, with mappings that are ordered by source line/column instead // of generated line/column. export default function buildBySources( - sourceFiles: (string | null)[], decoded: SourceMapSegment[][], + memos: MemoState[], ): Source[] { - const sources: Source[] = sourceFiles.map(buildNullArray); + const sources: Source[] = memos.map(buildNullArray); - let lastLine: ReverseSegment[] = []; - const memo = memoizedState(); for (let i = 0; i < decoded.length; i++) { const line = decoded[i]; for (let j = 0; j < line.length; j++) { const seg = line[j]; if (seg.length === 1) continue; + const sourceIndex = seg[SOURCES_INDEX]; + const sourceLine = seg[SOURCE_LINE]; const sourceColumn = seg[SOURCE_COLUMN]; - const originalSource = sources[seg[SOURCES_INDEX]]; - const originalLine = (originalSource[seg[SOURCE_LINE]] ||= []); - - // We really need two keys, the source index and the source line. But since that would cause a - // slowdown for the basic usecase, we instead directly manipulate the lastKey. By making it - // -1, and providing 0 as the key during memoized searches, we ensure that when the index/line - // changes, we will bust the cache and perform a real search. - if (lastLine !== originalLine) { - lastLine = originalLine; - memo.lastKey = -1; - } - - const index = memoizedBinarySearch(originalLine, sourceColumn, memo, 0); + const originalSource = sources[sourceIndex]; + const originalLine = (originalSource[sourceLine] ||= []); + const memo = memos[sourceIndex]; + + const index = memoizedBinarySearch(originalLine, sourceColumn, memo, sourceLine); if (!bsFound) insert(originalLine, index + 1, [sourceColumn, i, seg[COLUMN]]); } } diff --git a/src/trace-mapping.ts b/src/trace-mapping.ts index b9b3634..1a88a67 100644 --- a/src/trace-mapping.ts +++ b/src/trace-mapping.ts @@ -215,8 +215,11 @@ export class TraceMap implements SourceMap { if (sourceIndex === -1) sourceIndex = resolvedSources.indexOf(source); if (sourceIndex === -1) return INVALID_GENERATED_MAPPING; - const generated = (map._bySources ||= buildBySources(sources, map._decoded)); - const memos = (map._bySourceMemos ||= sources.map(memoizedState)); + const generated = (map._bySources ||= buildBySources( + map._decoded, + (map._bySourceMemos = sources.map(memoizedState)), + )); + const memos = map._bySourceMemos!; const segments = generated[sourceIndex][line]; From cf16e5e1cd7bb2c54145498c6fac217d18692ca8 Mon Sep 17 00:00:00 2001 From: Justin Ridgewell Date: Fri, 15 Apr 2022 16:27:09 -0400 Subject: [PATCH 4/6] We need upper and lower bounds --- src/binary-search.ts | 22 ++++++++++++ src/by-source.ts | 15 ++++++-- src/trace-mapping.ts | 85 +++++++++++++++++++++++++++++++++----------- src/types.ts | 4 +-- 4 files changed, 101 insertions(+), 25 deletions(-) diff --git a/src/binary-search.ts b/src/binary-search.ts index 990a5ec..faf5fe6 100644 --- a/src/binary-search.ts +++ b/src/binary-search.ts @@ -51,6 +51,28 @@ export function binarySearch( return low - 1; } +export function upperBound( + haystack: SourceMapSegment[] | ReverseSegment[], + needle: number, + index: number, +): number { + for (let i = index + 1; i < haystack.length; i++, index++) { + if (haystack[i][COLUMN] !== needle) break; + } + return index; +} + +export function lowerBound( + haystack: SourceMapSegment[] | ReverseSegment[], + needle: number, + index: number, +): number { + for (let i = index - 1; i >= 0; i--, index--) { + if (haystack[i][COLUMN] !== needle) break; + } + return index; +} + export function memoizedState(): MemoState { return { lastKey: -1, diff --git a/src/by-source.ts b/src/by-source.ts index 43a3067..1829326 100644 --- a/src/by-source.ts +++ b/src/by-source.ts @@ -1,5 +1,5 @@ import { COLUMN, SOURCES_INDEX, SOURCE_LINE, SOURCE_COLUMN } from './sourcemap-segment'; -import { memoizedBinarySearch, found as bsFound } from './binary-search'; +import { memoizedBinarySearch, upperBound } from './binary-search'; import type { ReverseSegment, SourceMapSegment } from './sourcemap-segment'; import type { MemoState } from './binary-search'; @@ -30,8 +30,17 @@ export default function buildBySources( const originalLine = (originalSource[sourceLine] ||= []); const memo = memos[sourceIndex]; - const index = memoizedBinarySearch(originalLine, sourceColumn, memo, sourceLine); - if (!bsFound) insert(originalLine, index + 1, [sourceColumn, i, seg[COLUMN]]); + // The binary search either found a match, or it found the left-index just before where the + // segment should go. Either way, we want to insert after that. And there may be multiple + // generated segments associated with an original location, so there may need to move several + // indexes before we find where we need to insert. + const index = upperBound( + originalLine, + sourceColumn, + memoizedBinarySearch(originalLine, sourceColumn, memo, sourceLine), + ); + + insert(originalLine, index + 1, [sourceColumn, i, seg[COLUMN]]); } } diff --git a/src/trace-mapping.ts b/src/trace-mapping.ts index 1a88a67..1e5080a 100644 --- a/src/trace-mapping.ts +++ b/src/trace-mapping.ts @@ -4,7 +4,13 @@ import resolve from './resolve'; import stripFilename from './strip-filename'; import maybeSort from './sort'; import buildBySources from './by-source'; -import { memoizedState, memoizedBinarySearch } from './binary-search'; +import { + memoizedState, + memoizedBinarySearch, + upperBound, + lowerBound, + found as bsFound, +} from './binary-search'; import { SOURCES_INDEX, SOURCE_LINE, @@ -14,7 +20,7 @@ import { REV_GENERATED_COLUMN, } from './sourcemap-segment'; -import type { SourceMapSegment } from './sourcemap-segment'; +import type { SourceMapSegment, ReverseSegment } from './sourcemap-segment'; import type { SourceMapV3, DecodedSourceMap, @@ -172,26 +178,15 @@ export class TraceMap implements SourceMap { }; traceSegment = (map, line, column) => { - const decoded = map._decoded; - - // It's common for parent source maps to have pointers to lines that have no - // mapping (like a "//# sourceMappingURL=") at the end of the child file. - if (line >= decoded.length) return null; - - const segments = decoded[line]; - const index = memoizedBinarySearch(segments, column, map._decodedMemo, line); - - // we come before any mapped segment - if (index < 0) return null; - return segments[index]; + return traceOriginalSegment(map, line, column, GREATEST_LOWER_BOUND); }; - originalPositionFor = (map, { line, column }) => { + originalPositionFor = (map, { line, column, bias }) => { line--; if (line < 0) throw new Error(LINE_GTR_ZERO); if (column < 0) throw new Error(COL_GTR_EQ_ZERO); - const segment = traceSegment(map, line, column); + const segment = traceOriginalSegment(map, line, column, bias || LEAST_UPPER_BOUND); if (segment == null) return INVALID_ORIGINAL_MAPPING; if (segment.length == 1) return INVALID_ORIGINAL_MAPPING; @@ -205,7 +200,7 @@ export class TraceMap implements SourceMap { }; }; - generatedPositionFor = (map, { source, line, column }) => { + generatedPositionFor = (map, { source, line, column, bias }) => { line--; if (line < 0) throw new Error(LINE_GTR_ZERO); if (column < 0) throw new Error(COL_GTR_EQ_ZERO); @@ -225,10 +220,15 @@ export class TraceMap implements SourceMap { if (segments == null) return INVALID_GENERATED_MAPPING; - const index = memoizedBinarySearch(segments, column, memos[sourceIndex], line); - if (index < 0) return INVALID_GENERATED_MAPPING; + const segment = traceSegmentInternal( + segments, + memos[sourceIndex], + line, + column, + bias || GREATEST_LOWER_BOUND, + ); - const segment = segments[index]; + if (segment == null) return INVALID_GENERATED_MAPPING; return { line: segment[REV_GENERATED_LINE] + 1, column: segment[REV_GENERATED_COLUMN], @@ -275,5 +275,50 @@ export class TraceMap implements SourceMap { tracer._decoded = map.mappings; return tracer; }; + + function traceOriginalSegment( + map: TraceMap, + line: number, + column: number, + bias: typeof LEAST_UPPER_BOUND | typeof GREATEST_LOWER_BOUND, + ): Readonly | null { + const decoded = map._decoded; + + // It's common for parent source maps to have pointers to lines that have no + // mapping (like a "//# sourceMappingURL=") at the end of the child file. + if (line >= decoded.length) return null; + + return traceSegmentInternal(decoded[line], map._decodedMemo, line, column, bias); + } + + function traceSegmentInternal( + segments: SourceMapSegment[], + memo: MemoState, + line: number, + column: number, + bias: typeof LEAST_UPPER_BOUND | typeof GREATEST_LOWER_BOUND, + ): Readonly | null; + function traceSegmentInternal( + segments: ReverseSegment[], + memo: MemoState, + line: number, + column: number, + bias: typeof LEAST_UPPER_BOUND | typeof GREATEST_LOWER_BOUND, + ): Readonly | null; + function traceSegmentInternal( + segments: SourceMapSegment[] | ReverseSegment[], + memo: MemoState, + line: number, + column: number, + bias: typeof LEAST_UPPER_BOUND | typeof GREATEST_LOWER_BOUND, + ): Readonly | null { + let index = memoizedBinarySearch(segments, column, memo, line); + if (bsFound) { + index = (bias === LEAST_UPPER_BOUND ? upperBound : lowerBound)(segments, column, index); + } else if (bias === LEAST_UPPER_BOUND) index++; + + if (index < 0 || index == segments.length) return null; + return segments[index]; + } } } diff --git a/src/types.ts b/src/types.ts index 8239a03..69e7339 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,8 +42,8 @@ export type InvalidGeneratedMapping = { export type SourceMapInput = string | EncodedSourceMap | DecodedSourceMap; -export type Needle = { line: number; column: number }; -export type SourceNeedle = { source: string; line: number; column: number }; +export type Needle = { line: number; column: number; bias?: 1 | -1 }; +export type SourceNeedle = { source: string; line: number; column: number; bias?: 1 | -1 }; export type EachMapping = | { From c65976a1e029c28f71c9eab9bd920cbca082a303 Mon Sep 17 00:00:00 2001 From: Justin Ridgewell Date: Fri, 15 Apr 2022 16:50:49 -0400 Subject: [PATCH 5/6] Test GREATEST_LOWER_BOUND and LEAST_UPPER_BOUND --- src/trace-mapping.ts | 2 +- test/trace-mapping.test.ts | 52 +++++++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/trace-mapping.ts b/src/trace-mapping.ts index 1e5080a..2b62913 100644 --- a/src/trace-mapping.ts +++ b/src/trace-mapping.ts @@ -186,7 +186,7 @@ export class TraceMap implements SourceMap { if (line < 0) throw new Error(LINE_GTR_ZERO); if (column < 0) throw new Error(COL_GTR_EQ_ZERO); - const segment = traceOriginalSegment(map, line, column, bias || LEAST_UPPER_BOUND); + const segment = traceOriginalSegment(map, line, column, bias || GREATEST_LOWER_BOUND); if (segment == null) return INVALID_ORIGINAL_MAPPING; if (segment.length == 1) return INVALID_ORIGINAL_MAPPING; diff --git a/test/trace-mapping.test.ts b/test/trace-mapping.test.ts index 27b7954..664ae21 100644 --- a/test/trace-mapping.test.ts +++ b/test/trace-mapping.test.ts @@ -12,6 +12,8 @@ import { generatedPositionFor, presortedDecodedMap, eachMapping, + GREATEST_LOWER_BOUND, + LEAST_UPPER_BOUND, } from '../src/trace-mapping'; import type { ExecutionContext } from 'ava'; @@ -185,6 +187,23 @@ describe('TraceMap', () => { name: 'Error', }); + t.deepEqual( + originalPositionFor(tracer, { line: 2, column: 13, bias: GREATEST_LOWER_BOUND }), + { + source: 'https://astexplorer.net/input.js', + line: 2, + column: 14, + name: 'Error', + }, + ); + + t.deepEqual(originalPositionFor(tracer, { line: 2, column: 13, bias: LEAST_UPPER_BOUND }), { + source: 'https://astexplorer.net/input.js', + line: 2, + column: 10, + name: null, + }); + t.deepEqual(originalPositionFor(tracer, { line: 100, column: 13 }), { source: null, line: null, @@ -201,7 +220,7 @@ describe('TraceMap', () => { }); }); - test.only('generatedPositionFor', (t) => { + test('generatedPositionFor', (t) => { const tracer = new TraceMap(map); t.deepEqual(generatedPositionFor(tracer, { source: 'input.js', line: 1, column: 0 }), { @@ -214,6 +233,37 @@ describe('TraceMap', () => { column: 18, }); + t.deepEqual(generatedPositionFor(tracer, { source: 'input.js', line: 1, column: 14 }), { + line: 1, + column: 13, + }); + + t.deepEqual( + generatedPositionFor(tracer, { + source: 'input.js', + line: 1, + column: 14, + bias: GREATEST_LOWER_BOUND, + }), + { + line: 1, + column: 13, + }, + ); + + t.deepEqual( + generatedPositionFor(tracer, { + source: 'input.js', + line: 1, + column: 14, + bias: LEAST_UPPER_BOUND, + }), + { + line: 1, + column: 18, + }, + ); + t.deepEqual(generatedPositionFor(tracer, { source: 'input.js', line: 4, column: 0 }), { line: 5, column: 0, From dd3d0ff447c9ccf70e4949bb7c035893f2ca3cfb Mon Sep 17 00:00:00 2001 From: Justin Ridgewell Date: Fri, 15 Apr 2022 17:04:49 -0400 Subject: [PATCH 6/6] Fix bug with memo after insertion --- README.md | 10 ++++++++-- src/by-source.ts | 2 +- test/trace-mapping.test.ts | 5 +++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7c98374..4c84de6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ original location in the source file through a source map. You may already be familiar with the [`source-map`][source-map] package's `SourceMapConsumer`. This -provides the same `originalPositionFor` API, without requires WASM. +provides the same `originalPositionFor` and `generatedPositionFor` API, without requires WASM. ## Installation @@ -17,7 +17,7 @@ npm install @jridgewell/trace-mapping ## Usage ```typescript -import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping'; +import { TraceMap, originalPositionFor, generatedPositionFor } from '@jridgewell/trace-mapping'; const tracer = new TraceMap({ version: 3, @@ -34,6 +34,12 @@ assert.deepEqual(traced, { column: 4, name: 'foo', }); + +const generated = generatedPositionFor(tracer, { source: 'input.js', line: 42, column: 4 }); +assert.deepEqual(generated, { + line: 1, + column: 5, +}); ``` We also provide a lower level API to get the actual segment that matches our line and column. Unlike diff --git a/src/by-source.ts b/src/by-source.ts index 1829326..9664492 100644 --- a/src/by-source.ts +++ b/src/by-source.ts @@ -40,7 +40,7 @@ export default function buildBySources( memoizedBinarySearch(originalLine, sourceColumn, memo, sourceLine), ); - insert(originalLine, index + 1, [sourceColumn, i, seg[COLUMN]]); + insert(originalLine, (memo.lastIndex = index + 1), [sourceColumn, i, seg[COLUMN]]); } } diff --git a/test/trace-mapping.test.ts b/test/trace-mapping.test.ts index 664ae21..7b1c4ed 100644 --- a/test/trace-mapping.test.ts +++ b/test/trace-mapping.test.ts @@ -223,6 +223,11 @@ describe('TraceMap', () => { test('generatedPositionFor', (t) => { const tracer = new TraceMap(map); + t.deepEqual(generatedPositionFor(tracer, { source: 'input.js', line: 4, column: 3 }), { + line: 5, + column: 3, + }); + t.deepEqual(generatedPositionFor(tracer, { source: 'input.js', line: 1, column: 0 }), { line: 1, column: 0,