diff --git a/package-lock.json b/package-lock.json index a716624..b59829f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.3.1", "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.0.0", + "@jridgewell/resolve-uri": "3.1.0", + "@jridgewell/set-array": "^1.0.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.9" }, @@ -89,9 +90,9 @@ } }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.0.6.tgz", - "integrity": "sha512-R7xHtBSNm+9SyvpJkdQl+qrM3Hm2fea3Ef197M3mUug+v+yR+Rhfbs7PBtcBUVnIWJ4JcAdjvij+c8hXS9p5aw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", "engines": { "node": ">=6.0.0" } @@ -3351,9 +3352,9 @@ "dev": true }, "@jridgewell/resolve-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.0.6.tgz", - "integrity": "sha512-R7xHtBSNm+9SyvpJkdQl+qrM3Hm2fea3Ef197M3mUug+v+yR+Rhfbs7PBtcBUVnIWJ4JcAdjvij+c8hXS9p5aw==" + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==" }, "@jridgewell/set-array": { "version": "1.1.0", diff --git a/package.json b/package.json index b2dc9b3..461c7ba 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "typescript": "4.6.3" }, "dependencies": { + "@jridgewell/resolve-uri": "3.1.0", "@jridgewell/set-array": "^1.0.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.9" diff --git a/src/gen-mapping.ts b/src/gen-mapping.ts index 601c745..3ef8043 100644 --- a/src/gen-mapping.ts +++ b/src/gen-mapping.ts @@ -1,6 +1,6 @@ -import { SetArray, put } from '@jridgewell/set-array'; +import { SetArray, put, get } from '@jridgewell/set-array'; import { encode } from '@jridgewell/sourcemap-codec'; -import { TraceMap, decodedMappings } from '@jridgewell/trace-mapping'; +import { TraceMap, decodedMappings, traceSegment } from '@jridgewell/trace-mapping'; import { COLUMN, @@ -10,6 +10,9 @@ import { NAMES_INDEX, } from './sourcemap-segment'; +import relativeUri from '@jridgewell/resolve-uri/relative'; +import resolveUri from '@jridgewell/resolve-uri'; + import type { SourceMapInput } from '@jridgewell/trace-mapping'; import type { SourceMapSegment } from './sourcemap-segment'; import type { DecodedSourceMap, EncodedSourceMap, Pos, Mapping } from './types'; @@ -139,6 +142,18 @@ export let fromMap: (input: SourceMapInput) => GenMapping; */ export let allMappings: (map: GenMapping) => Mapping[]; +/** + * Applies the mappings of a sub-source-map for a specific source file to the + * source map being generated. Each mapping to the supplied source file is + * rewritten using the supplied source map. + */ +export let applySourceMap: ( + map: GenMapping, + sourceMapConsumer: TraceMap, + sourceFile?: string, + sourceMapPath?: string, +) => void; + // This split declaration is only so that terser can elminiate the static initialization block. let addSegmentInternal: ( skipable: boolean, @@ -216,7 +231,11 @@ export class GenMapping { setSourceContent = (map, source, content) => { const { _sources: sources, _sourcesContent: sourcesContent } = map; - sourcesContent[put(sources, source)] = content; + + const sourceIndex = get(sources, source); + if (sourceIndex !== undefined) { + sourcesContent[sourceIndex] = content; + } }; toDecodedMap = (map) => { @@ -336,6 +355,113 @@ export class GenMapping { : [genColumn, sourcesIndex, sourceLine, sourceColumn], ); }; + + applySourceMap = ( + map: GenMapping, + consumer: TraceMap, + rawSourceFile?: string | null, + sourceMapPath?: string | null, + ) => { + let sourceFile = rawSourceFile ?? consumer.file; + if (sourceFile == null) { + throw new Error( + 'applySourceMap requires either an explicit source file, ' + + 'or the source map\'s "file" property. Both were omitted.', + ); + } + + if (!sourceMapPath) sourceMapPath = ''; + else if (!sourceMapPath.endsWith('/')) sourceMapPath += '/'; + + const { _mappings: mappings, _sourcesContent: sourcesContent, sourceRoot } = map; + const sources = map._sources.array; + const names = map._names.array; + + const { + sources: consumerSources, + sourcesContent: consumerSourcesContent, + names: consumerNames, + } = consumer; + + let sourceIndex = get(map._sources, sourceFile); + if (sourceIndex === undefined && sourceRoot) { + // If we couldn't fine the source file and there's a sourceRoot, the sourceFile may have + // been joined with the root already. Try again after making the file relative to the root. + sourceFile = relativeUri(sourceRoot, sourceFile); + sourceIndex = get(map._sources, sourceFile); + } + + // If we still can't find the source file, then there's no way we could remap the file with + // the new consumer map. + if (sourceIndex === undefined) return; + + // The source file and several names could be replaced by the remapped consumer map. To avoid + // leaving dead entires, we regenerate the both (and the paired sourcesContent). + const newSources = new SetArray(); + const newSourcesContent: (string | null)[] = []; + const newNames = new SetArray(); + + for (let i = 0; i < mappings.length; i++) { + const line = mappings[i]; + + for (let j = 0; j < line.length; j++) { + const seg = line[j]; + if (seg.length === 1) continue; + + let traced = null; + if (seg[1] === sourceIndex) { + traced = traceSegment(consumer, seg[2], seg[3]); + } + + if (traced == null) { + // Either this segment is mapping a different source file, or we couldn't traced the + // original mapping in the consumer. Either way, we'll keep the original. + const initialIndex = seg[1]; + const newSourceIndex = (seg[1] = put(newSources, sources[initialIndex])); + newSourcesContent[newSourceIndex] = sourcesContent[initialIndex]; + if (seg.length === 5) seg[4] = put(newNames, names[seg[4]]); + continue; + } + + if (traced.length === 1) { + // If the traced mapping points to a sourceless segment, we need to truncate the + // original to also be sourceless. + (seg as number[]).length = 1; + continue; + } + + let source = consumerSources[traced[1]] || ''; + if (sourceMapPath != null) { + source = resolveUri(source, sourceMapPath); + } + if (sourceRoot != null) { + source = relativeUri(sourceRoot, source); + } + + const newSourceIndex = put(newSources, source); + newSourcesContent[newSourceIndex] = consumerSourcesContent + ? consumerSourcesContent[traced[1]] + : null; + + seg[1] = newSourceIndex; + seg[2] = traced[2]; + seg[3] = traced[3]; + + // If the traced segment has a name, prioritize that. If not, we'll take the original's + // name. We can't shorten this into a ternary because we may not have a name at all, and + // we cannot set index 4 if there is no name. + if (traced.length === 5) { + seg[4] = put(newNames, consumerNames[traced[4]]); + } else if (seg.length == 5) { + seg[4] = put(newNames, names[seg[4]]); + } + } + } + + map._sources = newSources; + map._sourcesContent = newSourcesContent; + map._names = newNames; + }; } } diff --git a/test/gen-mapping.test.js b/test/gen-mapping.test.js index f6ca875..d1c14fb 100644 --- a/test/gen-mapping.test.js +++ b/test/gen-mapping.test.js @@ -1,3 +1,5 @@ +const { assertEqualMaps } = require('./utils'); + const { GenMapping, addSegment, @@ -9,7 +11,11 @@ const { fromMap, maybeAddSegment, maybeAddMapping, + applySourceMap, } = require('..'); + +const { TraceMap } = require('@jridgewell/trace-mapping'); + const assert = require('assert'); describe('GenMapping', () => { @@ -41,6 +47,7 @@ describe('GenMapping', () => { it('has sourcesContent', () => { const map = new GenMapping(); + addSegment(map, 0, 0, 'input.js', 0, 0, 'foo'); setSourceContent(map, 'input.js', 'input'); assert.deepEqual(toDecodedMap(map).sourcesContent, ['input']); @@ -89,6 +96,7 @@ describe('GenMapping', () => { it('has sourcesContent', () => { const map = new GenMapping(); + addSegment(map, 0, 0, 'input.js', 0, 0, 'foo'); setSourceContent(map, 'input.js', 'input'); assert.deepEqual(toEncodedMap(map).sourcesContent, ['input']); @@ -845,3 +853,279 @@ describe('GenMapping', () => { }); }); }); + +describe('applySourceMap', () => { + it('test applySourceMap basic', () => { + var mapStep1 = /** @type {import('@jridgewell/trace-mapping').EncodedSourceMap} */ { + version: 3, + sources: ['fileX', 'fileY'], + names: [], + mappings: 'AACA;;ACAA;;ADDA;;ACAA', + file: 'fileA', + sourcesContent: ['lineX1\nlineX2\n', null], + }; + + var mapStep2 = /** @type {import('@jridgewell/trace-mapping').EncodedSourceMap} */ { + version: 3, + sources: ['fileA', 'fileB'], + names: [], + mappings: ';AAAA;AACA;AACA;AACA;ACHA;AACA', + file: 'fileGen', + sourcesContent: [null, 'lineB1\nlineB2\n'], + }; + + var expectedMap = /** @type {import('@jridgewell/trace-mapping').EncodedSourceMap} */ { + version: 3, + sources: ['fileX', 'fileA', 'fileY', 'fileB'], + names: [], + mappings: ';AACA;ACAA;ACAA;ADEA;AEHA;AACA', + file: 'fileGen', + sourcesContent: ['lineX1\nlineX2\n', null, null, 'lineB1\nlineB2\n'], + }; + + // apply source map "mapStep1" to "mapStep2" + var generator = fromMap(mapStep2); + applySourceMap(generator, new TraceMap(mapStep1)); + var actualMap = toEncodedMap(generator); + + assertEqualMaps(assert, actualMap, expectedMap); + }); + + it('test applySourceMap throws when file is missing', () => { + var map = new GenMapping({ + file: 'test.js', + }); + var map2 = toEncodedMap(new GenMapping()); + assert.throws(function () { + applySourceMap(map, new TraceMap(map2)); + }); + }); + + const data = [ + [ + 'relative', + '../temp/temp_maps', + ['coffee/foo.coffee', '/bar.coffee', 'http://www.example.com/baz.coffee'], + ], + [ + 'absolute', + '/app/temp/temp_maps', + ['/app/coffee/foo.coffee', '/bar.coffee', 'http://www.example.com/baz.coffee'], + ], + [ + 'url', + 'http://foo.org/app/temp/temp_maps', + [ + 'http://foo.org/app/coffee/foo.coffee', + 'http://foo.org/bar.coffee', + 'http://www.example.com/baz.coffee', + ], + ], + // If the third parameter is omitted or set to the current working + // directory we get incorrect source paths: + [ + 'undefined', + undefined, + ['../coffee/foo.coffee', '/bar.coffee', 'http://www.example.com/baz.coffee'], + ], + [ + 'empty string', + '', + ['../coffee/foo.coffee', '/bar.coffee', 'http://www.example.com/baz.coffee'], + ], + ['dot', '.', ['../coffee/foo.coffee', '/bar.coffee', 'http://www.example.com/baz.coffee']], + [ + 'dot slash', + './', + ['../coffee/foo.coffee', '/bar.coffee', 'http://www.example.com/baz.coffee'], + ], + ]; + + data.forEach(([title, actualPath, expected]) => { + it(`test the two additional parameters of applySourceMap: ${title}`, () => { + // Assume the following directory structure: + // + // http://foo.org/ + // bar.coffee + // app/ + // coffee/ + // foo.coffee + // temp/ + // bundle.js + // temp_maps/ + // bundle.js.map + // public/ + // bundle.min.js + // bundle.min.js.map + // + // http://www.example.com/ + // baz.coffee + + const bundleMapSource = new GenMapping({ + file: 'bundle.js', + }); + addMapping(bundleMapSource, { + generated: { line: 3, column: 3 }, + original: { line: 2, column: 2 }, + source: '../../coffee/foo.coffee', + }); + setSourceContent(bundleMapSource, '../../coffee/foo.coffee', 'foo coffee'); + addMapping(bundleMapSource, { + generated: { line: 13, column: 13 }, + original: { line: 12, column: 12 }, + source: '/bar.coffee', + }); + setSourceContent(bundleMapSource, '/bar.coffee', 'bar coffee'); + addMapping(bundleMapSource, { + generated: { line: 23, column: 23 }, + original: { line: 22, column: 22 }, + source: 'http://www.example.com/baz.coffee', + }); + setSourceContent(bundleMapSource, 'http://www.example.com/baz.coffee', 'baz coffee'); + + const bundleMap = new TraceMap( + /** @type {import('@jridgewell/trace-mapping').EncodedSourceMap} */ ( + toEncodedMap(bundleMapSource) + ) + ); + + const minifiedMapSource = new GenMapping({ + file: 'bundle.min.js', + sourceRoot: '..', + }); + addMapping(minifiedMapSource, { + generated: { line: 1, column: 1 }, + original: { line: 3, column: 3 }, + source: 'temp/bundle.js', + }); + addMapping(minifiedMapSource, { + generated: { line: 11, column: 11 }, + original: { line: 13, column: 13 }, + source: 'temp/bundle.js', + }); + addMapping(minifiedMapSource, { + generated: { line: 21, column: 21 }, + original: { line: 23, column: 23 }, + source: 'temp/bundle.js', + }); + const minifiedMap = new TraceMap( + /** @type {import('@jridgewell/trace-mapping').EncodedSourceMap} */ ( + toEncodedMap(minifiedMapSource) + ) + ); + + /** + * + * @param {[string, string, string]} sources + * @returns {import('..').EncodedSourceMap} + */ + var expectedMap = function (sources) { + var map = new GenMapping({ + file: 'bundle.min.js', + sourceRoot: '..', + }); + addMapping(map, { + generated: { line: 1, column: 1 }, + original: { line: 2, column: 2 }, + source: sources[0], + }); + setSourceContent(map, sources[0], 'foo coffee'); + addMapping(map, { + generated: { line: 11, column: 11 }, + original: { line: 12, column: 12 }, + source: sources[1], + }); + setSourceContent(map, sources[1], 'bar coffee'); + addMapping(map, { + generated: { line: 21, column: 21 }, + original: { line: 22, column: 22 }, + source: sources[2], + }); + setSourceContent(map, sources[2], 'baz coffee'); + return toEncodedMap(map); + }; + + /** + * + * @param {string} aSourceMapPath + * @returns {import('..').EncodedSourceMap} + */ + var actualMap = function (aSourceMapPath) { + var map = fromMap(minifiedMap); + // Note that relying on `bundleMap.file` (which is simply 'bundle.js') + // instead of supplying the second parameter wouldn't work here. + applySourceMap(map, bundleMap, '../temp/bundle.js', aSourceMapPath); + return toEncodedMap(map); + }; + + assertEqualMaps(assert, actualMap(actualPath), expectedMap(expected)); + }); + }); + + const names = [ + // `foo = 1` -> `var foo = 1;` -> `var a=1` + // CoffeeScript doesn’t rename variables, so there’s no need for it to + // provide names in its source maps. Minifiers do rename variables and + // therefore do provide names in their source maps. So that name should be + // retained if the original map lacks names. + [null, 'foo', 'foo'], + + // `foo = 1` -> `var coffee$foo = 1;` -> `var a=1` + // Imagine that CoffeeScript prefixed all variables with `coffee$`. Even + // though the minifier then also provides a name, the original name is + // what corresponds to the source. + ['foo', 'coffee$foo', 'foo'], + + // `foo = 1` -> `var coffee$foo = 1;` -> `var coffee$foo=1` + // Minifiers can turn off variable mangling. Then there’s no need to + // provide names in the source map, but the names from the original map are + // still needed. + ['foo', null, 'foo'], + + // `foo = 1` -> `var foo = 1;` -> `var foo=1` + // No renaming at all. + [null, null, null], + ]; + + /** + * Imagine some CoffeeScript code being compiled into JavaScript and then minified. + * @param {string | null} coffeeName + * @param {string | null} jsName + * @param {string | null} expectedName + */ + names.forEach(([coffeeName, jsName, expectedName]) => { + it(`test applySourceMap name handling: ${coffeeName || 'null'}, ${jsName || 'null'}, ${ + expectedName || 'null' + }`, () => { + var minifiedMap = new GenMapping({ + file: 'test.js.min', + }); + addMapping(minifiedMap, { + generated: { line: 1, column: 4 }, + original: { line: 1, column: 4 }, + source: 'test.js', + name: jsName, + }); + + var coffeeMap = new GenMapping({ + file: 'test.js', + }); + addMapping(coffeeMap, { + generated: { line: 1, column: 4 }, + original: { line: 1, column: 0 }, + source: 'test.coffee', + name: coffeeName, + }); + + applySourceMap(minifiedMap, new TraceMap(toDecodedMap(coffeeMap))); + + const actualNames = toDecodedMap(minifiedMap).names; + if (expectedName == null) { + assert.equal(actualNames.length, 0); + } else { + assert.equal(actualNames.length, 1); + assert.deepEqual(actualNames, [expectedName]); + } + }); + }); +}); diff --git a/test/utils.js b/test/utils.js new file mode 100644 index 0000000..6f5d2ee --- /dev/null +++ b/test/utils.js @@ -0,0 +1,38 @@ +/** + * + * @param {*} assert + * @param {import('..').EncodedSourceMap} actualMap + * @param {import('..').EncodedSourceMap} expectedMap + */ +function assertEqualMaps(assert, actualMap, expectedMap) { + assert.equal(actualMap.version, expectedMap.version, 'version mismatch'); + assert.equal(actualMap.file, expectedMap.file, 'file mismatch'); + assert.deepEqual(actualMap.names, expectedMap.names, 'names mismatch'); + assert.deepEqual(actualMap.sources, expectedMap.sources, 'sources mismatch'); + + const aSourceRoot = actualMap.sourceRoot; + const eSourceRoot = expectedMap.sourceRoot; + assert.equal( + aSourceRoot, + eSourceRoot, + `sourceRoot mismatch: '${aSourceRoot}' != '${eSourceRoot}'` + ); + + assert.equal(actualMap.mappings, expectedMap.mappings, `mappings mismatch`); + + if (actualMap.sourcesContent) { + // The actualMap.sourcesContent could be an array of null, + // Which is actually equivalent to not having the key at all + const hasValues = actualMap.sourcesContent.filter((c) => c != null).length > 0; + + if (hasValues || expectedMap.sourcesContent) { + assert.deepEqual( + actualMap.sourcesContent, + expectedMap.sourcesContent, + 'sourcesContent mismatch' + ); + } + } +} + +exports.assertEqualMaps = assertEqualMaps;