UNPKG

chrome-devtools-frontend

Version:
473 lines (415 loc) • 15 kB
// Copyright 2024 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. /** * @fileoverview This file implements the current state of the "Scopes" proposal * for the source map spec. * * See https://github.com/tc39/source-map-rfc/blob/main/proposals/scopes.md. * * The proposal is still being worked on so we expect the implementation details * in this file to change frequently. */ import {type SourceMapV3Object, TokenIterator} from './SourceMap.js'; /** * A scope in the authored source. */ export interface OriginalScope { start: Position; end: Position; /** * JavaScript-like languages are encouraged to use 'global', 'class', 'function' and 'block'. * Other languages might require language-specific scope kinds, in which case we'll print the * kind as-is. */ kind?: string; name?: string; isStackFrame: boolean; variables: string[]; children: OriginalScope[]; parent?: OriginalScope; } /** * A range (can be a scope) in the generated JavaScript. */ export interface GeneratedRange { start: Position; end: Position; originalScope?: OriginalScope; /** * Whether this generated range is an actual JavaScript function in the generated code. */ isStackFrame: boolean; /** * Whether calls to this generated range should be hidden from stack traces even if * this range has an `originalScope`. */ isHidden: boolean; /** * If this `GeneratedRange` is the result of inlining `originalScope`, then `callsite` * refers to where `originalScope` was called in the original ("authored") code. */ callsite?: OriginalPosition; /** * Expressions that compute the values of the variables of this OriginalScope. The length * of `values` must match the length of `originalScope.variables`. * * For each variable this can either be a single expression (valid for the full `GeneratedRange`), * or an array of `BindingRange`s, e.g. if computing the value requires different expressions * throughout the range or if the variable is only available in parts of the `GeneratedRange`. * * `undefined` denotes that the value of a variable is unavailble in the whole range. * This can happen e.g. if the variable was optimized out and can't be recomputed. */ values: (string|undefined|BindingRange[])[]; children: GeneratedRange[]; } export interface BindingRange { value?: string; from: Position; to: Position; } export interface Position { line: number; column: number; } /** @returns 0 if both positions are equal, a negative number if a < b and a positive number if a > b */ export function comparePositions(a: Position, b: Position): number { return a.line - b.line || a.column - b.column; } export interface OriginalPosition extends Position { sourceIndex: number; } interface OriginalScopeTree { readonly root: OriginalScope; readonly scopeForItemIndex: Map<number, OriginalScope>; } export function decodeScopes( map: Pick<SourceMapV3Object, 'names'|'originalScopes'|'generatedRanges'>, basePosition: Position = { line: 0, column: 0 }): {originalScopes: OriginalScope[], generatedRanges: GeneratedRange[]} { if (!map.originalScopes || map.generatedRanges === undefined) { throw new Error('Cant decode scopes without "originalScopes" or "generatedRanges"'); } const scopeTrees = decodeOriginalScopes(map.originalScopes, map.names ?? []); const originalScopes = scopeTrees.map(tree => tree.root); const generatedRanges = decodeGeneratedRanges(map.generatedRanges, scopeTrees, map.names ?? [], basePosition); return {originalScopes, generatedRanges}; } export function decodeOriginalScopes(encodedOriginalScopes: string[], names: string[]): OriginalScopeTree[] { return encodedOriginalScopes.map(scope => decodeOriginalScope(scope, names)); } function decodeOriginalScope(encodedOriginalScope: string, names: string[]): OriginalScopeTree { const scopeForItemIndex = new Map<number, OriginalScope>(); const scopeStack: OriginalScope[] = []; let line = 0; let kindIdx = 0; for (const [index, item] of decodeOriginalScopeItems(encodedOriginalScope)) { line += item.line; const {column} = item; if (isStart(item)) { let kind: string|undefined; if (item.kind !== undefined) { kindIdx += item.kind; kind = resolveName(kindIdx, names); } const name = resolveName(item.name, names); const variables = item.variables.map(idx => names[idx]); const scope: OriginalScope = { start: {line, column}, end: {line, column}, kind, name, isStackFrame: Boolean(item.flags & EncodedOriginalScopeFlag.IS_STACK_FRAME), variables, children: [], }; scopeStack.push(scope); scopeForItemIndex.set(index, scope); } else { const scope = scopeStack.pop(); if (!scope) { throw new Error('Scope items not nested properly: encountered "end" item without "start" item'); } scope.end = {line, column}; if (scopeStack.length === 0) { // We are done. There might be more top-level scopes but we only allow one. return {root: scope, scopeForItemIndex}; } scope.parent = scopeStack[scopeStack.length - 1]; scopeStack[scopeStack.length - 1].children.push(scope); } } throw new Error('Malformed original scope encoding'); } interface EncodedOriginalScopeStart { line: number; column: number; flags: number; name?: number; kind?: number; variables: number[]; } export const enum EncodedOriginalScopeFlag { HAS_NAME = 0x1, HAS_KIND = 0x2, IS_STACK_FRAME = 0x4, } interface EncodedOriginalScopeEnd { line: number; column: number; } function isStart(item: EncodedOriginalScopeStart|EncodedOriginalScopeEnd): item is EncodedOriginalScopeStart { return 'flags' in item; } function* decodeOriginalScopeItems(encodedOriginalScope: string): Generator<[number, EncodedOriginalScopeStart | EncodedOriginalScopeEnd]> { const iter = new TokenIterator(encodedOriginalScope); let prevColumn = 0; let itemCount = 0; while (iter.hasNext()) { if (iter.peek() === ',') { iter.next(); // Consume ','. } const [line, column] = [iter.nextVLQ(), iter.nextVLQ()]; if (line === 0 && column < prevColumn) { throw new Error('Malformed original scope encoding: start/end items must be ordered w.r.t. source positions'); } prevColumn = column; if (!iter.hasNext() || iter.peek() === ',') { yield [itemCount++, {line, column}]; continue; } const startItem: EncodedOriginalScopeStart = { line, column, flags: iter.nextVLQ(), variables: [], }; if (startItem.flags & EncodedOriginalScopeFlag.HAS_NAME) { startItem.name = iter.nextVLQ(); } if (startItem.flags & EncodedOriginalScopeFlag.HAS_KIND) { startItem.kind = iter.nextVLQ(); } while (iter.hasNext() && iter.peek() !== ',') { startItem.variables.push(iter.nextVLQ()); } yield [itemCount++, startItem]; } } export function decodeGeneratedRanges( encodedGeneratedRange: string, originalScopeTrees: OriginalScopeTree[], names: string[], basePosition: Position = { line: 0, column: 0 }): GeneratedRange[] { // We insert a pseudo range as there could be multiple top-level ranges and we need a root range those can be attached to. const rangeStack: GeneratedRange[] = [{ start: {line: 0, column: 0}, end: {line: 0, column: 0}, isStackFrame: false, isHidden: false, children: [], values: [], }]; const rangeToStartItem = new Map<GeneratedRange, EncodedGeneratedRangeStart>(); for (const item of decodeGeneratedRangeItems(encodedGeneratedRange, basePosition)) { if (isRangeStart(item)) { const range: GeneratedRange = { start: {line: item.line, column: item.column}, end: {line: item.line, column: item.column}, isStackFrame: Boolean(item.flags & EncodedGeneratedRangeFlag.IS_STACK_FRAME), isHidden: Boolean(item.flags & EncodedGeneratedRangeFlag.IS_HIDDEN), values: [], children: [], }; if (item.definition) { const {scopeIdx, sourceIdx} = item.definition; if (!originalScopeTrees[sourceIdx]) { throw new Error('Invalid source index!'); } const originalScope = originalScopeTrees[sourceIdx].scopeForItemIndex.get(scopeIdx); if (!originalScope) { throw new Error('Invalid original scope index!'); } range.originalScope = originalScope; } if (item.callsite) { const {sourceIdx, line, column} = item.callsite; if (!originalScopeTrees[sourceIdx]) { throw new Error('Invalid source index!'); } range.callsite = { sourceIndex: sourceIdx, line, column, }; } rangeToStartItem.set(range, item); rangeStack.push(range); } else { const range = rangeStack.pop(); if (!range) { throw new Error('Range items not nested properly: encountered "end" item without "start" item'); } range.end = {line: item.line, column: item.column}; resolveBindings(range, names, rangeToStartItem.get(range)?.bindings); rangeStack[rangeStack.length - 1].children.push(range); } } if (rangeStack.length !== 1) { throw new Error('Malformed generated range encoding'); } return rangeStack[0].children; } function resolveBindings( range: GeneratedRange, names: string[], bindingsForAllVars: EncodedGeneratedRangeStart['bindings']|undefined): void { if (bindingsForAllVars === undefined) { return; } range.values = bindingsForAllVars.map(bindings => { if (bindings.length === 1) { return resolveName(bindings[0].nameIdx, names); } const bindingRanges: BindingRange[] = bindings.map(binding => ({ from: {line: binding.line, column: binding.column}, to: {line: binding.line, column: binding.column}, value: resolveName(binding.nameIdx, names), })); for (let i = 1; i < bindingRanges.length; ++i) { bindingRanges[i - 1].to = {...bindingRanges[i].from}; } bindingRanges[bindingRanges.length - 1].to = {...range.end}; return bindingRanges; }); } interface EncodedGeneratedRangeStart { line: number; column: number; flags: number; definition?: { sourceIdx: number, scopeIdx: number, }; callsite?: { sourceIdx: number, line: number, column: number, }; bindings: { line: number, column: number, nameIdx: number, }[][]; } interface EncodedGeneratedRangeEnd { line: number; column: number; } export const enum EncodedGeneratedRangeFlag { HAS_DEFINITION = 0x1, HAS_CALLSITE = 0x2, IS_STACK_FRAME = 0x4, IS_HIDDEN = 0x8, } function isRangeStart(item: EncodedGeneratedRangeStart|EncodedGeneratedRangeEnd): item is EncodedGeneratedRangeStart { return 'flags' in item; } function* decodeGeneratedRangeItems(encodedGeneratedRange: string, basePosition: Position): Generator<EncodedGeneratedRangeStart|EncodedGeneratedRangeEnd> { const iter = new TokenIterator(encodedGeneratedRange); let line = basePosition.line; // The state are the fields of the last produced item, tracked because many // are relative to the preceeding item. const state = { line: basePosition.line, column: basePosition.column, defSourceIdx: 0, defScopeIdx: 0, callsiteSourceIdx: 0, callsiteLine: 0, callsiteColumn: 0, }; while (iter.hasNext()) { if (iter.peek() === ';') { iter.next(); // Consume ';'. ++line; continue; } else if (iter.peek() === ',') { iter.next(); // Consume ','. continue; } state.column = iter.nextVLQ() + (line === state.line ? state.column : 0); state.line = line; if (iter.peekVLQ() === null) { yield {line, column: state.column}; continue; } const startItem: EncodedGeneratedRangeStart = { line, column: state.column, flags: iter.nextVLQ(), bindings: [], }; if (startItem.flags & EncodedGeneratedRangeFlag.HAS_DEFINITION) { const sourceIdx = iter.nextVLQ(); const scopeIdx = iter.nextVLQ(); state.defScopeIdx = scopeIdx + (sourceIdx === 0 ? state.defScopeIdx : 0); state.defSourceIdx += sourceIdx; startItem.definition = { sourceIdx: state.defSourceIdx, scopeIdx: state.defScopeIdx, }; } if (startItem.flags & EncodedGeneratedRangeFlag.HAS_CALLSITE) { const sourceIdx = iter.nextVLQ(); const line = iter.nextVLQ(); const column = iter.nextVLQ(); state.callsiteColumn = column + (line === 0 && sourceIdx === 0 ? state.callsiteColumn : 0); state.callsiteLine = line + (sourceIdx === 0 ? state.callsiteLine : 0); state.callsiteSourceIdx += sourceIdx; startItem.callsite = { sourceIdx: state.callsiteSourceIdx, line: state.callsiteLine, column: state.callsiteColumn, }; } while (iter.hasNext() && iter.peek() !== ';' && iter.peek() !== ',') { const bindings: EncodedGeneratedRangeStart['bindings'][number] = []; startItem.bindings.push(bindings); const idxOrSubrangeCount = iter.nextVLQ(); if (idxOrSubrangeCount >= -1) { // Variable is available under the same expression in the whole range, or it's unavailable in the whole range. bindings.push({line: startItem.line, column: startItem.column, nameIdx: idxOrSubrangeCount}); continue; } // Variable is available under different expressions in this range or unavailable in parts of this range. bindings.push({line: startItem.line, column: startItem.column, nameIdx: iter.nextVLQ()}); const rangeCount = -idxOrSubrangeCount; for (let i = 0; i < rangeCount - 1; ++i) { // line, column, valueOffset const line = iter.nextVLQ(); const column = iter.nextVLQ(); const nameIdx = iter.nextVLQ(); const lastLine = bindings.at(-1)?.line ?? 0; // Only to make TS happy. `bindings` has one entry guaranteed. const lastColumn = bindings.at(-1)?.column ?? 0; // Only to make TS happy. `bindings` has one entry guaranteed. bindings.push({ line: line + lastLine, column: column + (line === 0 ? lastColumn : 0), nameIdx, }); } } yield startItem; } } function resolveName(idx: number|undefined, names: string[]): string|undefined { if (idx === undefined || idx < 0) { return undefined; } return names[idx]; }