From 60aa05377c41c75a6d6de97446ff317bba3c329a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Goetz?= Date: Tue, 21 Jun 2022 22:40:48 +0200 Subject: [PATCH 1/4] Add applySourceMap --- package-lock.json | 2 +- src/gen-mapping.ts | 110 +++++++++++++- src/util.ts | 217 +++++++++++++++++++++++++++ test/gen-mapping.test.js | 308 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 634 insertions(+), 3 deletions(-) create mode 100644 src/util.ts diff --git a/package-lock.json b/package-lock.json index a716624..e24fe3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.3.1", "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.0.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..77424c5 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,8 @@ import { NAMES_INDEX, } from './sourcemap-segment'; +import { join, relative } from './util'; + import type { SourceMapInput } from '@jridgewell/trace-mapping'; import type { SourceMapSegment } from './sourcemap-segment'; import type { DecodedSourceMap, EncodedSourceMap, Pos, Mapping } from './types'; @@ -139,6 +141,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, @@ -336,6 +350,98 @@ export class GenMapping { : [genColumn, sourcesIndex, sourceLine, sourceColumn], ); }; + + applySourceMap = ( + map: GenMapping, + consumer: TraceMap, + rawSourceFile?: string, + sourceMapPath?: string, + ) => { + let sourceFile = rawSourceFile; + + if (sourceFile == null) { + if (consumer.file == null) { + throw new Error( + 'applySourceMap requires either an explicit source file, ' + + 'or the source map\'s "file" property. Both were omitted.', + ); + } + sourceFile = consumer.file; + } + + const sourceRoot = map.sourceRoot; + + // Make "sourceFile" relative if an absolute Url is passed. + if (sourceRoot != null) { + sourceFile = relative(sourceRoot, sourceFile); + } + + const sourceIndex = get(map._sources, sourceFile); + + // If the applied source map replaces a source entirely + // it's better to start with a fresh set of source, sourceContent and names + const newSources = new SetArray(); + const newSourceContents: (string | null)[] = []; + const newNames = new SetArray(); + + for (const line of map._mappings) { + for (const seg of line) { + if (seg.length === 1) continue; + + if (seg[1] !== sourceIndex) { + // This isn't the file we want to remap; keep the original mapping + const initialIndex = seg[1]; + seg[1] = put(newSources, map._sources.array[initialIndex]); + newSourceContents[seg[1]] = map._sourcesContent[initialIndex]; + if (seg.length === 5) { + seg[4] = put(newNames, map._names.array[seg[4]]); + } + continue; + } + + const traced = traceSegment(consumer, seg[2], seg[3]); + if (traced == null) { + // Could not find a mapping; keep the original mapping + const initialIndex = seg[1]; + seg[1] = put(newSources, map._sources.array[initialIndex]); + newSourceContents[seg[1]] = map._sourcesContent[initialIndex]; + if (seg.length === 5) { + seg[4] = put(newNames, map._names.array[seg[4]]); + } + continue; + } + + let source = consumer.sources[traced[1] as number] as string; + if (sourceMapPath != null) { + source = join(sourceMapPath, source); + } + if (sourceRoot != null) { + source = relative(sourceRoot, source); + } + + const newSourceIndex = put(newSources, source); + newSourceContents[newSourceIndex] = consumer.sourcesContent + ? consumer.sourcesContent[traced[1] as number] + : null; + + seg[1] = newSourceIndex; + seg[2] = traced[2] as number; + seg[3] = traced[3] as number; + + if (traced.length === 5) { + // Add the name mapping if found + seg[4] = put(newNames, consumer.names[traced[4]]); + } else if (seg.length == 5) { + // restore the previous name mapping if found + seg[4] = put(newNames, map._names.array[seg[4]]); + } + } + } + + map._sources = newSources; + map._sourcesContent = newSourceContents; + map._names = newNames; + }; } } diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..2671b29 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,217 @@ +const urlRegexp = /^(?:([\w+\-.]+):)?\/\/(?:(\w+:\w+)@)?([\w.-]*)(?::(\d+))?(.*)$/; +const dataUrlRegexp = /^data:.+,.+$/; + +interface ParsedURL { + scheme: string; + auth: string; + host: string; + port: string; + path: string; +} + +export function urlParse(aUrl: string): ParsedURL | null { + const match = aUrl.match(urlRegexp); + if (!match) { + return null; + } + return { + scheme: match[1], + auth: match[2], + host: match[3], + port: match[4], + path: match[5], + }; +} + +export function urlGenerate(aParsedUrl: ParsedURL) { + let url = ''; + if (aParsedUrl.scheme) { + url += aParsedUrl.scheme + ':'; + } + url += '//'; + if (aParsedUrl.auth) { + url += aParsedUrl.auth + '@'; + } + if (aParsedUrl.host) { + url += aParsedUrl.host; + } + if (aParsedUrl.port) { + url += ':' + aParsedUrl.port; + } + if (aParsedUrl.path) { + url += aParsedUrl.path; + } + return url; +} + +export function isAbsolute(aPath: string) { + return aPath.charAt(0) === '/' || urlRegexp.test(aPath); +} + +/** + * Normalizes a path, or the path portion of a URL: + * + * - Replaces consecutive slashes with one slash. + * - Removes unnecessary '.' parts. + * - Removes unnecessary '/..' parts. + * + * Based on code in the Node.js 'path' core module. + * + * @param aPath The path or url to normalize. + */ +export function normalize(aPath: string) { + let path = aPath; + const url = urlParse(aPath); + if (url) { + if (!url.path) { + return aPath; + } + path = url.path; + } + const isAbsoluteBool = isAbsolute(path); + // Split the path into parts between `/` characters. This is much faster than + // using `.split(/\/+/g)`. + const parts = []; + let start = 0; + let i = 0; + while (true) { + start = i; + i = path.indexOf('/', start); + if (i === -1) { + parts.push(path.slice(start)); + break; + } else { + parts.push(path.slice(start, i)); + while (i < path.length && path[i] === '/') { + i++; + } + } + } + + for (let part, up = 0, i = parts.length - 1; i >= 0; i--) { + part = parts[i]; + if (part === '.') { + parts.splice(i, 1); + } else if (part === '..') { + up++; + } else if (up > 0) { + if (part === '') { + // The first part is blank if the path is absolute. Trying to go + // above the root is a no-op. Therefore we can remove all '..' parts + // directly after the root. + parts.splice(i + 1, up); + up = 0; + } else { + parts.splice(i, 2); + up--; + } + } + } + path = parts.join('/'); + + if (path === '') { + path = isAbsoluteBool ? '/' : '.'; + } + + if (url) { + url.path = path; + return urlGenerate(url); + } + return path; +} + +/** + * Joins two paths/URLs. + * + * @param aRoot The root path or URL. + * @param aPath The path or URL to be joined with the root. + * + * - If aPath is a URL or a data URI, aPath is returned, unless aPath is a + * scheme-relative URL: Then the scheme of aRoot, if any, is prepended + * first. + * - Otherwise aPath is a path. If aRoot is a URL, then its path portion + * is updated with the result and aRoot is returned. Otherwise the result + * is returned. + * - If aPath is absolute, the result is aPath. + * - Otherwise the two paths are joined with a slash. + * - Joining for example 'http://' and 'www.example.com' is also supported. + */ +export function join(aRoot: string, aPath: string) { + if (aRoot === '') { + aRoot = '.'; + } + if (aPath === '') { + aPath = '.'; + } + const aPathUrl = urlParse(aPath); + const aRootUrl = urlParse(aRoot); + if (aRootUrl) { + aRoot = aRootUrl.path || '/'; + } + + // `join(foo, '//www.example.org')` + if (aPathUrl && !aPathUrl.scheme) { + if (aRootUrl) { + aPathUrl.scheme = aRootUrl.scheme; + } + return urlGenerate(aPathUrl); + } + + if (aPathUrl || aPath.match(dataUrlRegexp)) { + return aPath; + } + + // `join('http://', 'www.example.com')` + if (aRootUrl && !aRootUrl.host && !aRootUrl.path) { + aRootUrl.host = aPath; + return urlGenerate(aRootUrl); + } + + const joined = + aPath.charAt(0) === '/' ? aPath : normalize(aRoot.replace(/\/+$/, '') + '/' + aPath); + + if (aRootUrl) { + aRootUrl.path = joined; + return urlGenerate(aRootUrl); + } + return joined; +} + +/** + * Make a path relative to a URL or another path. + * + * @param aRoot The root path or URL. + * @param aPath The path or URL to be made relative to aRoot. + */ +export function relative(aRoot: string, aPath: string) { + if (aRoot === '') { + aRoot = '.'; + } + + aRoot = aRoot.replace(/\/$/, ''); + + // It is possible for the path to be above the root. In this case, simply + // checking whether the root is a prefix of the path won't work. Instead, we + // need to remove components from the root one by one, until either we find + // a prefix that fits, or we run out of components to remove. + let level = 0; + while (aPath.indexOf(aRoot + '/') !== 0) { + const index = aRoot.lastIndexOf('/'); + if (index < 0) { + return aPath; + } + + // If the only part of the root that is left is the scheme (i.e. http://, + // file:///, etc.), one or more slashes (/), or simply nothing at all, we + // have exhausted all components, so the path is not relative to the root. + aRoot = aRoot.slice(0, index); + if (aRoot.match(/^([^/]+:\/)?\/*$/)) { + return aPath; + } + + ++level; + } + + // Make sure we add a "../" for each component we removed from the root. + return Array(level + 1).join('../') + aPath.substr(aRoot.length + 1); +} diff --git a/test/gen-mapping.test.js b/test/gen-mapping.test.js index f6ca875..45a240c 100644 --- a/test/gen-mapping.test.js +++ b/test/gen-mapping.test.js @@ -9,7 +9,11 @@ const { fromMap, maybeAddSegment, maybeAddMapping, + applySourceMap, } = require('..'); + +const { TraceMap } = require('@jridgewell/trace-mapping'); + const assert = require('assert'); describe('GenMapping', () => { @@ -845,3 +849,307 @@ describe('GenMapping', () => { }); }); }); + +/** + * + * @param {*} assert + * @param {import('@jridgewell/trace-mapping').EncodedSourceMap} actualMap + * @param {import('@jridgewell/trace-mapping').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' + ); + } + } +} + +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(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(toEncodedMap(minifiedMapSource)); + + /** + * + * @param {[string, string, string]} sources + * @returns + */ + 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('@jridgewell/trace-mapping').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]); + } + }); + }); +}); From 515d89e26ca4e16480166b5194bddb269f32e2b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Goetz?= Date: Thu, 23 Jun 2022 06:19:49 +0000 Subject: [PATCH 2/4] Add early return if sourceIndex can't be found --- src/gen-mapping.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/gen-mapping.ts b/src/gen-mapping.ts index 77424c5..1f1b1ad 100644 --- a/src/gen-mapping.ts +++ b/src/gen-mapping.ts @@ -378,6 +378,11 @@ export class GenMapping { const sourceIndex = get(map._sources, sourceFile); + if (sourceIndex === undefined) { + // This source file isn't in this map, nothing to merge here + return; + } + // If the applied source map replaces a source entirely // it's better to start with a fresh set of source, sourceContent and names const newSources = new SetArray(); From 5b250fbc7e6b474b039734651b91e35a6cd524fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Goetz?= Date: Sun, 26 Jun 2022 12:02:30 +0200 Subject: [PATCH 3/4] only accept sourceContent for existing sources --- src/gen-mapping.ts | 6 +++++- test/gen-mapping.test.js | 40 +++++----------------------------------- test/utils.js | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 36 deletions(-) create mode 100644 test/utils.js diff --git a/src/gen-mapping.ts b/src/gen-mapping.ts index 1f1b1ad..b5cba8d 100644 --- a/src/gen-mapping.ts +++ b/src/gen-mapping.ts @@ -230,7 +230,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) => { diff --git a/test/gen-mapping.test.js b/test/gen-mapping.test.js index 45a240c..1242655 100644 --- a/test/gen-mapping.test.js +++ b/test/gen-mapping.test.js @@ -1,3 +1,5 @@ +const { assertEqualMaps } = require('./utils'); + const { GenMapping, addSegment, @@ -45,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']); @@ -93,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']); @@ -850,41 +854,7 @@ describe('GenMapping', () => { }); }); -/** - * - * @param {*} assert - * @param {import('@jridgewell/trace-mapping').EncodedSourceMap} actualMap - * @param {import('@jridgewell/trace-mapping').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' - ); - } - } -} + describe('applySourceMap', () => { it('test applySourceMap basic', () => { diff --git a/test/utils.js b/test/utils.js new file mode 100644 index 0000000..53d6539 --- /dev/null +++ b/test/utils.js @@ -0,0 +1,38 @@ +/** + * + * @param {*} assert + * @param {import('@jridgewell/trace-mapping').EncodedSourceMap} actualMap + * @param {import('@jridgewell/trace-mapping').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' + ); + } + } +} + +module.exports.assertEqualMaps = assertEqualMaps; From 851c38b7bbec3b7b1dcc46f564be4567f34e9e27 Mon Sep 17 00:00:00 2001 From: Justin Ridgewell Date: Sun, 28 Aug 2022 04:40:54 -0400 Subject: [PATCH 4/4] Switch to @jridgewell/resolve-uri (#1) --- package-lock.json | 13 +-- package.json | 1 + src/gen-mapping.ts | 121 ++++++++++++---------- src/util.ts | 217 --------------------------------------- test/gen-mapping.test.js | 18 ++-- test/utils.js | 8 +- 6 files changed, 90 insertions(+), 288 deletions(-) delete mode 100644 src/util.ts diff --git a/package-lock.json b/package-lock.json index e24fe3c..b59829f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.3.1", "license": "MIT", "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" @@ -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 b5cba8d..3ef8043 100644 --- a/src/gen-mapping.ts +++ b/src/gen-mapping.ts @@ -10,7 +10,8 @@ import { NAMES_INDEX, } from './sourcemap-segment'; -import { join, relative } from './util'; +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'; @@ -358,97 +359,107 @@ export class GenMapping { applySourceMap = ( map: GenMapping, consumer: TraceMap, - rawSourceFile?: string, - sourceMapPath?: string, + rawSourceFile?: string | null, + sourceMapPath?: string | null, ) => { - let sourceFile = rawSourceFile; - + let sourceFile = rawSourceFile ?? consumer.file; if (sourceFile == null) { - if (consumer.file == null) { - throw new Error( - 'applySourceMap requires either an explicit source file, ' + - 'or the source map\'s "file" property. Both were omitted.', - ); - } - sourceFile = consumer.file; + throw new Error( + 'applySourceMap requires either an explicit source file, ' + + 'or the source map\'s "file" property. Both were omitted.', + ); } - const sourceRoot = map.sourceRoot; + if (!sourceMapPath) sourceMapPath = ''; + else if (!sourceMapPath.endsWith('/')) sourceMapPath += '/'; - // Make "sourceFile" relative if an absolute Url is passed. - if (sourceRoot != null) { - sourceFile = relative(sourceRoot, sourceFile); - } + const { _mappings: mappings, _sourcesContent: sourcesContent, sourceRoot } = map; + const sources = map._sources.array; + const names = map._names.array; - const sourceIndex = get(map._sources, sourceFile); - - if (sourceIndex === undefined) { - // This source file isn't in this map, nothing to merge here - return; + 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 the applied source map replaces a source entirely - // it's better to start with a fresh set of source, sourceContent and names + // 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 newSourceContents: (string | null)[] = []; + const newSourcesContent: (string | null)[] = []; const newNames = new SetArray(); - for (const line of map._mappings) { - for (const seg of line) { + 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; - if (seg[1] !== sourceIndex) { - // This isn't the file we want to remap; keep the original mapping - const initialIndex = seg[1]; - seg[1] = put(newSources, map._sources.array[initialIndex]); - newSourceContents[seg[1]] = map._sourcesContent[initialIndex]; - if (seg.length === 5) { - seg[4] = put(newNames, map._names.array[seg[4]]); - } - continue; + let traced = null; + if (seg[1] === sourceIndex) { + traced = traceSegment(consumer, seg[2], seg[3]); } - const traced = traceSegment(consumer, seg[2], seg[3]); if (traced == null) { - // Could not find a mapping; keep the original mapping + // 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]; - seg[1] = put(newSources, map._sources.array[initialIndex]); - newSourceContents[seg[1]] = map._sourcesContent[initialIndex]; - if (seg.length === 5) { - seg[4] = put(newNames, map._names.array[seg[4]]); - } + 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 = consumer.sources[traced[1] as number] as string; + let source = consumerSources[traced[1]] || ''; if (sourceMapPath != null) { - source = join(sourceMapPath, source); + source = resolveUri(source, sourceMapPath); } if (sourceRoot != null) { - source = relative(sourceRoot, source); + source = relativeUri(sourceRoot, source); } const newSourceIndex = put(newSources, source); - newSourceContents[newSourceIndex] = consumer.sourcesContent - ? consumer.sourcesContent[traced[1] as number] + newSourcesContent[newSourceIndex] = consumerSourcesContent + ? consumerSourcesContent[traced[1]] : null; seg[1] = newSourceIndex; - seg[2] = traced[2] as number; - seg[3] = traced[3] as number; + 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) { - // Add the name mapping if found - seg[4] = put(newNames, consumer.names[traced[4]]); + seg[4] = put(newNames, consumerNames[traced[4]]); } else if (seg.length == 5) { - // restore the previous name mapping if found - seg[4] = put(newNames, map._names.array[seg[4]]); + seg[4] = put(newNames, names[seg[4]]); } } } map._sources = newSources; - map._sourcesContent = newSourceContents; + map._sourcesContent = newSourcesContent; map._names = newNames; }; } diff --git a/src/util.ts b/src/util.ts deleted file mode 100644 index 2671b29..0000000 --- a/src/util.ts +++ /dev/null @@ -1,217 +0,0 @@ -const urlRegexp = /^(?:([\w+\-.]+):)?\/\/(?:(\w+:\w+)@)?([\w.-]*)(?::(\d+))?(.*)$/; -const dataUrlRegexp = /^data:.+,.+$/; - -interface ParsedURL { - scheme: string; - auth: string; - host: string; - port: string; - path: string; -} - -export function urlParse(aUrl: string): ParsedURL | null { - const match = aUrl.match(urlRegexp); - if (!match) { - return null; - } - return { - scheme: match[1], - auth: match[2], - host: match[3], - port: match[4], - path: match[5], - }; -} - -export function urlGenerate(aParsedUrl: ParsedURL) { - let url = ''; - if (aParsedUrl.scheme) { - url += aParsedUrl.scheme + ':'; - } - url += '//'; - if (aParsedUrl.auth) { - url += aParsedUrl.auth + '@'; - } - if (aParsedUrl.host) { - url += aParsedUrl.host; - } - if (aParsedUrl.port) { - url += ':' + aParsedUrl.port; - } - if (aParsedUrl.path) { - url += aParsedUrl.path; - } - return url; -} - -export function isAbsolute(aPath: string) { - return aPath.charAt(0) === '/' || urlRegexp.test(aPath); -} - -/** - * Normalizes a path, or the path portion of a URL: - * - * - Replaces consecutive slashes with one slash. - * - Removes unnecessary '.' parts. - * - Removes unnecessary '/..' parts. - * - * Based on code in the Node.js 'path' core module. - * - * @param aPath The path or url to normalize. - */ -export function normalize(aPath: string) { - let path = aPath; - const url = urlParse(aPath); - if (url) { - if (!url.path) { - return aPath; - } - path = url.path; - } - const isAbsoluteBool = isAbsolute(path); - // Split the path into parts between `/` characters. This is much faster than - // using `.split(/\/+/g)`. - const parts = []; - let start = 0; - let i = 0; - while (true) { - start = i; - i = path.indexOf('/', start); - if (i === -1) { - parts.push(path.slice(start)); - break; - } else { - parts.push(path.slice(start, i)); - while (i < path.length && path[i] === '/') { - i++; - } - } - } - - for (let part, up = 0, i = parts.length - 1; i >= 0; i--) { - part = parts[i]; - if (part === '.') { - parts.splice(i, 1); - } else if (part === '..') { - up++; - } else if (up > 0) { - if (part === '') { - // The first part is blank if the path is absolute. Trying to go - // above the root is a no-op. Therefore we can remove all '..' parts - // directly after the root. - parts.splice(i + 1, up); - up = 0; - } else { - parts.splice(i, 2); - up--; - } - } - } - path = parts.join('/'); - - if (path === '') { - path = isAbsoluteBool ? '/' : '.'; - } - - if (url) { - url.path = path; - return urlGenerate(url); - } - return path; -} - -/** - * Joins two paths/URLs. - * - * @param aRoot The root path or URL. - * @param aPath The path or URL to be joined with the root. - * - * - If aPath is a URL or a data URI, aPath is returned, unless aPath is a - * scheme-relative URL: Then the scheme of aRoot, if any, is prepended - * first. - * - Otherwise aPath is a path. If aRoot is a URL, then its path portion - * is updated with the result and aRoot is returned. Otherwise the result - * is returned. - * - If aPath is absolute, the result is aPath. - * - Otherwise the two paths are joined with a slash. - * - Joining for example 'http://' and 'www.example.com' is also supported. - */ -export function join(aRoot: string, aPath: string) { - if (aRoot === '') { - aRoot = '.'; - } - if (aPath === '') { - aPath = '.'; - } - const aPathUrl = urlParse(aPath); - const aRootUrl = urlParse(aRoot); - if (aRootUrl) { - aRoot = aRootUrl.path || '/'; - } - - // `join(foo, '//www.example.org')` - if (aPathUrl && !aPathUrl.scheme) { - if (aRootUrl) { - aPathUrl.scheme = aRootUrl.scheme; - } - return urlGenerate(aPathUrl); - } - - if (aPathUrl || aPath.match(dataUrlRegexp)) { - return aPath; - } - - // `join('http://', 'www.example.com')` - if (aRootUrl && !aRootUrl.host && !aRootUrl.path) { - aRootUrl.host = aPath; - return urlGenerate(aRootUrl); - } - - const joined = - aPath.charAt(0) === '/' ? aPath : normalize(aRoot.replace(/\/+$/, '') + '/' + aPath); - - if (aRootUrl) { - aRootUrl.path = joined; - return urlGenerate(aRootUrl); - } - return joined; -} - -/** - * Make a path relative to a URL or another path. - * - * @param aRoot The root path or URL. - * @param aPath The path or URL to be made relative to aRoot. - */ -export function relative(aRoot: string, aPath: string) { - if (aRoot === '') { - aRoot = '.'; - } - - aRoot = aRoot.replace(/\/$/, ''); - - // It is possible for the path to be above the root. In this case, simply - // checking whether the root is a prefix of the path won't work. Instead, we - // need to remove components from the root one by one, until either we find - // a prefix that fits, or we run out of components to remove. - let level = 0; - while (aPath.indexOf(aRoot + '/') !== 0) { - const index = aRoot.lastIndexOf('/'); - if (index < 0) { - return aPath; - } - - // If the only part of the root that is left is the scheme (i.e. http://, - // file:///, etc.), one or more slashes (/), or simply nothing at all, we - // have exhausted all components, so the path is not relative to the root. - aRoot = aRoot.slice(0, index); - if (aRoot.match(/^([^/]+:\/)?\/*$/)) { - return aPath; - } - - ++level; - } - - // Make sure we add a "../" for each component we removed from the root. - return Array(level + 1).join('../') + aPath.substr(aRoot.length + 1); -} diff --git a/test/gen-mapping.test.js b/test/gen-mapping.test.js index 1242655..d1c14fb 100644 --- a/test/gen-mapping.test.js +++ b/test/gen-mapping.test.js @@ -854,8 +854,6 @@ describe('GenMapping', () => { }); }); - - describe('applySourceMap', () => { it('test applySourceMap basic', () => { var mapStep1 = /** @type {import('@jridgewell/trace-mapping').EncodedSourceMap} */ { @@ -985,7 +983,11 @@ describe('applySourceMap', () => { }); setSourceContent(bundleMapSource, 'http://www.example.com/baz.coffee', 'baz coffee'); - const bundleMap = new TraceMap(toEncodedMap(bundleMapSource)); + const bundleMap = new TraceMap( + /** @type {import('@jridgewell/trace-mapping').EncodedSourceMap} */ ( + toEncodedMap(bundleMapSource) + ) + ); const minifiedMapSource = new GenMapping({ file: 'bundle.min.js', @@ -1006,12 +1008,16 @@ describe('applySourceMap', () => { original: { line: 23, column: 23 }, source: 'temp/bundle.js', }); - const minifiedMap = new TraceMap(toEncodedMap(minifiedMapSource)); + const minifiedMap = new TraceMap( + /** @type {import('@jridgewell/trace-mapping').EncodedSourceMap} */ ( + toEncodedMap(minifiedMapSource) + ) + ); /** * * @param {[string, string, string]} sources - * @returns + * @returns {import('..').EncodedSourceMap} */ var expectedMap = function (sources) { var map = new GenMapping({ @@ -1042,7 +1048,7 @@ describe('applySourceMap', () => { /** * * @param {string} aSourceMapPath - * @returns {import('@jridgewell/trace-mapping').EncodedSourceMap} + * @returns {import('..').EncodedSourceMap} */ var actualMap = function (aSourceMapPath) { var map = fromMap(minifiedMap); diff --git a/test/utils.js b/test/utils.js index 53d6539..6f5d2ee 100644 --- a/test/utils.js +++ b/test/utils.js @@ -1,10 +1,10 @@ /** * * @param {*} assert - * @param {import('@jridgewell/trace-mapping').EncodedSourceMap} actualMap - * @param {import('@jridgewell/trace-mapping').EncodedSourceMap} expectedMap + * @param {import('..').EncodedSourceMap} actualMap + * @param {import('..').EncodedSourceMap} expectedMap */ - function assertEqualMaps(assert, actualMap, 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'); @@ -35,4 +35,4 @@ } } -module.exports.assertEqualMaps = assertEqualMaps; +exports.assertEqualMaps = assertEqualMaps;