UNPKG

chrome-devtools-frontend

Version:
183 lines (170 loc) • 6.47 kB
// Copyright 2022 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import * as Acorn from '../../third_party/acorn/acorn.js'; import {DefinitionKind} from '../formatter_actions/formatter_actions.js'; import {ECMA_VERSION} from './AcornTokenizer.js'; import {ScopeVariableAnalysis, type VariableUses} from './ScopeParser.js'; export function substituteExpression(expression: string, nameMaps: Array<Map<string, string|null>>): string { const replacements = computeSubstitution(expression, nameMaps); return applySubstitution(expression, replacements); } interface Replacement { from: string; to: string; offset: number; isShorthandAssignmentProperty: boolean; } function parseBindingExpression(expression: string): { replacement: string, freeVariables: string[], allNames: Set<string>, } { const options = { ecmaVersion: ECMA_VERSION, allowAwaitOutsideFunction: true, allowImportExportEverywhere: true, checkPrivateFields: false, ranges: false, } as const; const root = Acorn.parse(`(${expression})`, options); const exprAt = Acorn.Parser.parseExpressionAt(expression, 0, options); if (root.body.length !== 1 || root.body[0].type !== 'ExpressionStatement') { throw new SyntaxError(`Invalid binding expression '${expression}'`); } const expr = root.body[0].expression; if (exprAt.start !== expr.start - 1 || exprAt.end !== expr.end - 1) { throw new SyntaxError(`Invalid binding expression '${expression}'`); } const analysis = new ScopeVariableAnalysis(root as Acorn.ESTree.Node, `(${expression})`); analysis.run(); const needsParens = expr.type !== 'Identifier' && expr.type !== 'MemberExpression' && expr.type !== 'ThisExpression'; return { replacement: needsParens ? `(${expression})` : expression, freeVariables: [...analysis.getFreeVariables().keys()], allNames: analysis.getAllNames(), }; } /** * Given an |expression| and a mapping from names to new names, the |computeSubstitution| * function returns a list of replacements sorted by the offset. The function throws if * it cannot parse the expression or the substitution is impossible to perform (for example * if the substitution target is 'this' within a function, it would become bound there). **/ function computeSubstitution(expression: string, nameMaps: Array<Map<string, string|null>>): Replacement[] { // Parse the expression and find variables and scopes. const root = Acorn.parse(expression, { ecmaVersion: ECMA_VERSION, allowAwaitOutsideFunction: true, allowImportExportEverywhere: true, checkPrivateFields: false, ranges: false, }) as Acorn.ESTree.Node; const scopeVariables = new ScopeVariableAnalysis(root, expression); scopeVariables.run(); const freeVariables = scopeVariables.getFreeVariables(); const result: Replacement[] = []; // Prepare the machinery for generating fresh names (to avoid variable captures). const allNames = scopeVariables.getAllNames(); const nameMap = new Map<string, string|null>(); const parsedBindings = new Map<string, ReturnType<typeof parseBindingExpression>>(); const shadowedNames = new Set<string>(); for (const scopeMap of nameMaps) { const scopeNames = new Set<string>(); for (const [name, rename] of scopeMap.entries()) { let parsed: ReturnType<typeof parseBindingExpression>|undefined; if (rename !== null) { try { parsed = parseBindingExpression(rename); parsed.allNames.forEach(id => allNames.add(id)); parsed.freeVariables.forEach(id => scopeNames.add(id)); } catch (error) { if (!nameMap.has(name) && freeVariables.has(name)) { throw error; } } } if (!nameMap.has(name)) { const isShadowed = parsed?.freeVariables.some(id => shadowedNames.has(id)); nameMap.set(name, isShadowed ? null : rename); if (parsed && !isShadowed) { parsedBindings.set(name, parsed); } } } scopeNames.forEach(id => shadowedNames.add(id)); } function getNewName(base: string): string { let i = 1; while (allNames.has(`${base}_${i}`)) { i++; } const newName = `${base}_${i}`; allNames.add(newName); return newName; } // Perform the substitutions. const capturedBinders = new Map<VariableUses, string>(); for (const [name, rename] of nameMap.entries()) { const defUse = freeVariables.get(name); if (!defUse) { continue; } if (rename === null) { throw new Error(`Cannot substitute '${name}' as the underlying variable '${rename}' is unavailable`); } const parsed = parsedBindings.get(name); if (!parsed) { continue; } const freeIds = [...parsed.freeVariables]; for (const use of defUse) { result.push({ from: name, to: parsed.replacement, offset: use.offset, isShorthandAssignmentProperty: use.isShorthandAssignmentProperty, }); for (const freeId of freeIds) { for (const binder of use.scope.findBinders(freeId)) { capturedBinders.set(binder, freeId); } } } } // If there is a capturing binder, rename the bound variable. for (const [binder, freeId] of capturedBinders.entries()) { if (binder.definitionKind === DefinitionKind.FIXED) { // If the identifier is bound to a fixed name, such as 'this', // then refuse to do the substitution. throw new Error(`Cannot avoid capture of '${freeId}'`); } const newName = getNewName(freeId); for (const use of binder.uses) { result.push({ from: freeId, to: newName, offset: use.offset, isShorthandAssignmentProperty: use.isShorthandAssignmentProperty, }); } } result.sort((l, r) => l.offset - r.offset); return result; } function applySubstitution(expression: string, replacements: Replacement[]): string { const accumulator = []; let last = 0; for (const r of replacements) { accumulator.push(expression.slice(last, r.offset)); let replacement = r.to; if (r.isShorthandAssignmentProperty) { // Let us expand the shorthand to full assignment. replacement = `${r.from}: ${r.to}`; } accumulator.push(replacement); last = r.offset + r.from.length; } accumulator.push(expression.slice(last)); return accumulator.join(''); }