├── test ├── _init.js └── index.js ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── bench ├── d3.js └── three.js ├── .gitignore ├── LICENSE.md ├── binding.js ├── package.json ├── scope.js ├── CHANGELOG.md ├── README.md └── index.js /test/_init.js: -------------------------------------------------------------------------------- 1 | if (!require('has-template-literals')()) { 2 | require('babel-core/register')({ 3 | plugins: ['transform-es2015-template-literals'] 4 | }) 5 | } 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /bench/d3.js: -------------------------------------------------------------------------------- 1 | var bench = require('nanobench') 2 | var parse = require('acorn').parse 3 | var scan = require('../') 4 | var src = require('fs').readFileSync(require.resolve('d3/build/d3'), 'utf8') 5 | 6 | bench('scope-analyze d3', function (b) { 7 | var ast = parse(src) 8 | 9 | b.start() 10 | scan.crawl(ast) 11 | b.end() 12 | }) 13 | -------------------------------------------------------------------------------- /bench/three.js: -------------------------------------------------------------------------------- 1 | var bench = require('nanobench') 2 | var parse = require('acorn').parse 3 | var scan = require('../') 4 | var src = require('fs').readFileSync(require.resolve('three'), 'utf8') 5 | 6 | bench('scope-analyze three.js', function (b) { 7 | var ast = parse(src) 8 | 9 | b.start() 10 | scan.crawl(ast) 11 | b.end() 12 | }) 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | package-lock.json 29 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # [Apache License 2.0](https://spdx.org/licenses/Apache-2.0) 2 | 3 | Copyright 2017 Renée Kooi 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | > http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: ['0.10', '0.12', 4.x, 6.x, 8.x, 10.x, 12.x, 14.x, 16.x] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - name: npm install 20 | run: npm install 21 | - name: npm test 22 | run: npm run tests-only 23 | 24 | lint: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - uses: actions/checkout@v2 29 | - name: Use Node.js 16.x 30 | uses: actions/setup-node@v2 31 | with: 32 | node-version: 16.x 33 | - name: npm install 34 | run: npm install 35 | - name: npm run lint 36 | run: npm run lint 37 | -------------------------------------------------------------------------------- /binding.js: -------------------------------------------------------------------------------- 1 | var Set = require('es6-set') 2 | 3 | module.exports = Binding 4 | 5 | function Binding (name, definition) { 6 | this.name = name 7 | this.definition = definition 8 | this.references = new Set() 9 | 10 | if (definition) this.add(definition) 11 | } 12 | 13 | Binding.prototype.add = function (node) { 14 | this.references.add(node) 15 | return this 16 | } 17 | 18 | Binding.prototype.remove = function (node) { 19 | if (!this.references.has(node)) { 20 | throw new Error('Tried removing nonexistent reference') 21 | } 22 | this.references.delete(node) 23 | return this 24 | } 25 | 26 | Binding.prototype.isReferenced = function () { 27 | var definition = this.definition 28 | var isReferenced = false 29 | this.each(function (ref) { 30 | if (ref !== definition) isReferenced = true 31 | }) 32 | return isReferenced 33 | } 34 | 35 | Binding.prototype.getReferences = function () { 36 | var arr = [] 37 | this.each(function (ref) { arr.push(ref) }) 38 | return arr 39 | } 40 | 41 | Binding.prototype.each = function (cb) { 42 | this.references.forEach(function (ref) { cb(ref) }) 43 | return this 44 | } 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scope-analyzer", 3 | "description": "simple scope analysis for javascript ASTs", 4 | "version": "2.1.2", 5 | "author": "Renée Kooi ", 6 | "bugs": { 7 | "url": "https://github.com/goto-bus-stop/scope-analyzer/issues" 8 | }, 9 | "dependencies": { 10 | "array-from": "^2.1.1", 11 | "dash-ast": "^2.0.1", 12 | "es6-map": "^0.1.5", 13 | "es6-set": "^0.1.5", 14 | "es6-symbol": "^3.1.1", 15 | "estree-is-function": "^1.0.0", 16 | "get-assigned-identifiers": "^1.1.0" 17 | }, 18 | "devDependencies": { 19 | "acorn": "^8.0.1", 20 | "babel-core": "^6.26.3", 21 | "babel-plugin-transform-es2015-template-literals": "^6.22.0", 22 | "d3": "^4.13.0", 23 | "has-template-literals": "^1.0.0", 24 | "nanobench": "^2.1.1", 25 | "recast": "^0.20.5", 26 | "standard": "^16.0.4", 27 | "tape": "^5.0.1", 28 | "three": "^0.89.0" 29 | }, 30 | "homepage": "https://github.com/goto-bus-stop/scope-analyzer", 31 | "keywords": [ 32 | "analysis", 33 | "ast", 34 | "javascript", 35 | "nodes", 36 | "refactor", 37 | "rename", 38 | "scope" 39 | ], 40 | "license": "Apache-2.0", 41 | "main": "index.js", 42 | "repository": { 43 | "type": "git", 44 | "url": "https://github.com/goto-bus-stop/scope-analyzer.git" 45 | }, 46 | "scripts": { 47 | "bench": "nanobench bench/*.js", 48 | "lint": "standard", 49 | "tests-only": "tape -r ./test/_init.js test/index.js", 50 | "test": "npm run lint && npm run tests-only" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /scope.js: -------------------------------------------------------------------------------- 1 | var Map = require('es6-map') 2 | var Set = require('es6-set') 3 | var ArrayFrom = require('array-from') 4 | var Binding = require('./binding') 5 | 6 | module.exports = Scope 7 | 8 | function Scope (parent) { 9 | this.parent = parent 10 | this.bindings = new Map() 11 | this.undeclaredBindings = new Map() 12 | } 13 | 14 | Scope.prototype.define = function (binding) { 15 | if (this.bindings.has(binding.name)) { 16 | var existing = this.bindings.get(binding.name) 17 | binding.getReferences().forEach(function (ref) { 18 | existing.add(ref) 19 | }) 20 | } else { 21 | this.bindings.set(binding.name, binding) 22 | } 23 | return this 24 | } 25 | 26 | Scope.prototype.has = function (name) { 27 | return this.bindings.has(name) 28 | } 29 | 30 | Scope.prototype.add = function (name, ref) { 31 | var binding = this.bindings.get(name) 32 | if (binding) { 33 | binding.add(ref) 34 | } 35 | return this 36 | } 37 | 38 | Scope.prototype.addUndeclared = function (name, ref) { 39 | if (!this.undeclaredBindings.has(name)) { 40 | this.undeclaredBindings.set(name, new Binding(name)) 41 | } 42 | 43 | var binding = this.undeclaredBindings.get(name) 44 | binding.add(ref) 45 | return this 46 | } 47 | 48 | Scope.prototype.getBinding = function (name) { 49 | return this.bindings.get(name) 50 | } 51 | 52 | Scope.prototype.getReferences = function (name) { 53 | return this.has(name) ? this.bindings.get(name).getReferences() : [] 54 | } 55 | 56 | Scope.prototype.getUndeclaredNames = function () { 57 | return ArrayFrom(this.undeclaredBindings.keys()) 58 | } 59 | 60 | Scope.prototype.forEach = function () { 61 | this.bindings.forEach.apply(this.bindings, arguments) 62 | } 63 | 64 | Scope.prototype.forEachAvailable = function (cb) { 65 | var seen = new Set() 66 | this.bindings.forEach(function (binding, name) { 67 | seen.add(name) 68 | cb(binding, name) 69 | }) 70 | this.parent && this.parent.forEachAvailable(function (binding, name) { 71 | if (!seen.has(name)) { 72 | seen.add(name) 73 | cb(binding, name) 74 | } 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # scope-analyzer change log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | This project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ## 2.1.2 / 2021-10-05 8 | * make `.parent` and `[kScope]` properties non-enumerable, fixing compatibility with `recast`. anecdotally a 20-30% performance regression. 9 | 10 | you can pin to 2.1.1 if you need the 20% and don't need safe traversal of node properties. 11 | 12 | ## 2.1.1 / 2020-03-06 13 | * use `dash-ast` for faster tree walking. anecdotally results in a 10-20% analysis speedup. 14 | 15 | ## 2.1.0 / 2020-03-06 16 | * add `deleteScope()` and `clear()` methods to delete scope information from a single node or an entire tree. Thanks @fabiosantoscode! 17 | 18 | ## 2.0.6 / 2020-03-05 19 | * detect the `.value` part of a shorthand property as the variable reference, instead of the `.key` part. 20 | 21 | This is a bugfix only for ASTs that were already modified prior to being crawled with scope-analyzer. Thanks @fabiosantoscode! 22 | 23 | ## 2.0.5 / 2018-06-25 24 | * detect `catch(param){}` bindings 25 | 26 | ## 2.0.4 / 2018-05-22 27 | * fix class method definition names being counted as references to outer variables. 28 | 29 | ## 2.0.3 / 2018-04-20 30 | * revert to custom walker, acorn.walk behaviour is different and not faster 31 | 32 | ## 2.0.2 / 2018-04-20 33 | * fix valid references that occur above a value is declared in source code 34 | 35 | ## 2.0.1 / 2018-03-30 36 | * always initialise scope on the root node, so that undeclared names can be attached 37 | 38 | ## 2.0.0 / 2018-03-08 39 | 40 | * add support for ES5 environments (Node 0.10+) 41 | * `scan.getBinding()` now also returns bindings for undeclared identifiers. `binding.definition` will be undefined for undeclared identifiers. 42 | 43 | ## 1.3.0 / 2018-01-13 44 | 45 | * add `binding.remove(node)` to remove a reference to a binding from the list of references. use `binding.isReferenced()` to check if there are any references left. 46 | 47 | ## 1.2.0 / 2018-01-02 48 | 49 | * track uses of undeclared variable names. use `getUndeclaredNames()` on the root scope to get a list of undeclared names used in the AST. 50 | 51 | ## 1.1.1 / 2018-01-02 52 | 53 | * fix `import { a as b }` being counted as a reference to `a` 54 | 55 | ## 1.1.0 / 2017-12-26 56 | 57 | * account for import declarations 58 | * rename `analyze` to `crawl` (analyze is still available as alias) 59 | * some tests 60 | 61 | ## 1.0.0 / 2017-11-15 62 | 63 | * initial release 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # scope-analyzer 2 | 3 | simple scope analysis for javascript ASTs. tracks scopes and collects references to variables. 4 | 5 | Caveats and/or todos: 6 | 7 | - May be missing edge cases. 8 | - Things like `label:`s are not considered at all, but ideally in the future they will! 9 | 10 | [![stability][stability-image]][stability-url] 11 | [![npm][npm-image]][npm-url] 12 | [![travis][travis-image]][travis-url] 13 | [![standard][standard-image]][standard-url] 14 | 15 | [stability-image]: https://img.shields.io/badge/stability-experimental-orange.svg?style=flat-square 16 | [stability-url]: https://nodejs.org/api/documentation.html#documentation_stability_index 17 | [npm-image]: https://img.shields.io/npm/v/scope-analyzer.svg?style=flat-square 18 | [npm-url]: https://www.npmjs.com/package/scope-analyzer 19 | [travis-image]: https://img.shields.io/travis/goto-bus-stop/scope-analyzer.svg?style=flat-square 20 | [travis-url]: https://travis-ci.org/goto-bus-stop/scope-analyzer 21 | [standard-image]: https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square 22 | [standard-url]: http://npm.im/standard 23 | 24 | ## Install 25 | 26 | ``` 27 | npm install scope-analyzer 28 | ``` 29 | 30 | ## Usage 31 | 32 | Note: AST nodes passed to `scope-analyzer` functions are expected to reference the parent node on a `node.parent` property. 33 | Nodes from [falafel](https://github.com/substack/node-falafel) or [transform-ast](https://github.com/goto-bus-stop/transform-ast) have a `.parent` property, but others may not. You can use [estree-assign-parent](https://github.com/goto-bus-stop/estree-assign-parent) to quickly assign a parent property to all nodes in an AST. 34 | 35 | ```js 36 | var scan = require('scope-analyzer') 37 | 38 | var ast = parse('...') 39 | // Initialize node module variables 40 | scan.createScope(ast, ['module', 'exports', '__dirname', '__filename']) 41 | scan.crawl(ast) 42 | 43 | var binding = scan.getBinding(ast, 'exports') 44 | binding.getReferences().forEach(function (reference) { 45 | // Assume for the sake of the example that all references to `exports` are assignments like 46 | // `exports.xyz = abc` 47 | console.log('found export:', reference.parent.property.name) 48 | }) 49 | ``` 50 | 51 | ## API 52 | 53 | ### `crawl(ast)` 54 | 55 | Walk the ast and analyze all scopes. This will immediately allow you to use the `get*` methods on any node in the tree. 56 | 57 | ### `clear(ast)` 58 | 59 | Clear scope information in all nodes of the AST. 60 | 61 | ### `visitScope(node)` 62 | 63 | Visit a node to check if it initialises any scopes. 64 | For example, a function declaration will initialise a new scope to hold bindings for its parameters. 65 | Use this if you are already walking the AST manually, and if you don't need the scope information during this walk. 66 | 67 | ### `visitBinding(node)` 68 | 69 | Visit a node to check if it is a reference to an existing binding. 70 | If it is, the reference is added to the parent scope. 71 | Use this if you are already walking the AST manually. 72 | 73 | ### `createScope(node, bindings)` 74 | 75 | Initialise a new scope at the given node. `bindings` is an array of variable names. 76 | This can be useful to make the scope analyzer aware of preexisting global variables. 77 | In that case, call `createScope` on the root node with the names of globals: 78 | 79 | ```js 80 | var ast = parse('xyz') 81 | scopeAnalyzer.createScope(ast, ['HTMLElement', 'Notification', ...]) 82 | ``` 83 | 84 | ### `deleteScope(node)` 85 | 86 | Delete the scope initialised by node. 87 | 88 | ### `scope(node)` 89 | 90 | Get the [Scope](#scope) initialised by the given node. 91 | 92 | ### `getBinding(node)` 93 | 94 | Get the [Binding](#binding) referenced by the `Identifier` `node`. 95 | 96 | ### Scope 97 | 98 | #### `scope.has(name)` 99 | 100 | Check if this scope defines `name`. 101 | 102 | #### `scope.getBinding(name)` 103 | 104 | Get the [Binding](#binding) named `name` that is declared by this scope. 105 | 106 | #### `scope.getReferences(name)` 107 | 108 | Get a list of all nodes referencing the `name` binding that is declared by this scope. 109 | 110 | #### `scope.getUndeclaredNames()` 111 | 112 | Get a list of all names that were used in this scope, but not defined anywhere in the AST. 113 | 114 | #### `scope.forEach(cb(binding, name))` 115 | 116 | Loop over all bindings declared by this scope. 117 | 118 | #### `scope.forEachAvailable(cb(binding, name))` 119 | 120 | Loop over all bindings available to this scope, declared in this scope or any parent scope. 121 | 122 | ### Binding 123 | 124 | #### `binding.definition` 125 | 126 | The node that defined this binding. If this binding was not declared in the AST, `binding.definition` will be undefined. 127 | 128 | #### `binding.getReferences()` 129 | 130 | Return an array of nodes that reference this binding. 131 | 132 | #### `binding.isReferenced()` 133 | 134 | Check if the binding is referenced, i.e., if there are any identifier Nodes (other than `binding.definition`) referencing this binding. 135 | 136 | #### `binding.remove(node)` 137 | 138 | Remove a reference to this binding. Use this when you are replacing the node referencing the binding with something else. 139 | 140 | ## License 141 | 142 | [Apache-2.0](LICENSE.md) 143 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-redeclare */ 2 | var assert = require('assert') 3 | var dash = require('dash-ast') 4 | var Symbol = require('es6-symbol') 5 | var getAssignedIdentifiers = require('get-assigned-identifiers') 6 | var isFunction = require('estree-is-function') 7 | var Binding = require('./binding') 8 | var Scope = require('./scope') 9 | 10 | var kScope = Symbol('scope') 11 | 12 | exports.createScope = createScope 13 | exports.visitScope = visitScope 14 | exports.visitBinding = visitBinding 15 | exports.crawl = crawl 16 | exports.analyze = crawl // old name 17 | exports.clear = clear 18 | exports.deleteScope = deleteScope 19 | exports.nearestScope = getNearestScope 20 | exports.scope = getScope 21 | exports.getBinding = getBinding 22 | 23 | function set (object, property, value) { 24 | Object.defineProperty(object, property, { 25 | value: value, 26 | enumerable: false, 27 | configurable: true, 28 | writable: true 29 | }) 30 | } 31 | 32 | // create a new scope at a node. 33 | function createScope (node, bindings) { 34 | assert.ok(typeof node === 'object' && node && typeof node.type === 'string', 'scope-analyzer: createScope: node must be an ast node') 35 | if (!node[kScope]) { 36 | var parent = getParentScope(node) 37 | set(node, kScope, new Scope(parent)) 38 | } 39 | if (bindings) { 40 | for (var i = 0; i < bindings.length; i++) { 41 | node[kScope].define(new Binding(bindings[i])) 42 | } 43 | } 44 | return node[kScope] 45 | } 46 | 47 | // Separate scope and binding registration steps, for post-order tree walkers. 48 | // Those will typically walk the scope-defining node _after_ the bindings that belong to that scope, 49 | // so they need to do it in two steps in order to define scopes first. 50 | function visitScope (node) { 51 | assert.ok(typeof node === 'object' && node && typeof node.type === 'string', 'scope-analyzer: visitScope: node must be an ast node') 52 | registerScopeBindings(node) 53 | } 54 | function visitBinding (node) { 55 | assert.ok(typeof node === 'object' && node && typeof node.type === 'string', 'scope-analyzer: visitBinding: node must be an ast node') 56 | if (isVariable(node)) { 57 | registerReference(node) 58 | } 59 | } 60 | 61 | function crawl (ast) { 62 | assert.ok(typeof ast === 'object' && ast && typeof ast.type === 'string', 'scope-analyzer: crawl: ast must be an ast node') 63 | dash(ast, function (node, parent) { 64 | set(node, 'parent', parent) 65 | visitScope(node) 66 | }) 67 | dash(ast, visitBinding) 68 | 69 | return ast 70 | } 71 | 72 | function clear (ast) { 73 | assert.ok(typeof ast === 'object' && ast && typeof ast.type === 'string', 'scope-analyzer: clear: ast must be an ast node') 74 | dash(ast, deleteScope) 75 | } 76 | 77 | function deleteScope (node) { 78 | if (node) { 79 | delete node[kScope] 80 | } 81 | } 82 | 83 | function getScope (node) { 84 | if (node && node[kScope]) { 85 | return node[kScope] 86 | } 87 | return null 88 | } 89 | 90 | function getBinding (identifier) { 91 | assert.strictEqual(typeof identifier, 'object', 'scope-analyzer: getBinding: identifier must be a node') 92 | assert.strictEqual(identifier.type, 'Identifier', 'scope-analyzer: getBinding: identifier must be an Identifier node') 93 | 94 | var scopeNode = getDeclaredScope(identifier) 95 | if (!scopeNode) return null 96 | var scope = getScope(scopeNode) 97 | if (!scope) return null 98 | return scope.getBinding(identifier.name) || scope.undeclaredBindings.get(identifier.name) 99 | } 100 | 101 | function registerScopeBindings (node) { 102 | if (node.type === 'Program') { 103 | createScope(node) 104 | } 105 | if (node.type === 'VariableDeclaration') { 106 | var scopeNode = getNearestScope(node, node.kind !== 'var') 107 | var scope = createScope(scopeNode) 108 | node.declarations.forEach(function (decl) { 109 | getAssignedIdentifiers(decl.id).forEach(function (id) { 110 | scope.define(new Binding(id.name, id)) 111 | }) 112 | }) 113 | } 114 | if (node.type === 'ClassDeclaration') { 115 | var scopeNode = getNearestScope(node) 116 | var scope = createScope(scopeNode) 117 | if (node.id && node.id.type === 'Identifier') { 118 | scope.define(new Binding(node.id.name, node.id)) 119 | } 120 | } 121 | if (node.type === 'FunctionDeclaration') { 122 | var scopeNode = getNearestScope(node, false) 123 | var scope = createScope(scopeNode) 124 | if (node.id && node.id.type === 'Identifier') { 125 | scope.define(new Binding(node.id.name, node.id)) 126 | } 127 | } 128 | if (isFunction(node)) { 129 | var scope = createScope(node) 130 | node.params.forEach(function (param) { 131 | getAssignedIdentifiers(param).forEach(function (id) { 132 | scope.define(new Binding(id.name, id)) 133 | }) 134 | }) 135 | } 136 | if (node.type === 'FunctionExpression' || node.type === 'ClassExpression') { 137 | var scope = createScope(node) 138 | if (node.id && node.id.type === 'Identifier') { 139 | scope.define(new Binding(node.id.name, node.id)) 140 | } 141 | } 142 | if (node.type === 'ImportDeclaration') { 143 | var scopeNode = getNearestScope(node, false) 144 | var scope = createScope(scopeNode) 145 | getAssignedIdentifiers(node).forEach(function (id) { 146 | scope.define(new Binding(id.name, id)) 147 | }) 148 | } 149 | if (node.type === 'CatchClause') { 150 | var scope = createScope(node) 151 | if (node.param) { 152 | getAssignedIdentifiers(node.param).forEach(function (id) { 153 | scope.define(new Binding(id.name, id)) 154 | }) 155 | } 156 | } 157 | } 158 | 159 | function getParentScope (node) { 160 | var parent = node 161 | while (parent.parent) { 162 | parent = parent.parent 163 | if (getScope(parent)) return getScope(parent) 164 | } 165 | } 166 | 167 | // Get the scope that a declaration will be declared in 168 | function getNearestScope (node, blockScope) { 169 | var parent = node 170 | while (parent.parent) { 171 | parent = parent.parent 172 | if (isFunction(parent)) { 173 | break 174 | } 175 | if (blockScope && parent.type === 'BlockStatement') { 176 | break 177 | } 178 | if (parent.type === 'Program') { 179 | break 180 | } 181 | } 182 | return parent 183 | } 184 | 185 | // Get the scope that this identifier has been declared in 186 | function getDeclaredScope (id) { 187 | var parent = id 188 | // Jump over one parent if this is a function's name--the variables 189 | // and parameters _inside_ the function are attached to the FunctionDeclaration 190 | // so if a variable inside the function has the same name as the function, 191 | // they will conflict. 192 | // Here we jump out of the FunctionDeclaration so we can start by looking at the 193 | // surrounding scope 194 | if (id.parent.type === 'FunctionDeclaration' && id.parent.id === id) { 195 | parent = id.parent 196 | } 197 | while (parent.parent) { 198 | parent = parent.parent 199 | if (parent[kScope] && parent[kScope].has(id.name)) { 200 | break 201 | } 202 | } 203 | return parent 204 | } 205 | 206 | function registerReference (node) { 207 | var scopeNode = getDeclaredScope(node) 208 | var scope = getScope(scopeNode) 209 | if (scope && scope.has(node.name)) { 210 | scope.add(node.name, node) 211 | } 212 | if (scope && !scope.has(node.name)) { 213 | scope.addUndeclared(node.name, node) 214 | } 215 | } 216 | 217 | function isObjectKey (node) { 218 | return node.parent.type === 'Property' && 219 | node.parent.key === node && 220 | // a shorthand property may have the ===-same node as both the key and the value. 221 | // we should detect the value part. 222 | node.parent.value !== node 223 | } 224 | function isMethodDefinition (node) { 225 | return node.parent.type === 'MethodDefinition' && node.parent.key === node 226 | } 227 | function isImportName (node) { 228 | return node.parent.type === 'ImportSpecifier' && node.parent.imported === node 229 | } 230 | 231 | function isVariable (node) { 232 | return node.type === 'Identifier' && 233 | !isObjectKey(node) && 234 | !isMethodDefinition(node) && 235 | (node.parent.type !== 'MemberExpression' || node.parent.object === node || 236 | (node.parent.property === node && node.parent.computed)) && 237 | !isImportName(node) 238 | } 239 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | var test = require('tape') 2 | var parse = require('acorn').parse 3 | var recast = require('recast') 4 | var ArrayFrom = require('array-from') 5 | var scan = require('../') 6 | 7 | function crawl (src, opts) { 8 | var ast = parse(src, opts) 9 | scan.crawl(ast) 10 | return ast 11 | } 12 | 13 | function cloneNode (node) { 14 | var cloned = {} 15 | var keys = Object.keys(node) 16 | for (var i = 0; i < keys.length; i++) { 17 | cloned[keys[i]] = node[keys[i]] 18 | } 19 | return cloned 20 | } 21 | 22 | test('register variable declarations in scope', function (t) { 23 | t.plan(5) 24 | var ast = crawl('var a, b; const c = 0; let d') 25 | 26 | var scope = scan.scope(ast) 27 | t.ok(scope.has('a'), 'should find var') 28 | t.ok(scope.has('b'), 'should find second declarator in var statement') 29 | t.ok(scope.has('c'), 'should find const') 30 | t.ok(scope.has('d'), 'should find let') 31 | t.notOk(scope.has('e'), 'nonexistent names should return false') 32 | }) 33 | 34 | test('register variable declarations in block scope', function (t) { 35 | t.plan(4) 36 | var ast = crawl('var a, b; { let b; }') 37 | var scope = scan.scope(ast) 38 | t.ok(scope.has('a')) 39 | t.ok(scope.has('b')) 40 | scope = scan.scope(ast.body[1]) 41 | t.ok(scope.has('b'), 'should declare `let` variable in BlockStatement scope') 42 | t.notOk(scope.has('a'), 'should only return true for names declared here') 43 | }) 44 | 45 | test('register non variable declarations (function, class, parameter)', function (t) { 46 | t.plan(4) 47 | var ast = crawl('function a (b, a) {} class X {}') 48 | var scope = scan.scope(ast) 49 | t.ok(scope.has('a'), 'should find function declarations') 50 | t.ok(scope.has('X'), 'should find class definition') 51 | scope = scan.scope(ast.body[0]) // function declaration 52 | t.ok(scope.has('a'), 'should find shadowed parameter') 53 | t.ok(scope.has('b'), 'should find parameter') 54 | }) 55 | 56 | test('use the value portion of a shorthand declaration property', function (t) { 57 | t.plan(2) 58 | 59 | var ast = parse('const { x } = y') 60 | var property = ast.body[0].declarations[0].id.properties[0] 61 | property.key = cloneNode(property.value) 62 | scan.crawl(ast) 63 | 64 | var binding = scan.scope(ast).getBinding('x') 65 | 66 | t.ok(binding.references.has(property.value)) 67 | t.notOk(binding.references.has(property.key)) 68 | }) 69 | 70 | test('use the value portion of a shorthand object property', function (t) { 71 | t.plan(2) 72 | 73 | var ast = parse('({ x })') 74 | var property = ast.body[0].expression.properties[0] 75 | property.key = cloneNode(property.value) 76 | scan.crawl(ast) 77 | 78 | var binding = scan.scope(ast).undeclaredBindings.get('x') 79 | 80 | t.ok(binding.references.has(property.value)) 81 | t.notOk(binding.references.has(property.key)) 82 | }) 83 | 84 | test('shadowing', function (t) { 85 | t.plan(8) 86 | var ast = crawl(` 87 | var a 88 | { let a } 89 | function b (b) { 90 | var a 91 | } 92 | `) 93 | var root = scan.scope(ast) 94 | var block = scan.scope(ast.body[1]) 95 | var fn = scan.scope(ast.body[2]) 96 | t.ok(root.has('a'), 'should find global var') 97 | t.ok(root.has('b'), 'should find function declaration') 98 | t.ok(block.has('a'), 'should shadow vars using `let` in block scope') 99 | t.notEqual(block.getBinding('a'), root.getBinding('a'), 'shadowing should define different bindings') 100 | t.ok(fn.has('b'), 'should find function parameter') 101 | t.notEqual(fn.getBinding('b'), root.getBinding('b'), 'shadowing function name with parameter should define different bindings') 102 | t.ok(fn.has('a'), 'should find local var') 103 | t.notEqual(fn.getBinding('a'), root.getBinding('a'), 'shadowing vars in function scope should define different bindings') 104 | }) 105 | 106 | test('references', function (t) { 107 | t.plan(5) 108 | 109 | var src = ` 110 | var a = 0 111 | a++ 112 | a++ 113 | function b (b) { 114 | console.log(b(a)) 115 | } 116 | b(function (b) { return a + b }) 117 | ` 118 | var ast = crawl(src) 119 | 120 | var root = scan.scope(ast) 121 | var fn = scan.scope(ast.body[3]) 122 | var callback = scan.scope(ast.body[4].expression.arguments[0]) 123 | 124 | var a = root.getBinding('a') 125 | t.equal(a.getReferences().length, 5, 'should collect references in same and nested scopes') 126 | var b = root.getBinding('b') 127 | t.equal(b.getReferences().length, 2, 'should collect references to function declaration') 128 | var b2 = fn.getBinding('b') 129 | t.equal(b2.getReferences().length, 2, 'should collect references to shadowed function parameter') 130 | var b3 = callback.getBinding('b') 131 | t.equal(b3.getReferences().length, 2, 'should collect references to shadowed function parameter') 132 | 133 | // try to rewrite some things 134 | var result = src.split('') 135 | a.getReferences().forEach(function (ref) { result[ref.start] = 'x' }) 136 | b.getReferences().forEach(function (ref) { result[ref.start] = 'y' }) 137 | b2.getReferences().forEach(function (ref) { result[ref.start] = 'z' }) 138 | b3.getReferences().forEach(function (ref) { result[ref.start] = 'w' }) 139 | t.equal(result.join(''), ` 140 | var x = 0 141 | x++ 142 | x++ 143 | function y (z) { 144 | console.log(z(x)) 145 | } 146 | y(function (w) { return x + w }) 147 | `, 'references were associated correctly') 148 | }) 149 | 150 | test('references that are declared later', function (t) { 151 | t.plan(4) 152 | 153 | var src = ` 154 | if (true) { b(function () { c() }) } 155 | function b () {} 156 | function c () {} 157 | ` 158 | var ast = crawl(src) 159 | 160 | var scope = scan.scope(ast) 161 | var b = scope.getBinding('b') 162 | t.ok(b, 'should have a binding for function b(){}') 163 | var c = scope.getBinding('c') 164 | t.ok(c, 'should have a binding for function c(){}') 165 | t.equal(b.getReferences().length, 2, 'should find all references for b') 166 | t.equal(c.getReferences().length, 2, 'should find all references for c') 167 | }) 168 | 169 | test('shorthand properties', function (t) { 170 | t.plan(3) 171 | 172 | var src = ` 173 | var b = 1 174 | var a = { b } 175 | var { c } = a 176 | console.log({ c, b, a }) 177 | ` 178 | var ast = crawl(src) 179 | var body = ast.body 180 | 181 | var scope = scan.scope(ast) 182 | var a = scope.getBinding('a') 183 | var b = scope.getBinding('b') 184 | var c = scope.getBinding('c') 185 | t.deepEqual(a.getReferences(), [a.definition, body[2].declarations[0].init, body[3].expression.arguments[0].properties[2].value]) 186 | t.deepEqual(b.getReferences(), [b.definition, body[1].declarations[0].init.properties[0].value, body[3].expression.arguments[0].properties[1].value]) 187 | t.deepEqual(c.getReferences(), [c.definition, body[3].expression.arguments[0].properties[0].value]) 188 | }) 189 | 190 | test('do not count object keys and method definitions as references', function (t) { 191 | t.plan(2) 192 | 193 | var src = ` 194 | var a 195 | class B { a () {} } 196 | class C { get a () {} } 197 | class D { set a (b) {} } 198 | var e = { a: null } 199 | ` 200 | var ast = crawl(src) 201 | 202 | var scope = scan.scope(ast) 203 | var a = scope.getBinding('a') 204 | t.equal(a.getReferences().length, 1) 205 | t.deepEqual(a.getReferences(), [a.definition]) 206 | }) 207 | 208 | test('do not count renamed imported identifiers as references', function (t) { 209 | t.plan(2) 210 | 211 | var src = ` 212 | var a = 0 213 | a++ 214 | a++ 215 | import { a as b } from "b" 216 | b() 217 | ` 218 | var ast = crawl(src, { sourceType: 'module' }) 219 | 220 | var root = scan.scope(ast) 221 | 222 | var a = root.getBinding('a') 223 | var b = root.getBinding('b') 224 | t.equal(a.getReferences().length, 3, 'should not have counted renamed `a` import as a reference') 225 | t.equal(b.getReferences().length, 2, 'should have counted local name of renamed import') 226 | }) 227 | 228 | test('remove references', function (t) { 229 | t.plan(6) 230 | 231 | var src = ` 232 | function a () {} 233 | a() 234 | a() 235 | ` 236 | var ast = crawl(src) 237 | 238 | var root = scan.scope(ast) 239 | var a = root.getBinding('a') 240 | t.equal(a.getReferences().length, 3, 'should have 3 references') 241 | t.ok(a.isReferenced(), 'should be referenced') 242 | var reference = ast.body[1].expression.callee 243 | a.remove(reference) 244 | t.equal(a.getReferences().length, 2, 'should have removed the reference') 245 | t.ok(a.isReferenced(), 'should still be referenced') 246 | reference = ast.body[2].expression.callee 247 | a.remove(reference) 248 | t.equal(a.getReferences().length, 1, 'should still have the definition reference') 249 | t.notOk(a.isReferenced(), 'should no longer be referenced') 250 | }) 251 | 252 | test('collect references to undeclared variables', function (t) { 253 | t.plan(2) 254 | 255 | var src = ` 256 | var a = b 257 | b = a 258 | a(b) 259 | function c () { 260 | return d 261 | } 262 | ` 263 | var ast = crawl(src) 264 | 265 | var root = scan.scope(ast) 266 | var undeclared = ArrayFrom(root.undeclaredBindings.keys()) 267 | var declared = ArrayFrom(root.bindings.keys()) 268 | t.deepEqual(undeclared, ['b', 'd']) 269 | t.deepEqual(declared, ['a', 'c']) 270 | }) 271 | 272 | test('loop over all available bindings, including declared in parent scope', function (t) { 273 | t.plan(1) 274 | 275 | var src = ` 276 | var a = 0 277 | var b = 1, c = 2 278 | function d() { 279 | function e() {} 280 | function f() { 281 | var b = 3 282 | console.log('bindings') 283 | } 284 | } 285 | ` 286 | 287 | var ast = crawl(src) 288 | var scope = scan.scope(ast.body[2].body.body[1]) 289 | var names = [] 290 | scope.forEachAvailable(function (binding, name) { 291 | names.push(name) 292 | }) 293 | t.deepEqual(names, ['b', 'e', 'f', 'a', 'c', 'd']) 294 | }) 295 | 296 | test('always initialise a scope for the root', function (t) { 297 | t.plan(2) 298 | 299 | var src = ` 300 | console.log("null") 301 | ` 302 | 303 | var ast = crawl(src) 304 | var scope = scan.scope(ast) 305 | 306 | t.ok(scope) 307 | t.deepEqual(scope.getUndeclaredNames(), ['console']) 308 | }) 309 | 310 | test('initialises a scope for catch clauses', function (t) { 311 | t.plan(5) 312 | var ast = crawl(` 313 | var a = null 314 | a = 1 315 | try { 316 | } catch (a) { 317 | a = 2 318 | } 319 | `) 320 | 321 | var scope = scan.scope(ast) 322 | t.ok(scope.has('a'), 'should find var') 323 | t.equal(scope.getBinding('a').getReferences().length, 2, 'only counts references to outer `a`') 324 | var clause = ast.body[2].handler 325 | var catchScope = scan.scope(clause) 326 | t.ok(catchScope.has('a'), 'should find param') 327 | t.notEqual(scope.getBinding('a'), catchScope.getBinding('a'), 'introduced a different binding') 328 | t.equal(catchScope.getBinding('a').getReferences().length, 2, 'only counts references to inner `a`') 329 | }) 330 | 331 | test('clear all scope information', function (t) { 332 | t.plan(6) 333 | 334 | var ast = crawl(` 335 | function x() { 336 | var y = z 337 | } 338 | var z = x 339 | `) 340 | 341 | var fn = ast.body[0] 342 | 343 | t.ok(scan.scope(ast)) 344 | t.ok(scan.scope(fn)) 345 | t.ok(scan.getBinding(fn.id)) 346 | 347 | scan.clear(ast) 348 | 349 | t.notOk(scan.scope(ast)) 350 | t.notOk(scan.scope(fn)) 351 | t.notOk(scan.getBinding(fn.id)) 352 | }) 353 | 354 | test('clear partial scope information', function (t) { 355 | t.plan(4) 356 | 357 | var ast = crawl('function x() {}') 358 | 359 | var fn = ast.body[0] 360 | 361 | t.ok(scan.scope(fn)) 362 | t.ok(scan.getBinding(fn.id)) 363 | 364 | scan.deleteScope(fn) 365 | 366 | t.notOk(scan.scope(fn)) 367 | t.ok(scan.getBinding(fn.id)) 368 | }) 369 | 370 | test('recast: does not touch all nodes', function (t) { 371 | t.plan(1) 372 | 373 | var input = 'function *weirdly(){ const formatted =0; }' 374 | var ast = recast.parse(input) 375 | scan.analyze(ast) 376 | var output = recast.print(ast).code 377 | t.equal(input, output) 378 | }) 379 | --------------------------------------------------------------------------------