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/binary-search.ts b/src/binary-search.ts index 49d334f..faf5fe6 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[] | 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, @@ -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..9664492 --- /dev/null +++ b/src/by-source.ts @@ -0,0 +1,64 @@ +import { COLUMN, SOURCES_INDEX, SOURCE_LINE, SOURCE_COLUMN } from './sourcemap-segment'; +import { memoizedBinarySearch, upperBound } from './binary-search'; + +import type { ReverseSegment, SourceMapSegment } from './sourcemap-segment'; +import type { MemoState } from './binary-search'; + +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( + decoded: SourceMapSegment[][], + memos: MemoState[], +): Source[] { + const sources: Source[] = memos.map(buildNullArray); + + 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[sourceIndex]; + const originalLine = (originalSource[sourceLine] ||= []); + const memo = memos[sourceIndex]; + + // 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, (memo.lastIndex = 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..2b62913 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 { + memoizedState, + memoizedBinarySearch, + upperBound, + lowerBound, + found as bsFound, +} from './binary-search'; +import { + SOURCES_INDEX, + SOURCE_LINE, + SOURCE_COLUMN, + NAMES_INDEX, + REV_GENERATED_LINE, + REV_GENERATED_COLUMN, +} from './sourcemap-segment'; +import type { SourceMapSegment, ReverseSegment } 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 _decodedMemo = 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'; @@ -127,42 +178,65 @@ export class TraceMap implements SourceMap { }; traceSegment = (map, line, column) => { - const decoded = map._decoded; + return traceOriginalSegment(map, line, column, GREATEST_LOWER_BOUND); + }; - // 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; + 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 segments = decoded[line]; - const index = memoizedBinarySearch(segments, column, map._binarySearchMemo, line); + const segment = traceOriginalSegment(map, line, column, bias || GREATEST_LOWER_BOUND); - // we come before any mapped segment - if (index < 0) return null; - return segments[index]; + if (segment == null) return INVALID_ORIGINAL_MAPPING; + if (segment.length == 1) return INVALID_ORIGINAL_MAPPING; + + const { names, resolvedSources } = map; + return { + source: resolvedSources[segment[SOURCES_INDEX]], + line: segment[SOURCE_LINE] + 1, + column: segment[SOURCE_COLUMN], + name: segment.length === 5 ? names[segment[NAMES_INDEX]] : null, + }; }; - 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)'); - } + 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 segment = traceSegment(map, line - 1, column); - if (segment == null) return INVALID_MAPPING; - if (segment.length == 1) return INVALID_MAPPING; + const { sources, resolvedSources } = map; + let sourceIndex = sources.indexOf(source); + if (sourceIndex === -1) sourceIndex = resolvedSources.indexOf(source); + if (sourceIndex === -1) return INVALID_GENERATED_MAPPING; - const { names, resolvedSources } = map; + const generated = (map._bySources ||= buildBySources( + map._decoded, + (map._bySourceMemos = sources.map(memoizedState)), + )); + const memos = map._bySourceMemos!; + + const segments = generated[sourceIndex][line]; + + if (segments == null) return INVALID_GENERATED_MAPPING; + + const segment = traceSegmentInternal( + segments, + memos[sourceIndex], + line, + column, + bias || GREATEST_LOWER_BOUND, + ); + + if (segment == null) return INVALID_GENERATED_MAPPING; return { - source: resolvedSources[segment[1]], - line: segment[2] + 1, - column: segment[3], - name: segment.length === 5 ? names[segment[4]] : null, + 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]; @@ -201,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 44542df..69e7339 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 Needle = { line: number; column: number; bias?: 1 | -1 }; +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..7b1c4ed 100644 --- a/test/trace-mapping.test.ts +++ b/test/trace-mapping.test.ts @@ -9,8 +9,11 @@ import { decodedMappings, traceSegment, originalPositionFor, + generatedPositionFor, presortedDecodedMap, eachMapping, + GREATEST_LOWER_BOUND, + LEAST_UPPER_BOUND, } from '../src/trace-mapping'; import type { ExecutionContext } from 'ava'; @@ -184,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, @@ -199,6 +219,61 @@ describe('TraceMap', () => { originalPositionFor(tracer, { line: 1, column: -1 }); }); }); + + 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, + }); + + t.deepEqual(generatedPositionFor(tracer, { source: 'input.js', line: 1, column: 33 }), { + line: 1, + 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, + }); + }); }; }