From 21ca07c17a712c532890901417f91e453ae5a2f9 Mon Sep 17 00:00:00 2001 From: Justin Ridgewell Date: Tue, 19 Apr 2022 19:09:32 -0400 Subject: [PATCH] Support sectioned sourcemaps with AnyMap --- src/any-map.ts | 128 +++++++++++++++++++++++++++++++++++++ src/trace-mapping.ts | 4 ++ src/types.ts | 15 +++++ test/trace-mapping.test.ts | 102 +++++++++++++++++++++++++++++ 4 files changed, 249 insertions(+) create mode 100644 src/any-map.ts diff --git a/src/any-map.ts b/src/any-map.ts new file mode 100644 index 0000000..8791fde --- /dev/null +++ b/src/any-map.ts @@ -0,0 +1,128 @@ +import { TraceMap, presortedDecodedMap, decodedMappings } from './trace-mapping'; +import { + COLUMN, + SOURCES_INDEX, + SOURCE_LINE, + SOURCE_COLUMN, + NAMES_INDEX, +} from './sourcemap-segment'; + +import type { Section, DecodedSourceMap, SectionedSourceMapInput } from './types'; +import type { SourceMapSegment } from './sourcemap-segment'; + +type AnyMap = { + new (map: SectionedSourceMapInput, mapUrl?: string | null): TraceMap; + (map: SectionedSourceMapInput, mapUrl?: string | null): TraceMap; +}; + +export const AnyMap: AnyMap = function (map, mapUrl) { + const parsed = + typeof map === 'string' ? (JSON.parse(map) as Exclude) : map; + + if (!('sections' in parsed)) return new TraceMap(parsed, mapUrl); + + const mappings: SourceMapSegment[][] = []; + const sources: string[] = []; + const sourcesContent: (string | null)[] = []; + const names: string[] = []; + const { sections } = parsed; + + let i = 0; + for (; i < sections.length - 1; i++) { + const no = sections[i + 1].offset; + addSection(sections[i], mapUrl, mappings, sources, sourcesContent, names, no.line, no.column); + } + for (; i < sections.length; i++) { + addSection(sections[i], mapUrl, mappings, sources, sourcesContent, names, -1, -1); + } + + const joined: DecodedSourceMap = { + version: 3, + file: parsed.file, + names, + sources, + sourcesContent, + mappings, + }; + + return presortedDecodedMap(joined); +} as AnyMap; + +function addSection( + section: Section, + mapUrl: string | null | undefined, + mappings: SourceMapSegment[][], + sources: string[], + sourcesContent: (string | null)[], + names: string[], + stopLine: number, + stopColumn: number, +) { + const { offset, map } = section; + const { line: lineOffset, column: columnOffset } = offset; + + // If this section jumps forwards several lines, we need to add lines to the output mappings catch up. + for (let i = mappings.length; i <= lineOffset; i++) mappings.push([]); + + const trace = AnyMap(map, mapUrl); + const sourcesOffset = sources.length; + const namesOffset = names.length; + const decoded = decodedMappings(trace); + const { resolvedSources } = trace; + append(sources, resolvedSources); + append(sourcesContent, trace.sourcesContent || fillSourcesContent(resolvedSources.length)); + append(names, trace.names); + + // We can only add so many lines before we step into the range that the next section's map + // controls. When we get to the last line, then we'll start checking the segments to see if + // they've crossed into the column range. + const len = stopLine === -1 ? decoded.length : Math.min(decoded.length, stopLine + 1); + + for (let i = 0; i < len; i++) { + const line = decoded[i]; + // On the 0th loop, the line will already exist due to a previous section, or the line catch up + // loop above. + const out = i === 0 ? mappings[lineOffset] : (mappings[lineOffset + i] = []); + // On the 0th loop, the section's column offset shifts us forward. On all other lines (since the + // map can be multiple lines), it doesn't. + const cOffset = i === 0 ? columnOffset : 0; + + for (let j = 0; j < line.length; j++) { + const seg = line[j]; + const column = cOffset + seg[COLUMN]; + + // If this segment steps into the column range that the next section's map controls, we need + // to stop early. + if (i === stopLine && column >= stopColumn) break; + + if (seg.length === 1) { + out.push([column]); + continue; + } + + const sourcesIndex = sourcesOffset + seg[SOURCES_INDEX]; + const sourceLine = seg[SOURCE_LINE]; + const sourceColumn = seg[SOURCE_COLUMN]; + if (seg.length === 4) { + out.push([column, sourcesIndex, sourceLine, sourceColumn]); + continue; + } + + out.push([column, sourcesIndex, sourceLine, sourceColumn, namesOffset + seg[NAMES_INDEX]]); + } + } +} + +function append(arr: T[], other: T[]) { + for (let i = 0; i < other.length; i++) arr.push(other[i]); +} + +// Sourcemaps don't need to have sourcesContent, and if they don't, we need to create an array of +// equal length to the sources. This is because the sources and sourcesContent are paired arrays, +// where `sourcesContent[i]` is the content of the `sources[i]` file. If we didn't, then joined +// sourcemap would desynchronize the sources/contents. +function fillSourcesContent(len: number): null[] { + const sourcesContent = []; + for (let i = 0; i < len; i++) sourcesContent[i] = null; + return sourcesContent; +} diff --git a/src/trace-mapping.ts b/src/trace-mapping.ts index 2b62913..e9faf67 100644 --- a/src/trace-mapping.ts +++ b/src/trace-mapping.ts @@ -41,8 +41,10 @@ import type { MemoState } from './binary-search'; export type { SourceMapSegment } from './sourcemap-segment'; export type { SourceMapInput, + SectionedSourceMapInput, DecodedSourceMap, EncodedSourceMap, + SectionedSourceMap, InvalidOriginalMapping, OriginalMapping as Mapping, OriginalMapping, @@ -122,6 +124,8 @@ export let eachMapping: (map: TraceMap, cb: (mapping: EachMapping) => void) => v */ export let presortedDecodedMap: (map: DecodedSourceMap, mapUrl?: string) => TraceMap; +export { AnyMap } from './any-map'; + export class TraceMap implements SourceMap { declare version: SourceMapV3['version']; declare file: SourceMapV3['file']; diff --git a/src/types.ts b/src/types.ts index 69e7339..7d829b0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,20 @@ export interface DecodedSourceMap extends SourceMapV3 { mappings: SourceMapSegment[][]; } +export interface Section { + offset: { + line: number; + column: number; + }; + map: EncodedSourceMap | DecodedSourceMap | SectionedSourceMap; +} + +export interface SectionedSourceMap { + file?: string | null; + sections: Section[]; + version: 3; +} + export type OriginalMapping = { source: string | null; line: number; @@ -41,6 +55,7 @@ export type InvalidGeneratedMapping = { }; export type SourceMapInput = string | EncodedSourceMap | DecodedSourceMap; +export type SectionedSourceMapInput = SourceMapInput | SectionedSourceMap; export type Needle = { line: number; column: number; bias?: 1 | -1 }; export type SourceNeedle = { source: string; line: number; column: number; bias?: 1 | -1 }; diff --git a/test/trace-mapping.test.ts b/test/trace-mapping.test.ts index 7b1c4ed..836e996 100644 --- a/test/trace-mapping.test.ts +++ b/test/trace-mapping.test.ts @@ -5,6 +5,7 @@ import { encode, decode } from '@jridgewell/sourcemap-codec'; import { test, describe } from './setup'; import { TraceMap, + AnyMap, encodedMappings, decodedMappings, traceSegment, @@ -22,6 +23,7 @@ import type { EncodedSourceMap, DecodedSourceMap, EachMapping, + SectionedSourceMap, } from '../src/trace-mapping'; describe('TraceMap', () => { @@ -373,3 +375,103 @@ describe('TraceMap', () => { }); }); }); + +describe('AnyMap', () => { + const map: SectionedSourceMap = { + version: 3, + file: 'sectioned.js', + sections: [ + { + offset: { line: 1, column: 1 }, + map: { + version: 3, + names: ['first'], + sources: ['first.js'], + sourcesContent: ['firstsource'], + mappings: 'AAAAA,CAAC', + }, + }, + { + offset: { line: 2, column: 0 }, + map: { + version: 3, + sections: [ + { + offset: { line: 0, column: 0 }, + map: { + version: 3, + names: ['second'], + sources: ['second.js'], + sourcesContent: ['secondsource'], + sourceRoot: 'nested', + mappings: 'AAAAA,CAAA;AAAA', + }, + }, + { + offset: { line: 0, column: 1 }, + map: { + version: 3, + names: [], + sources: ['third.js'], + sourcesContent: ['thirdsource'], + mappings: 'AAAA', + }, + }, + ], + }, + }, + ], + }; + + describe('map properties', () => { + test('version', (t) => { + const tracer = new AnyMap(map); + t.is(tracer.version, map.version); + }); + + test('file', (t) => { + const tracer = new AnyMap(map); + t.is(tracer.file, map.file); + }); + + test('sourceRoot', (t) => { + const tracer = new AnyMap(map); + t.is(tracer.sourceRoot, undefined); + }); + + test('sources', (t) => { + const tracer = new AnyMap(map); + t.deepEqual(tracer.sources, ['first.js', 'nested/second.js', 'third.js']); + }); + + test('names', (t) => { + const tracer = new AnyMap(map); + t.deepEqual(tracer.names, ['first', 'second']); + }); + + test('encodedMappings', (t) => { + const tracer = new AnyMap(map); + t.is(encodedMappings(tracer), ';CAAAA,CAAC;ACADC,CCAA'); + }); + + test('decodedMappings', (t) => { + const tracer = new AnyMap(map); + t.deepEqual(decodedMappings(tracer), [ + [], + [ + [1, 0, 0, 0, 0], + [2, 0, 0, 1], + ], + [ + [0, 1, 0, 0, 1], + [1, 2, 0, 0], + ], + ]); + }); + + test('sourcesContent', (t) => { + const tracer = new AnyMap(map); + t.deepEqual(tracer.sourcesContent, ['firstsource', 'secondsource', 'thirdsource']); + }); + }); +});