chrome-devtools-frontend
Version:
Chrome DevTools UI
559 lines (524 loc) • 19.8 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 {ECMA_VERSION} from './AcornTokenizer.js';
import {DefinitionKind, ScopeKind, type ScopeTreeNode} from './FormatterActions.js';
export function parseScopes(expression: string, sourceType: 'module'|'script' = 'script'): Scope|null {
// Parse the expression and find variables and scopes.
let root: Acorn.ESTree.Node|null = null;
try {
root = Acorn.parse(
expression, {ecmaVersion: ECMA_VERSION, allowAwaitOutsideFunction: true, ranges: false, sourceType}) as
Acorn.ESTree.Node;
} catch {
return null;
}
return new ScopeVariableAnalysis(root, expression).run();
}
export interface Use {
offset: number;
scope: Scope;
isShorthandAssignmentProperty: boolean;
}
export interface VariableUses {
definitionKind: DefinitionKind;
uses: Use[];
}
export class Scope {
readonly variables = new Map<string, VariableUses>();
readonly parent: Scope|null;
readonly start: number;
readonly end: number;
readonly kind: ScopeKind;
readonly nameMappingLocations?: number[];
readonly children: Scope[] = [];
constructor(start: number, end: number, parent: Scope|null, kind: ScopeKind, nameMappingLocations?: number[]) {
this.start = start;
this.end = end;
this.parent = parent;
this.kind = kind;
this.nameMappingLocations = nameMappingLocations;
if (parent) {
parent.children.push(this);
}
}
export(): ScopeTreeNode {
const variables = [];
for (const variable of this.variables) {
const offsets = [];
for (const use of variable[1].uses) {
offsets.push(use.offset);
}
variables.push({name: variable[0], kind: variable[1].definitionKind, offsets});
}
const children = this.children.map(c => c.export());
return {
start: this.start,
end: this.end,
variables,
kind: this.kind,
nameMappingLocations: this.nameMappingLocations,
children,
};
}
addVariable(name: string, offset: number, definitionKind: DefinitionKind, isShorthandAssignmentProperty: boolean):
void {
const variable = this.variables.get(name);
const use = {offset, scope: this, isShorthandAssignmentProperty};
if (!variable) {
this.variables.set(name, {definitionKind, uses: [use]});
return;
}
if (variable.definitionKind === DefinitionKind.NONE) {
variable.definitionKind = definitionKind;
}
variable.uses.push(use);
}
findBinders(name: string): VariableUses[] {
const result = [];
let scope: Scope|null = this;
while (scope !== null) {
const defUse = scope.variables.get(name);
if (defUse && defUse.definitionKind !== DefinitionKind.NONE) {
result.push(defUse);
}
scope = scope.parent;
}
return result;
}
#mergeChildDefUses(name: string, defUses: VariableUses): void {
const variable = this.variables.get(name);
if (!variable) {
this.variables.set(name, defUses);
return;
}
variable.uses.push(...defUses.uses);
if (defUses.definitionKind === DefinitionKind.VAR) {
console.assert(variable.definitionKind !== DefinitionKind.LET);
if (variable.definitionKind === DefinitionKind.NONE) {
variable.definitionKind = defUses.definitionKind;
}
} else {
console.assert(defUses.definitionKind === DefinitionKind.NONE);
}
}
finalizeToParent(isFunctionScope: boolean): void {
if (!this.parent) {
console.error('Internal error: wrong nesting in scope analysis.');
throw new Error('Internal error');
}
// Move all unbound variables to the parent (also move var-bound variables
// if the parent is not a function).
const keysToRemove = [];
for (const [name, defUse] of this.variables.entries()) {
if (defUse.definitionKind === DefinitionKind.NONE ||
(defUse.definitionKind === DefinitionKind.VAR && !isFunctionScope)) {
this.parent.#mergeChildDefUses(name, defUse);
keysToRemove.push(name);
}
}
keysToRemove.forEach(k => this.variables.delete(k));
}
}
export class ScopeVariableAnalysis {
readonly #rootScope: Scope;
readonly #allNames = new Set<string>();
#currentScope: Scope;
readonly #rootNode: Acorn.ESTree.Node;
readonly #sourceText: string;
#additionalMappingLocations: number[] = [];
constructor(node: Acorn.ESTree.Node, sourceText: string) {
this.#rootNode = node;
this.#sourceText = sourceText;
this.#rootScope = new Scope(node.start, node.end, null, ScopeKind.GLOBAL);
this.#currentScope = this.#rootScope;
}
run(): Scope {
this.#processNode(this.#rootNode);
return this.#rootScope;
}
#processNode(node: Acorn.ESTree.Node|null): void {
if (node === null) {
return;
}
switch (node.type) {
case 'AwaitExpression':
this.#processNode(node.argument);
break;
case 'ArrayExpression':
node.elements.forEach(item => this.#processNode(item));
break;
case 'ExpressionStatement':
this.#processNode(node.expression);
break;
case 'Program':
console.assert(this.#currentScope === this.#rootScope);
node.body.forEach(item => this.#processNode(item));
console.assert(this.#currentScope === this.#rootScope);
break;
case 'ArrayPattern':
node.elements.forEach(item => this.#processNode(item));
break;
case 'ArrowFunctionExpression': {
this.#pushScope(node.start, node.end, ScopeKind.ARROW_FUNCTION);
node.params.forEach(this.#processNodeAsDefinition.bind(this, DefinitionKind.VAR, false));
if (node.body.type === 'BlockStatement') {
// Include the body of the arrow function in the same scope as the arguments.
node.body.body.forEach(this.#processNode.bind(this));
} else {
this.#processNode(node.body);
}
this.#popScope(true);
break;
}
case 'AssignmentExpression':
case 'AssignmentPattern':
case 'BinaryExpression':
case 'LogicalExpression':
this.#processNode(node.left);
this.#processNode(node.right);
break;
case 'BlockStatement':
this.#pushScope(node.start, node.end, ScopeKind.BLOCK);
node.body.forEach(this.#processNode.bind(this));
this.#popScope(false);
break;
case 'CallExpression':
this.#processNode(node.callee);
node.arguments.forEach(this.#processNode.bind(this));
break;
case 'VariableDeclaration': {
const definitionKind = node.kind === 'var' ? DefinitionKind.VAR : DefinitionKind.LET;
node.declarations.forEach(this.#processVariableDeclarator.bind(this, definitionKind));
break;
}
case 'CatchClause':
this.#pushScope(node.start, node.end, ScopeKind.BLOCK);
this.#processNodeAsDefinition(DefinitionKind.LET, false, node.param);
this.#processNode(node.body);
this.#popScope(false);
break;
case 'ClassBody':
node.body.forEach(this.#processNode.bind(this));
break;
case 'ClassDeclaration':
this.#processNodeAsDefinition(DefinitionKind.LET, false, node.id);
this.#processNode(node.superClass ?? null);
this.#processNode(node.body);
break;
case 'ClassExpression':
// Intentionally ignore the id.
this.#processNode(node.superClass ?? null);
this.#processNode(node.body);
break;
case 'ChainExpression':
this.#processNode(node.expression);
break;
case 'ConditionalExpression':
this.#processNode(node.test);
this.#processNode(node.consequent);
this.#processNode(node.alternate);
break;
case 'DoWhileStatement':
this.#processNode(node.body);
this.#processNode(node.test);
break;
case 'ForInStatement':
case 'ForOfStatement':
this.#pushScope(node.start, node.end, ScopeKind.BLOCK);
this.#processNode(node.left);
this.#processNode(node.right);
this.#processNode(node.body);
this.#popScope(false);
break;
case 'ForStatement':
this.#pushScope(node.start, node.end, ScopeKind.BLOCK);
this.#processNode(node.init ?? null);
this.#processNode(node.test ?? null);
this.#processNode(node.update ?? null);
this.#processNode(node.body);
this.#popScope(false);
break;
case 'FunctionDeclaration':
this.#processNodeAsDefinition(DefinitionKind.VAR, false, node.id);
this.#pushScope(
node.id?.end ?? node.start, node.end, ScopeKind.FUNCTION,
mappingLocationsForFunctionDeclaration(node, this.#sourceText));
this.#addVariable('this', node.start, DefinitionKind.FIXED);
this.#addVariable('arguments', node.start, DefinitionKind.FIXED);
node.params.forEach(this.#processNodeAsDefinition.bind(this, DefinitionKind.LET, false));
// Process the body of the block statement directly to avoid creating new scope.
node.body.body.forEach(this.#processNode.bind(this));
this.#popScope(true);
break;
case 'FunctionExpression':
this.#pushScope(
node.id?.end ?? node.start, node.end, ScopeKind.FUNCTION,
[...this.#additionalMappingLocations, ...mappingLocationsForFunctionExpression(node, this.#sourceText)]);
this.#addVariable('this', node.start, DefinitionKind.FIXED);
this.#addVariable('arguments', node.start, DefinitionKind.FIXED);
node.params.forEach(this.#processNodeAsDefinition.bind(this, DefinitionKind.LET, false));
// Process the body of the block statement directly to avoid creating new scope.
node.body.body.forEach(this.#processNode.bind(this));
this.#popScope(true);
break;
case 'Identifier':
this.#addVariable(node.name, node.start);
break;
case 'IfStatement':
this.#processNode(node.test);
this.#processNode(node.consequent);
this.#processNode(node.alternate ?? null);
break;
case 'LabeledStatement':
this.#processNode(node.body);
break;
case 'MetaProperty':
break;
case 'MethodDefinition':
if (node.computed) {
this.#processNode(node.key);
} else {
this.#additionalMappingLocations = mappingLocationsForMethodDefinition(node);
}
this.#processNode(node.value);
this.#additionalMappingLocations = [];
break;
case 'NewExpression':
this.#processNode(node.callee);
node.arguments.forEach(this.#processNode.bind(this));
break;
case 'MemberExpression':
this.#processNode(node.object);
if (node.computed) {
this.#processNode(node.property);
}
break;
case 'ObjectExpression':
node.properties.forEach(this.#processNode.bind(this));
break;
case 'ObjectPattern':
node.properties.forEach(this.#processNode.bind(this));
break;
case 'PrivateIdentifier':
break;
case 'PropertyDefinition':
if (node.computed) {
this.#processNode(node.key);
}
this.#processNode(node.value ?? null);
break;
case 'Property':
if (node.shorthand) {
console.assert(node.value.type === 'Identifier');
console.assert(node.key.type === 'Identifier');
console.assert((node.value as Acorn.ESTree.Identifier).name === (node.key as Acorn.ESTree.Identifier).name);
this.#addVariable((node.value as Acorn.ESTree.Identifier).name, node.value.start, DefinitionKind.NONE, true);
} else {
if (node.computed) {
this.#processNode(node.key);
}
this.#processNode(node.value);
}
break;
case 'RestElement':
this.#processNodeAsDefinition(DefinitionKind.LET, false, node.argument);
break;
case 'ReturnStatement':
this.#processNode(node.argument ?? null);
break;
case 'SequenceExpression':
node.expressions.forEach(this.#processNode.bind(this));
break;
case 'SpreadElement':
this.#processNode(node.argument);
break;
case 'SwitchCase':
this.#processNode(node.test ?? null);
node.consequent.forEach(this.#processNode.bind(this));
break;
case 'SwitchStatement':
this.#processNode(node.discriminant);
node.cases.forEach(this.#processNode.bind(this));
break;
case 'TaggedTemplateExpression':
this.#processNode(node.tag);
this.#processNode(node.quasi);
break;
case 'TemplateLiteral':
node.expressions.forEach(this.#processNode.bind(this));
break;
case 'ThisExpression':
this.#addVariable('this', node.start);
break;
case 'ThrowStatement':
this.#processNode(node.argument);
break;
case 'TryStatement':
this.#processNode(node.block);
this.#processNode(node.handler ?? null);
this.#processNode(node.finalizer ?? null);
break;
case 'WithStatement':
this.#processNode(node.object);
// TODO jarin figure how to treat the with body.
this.#processNode(node.body);
break;
case 'YieldExpression':
this.#processNode(node.argument ?? null);
break;
case 'UnaryExpression':
case 'UpdateExpression':
this.#processNode(node.argument);
break;
case 'WhileStatement':
this.#processNode(node.test);
this.#processNode(node.body);
break;
// Ignore, no expressions involved.
case 'BreakStatement':
case 'ContinueStatement':
case 'DebuggerStatement':
case 'EmptyStatement':
case 'Literal':
case 'Super':
case 'TemplateElement':
break;
// Ignore, cannot be used outside of a module.
case 'ImportDeclaration':
case 'ImportDefaultSpecifier':
case 'ImportNamespaceSpecifier':
case 'ImportSpecifier':
case 'ImportExpression':
case 'ExportAllDeclaration':
case 'ExportDefaultDeclaration':
case 'ExportNamedDeclaration':
case 'ExportSpecifier':
break;
case 'VariableDeclarator':
console.error('Should not encounter VariableDeclarator in general traversal.');
break;
}
}
getFreeVariables(): Map<string, Use[]> {
const result = new Map<string, Use[]>();
for (const [name, defUse] of this.#rootScope.variables) {
if (defUse.definitionKind !== DefinitionKind.NONE) {
// Skip bound variables.
continue;
}
result.set(name, defUse.uses);
}
return result;
}
getAllNames(): Set<string> {
return this.#allNames;
}
#pushScope(start: number, end: number, kind: ScopeKind, nameMappingLocations?: number[]): void {
this.#currentScope = new Scope(start, end, this.#currentScope, kind, nameMappingLocations);
}
#popScope(isFunctionContext: boolean): void {
if (this.#currentScope.parent === null) {
console.error('Internal error: wrong nesting in scope analysis.');
throw new Error('Internal error');
}
this.#currentScope.finalizeToParent(isFunctionContext);
this.#currentScope = this.#currentScope.parent;
}
#addVariable(
name: string, offset: number, definitionKind: DefinitionKind = DefinitionKind.NONE,
isShorthandAssignmentProperty = false): void {
this.#allNames.add(name);
this.#currentScope.addVariable(name, offset, definitionKind, isShorthandAssignmentProperty);
}
#processNodeAsDefinition(
definitionKind: DefinitionKind.LET|DefinitionKind.VAR, isShorthandAssignmentProperty: boolean,
node: Acorn.ESTree.Pattern|Acorn.ESTree.AssignmentProperty|null): void {
if (node === null) {
return;
}
switch (node.type) {
case 'ArrayPattern':
node.elements.forEach(this.#processNodeAsDefinition.bind(this, definitionKind, false));
break;
case 'AssignmentPattern':
this.#processNodeAsDefinition(definitionKind, isShorthandAssignmentProperty, node.left);
this.#processNode(node.right);
break;
case 'Identifier':
this.#addVariable(node.name, node.start, definitionKind, isShorthandAssignmentProperty);
break;
case 'MemberExpression':
this.#processNode(node.object);
if (node.computed) {
this.#processNode(node.property);
}
break;
case 'ObjectPattern':
node.properties.forEach(this.#processNodeAsDefinition.bind(this, definitionKind, false));
break;
case 'Property':
if (node.computed) {
this.#processNode(node.key);
}
this.#processNodeAsDefinition(definitionKind, node.shorthand, node.value);
break;
case 'RestElement':
this.#processNodeAsDefinition(definitionKind, false, node.argument);
break;
}
}
#processVariableDeclarator(
definitionKind: DefinitionKind.LET|DefinitionKind.VAR, decl: Acorn.ESTree.VariableDeclarator): void {
this.#processNodeAsDefinition(definitionKind, false, decl.id);
this.#processNode(decl.init ?? null);
}
}
function mappingLocationsForFunctionDeclaration(node: Acorn.ESTree.FunctionDeclaration, sourceText: string): number[] {
// For function declarations we prefer the position of the identifier as per spec, but we'll also return
// the beginning of the opening parenthesis '('.
const result = [node.id.start];
const searchParenEndPos = node.params.length ? node.params[0].start : node.body.start;
const parenPos = indexOfCharInBounds(sourceText, '(', node.id.end, searchParenEndPos);
if (parenPos >= 0) {
// Note that this is not 100% accurate as there might be a comment containing a open parenthesis between
// the identifier the and the argument list (unlikely though).
result.push(parenPos);
}
return result;
}
function mappingLocationsForFunctionExpression(node: Acorn.ESTree.FunctionExpression, sourceText: string): number[] {
// For function expressions we prefer the position of the identifier or '(' if none is present, as per spec.
const result = [];
if (node.id) {
result.push(node.id.start);
}
const searchParenStartPos = node.id ? node.id.end : node.start;
const searchParenEndPos = node.params.length ? node.params[0].start : node.body.start;
const parenPos = indexOfCharInBounds(sourceText, '(', searchParenStartPos, searchParenEndPos);
if (parenPos >= 0) {
// Note that this is not 100% accurate as there might be a comment containing a open parenthesis between
// the identifier the and the argument list (unlikely though).
result.push(parenPos);
}
return result;
}
function mappingLocationsForMethodDefinition(node: Acorn.ESTree.MethodDefinition): number[] {
// Method definitions use a FunctionExpression as their "value" child. So we only
// record the start of the "key" here and let 'mappingLocationsForFunctionExpression' handle
// the parenthesis.
if (node.key.type === 'Identifier' || node.key.type === 'PrivateIdentifier') {
const id = node.key as Acorn.ESTree.Identifier | Acorn.ESTree.PrivateIdentifier;
return [id.start];
}
return [];
}
function indexOfCharInBounds(str: string, needle: string, start: number, end: number): number {
for (let i = start; i < end; ++i) {
if (str[i] === needle) {
return i;
}
}
return -1;
}