| 1 | /** |
| 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | * |
| 7 | * @flow |
| 8 | */ |
| 9 | import {withSyncPerfMeasurements} from 'react-devtools-shared/src/PerformanceLoggingUtils'; |
| 10 | import {decode} from '@jridgewell/sourcemap-codec'; |
| 11 | |
| 12 | import type { |
| 13 | IndexSourceMap, |
| 14 | IndexSourceMapSection, |
| 15 | BasicSourceMap, |
| 16 | MixedSourceMap, |
| 17 | } from './SourceMapTypes'; |
| 18 | |
| 19 | type SearchPosition = { |
| 20 | columnNumber: number, |
| 21 | lineNumber: number, |
| 22 | }; |
| 23 | |
| 24 | type ResultPosition = { |
| 25 | column: number, |
| 26 | line: number, |
| 27 | sourceContent: string | null, |
| 28 | sourceURL: string | null, |
| 29 | ignored: boolean, |
| 30 | }; |
| 31 | |
| 32 | export type SourceMapConsumerType = { |
| 33 | originalPositionFor: SearchPosition => ResultPosition, |
| 34 | }; |
| 35 | |
| 36 | type Mappings = Array<Array<Array<number>>>; |
| 37 | |
| 38 | export default function SourceMapConsumer( |
| 39 | sourceMapJSON: MixedSourceMap | IndexSourceMapSection, |
| 40 | ): SourceMapConsumerType { |
| 41 | if (sourceMapJSON.sections != null) { |
| 42 | return IndexedSourceMapConsumer(sourceMapJSON as any as IndexSourceMap); |
| 43 | } else { |
| 44 | return BasicSourceMapConsumer(sourceMapJSON as any as BasicSourceMap); |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | function BasicSourceMapConsumer(sourceMapJSON: BasicSourceMap) { |
| 49 | const decodedMappings: Mappings = withSyncPerfMeasurements( |
| 50 | 'Decoding source map mappings with @jridgewell/sourcemap-codec', |
| 51 | () => decode(sourceMapJSON.mappings), |
| 52 | ); |
| 53 | |
| 54 | function originalPositionFor({ |
| 55 | columnNumber, |
| 56 | lineNumber, |
| 57 | }: SearchPosition): ResultPosition { |
| 58 | // Error.prototype.stack columns are 1-based (like most IDEs) but ASTs are 0-based. |
| 59 | const targetColumnNumber = columnNumber - 1; |
| 60 | |
| 61 | const lineMappings = decodedMappings[lineNumber - 1]; |
| 62 | |
| 63 | let nearestEntry = null; |
| 64 | |
| 65 | let startIndex = 0; |
| 66 | let stopIndex = lineMappings.length - 1; |
| 67 | let index = -1; |
| 68 | while (startIndex <= stopIndex) { |
| 69 | index = Math.floor((stopIndex + startIndex) / 2); |
| 70 | nearestEntry = lineMappings[index]; |
| 71 | |
| 72 | const currentColumn = nearestEntry[0]; |
| 73 | if (currentColumn === targetColumnNumber) { |
| 74 | break; |
| 75 | } else { |
| 76 | if (currentColumn > targetColumnNumber) { |
| 77 | if (stopIndex - index > 0) { |
| 78 | stopIndex = index; |
| 79 | } else { |
| 80 | index = stopIndex; |
| 81 | break; |
| 82 | } |
| 83 | } else { |
| 84 | if (index - startIndex > 0) { |
| 85 | startIndex = index; |
| 86 | } else { |
| 87 | index = startIndex; |
| 88 | break; |
| 89 | } |
| 90 | } |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | // We have found either the exact element, or the next-closest element. |
| 95 | // However there may be more than one such element. |
| 96 | // Make sure we always return the smallest of these. |
| 97 | while (index > 0) { |
| 98 | const previousEntry = lineMappings[index - 1]; |
| 99 | const currentColumn = previousEntry[0]; |
| 100 | if (currentColumn !== targetColumnNumber) { |
| 101 | break; |
| 102 | } |
| 103 | index--; |
| 104 | } |
| 105 | |
| 106 | if (nearestEntry == null) { |
| 107 | // TODO maybe fall back to the runtime source instead of throwing? |
| 108 | throw Error( |
| 109 | `Could not find runtime location for line:${lineNumber} and column:${columnNumber}`, |
| 110 | ); |
| 111 | } |
| 112 | |
| 113 | const sourceIndex = nearestEntry[1]; |
| 114 | const sourceContent = |
| 115 | sourceMapJSON.sourcesContent != null |
| 116 | ? sourceMapJSON.sourcesContent[sourceIndex] |
| 117 | : null; |
| 118 | const sourceURL = sourceMapJSON.sources[sourceIndex] ?? null; |
| 119 | const line = nearestEntry[2] + 1; |
| 120 | const column = nearestEntry[3]; |
| 121 | const ignored = |
| 122 | sourceMapJSON.ignoreList != null && |
| 123 | sourceMapJSON.ignoreList.includes(sourceIndex); |
| 124 | return { |
| 125 | column, |
| 126 | line, |
| 127 | sourceContent: sourceContent as any as string | null, |
| 128 | sourceURL: sourceURL as any as string | null, |
| 129 | ignored, |
| 130 | }; |
| 131 | } |
| 132 | |
| 133 | return { |
| 134 | originalPositionFor, |
| 135 | } as any as SourceMapConsumerType; |
| 136 | } |
| 137 | |
| 138 | type Section = { |
| 139 | +offsetColumn0: number, |
| 140 | +offsetLine0: number, |
| 141 | +map: BasicSourceMap, |
| 142 | |
| 143 | // Lazily parsed only when/as the section is needed. |
| 144 | sourceMapConsumer: SourceMapConsumerType | null, |
| 145 | }; |
| 146 | |
| 147 | function IndexedSourceMapConsumer(sourceMapJSON: IndexSourceMap) { |
| 148 | let lastOffset: { |
| 149 | line: number, |
| 150 | column: number, |
| 151 | ... |
| 152 | } = { |
| 153 | line: -1, |
| 154 | column: 0, |
| 155 | }; |
| 156 | |
| 157 | const sections: Array<Section> = sourceMapJSON.sections.map(section => { |
| 158 | const offset: { |
| 159 | line: number, |
| 160 | column: number, |
| 161 | ... |
| 162 | } = section.offset; |
| 163 | const offsetLine0 = offset.line; |
| 164 | const offsetColumn0 = offset.column; |
| 165 | |
| 166 | if ( |
| 167 | offsetLine0 < lastOffset.line || |
| 168 | (offsetLine0 === lastOffset.line && offsetColumn0 < lastOffset.column) |
| 169 | ) { |
| 170 | throw new Error('Section offsets must be ordered and non-overlapping.'); |
| 171 | } |
| 172 | |
| 173 | lastOffset = offset; |
| 174 | |
| 175 | return { |
| 176 | offsetLine0, |
| 177 | offsetColumn0, |
| 178 | map: section.map, |
| 179 | sourceMapConsumer: null, |
| 180 | }; |
| 181 | }); |
| 182 | |
| 183 | function originalPositionFor({ |
| 184 | columnNumber, |
| 185 | lineNumber, |
| 186 | }: SearchPosition): ResultPosition { |
| 187 | // Error.prototype.stack columns are 1-based (like most IDEs) but ASTs are 0-based. |
| 188 | const column0 = columnNumber - 1; |
| 189 | const line0 = lineNumber - 1; |
| 190 | |
| 191 | // Sections must not overlap and must be sorted: https://tc39.es/source-map/#section-object |
| 192 | // Therefore the last section that has an offset less than or equal to the frame is the applicable one. |
| 193 | let left = 0; |
| 194 | let right = sections.length - 1; |
| 195 | let section: Section | null = null; |
| 196 | |
| 197 | while (left <= right) { |
| 198 | // fast Math.floor |
| 199 | const middle = ~~((left + right) / 2); |
| 200 | const currentSection = sections[middle]; |
| 201 | |
| 202 | if ( |
| 203 | currentSection.offsetLine0 < line0 || |
| 204 | (currentSection.offsetLine0 === line0 && |
| 205 | currentSection.offsetColumn0 <= column0) |
| 206 | ) { |
| 207 | section = currentSection; |
| 208 | left = middle + 1; |
| 209 | } else { |
| 210 | right = middle - 1; |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | if (section == null) { |
| 215 | // TODO maybe fall back to the runtime source instead of throwing? |
| 216 | throw Error( |
| 217 | `Could not find matching section for line:${lineNumber} and column:${columnNumber}`, |
| 218 | ); |
| 219 | } |
| 220 | |
| 221 | if (section.sourceMapConsumer === null) { |
| 222 | // Lazily parse the section only when it's needed. |
| 223 | // $FlowFixMe[invalid-constructor] Flow no longer supports calling new on functions |
| 224 | section.sourceMapConsumer = new SourceMapConsumer(section.map); |
| 225 | } |
| 226 | |
| 227 | return section.sourceMapConsumer.originalPositionFor({ |
| 228 | // The mappings in a Source Map section are relative to the section offset. |
| 229 | columnNumber: columnNumber - section.offsetColumn0, |
| 230 | lineNumber: lineNumber - section.offsetLine0, |
| 231 | }); |
| 232 | } |
| 233 | |
| 234 | return { |
| 235 | originalPositionFor, |
| 236 | } as any as SourceMapConsumerType; |
| 237 | } |