| // Copyright 2025 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import { |
| GeneratedRangeFlags, |
| type GeneratedRangeStartItem, |
| OriginalScopeFlags, |
| type OriginalScopeStartItem, |
| Tag, |
| } from "../codec.js"; |
| import type { |
| DecodedScopeInfo, |
| GeneratedRange, |
| IndexSourceMapJson, |
| OriginalScope, |
| Position, |
| SourceMap, |
| SourceMapJson, |
| SubRangeBinding, |
| } from "../scopes.ts"; |
| import { TokenIterator } from "../vlq.js"; |
| |
| /** |
| * The mode decides how well-formed the encoded scopes have to be, to be accepted by the decoder. |
| * |
| * LAX is the default and is much more lenient. It's still best effort though and the decoder doesn't |
| * implement any error recovery: e.g. superfluous "start" items can lead to whole trees being omitted. |
| * |
| * STRICT mode will throw in the following situations: |
| * |
| * - Encountering ORIGINAL_SCOPE_END, or GENERATED_RANGE_END items that don't have matching *_START items. |
| * - Encountering ORIGINAL_SCOPE_VARIABLES items outside a surrounding scope START/END. |
| * - Encountering GENERATED_RANGE_BINDINGS items outside a surrounding range START/END. |
| * - Miss-matches between the number of variables in a scope vs the number of value expressions in the ranges. |
| * - Out-of-bound indices into the "names" array. |
| */ |
| export const enum DecodeMode { |
| STRICT = 1, |
| LAX = 2, |
| } |
| |
| export interface DecodeOptions { |
| mode: DecodeMode; |
| |
| /** |
| * Offsets `start` and `end` of all generated ranges by the specified amount. |
| * Intended to be used when decoding sections of index source maps one-by-one. |
| * |
| * Has no effect when passing a {@link IndexSourceMapJson} directly to {@link decode}. |
| */ |
| generatedOffset: Position; |
| } |
| |
| export const DEFAULT_DECODE_OPTIONS: DecodeOptions = { |
| mode: DecodeMode.LAX, |
| generatedOffset: { line: 0, column: 0 }, |
| }; |
| |
| export function decode( |
| sourceMap: SourceMap, |
| options: Partial<DecodeOptions> = DEFAULT_DECODE_OPTIONS, |
| ): DecodedScopeInfo { |
| const opts = { ...DEFAULT_DECODE_OPTIONS, ...options }; |
| if ("sections" in sourceMap) { |
| return decodeIndexMap(sourceMap, { |
| ...opts, |
| generatedOffset: { line: 0, column: 0 }, |
| }); |
| } |
| return decodeMap(sourceMap, opts); |
| } |
| |
| function decodeMap( |
| sourceMap: SourceMapJson, |
| options: DecodeOptions, |
| ): DecodedScopeInfo { |
| if (!sourceMap.scopes || !sourceMap.names) { |
| return { scopes: [], ranges: [], hasVariableAndBindingInfo: false }; |
| } |
| |
| return new Decoder(sourceMap.scopes, sourceMap.names, options).decode(); |
| } |
| |
| function decodeIndexMap( |
| sourceMap: IndexSourceMapJson, |
| options: DecodeOptions, |
| ): DecodedScopeInfo { |
| const scopeInfo: DecodedScopeInfo = { |
| scopes: [], |
| ranges: [], |
| hasVariableAndBindingInfo: false, |
| }; |
| |
| for (const section of sourceMap.sections) { |
| const { scopes, ranges, hasVariableAndBindingInfo } = decode(section.map, { |
| ...options, |
| generatedOffset: section.offset, |
| }); |
| for (const scope of scopes) scopeInfo.scopes.push(scope); |
| for (const range of ranges) scopeInfo.ranges.push(range); |
| scopeInfo.hasVariableAndBindingInfo ||= hasVariableAndBindingInfo; |
| } |
| |
| return scopeInfo; |
| } |
| |
| const DEFAULT_SCOPE_STATE = { |
| line: 0, |
| column: 0, |
| name: 0, |
| kind: 0, |
| variable: 0, |
| }; |
| |
| const DEFAULT_RANGE_STATE = { |
| line: 0, |
| column: 0, |
| defScopeIdx: 0, |
| }; |
| |
| class Decoder { |
| readonly #encodedScopes: string; |
| readonly #names: string[]; |
| readonly #mode: DecodeMode; |
| |
| #scopes: (OriginalScope | null)[] = []; |
| #ranges: GeneratedRange[] = []; |
| |
| readonly #scopeState = { ...DEFAULT_SCOPE_STATE }; |
| readonly #rangeState = { ...DEFAULT_RANGE_STATE }; |
| |
| readonly #scopeStack: OriginalScope[] = []; |
| readonly #rangeStack: GeneratedRange[] = []; |
| |
| #flatOriginalScopes: OriginalScope[] = []; |
| #subRangeBindingsForRange = new Map< |
| GeneratedRange, |
| Map<number, [number, number, number][]> |
| >(); |
| |
| #seenOriginalScopeVariables = false; |
| #seenGeneratedRangeBindings = false; |
| |
| constructor(scopes: string, names: string[], options: DecodeOptions) { |
| this.#encodedScopes = scopes; |
| this.#names = names; |
| this.#mode = options.mode; |
| this.#rangeState.line = options.generatedOffset.line; |
| this.#rangeState.column = options.generatedOffset.column; |
| } |
| |
| decode(): DecodedScopeInfo { |
| const iter = new TokenIterator(this.#encodedScopes); |
| |
| while (iter.hasNext()) { |
| const tag = iter.nextUnsignedVLQ(); |
| switch (tag) { |
| case Tag.EMPTY: { |
| this.#scopes.push(null); |
| break; |
| } |
| case Tag.ORIGINAL_SCOPE_START: { |
| const item: OriginalScopeStartItem = { |
| flags: iter.nextUnsignedVLQ(), |
| line: iter.nextUnsignedVLQ(), |
| column: iter.nextUnsignedVLQ(), |
| }; |
| |
| if (item.flags & OriginalScopeFlags.HAS_NAME) { |
| item.nameIdx = iter.nextSignedVLQ(); |
| } |
| if (item.flags & OriginalScopeFlags.HAS_KIND) { |
| item.kindIdx = iter.nextSignedVLQ(); |
| } |
| |
| this.#handleOriginalScopeStartItem(item); |
| break; |
| } |
| case Tag.ORIGINAL_SCOPE_VARIABLES: { |
| const variableIdxs: number[] = []; |
| |
| while (iter.hasNext() && iter.peek() !== ",") { |
| variableIdxs.push(iter.nextSignedVLQ()); |
| } |
| |
| this.#handleOriginalScopeVariablesItem(variableIdxs); |
| this.#seenOriginalScopeVariables = true; |
| break; |
| } |
| case Tag.ORIGINAL_SCOPE_END: { |
| this.#handleOriginalScopeEndItem( |
| iter.nextUnsignedVLQ(), |
| iter.nextUnsignedVLQ(), |
| ); |
| break; |
| } |
| case Tag.GENERATED_RANGE_START: { |
| const flags = iter.nextUnsignedVLQ(); |
| const line = flags & GeneratedRangeFlags.HAS_LINE |
| ? iter.nextUnsignedVLQ() |
| : undefined; |
| const column = iter.nextUnsignedVLQ(); |
| |
| const definitionIdx = flags & GeneratedRangeFlags.HAS_DEFINITION |
| ? iter.nextSignedVLQ() |
| : undefined; |
| |
| this.#handleGeneratedRangeStartItem({ |
| flags, |
| line, |
| column, |
| definitionIdx, |
| }); |
| break; |
| } |
| case Tag.GENERATED_RANGE_END: { |
| const lineOrColumn = iter.nextUnsignedVLQ(); |
| const maybeColumn = iter.hasNext() && iter.peek() !== "," |
| ? iter.nextUnsignedVLQ() |
| : undefined; |
| |
| if (maybeColumn !== undefined) { |
| this.#handleGeneratedRangeEndItem(lineOrColumn, maybeColumn); |
| } else { |
| this.#handleGeneratedRangeEndItem(0, lineOrColumn); |
| } |
| break; |
| } |
| case Tag.GENERATED_RANGE_BINDINGS: { |
| const valueIdxs: number[] = []; |
| |
| while (iter.hasNext() && iter.peek() !== ",") { |
| valueIdxs.push(iter.nextUnsignedVLQ()); |
| } |
| |
| this.#handleGeneratedRangeBindingsItem(valueIdxs); |
| this.#seenGeneratedRangeBindings = true; |
| break; |
| } |
| case Tag.GENERATED_RANGE_SUBRANGE_BINDING: { |
| const variableIndex = iter.nextUnsignedVLQ(); |
| const bindings: [number, number, number][] = []; |
| |
| while (iter.hasNext() && iter.peek() !== ",") { |
| bindings.push([ |
| iter.nextUnsignedVLQ(), |
| iter.nextUnsignedVLQ(), |
| iter.nextUnsignedVLQ(), |
| ]); |
| } |
| |
| this.#recordGeneratedSubRangeBindingItem(variableIndex, bindings); |
| this.#seenGeneratedRangeBindings = true; |
| break; |
| } |
| case Tag.GENERATED_RANGE_CALL_SITE: { |
| this.#handleGeneratedRangeCallSite( |
| iter.nextUnsignedVLQ(), |
| iter.nextUnsignedVLQ(), |
| iter.nextUnsignedVLQ(), |
| ); |
| break; |
| } |
| case Tag.VENDOR_EXTENSION: { |
| const _extensionNameIdx = iter.nextUnsignedVLQ(); |
| break; |
| } |
| default: { |
| this.#throwInStrictMode(`Encountered illegal item tag ${tag}`); |
| break; |
| } |
| } |
| |
| // Consume any trailing VLQ and the the "," |
| while (iter.hasNext() && iter.peek() !== ",") iter.nextUnsignedVLQ(); |
| if (iter.hasNext()) iter.nextChar(); |
| } |
| |
| if (this.#scopeStack.length > 0) { |
| this.#throwInStrictMode( |
| "Encountered ORIGINAL_SCOPE_START without matching END!", |
| ); |
| } |
| if (this.#rangeStack.length > 0) { |
| this.#throwInStrictMode( |
| "Encountered GENERATED_RANGE_START without matching END!", |
| ); |
| } |
| |
| const info = { |
| scopes: this.#scopes, |
| ranges: this.#ranges, |
| hasVariableAndBindingInfo: this.#seenOriginalScopeVariables && |
| this.#seenGeneratedRangeBindings, |
| }; |
| |
| this.#scopes = []; |
| this.#ranges = []; |
| this.#flatOriginalScopes = []; |
| this.#seenOriginalScopeVariables = false; |
| this.#seenGeneratedRangeBindings = false; |
| |
| return info; |
| } |
| |
| #throwInStrictMode(message: string) { |
| if (this.#mode === DecodeMode.STRICT) throw new Error(message); |
| } |
| |
| #handleOriginalScopeStartItem(item: OriginalScopeStartItem) { |
| this.#scopeState.line += item.line; |
| if (item.line === 0) { |
| this.#scopeState.column += item.column; |
| } else { |
| this.#scopeState.column = item.column; |
| } |
| const scope: OriginalScope = { |
| start: { line: this.#scopeState.line, column: this.#scopeState.column }, |
| end: { line: this.#scopeState.line, column: this.#scopeState.column }, |
| isStackFrame: false, |
| variables: [], |
| children: [], |
| }; |
| |
| if (item.nameIdx !== undefined) { |
| this.#scopeState.name += item.nameIdx; |
| scope.name = this.#resolveName(this.#scopeState.name); |
| } |
| if (item.kindIdx !== undefined) { |
| this.#scopeState.kind += item.kindIdx; |
| scope.kind = this.#resolveName(this.#scopeState.kind); |
| } |
| |
| scope.isStackFrame = Boolean( |
| item.flags & OriginalScopeFlags.IS_STACK_FRAME, |
| ); |
| |
| this.#scopeStack.push(scope); |
| this.#flatOriginalScopes.push(scope); |
| } |
| |
| #handleOriginalScopeVariablesItem(variableIdxs: number[]) { |
| const scope = this.#scopeStack.at(-1); |
| if (!scope) { |
| this.#throwInStrictMode( |
| "Encountered ORIGINAL_SCOPE_VARIABLES without surrounding ORIGINAL_SCOPE_START", |
| ); |
| return; |
| } |
| |
| for (const variableIdx of variableIdxs) { |
| this.#scopeState.variable += variableIdx; |
| scope.variables.push(this.#resolveName(this.#scopeState.variable)); |
| } |
| } |
| |
| #handleOriginalScopeEndItem(line: number, column: number) { |
| this.#scopeState.line += line; |
| if (line === 0) { |
| this.#scopeState.column += column; |
| } else { |
| this.#scopeState.column = column; |
| } |
| |
| const scope = this.#scopeStack.pop(); |
| if (!scope) { |
| this.#throwInStrictMode( |
| "Encountered ORIGINAL_SCOPE_END without matching ORIGINAL_SCOPE_START!", |
| ); |
| return; |
| } |
| |
| scope.end = { |
| line: this.#scopeState.line, |
| column: this.#scopeState.column, |
| }; |
| |
| if (this.#scopeStack.length > 0) { |
| const parent = this.#scopeStack.at(-1)!; |
| scope.parent = parent; |
| parent.children.push(scope); |
| } else { |
| this.#scopes.push(scope); |
| this.#scopeState.line = 0; |
| this.#scopeState.column = 0; |
| } |
| } |
| |
| #handleGeneratedRangeStartItem(item: GeneratedRangeStartItem) { |
| if (item.line !== undefined) { |
| this.#rangeState.line += item.line; |
| this.#rangeState.column = item.column; |
| } else { |
| this.#rangeState.column += item.column; |
| } |
| |
| const range: GeneratedRange = { |
| start: { |
| line: this.#rangeState.line, |
| column: this.#rangeState.column, |
| }, |
| end: { |
| line: this.#rangeState.line, |
| column: this.#rangeState.column, |
| }, |
| isStackFrame: Boolean( |
| item.flags & GeneratedRangeFlags.IS_STACK_FRAME, |
| ), |
| isHidden: Boolean(item.flags & GeneratedRangeFlags.IS_HIDDEN), |
| values: [], |
| children: [], |
| }; |
| |
| if (item.definitionIdx !== undefined) { |
| this.#rangeState.defScopeIdx += item.definitionIdx; |
| if ( |
| this.#rangeState.defScopeIdx < 0 || |
| this.#rangeState.defScopeIdx >= this.#flatOriginalScopes.length |
| ) { |
| this.#throwInStrictMode("Invalid definition scope index"); |
| } else { |
| range.originalScope = |
| this.#flatOriginalScopes[this.#rangeState.defScopeIdx]; |
| } |
| } |
| |
| this.#rangeStack.push(range); |
| } |
| |
| #handleGeneratedRangeBindingsItem(valueIdxs: number[]) { |
| const range = this.#rangeStack.at(-1); |
| if (!range) { |
| this.#throwInStrictMode( |
| "Encountered GENERATED_RANGE_BINDINGS without surrounding GENERATED_RANGE_START", |
| ); |
| return; |
| } |
| |
| for (const valueIdx of valueIdxs) { |
| if (valueIdx === 0) { |
| range.values.push(null); |
| } else { |
| range.values.push(this.#resolveName(valueIdx - 1)); |
| } |
| } |
| } |
| |
| #recordGeneratedSubRangeBindingItem( |
| variableIndex: number, |
| bindings: [number, number, number][], |
| ) { |
| const range = this.#rangeStack.at(-1); |
| if (!range) { |
| this.#throwInStrictMode( |
| "Encountered GENERATED_RANGE_SUBRANGE_BINDING without surrounding GENERATED_RANGE_START", |
| ); |
| return; |
| } |
| let subRangeBindings = this.#subRangeBindingsForRange.get(range); |
| if (!subRangeBindings) { |
| subRangeBindings = new Map(); |
| this.#subRangeBindingsForRange.set(range, subRangeBindings); |
| } |
| if (subRangeBindings.has(variableIndex)) { |
| this.#throwInStrictMode( |
| "Encountered multiple GENERATED_RANGE_SUBRANGE_BINDING items for the same variable", |
| ); |
| return; |
| } |
| subRangeBindings.set(variableIndex, bindings); |
| } |
| |
| #handleGeneratedRangeCallSite( |
| sourceIndex: number, |
| line: number, |
| column: number, |
| ) { |
| const range = this.#rangeStack.at(-1); |
| if (!range) { |
| this.#throwInStrictMode( |
| "Encountered GENERATED_RANGE_CALL_SITE without surrounding GENERATED_RANGE_START", |
| ); |
| return; |
| } |
| |
| range.callSite = { |
| sourceIndex, |
| line, |
| column, |
| }; |
| } |
| |
| #handleGeneratedRangeEndItem(line: number, column: number) { |
| if (line !== 0) { |
| this.#rangeState.line += line; |
| this.#rangeState.column = column; |
| } else { |
| this.#rangeState.column += column; |
| } |
| |
| const range = this.#rangeStack.pop(); |
| if (!range) { |
| this.#throwInStrictMode( |
| "Encountered GENERATED_RANGE_END without matching GENERATED_RANGE_START!", |
| ); |
| return; |
| } |
| |
| range.end = { |
| line: this.#rangeState.line, |
| column: this.#rangeState.column, |
| }; |
| |
| this.#handleGeneratedRangeSubRangeBindings(range); |
| |
| if (this.#rangeStack.length > 0) { |
| const parent = this.#rangeStack.at(-1)!; |
| range.parent = parent; |
| parent.children.push(range); |
| } else { |
| this.#ranges.push(range); |
| } |
| } |
| |
| #handleGeneratedRangeSubRangeBindings(range: GeneratedRange) { |
| const subRangeBindings = this.#subRangeBindingsForRange.get(range); |
| if (!subRangeBindings) return; |
| |
| for (const [variableIndex, bindings] of subRangeBindings) { |
| const value = range.values[variableIndex]; |
| const subRanges: SubRangeBinding[] = []; |
| range.values[variableIndex] = subRanges; |
| |
| let lastLine = range.start.line; |
| let lastColumn = range.start.column; |
| |
| subRanges.push({ |
| from: { line: lastLine, column: lastColumn }, |
| to: { line: 0, column: 0 }, |
| value: value as string | undefined, |
| }); |
| |
| for (const [binding, line, column] of bindings) { |
| lastLine += line; |
| if (line === 0) { |
| lastColumn += column; |
| } else { |
| lastColumn = column; |
| } |
| |
| subRanges.push({ |
| from: { line: lastLine, column: lastColumn }, |
| to: { line: 0, column: 0 }, // This will be fixed in the post-processing step. |
| value: binding === 0 ? undefined : this.#resolveName(binding - 1), |
| }); |
| } |
| } |
| |
| for (const value of range.values) { |
| if (Array.isArray(value)) { |
| const subRanges = value; |
| for (let i = 0; i < subRanges.length - 1; ++i) { |
| subRanges[i].to = subRanges[i + 1].from; |
| } |
| subRanges[subRanges.length - 1].to = range.end; |
| } |
| } |
| } |
| |
| #resolveName(index: number): string { |
| if (index < 0 || index >= this.#names.length) { |
| this.#throwInStrictMode("Illegal index into the 'names' array"); |
| } |
| return this.#names[index] ?? ""; |
| } |
| } |