├── .gitignore ├── .jshintrc ├── .travis.yml ├── LICENSE-MIT ├── README.md ├── lib ├── comparators │ ├── fuzzy-identifiers.js │ └── fuzzy-strings.js ├── compare.js ├── match-error.js └── util │ └── normalize-member-expr.js ├── package.json └── test └── compare.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "quotmark": "single", 3 | "esnext": true, 4 | "trailing": true, 5 | "unused": true, 6 | "undef": true, 7 | "node": true, 8 | "globals": { 9 | "suite": true, 10 | "test": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | script: npm run ci 3 | 4 | node_js: 5 | - '0.10' 6 | - 0.11 7 | 8 | branches: 9 | only: 10 | - master 11 | 12 | matrix: 13 | fast_finish: true 14 | allow_failures: 15 | - node_js: 0.11 16 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Tim Branyen & Mike Pennisi 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # compareAst 2 | 3 | Determine if two strings of JavaScript have equivalent abstract syntax trees. 4 | This can be useful to test tools that perform source code transformations. 5 | 6 | [![Build 7 | Status](https://travis-ci.org/jugglinmike/compare-ast.png)](https://travis-ci.org/jugglinmike/compare-ast) 8 | 9 | ## API 10 | 11 | This module exports a function with the following signature: 12 | 13 | compareAst(expectedJsSource, actualJsSource [, options]) 14 | 15 | ### Built-in Relaxing Options 16 | 17 | compareAst supports configuration of the following relaxing options: 18 | 19 | - `varPattern` - a regular expression describing identifier names that, when 20 | encountered in the "expected" AST, will be "bound" to the corresponding 21 | identifier in the "actual" AST. All further occurences of that identifier 22 | must match the original bound value. 23 | - `stringPattern` - a regular expression describing string values that, when 24 | encountered in the "expected" AST, will be "bound" to the corresponding 25 | string value in the "actual" AST. All further occurences of that string value 26 | must match the original bound value. 27 | 28 | See the "Examples" section below for more information on defining these 29 | relaxing options. 30 | 31 | ### Custom Comparators 32 | 33 | The `options` object may specify an array of `comparators`. These functions can 34 | be used to further relax the criteria for equivalency. Each will be invoked for 35 | every node under comparison. These nodes are generated by 36 | [esprima](http://esprima.org/); see the [esprima 37 | documentation](http://esprima.org/doc/index.html) for a description of their 38 | structure. 39 | 40 | compareAst recognizes the following comparator return types: 41 | 42 | - Instance of `compareAst.Errors` - the two nodes are not equivalent 43 | - `true` - the two nodes are equivalent 44 | - `undefined` - equivalency cannot be determined by this comparator 45 | 46 | ## Examples 47 | 48 | // Identical sources will not trigger an error: 49 | compareAst("var a = 3; a++;", " var a =3; a++;"); 50 | 51 | // Because whitespace is insignificant in JavaScript, two sources which 52 | // only differ in their spacing will not trigger an error: 53 | compareAst("var a = 3; a++;", " var a \t=3;\na++;"); 54 | 55 | // Code that differs structurally will throw an error 56 | compareAst("var a = 3; a++;", "var a = 3; a--;"); 57 | 58 | // Allow for "fuzzy" variable names by specifying the `varPattern` option 59 | // as a regular expression: 60 | compareAst( 61 | "var a = 3, b = 2; a += b;", 62 | "var __x1__ = 3, __x2__ = 2; __x1__ += __x2__;", 63 | { varPattern: /__x\d+__/ } 64 | ); 65 | 66 | // Allow for "fuzzy" string values by specifying the `stringPattern` option 67 | // as a regular expression: 68 | compareAst( 69 | "var a = 'one', b = 'two', c = 'three';" 70 | "var a = '__s1__', b = '__s2__', c = '__s3__';", 71 | { stringPattern: /__s\d+__/ } 72 | ); 73 | 74 | ## Tests 75 | 76 | Run via: 77 | 78 | $ npm test 79 | 80 | ## License 81 | 82 | Copyright (c) 2014 Mike Pennisi 83 | Licensed under the MIT license. 84 | -------------------------------------------------------------------------------- /lib/comparators/fuzzy-identifiers.js: -------------------------------------------------------------------------------- 1 | var Errors = require('../match-error'); 2 | 3 | var noop = function() {}; 4 | 5 | /** 6 | * Update unbound variable names in the expected AST, using the 7 | * previously-bound value when available. 8 | */ 9 | module.exports = function(options) { 10 | // Lookup table of bound variable names 11 | var boundVars = {}; 12 | var varPattern = options.varPattern; 13 | 14 | if (!varPattern) { 15 | return noop; 16 | } 17 | 18 | return function(actual, expected) { 19 | var unboundName; 20 | 21 | if (actual.type !== 'Identifier' || expected.type !== 'Identifier') { 22 | return; 23 | } 24 | 25 | if (varPattern.test(expected.name)) { 26 | unboundName = expected.name; 27 | if (!(unboundName in boundVars)) { 28 | boundVars[unboundName] = actual.name; 29 | } 30 | expected.name = boundVars[unboundName]; 31 | 32 | // This inequality would be caught by the next recursion, but it is 33 | // asserted here to provide a more specific error--the ASTs do not 34 | // match because a variable was re-bound. 35 | if (expected.name !== actual.name) { 36 | return new Errors.BindingError(actual.name, expected.name); 37 | } else { 38 | return true; 39 | } 40 | } 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /lib/comparators/fuzzy-strings.js: -------------------------------------------------------------------------------- 1 | var Errors = require('../match-error'); 2 | 3 | var noop = function() {}; 4 | 5 | module.exports = function(options) { 6 | var stringPattern = options.stringPattern; 7 | 8 | if (!stringPattern) { 9 | return noop; 10 | } 11 | 12 | return function(actual, expected) { 13 | var boundStrings; 14 | 15 | if (actual.type !== 'Literal' || expected.type !== 'Literal') { 16 | return; 17 | } 18 | 19 | if (!options.boundStrings) { 20 | options.boundStrings = {}; 21 | } 22 | boundStrings = options.boundStrings; 23 | 24 | if (!stringPattern.test(expected.value)) { 25 | return false; 26 | } 27 | 28 | var expectedValue = boundStrings[expected.value]; 29 | // This string value has previously been bound 30 | if (expectedValue) { 31 | if (expectedValue !== actual.value) { 32 | return new Errors.BindingError(actual.value, expected.value); 33 | } 34 | } else { 35 | boundStrings[expected.value] = actual.value; 36 | } 37 | 38 | expected.value = actual.value; 39 | 40 | return true; 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /lib/compare.js: -------------------------------------------------------------------------------- 1 | var parse = require('esprima').parse; 2 | var Errors = require('./match-error'); 3 | var normalizeMemberExpr = require('./util/normalize-member-expr'); 4 | 5 | // Given a "template" expected AST that defines abstract identifier names 6 | // described by `options.varPattern`, "bind" those identifiers to their 7 | // concrete names in the "actual" AST. 8 | function compareAst(actualSrc, expectedSrc, options) { 9 | var actualAst, expectedAst; 10 | options = options || {}; 11 | 12 | if (!options.comparators) { 13 | options.comparators = []; 14 | } 15 | 16 | /* 17 | * A collection of comparator functions that recognize equivalent nodes 18 | * that would otherwise be reported as unequal by simple object comparison. 19 | * These may: 20 | * 21 | * - return `true` if the given nodes can be verified as equivalent with no 22 | * further processing 23 | * - return an instance of MatchError if the nodes are verified as 24 | * uniquivalent 25 | * - return any other value if equivalency cannot be determined 26 | */ 27 | Array.prototype.push.apply(options.comparators, [ 28 | require('./comparators/fuzzy-identifiers')(options), 29 | require('./comparators/fuzzy-strings')(options) 30 | ]); 31 | 32 | try { 33 | actualAst = parse(actualSrc).body; 34 | } catch(err) { 35 | throw new Errors.ParseError(); 36 | } 37 | 38 | try { 39 | expectedAst = parse(expectedSrc).body; 40 | } catch (err) { 41 | throw new Errors.ParseError(); 42 | } 43 | 44 | function _bind(actual, expected) { 45 | var attr; 46 | 47 | // Literal values 48 | if (Object(actual) !== actual) { 49 | if (actual !== expected) { 50 | throw new Errors.MatchError(actualAst, expectedAst); 51 | } 52 | return; 53 | } 54 | 55 | // Arrays 56 | if (Array.isArray(actual)) { 57 | if (actual.length !== expected.length) { 58 | throw new Errors.MatchError(actualAst, expectedAst); 59 | } 60 | actual.forEach(function(_, i) { 61 | _bind(actual[i], expected[i]); 62 | }); 63 | return; 64 | } 65 | 66 | // Nodes 67 | 68 | normalizeMemberExpr(actual); 69 | normalizeMemberExpr(expected); 70 | 71 | if (checkEquivalency(options.comparators, actual, expected)) { 72 | return; 73 | } 74 | 75 | // Either remove attributes or recurse on their values 76 | for (attr in actual) { 77 | if (expected && attr in expected) { 78 | _bind(actual[attr], expected[attr]); 79 | } else { 80 | throw new Errors.MatchError(actualAst, expectedAst); 81 | } 82 | } 83 | } 84 | 85 | // Start recursing on the ASTs from the top level. 86 | _bind(actualAst, expectedAst); 87 | 88 | return null; 89 | } 90 | 91 | var checkEquivalency = function(comparators, actual, expected) { 92 | var result = comparators.map(function(comparator) { 93 | return comparator(actual, expected); 94 | }).reduce(function(prev, current) { 95 | if (current === true) { 96 | return true; 97 | } 98 | return prev || current; 99 | }, null); 100 | 101 | if (result instanceof Errors) { 102 | throw result; 103 | } else if (result === true) { 104 | return true; 105 | } 106 | }; 107 | 108 | compareAst.Error = Errors; 109 | 110 | module.exports = compareAst; 111 | -------------------------------------------------------------------------------- /lib/match-error.js: -------------------------------------------------------------------------------- 1 | function CompareAstError(actual, expected) { 2 | this.actual = actual; 3 | this.expected = expected; 4 | } 5 | 6 | var ParseError = CompareAstError.ParseError = function() { 7 | CompareAstError.apply(this, arguments); 8 | }; 9 | ParseError.prototype = Object.create(CompareAstError.prototype); 10 | ParseError.prototype.code = 1; 11 | ParseError.prototype.message = 'Parse error'; 12 | 13 | var BindingError = CompareAstError.BindingError = function() { 14 | CompareAstError.apply(this, arguments); 15 | }; 16 | BindingError.prototype = Object.create(CompareAstError.prototype); 17 | BindingError.prototype.code = 2; 18 | BindingError.prototype.message = 'Re-bound variable'; 19 | 20 | var MatchError = CompareAstError.MatchError = function() { 21 | CompareAstError.apply(this, arguments); 22 | this.showDiff = true; 23 | }; 24 | MatchError.prototype = Object.create(CompareAstError.prototype); 25 | MatchError.prototype.code = 3; 26 | MatchError.prototype.message = 'Unmatched ASTs'; 27 | 28 | module.exports = CompareAstError; 29 | -------------------------------------------------------------------------------- /lib/util/normalize-member-expr.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Re-write identifier property access to use literal values, e.g. 3 | * 4 | * object.property => object["property"] 5 | */ 6 | module.exports = function(node) { 7 | if (node.type !== 'MemberExpression' || 8 | node.property.type !== 'Identifier') { 9 | return; 10 | } 11 | 12 | // When the actual property is an Identifier, compare its literal 13 | // representation against the expected property. 14 | node.computed = true; 15 | var name = node.property.name; 16 | node.property = { 17 | type: 'Literal', 18 | value: name 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "compare-ast", 3 | "version": "0.2.0", 4 | "description": "Determine if two strings of JavaScript have equivalent abstract syntax trees.", 5 | "main": "lib/compare.js", 6 | "dependencies": { 7 | "esprima": "~1.0.4" 8 | }, 9 | "devDependencies": { 10 | "jshint": "~2.4.1", 11 | "mocha": "~1.17.0" 12 | }, 13 | "scripts": { 14 | "test": "mocha --ui tdd", 15 | "jshint": "jshint lib/ test/", 16 | "ci": "npm run jshint && npm test" 17 | }, 18 | "author": "Mike Pennisi", 19 | "license": "MIT", 20 | "directories": { 21 | "test": "test" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git://github.com/jugglinmike/compare-ast.git" 26 | }, 27 | "keywords": [ 28 | "assertions" 29 | ], 30 | "bugs": { 31 | "url": "https://github.com/jugglinmike/compare-ast/issues" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/compare.js: -------------------------------------------------------------------------------- 1 | var compareAst = require('..'); 2 | 3 | suite('compareAst', function() { 4 | test('whitespace', function() { 5 | compareAst('\tvar a=0;\n \t a +=4;\n\n', 'var a = 0; a += 4;'); 6 | }); 7 | 8 | suite('dereferencing', function() { 9 | test('identifier to literal', function() { 10 | compareAst('a.b;', 'a["b"];'); 11 | }); 12 | 13 | test('literal to identifier', function() { 14 | compareAst('a["b"];', 'a.b;'); 15 | }); 16 | }); 17 | 18 | test('IIFE parenthesis placement', function() { 19 | compareAst('(function() {}());', '(function() {})();'); 20 | }); 21 | 22 | test.skip('variable lists', function() { 23 | compareAst('var a; var b;', 'var a, b;'); 24 | }); 25 | 26 | test('variable binding', function() { 27 | compareAst( 28 | '(function(a, b) { console.log(a + b); })(1, 3);', 29 | '(function(__UNBOUND0__, __UNBOUND1__) {' + 30 | 'console.log(__UNBOUND0__ + __UNBOUND1__);' + 31 | '}) (1,3);', 32 | { varPattern: /__UNBOUND\d+__/ } 33 | ); 34 | }); 35 | 36 | test('string binding', function() { 37 | compareAst( 38 | 'a["something"];"something2";"something";', 39 | 'a["__STR1__"]; "__STR2__"; "__STR1__";', 40 | { stringPattern: /__STR\d+__/ } 41 | ); 42 | }); 43 | 44 | test('string binding with object dereferencing', function() { 45 | compareAst( 46 | 'a.b;', 47 | 'a["_s1_"];', 48 | { stringPattern: /_s\d_/ } 49 | ); 50 | }); 51 | 52 | test('variable binding and string binding', function() { 53 | compareAst( 54 | 'a["b"];', 55 | '_v1_["_s1_"];', 56 | { stringPattern: /_s\d_/, varPattern: /_v\d_/ } 57 | ); 58 | }); 59 | 60 | test('custom comparator', function() { 61 | var vals = [3, 4]; 62 | var threeOrFour = function(actual, expected) { 63 | if (actual.type !== 'Literal' || expected.type !== 'Literal') { 64 | return; 65 | } 66 | 67 | if (vals.indexOf(actual.value) > -1 && 68 | vals.indexOf(expected.value) > -1) { 69 | return true; 70 | } 71 | }; 72 | compareAst( 73 | 'a.b + 3', 74 | 'a["b"] + 4', 75 | { comparators: [threeOrFour] } 76 | ); 77 | }); 78 | 79 | suite('expected failures', function() { 80 | 81 | function noMatch(args, expectedCode) { 82 | try { 83 | compareAst.apply(null, args); 84 | } catch(err) { 85 | if (!(err instanceof compareAst.Error)) { 86 | throw new Error( 87 | 'Expected a compareAst error, but caught a generic ' + 88 | 'error: "' + err.message + '"' 89 | ); 90 | } 91 | if (err.code === expectedCode) { 92 | return; 93 | } 94 | throw new Error( 95 | 'Expected error with code "' + expectedCode + 96 | '", but received error with code "' + err.code + '".' 97 | ); 98 | } 99 | throw new Error('Expected an error, but no error was thrown.'); 100 | } 101 | 102 | test('unmatched statements', function() { 103 | noMatch(['', 'var x = 0;'], 3); 104 | noMatch(['var x = 0;', ''], 3); 105 | }); 106 | 107 | test('parse failure', function() { 108 | noMatch(['var a = !;', 'var a = !;'], 1); 109 | }); 110 | 111 | test('name change', function() { 112 | noMatch(['(function(a) {});', '(function(b) {});'], 3); 113 | }); 114 | 115 | test('value change', function() { 116 | noMatch(['var a = 3;', 'var a = 4;'], 3); 117 | }); 118 | 119 | test('dereferencing', function() { 120 | noMatch(['a.b;', 'a["b "];'], 3); 121 | }); 122 | 123 | test('double variable binding', function() { 124 | noMatch([ 125 | '(function(a, b) { console.log(a); });', 126 | '(function(__UNBOUND0__, __UNBOUND1__) { console.log(__UNBOUND1__); });', 127 | { varPattern: /__UNBOUND\d+__/ } 128 | ], 129 | 2 130 | ); 131 | }); 132 | 133 | test('double string binding', function() { 134 | noMatch([ 135 | 'var a = "one", b = "two", c = "three";', 136 | 'var a = "_s1_", b = "_s2_", c = "_s1_";', 137 | { stringPattern: /_s\d_/ }, 138 | ], 139 | 2 140 | ); 141 | }); 142 | 143 | test('double string binding (through object dereference)', function() { 144 | noMatch([ 145 | 'a.a; a.b; a.c;', 146 | 'a["_s1_"]; a["_s2_"]; a["_s1_"];', 147 | { stringPattern: /_s\d/ }, 148 | ], 149 | 2 150 | ); 151 | }); 152 | 153 | test('extra statements', function() { 154 | noMatch(['a;', ''], 3); 155 | }); 156 | 157 | test('unmatched member expression', function() { 158 | noMatch(['a.b;', '3;'], 3); 159 | }); 160 | 161 | }); 162 | }); 163 | --------------------------------------------------------------------------------