|
| 1 | +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. |
| 2 | +// See LICENSE in the project root for license information. |
| 3 | + |
| 4 | +import * as path from 'path'; |
| 5 | +import { SourceMapConsumer, RawSourceMap, MappingItem, Position } from 'source-map'; |
| 6 | +import { IExtractorMessageOptions } from '../api/ExtractorMessage'; |
| 7 | +import { FileSystem, InternalError, JsonFile, NewlineKind } from '@microsoft/node-core-library'; |
| 8 | + |
| 9 | +interface ISourceMap { |
| 10 | + sourceMapConsumer: SourceMapConsumer; |
| 11 | + |
| 12 | + // SourceMapConsumer.originalPositionFor() is useless because the mapping contains numerous gaps, |
| 13 | + // and the API provides no way to find the nearest match. So instead we extract all the mapping items |
| 14 | + // and search them using SourceMapper._findNearestMappingItem(). |
| 15 | + mappingItems: MappingItem[]; |
| 16 | +} |
| 17 | + |
| 18 | +interface IOriginalFileInfo { |
| 19 | + // Whether the .ts file exists |
| 20 | + fileExists: boolean; |
| 21 | + |
| 22 | + // This is used to check whether the guessed position is out of bounds. |
| 23 | + // Since column/line numbers are 1-based, the 0th item in this array is unused. |
| 24 | + maxColumnForLine: number[]; |
| 25 | +} |
| 26 | + |
| 27 | +export class SourceMapper { |
| 28 | + // Map from .d.ts file path --> ISourceMap if a source map was found, or null if not found |
| 29 | + private _sourceMapByFilePath: Map<string, ISourceMap | null> |
| 30 | + = new Map<string, ISourceMap | null>(); |
| 31 | + |
| 32 | + // Cache the FileSystem.exists() result for mapped .ts files |
| 33 | + private _originalFileInfoByPath: Map<string, IOriginalFileInfo> = new Map<string, IOriginalFileInfo>(); |
| 34 | + |
| 35 | + /** |
| 36 | + * If the `IExtractorMessageOptions` refers to a `.d.ts` file, look for a `.d.ts.map` and |
| 37 | + * if possible update the coordinates to refer to the original `.ts` file. |
| 38 | + */ |
| 39 | + public updateExtractorMessageOptions(options: IExtractorMessageOptions): void { |
| 40 | + if (!options.sourceFilePath) { |
| 41 | + return; |
| 42 | + } |
| 43 | + |
| 44 | + if (!FileSystem.exists(options.sourceFilePath)) { |
| 45 | + // Sanity check |
| 46 | + throw new InternalError('The referenced path was not found: ' + options.sourceFilePath); |
| 47 | + } |
| 48 | + |
| 49 | + let sourceMap: ISourceMap | null | undefined = this._sourceMapByFilePath.get(options.sourceFilePath); |
| 50 | + |
| 51 | + if (sourceMap === undefined) { |
| 52 | + // Normalize the path and redo the lookup |
| 53 | + const normalizedPath: string = FileSystem.getRealPath(options.sourceFilePath); |
| 54 | + |
| 55 | + sourceMap = this._sourceMapByFilePath.get(normalizedPath); |
| 56 | + if (sourceMap !== undefined) { |
| 57 | + // Copy the result from the normalized to the non-normalized key |
| 58 | + this._sourceMapByFilePath.set(options.sourceFilePath, sourceMap); |
| 59 | + } else { |
| 60 | + // Given "folder/file.d.ts", check for a corresponding "folder/file.d.ts.map" |
| 61 | + const sourceMapPath: string = normalizedPath + '.map'; |
| 62 | + if (FileSystem.exists(sourceMapPath)) { |
| 63 | + // Load up the source map |
| 64 | + const rawSourceMap: RawSourceMap = JsonFile.load(sourceMapPath) as RawSourceMap; |
| 65 | + |
| 66 | + const sourceMapConsumer: SourceMapConsumer = new SourceMapConsumer(rawSourceMap); |
| 67 | + const mappingItems: MappingItem[] = []; |
| 68 | + |
| 69 | + // Extract the list of mapping items |
| 70 | + sourceMapConsumer.eachMapping( |
| 71 | + (mappingItem: MappingItem) => { |
| 72 | + mappingItems.push({ |
| 73 | + ...mappingItem, |
| 74 | + // The "source-map" package inexplicably uses 1-based line numbers but 0-based column numbers. |
| 75 | + // Fix that up proactively so we don't have to deal with it later. |
| 76 | + generatedColumn: mappingItem.generatedColumn + 1, |
| 77 | + originalColumn: mappingItem.originalColumn + 1 |
| 78 | + }); |
| 79 | + }, |
| 80 | + this, |
| 81 | + SourceMapConsumer.GENERATED_ORDER |
| 82 | + ); |
| 83 | + |
| 84 | + sourceMap = { sourceMapConsumer, mappingItems}; |
| 85 | + } else { |
| 86 | + // No source map for this filename |
| 87 | + sourceMap = null; // tslint:disable-line:no-null-keyword |
| 88 | + } |
| 89 | + |
| 90 | + this._sourceMapByFilePath.set(normalizedPath, sourceMap); |
| 91 | + if (options.sourceFilePath !== normalizedPath) { |
| 92 | + // Add both keys to the map |
| 93 | + this._sourceMapByFilePath.set(options.sourceFilePath, sourceMap); |
| 94 | + } |
| 95 | + } |
| 96 | + } |
| 97 | + |
| 98 | + if (sourceMap === null) { |
| 99 | + // No source map for this filename |
| 100 | + return; |
| 101 | + } |
| 102 | + |
| 103 | + // Make sure sourceFileLine and sourceFileColumn are defined |
| 104 | + if (options.sourceFileLine === undefined) { |
| 105 | + options.sourceFileLine = 1; |
| 106 | + } |
| 107 | + if (options.sourceFileColumn === undefined) { |
| 108 | + options.sourceFileColumn = 1; |
| 109 | + } |
| 110 | + |
| 111 | + const nearestMappingItem: MappingItem | undefined = SourceMapper._findNearestMappingItem(sourceMap.mappingItems, |
| 112 | + { |
| 113 | + line: options.sourceFileLine, |
| 114 | + column: options.sourceFileColumn |
| 115 | + } |
| 116 | + ); |
| 117 | + |
| 118 | + if (nearestMappingItem === undefined) { |
| 119 | + // No mapping for this location |
| 120 | + return; |
| 121 | + } |
| 122 | + |
| 123 | + const mappedFilePath: string = path.resolve(path.dirname(options.sourceFilePath), nearestMappingItem.source); |
| 124 | + |
| 125 | + // Does the mapped filename exist? Use a cache to remember the answer. |
| 126 | + let originalFileInfo: IOriginalFileInfo | undefined = this._originalFileInfoByPath.get(mappedFilePath); |
| 127 | + if (originalFileInfo === undefined) { |
| 128 | + originalFileInfo = { |
| 129 | + fileExists: FileSystem.exists(mappedFilePath), |
| 130 | + maxColumnForLine: [] |
| 131 | + }; |
| 132 | + |
| 133 | + if (originalFileInfo.fileExists) { |
| 134 | + // Read the file and measure the length of each line |
| 135 | + originalFileInfo.maxColumnForLine = |
| 136 | + FileSystem.readFile(mappedFilePath, { convertLineEndings: NewlineKind.Lf }) |
| 137 | + .split('\n') |
| 138 | + .map(x => x.length + 1); // +1 since columns are 1-based |
| 139 | + originalFileInfo.maxColumnForLine.unshift(0); // Extra item since lines are 1-based |
| 140 | + } |
| 141 | + |
| 142 | + this._originalFileInfoByPath.set(mappedFilePath, originalFileInfo); |
| 143 | + } |
| 144 | + |
| 145 | + if (!originalFileInfo.fileExists) { |
| 146 | + // Don't translate coordinates to a file that doesn't exist |
| 147 | + return; |
| 148 | + } |
| 149 | + |
| 150 | + // The nearestMappingItem anchor may be above/left of the real position, due to gaps in the mapping. Calculate |
| 151 | + // the delta and apply it to the original position. |
| 152 | + const guessedPosition: Position = { |
| 153 | + line: nearestMappingItem.originalLine + options.sourceFileLine - nearestMappingItem.generatedLine, |
| 154 | + column: nearestMappingItem.originalColumn + options.sourceFileColumn - nearestMappingItem.generatedColumn |
| 155 | + }; |
| 156 | + |
| 157 | + // Verify that the result is not out of bounds, in cause our heuristic failed |
| 158 | + if (guessedPosition.line >= 1 |
| 159 | + && guessedPosition.line < originalFileInfo.maxColumnForLine.length |
| 160 | + && guessedPosition.column >= 1 |
| 161 | + && guessedPosition.column <= originalFileInfo.maxColumnForLine[guessedPosition.line]) { |
| 162 | + |
| 163 | + options.sourceFilePath = mappedFilePath; |
| 164 | + options.sourceFileLine = guessedPosition.line; |
| 165 | + options.sourceFileColumn = guessedPosition.column; |
| 166 | + } else { |
| 167 | + // The guessed position was out of bounds, so use the nearestMappingItem position instead. |
| 168 | + options.sourceFilePath = mappedFilePath; |
| 169 | + options.sourceFileLine = nearestMappingItem.originalLine; |
| 170 | + options.sourceFileColumn = nearestMappingItem.originalColumn; |
| 171 | + } |
| 172 | + } |
| 173 | + |
| 174 | + // The `mappingItems` array is sorted by generatedLine/generatedColumn (GENERATED_ORDER). |
| 175 | + // The _findNearestMappingItem() lookup is a simple binary search that returns the previous item |
| 176 | + // if there is no exact match. |
| 177 | + private static _findNearestMappingItem(mappingItems: MappingItem[], position: Position): MappingItem | undefined { |
| 178 | + if (mappingItems.length === 0) { |
| 179 | + return undefined; |
| 180 | + } |
| 181 | + |
| 182 | + let startIndex: number = 0; |
| 183 | + let endIndex: number = mappingItems.length - 1; |
| 184 | + |
| 185 | + while (startIndex <= endIndex) { |
| 186 | + const middleIndex: number = startIndex + Math.floor((endIndex - startIndex) / 2); |
| 187 | + |
| 188 | + const diff: number = SourceMapper._compareMappingItem(mappingItems[middleIndex], position); |
| 189 | + |
| 190 | + if (diff < 0) { |
| 191 | + startIndex = middleIndex + 1; |
| 192 | + } if (diff > 0) { |
| 193 | + endIndex = middleIndex - 1; |
| 194 | + } else { |
| 195 | + // Exact match |
| 196 | + return mappingItems[middleIndex]; |
| 197 | + } |
| 198 | + } |
| 199 | + |
| 200 | + // If we didn't find an exact match, then endIndex < startIndex. |
| 201 | + // Take endIndex because it's the smaller value. |
| 202 | + return mappingItems[endIndex]; |
| 203 | + } |
| 204 | + |
| 205 | + private static _compareMappingItem(mappingItem: MappingItem, position: Position): number { |
| 206 | + const diff: number = mappingItem.generatedLine - position.line; |
| 207 | + if (diff !== 0) { |
| 208 | + return diff; |
| 209 | + } |
| 210 | + return mappingItem.generatedColumn - position.column; |
| 211 | + } |
| 212 | +} |
0 commit comments