| // 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 name?: string; |
| readonly nameMappingLocations?: number[]; |
| readonly children: Scope[] = []; |
| |
| constructor( |
| start: number, end: number, parent: Scope|null, kind: ScopeKind, name?: string, nameMappingLocations?: number[]) { |
| this.start = start; |
| this.end = end; |
| this.parent = parent; |
| this.kind = kind; |
| this.name = name; |
| 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, |
| name: this.name, |
| 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; |
| #methodName: string|undefined; |
| #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, undefined, |
| mappingLocationsForArrowFunctions(node, this.#sourceText)); |
| 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, node.id.name, |
| 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.#methodName ?? node.id?.name, |
| [...this.#additionalMappingLocations, ...mappingLocationsForFunctionExpression(node, this.#sourceText)]); |
| this.#additionalMappingLocations = []; |
| this.#methodName = undefined; |
| 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.#methodName = nameForMethodDefinition(node); |
| } |
| this.#processNode(node.value); |
| 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); |
| } else if (node.value.type === 'FunctionExpression') { |
| this.#additionalMappingLocations = mappingLocationsForMethodDefinition(node); |
| this.#methodName = nameForMethodDefinition(node); |
| } |
| 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, name?: string, nameMappingLocations?: number[]): void { |
| this.#currentScope = new Scope(start, end, this.#currentScope, kind, name, 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|Acorn.ESTree.Property): 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 nameForMethodDefinition(node: Acorn.ESTree.MethodDefinition|Acorn.ESTree.Property): string|undefined { |
| if (node.key.type === 'Identifier') { |
| return node.key.name; |
| } |
| if (node.key.type === 'PrivateIdentifier') { |
| return '#' + node.key.name; |
| } |
| return undefined; |
| } |
| |
| function mappingLocationsForArrowFunctions(node: Acorn.ESTree.ArrowFunctionExpression, sourceText: string): number[] { |
| // For arrow functions we use the `(' parenthesis if present, and the `=>` arrow as per spec. |
| // Both are not 100% accurate as acorn doesn't tell us their location so we have to search, which is brittle. |
| const result = []; |
| |
| const searchParenStartPos = node.async ? node.start + 5 : node.start; |
| const searchParenEndPos = node.params.length ? node.params[0].start : node.body.start; |
| const parenPos = indexOfCharInBounds(sourceText, '(', searchParenStartPos, searchParenEndPos); |
| if (parenPos >= 0) { |
| result.push(parenPos); |
| } |
| |
| const searchArrowStartPos = node.params.length ? node.params[node.params.length - 1].end : node.start; |
| const arrowPos = indexOfCharInBounds(sourceText, '=', searchArrowStartPos, node.body.start); |
| if (arrowPos >= 0 && sourceText[arrowPos + 1] === '>') { |
| result.push(arrowPos); |
| } |
| |
| return result; |
| } |
| |
| 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; |
| } |