chrome-devtools-frontend
Version:
Chrome DevTools UI
183 lines (170 loc) • 6.47 kB
text/typescript
// 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('');
}