├── .babelrc ├── .eslintrc ├── .flowconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── examples └── tonic │ └── tonicExample.js ├── package.json └── src ├── Closure.js ├── Environment.js ├── Interp ├── ExpressionInterp.js ├── StatementInterp.js ├── __tests__ │ ├── ExpressionInterp-test.js │ └── StatementInterp-test.js └── index.js ├── Options.js ├── Parser.js ├── Prims.js ├── __tests__ ├── Environment-test.js └── utils-test.js ├── index.js ├── typeFlags.js └── utils.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ "es2015", "stage-0" ], 3 | "plugins": ["transform-flow-strip-types"], 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "env": { 4 | "node": true, 5 | "es6": true, 6 | "jest": true, 7 | "jasmine": true, 8 | }, 9 | "globals": { 10 | "__DEV__": true, 11 | }, 12 | "parser": "babel-eslint", 13 | "rules": { 14 | "no-void": 0, 15 | "no-mixed-operators": 0, 16 | "no-continue": 0, 17 | "import/prefer-default-export": 0, 18 | }, 19 | "plugins": [ 20 | "babel", 21 | ], 22 | } 23 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/__tests__/.* 3 | ./dist/.* 4 | ./lib/.* 5 | .*/node_modules/.* 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | .DS_Store 36 | lib 37 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | .DS_Store 36 | src 37 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 5.10.1 5 | 6 | compiler: clang-3.6 7 | 8 | env: 9 | - CXX=clang-3.6 10 | 11 | addons: 12 | apt: 13 | sources: 14 | - llvm-toolchain-precise-3.6 15 | - ubuntu-toolchain-r-test 16 | packages: 17 | - clang-3.6 18 | - g++-4.8 19 | 20 | after_success: npm run coveralls 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Keyan Zhang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Super Tiny _Interpreter_ 2 | [![build status](https://img.shields.io/travis/keyanzhang/the-super-tiny-interpreter/master.svg?style=flat-square)](https://travis-ci.org/keyanzhang/the-super-tiny-interpreter) 3 | [![test coverage](https://img.shields.io/coveralls/keyanzhang/the-super-tiny-interpreter/master.svg?style=flat-square)](https://coveralls.io/github/keyanzhang/the-super-tiny-interpreter?branch=master) 4 | 5 | Let's explain what a **[closure](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures)** is by writing a JavaScript interpreter in JavaScript. 6 | 7 | Try it here: https://tonicdev.com/npm/the-super-tiny-interpreter 8 | 9 | This project is still a **work in progress**, but feel free to poke around and check out the unit tests. 10 | 11 | ## Disclaimer 12 | The goal of this project is **not** to make a spec-compliant or blazing-fast interpreter. The goal, however, is to interpret a tiny subset of JavaScript features in **super-easy-to-read™** code. 13 | 14 | ## Supported Syntax 15 | 1. Numbers, Booleans, `null`, and `undefined` 16 | 17 | ```javascript 18 | 12 // Numeric Literal 19 | true // Boolean Literal 20 | null // Null Literal 21 | undefined // Do you know that `undefined` is actually an identifier? Paul Irish calls shadowing it the "Asshole Effect". 22 | ``` 23 | 24 | 2. Variable, a.k.a. Identifier 25 | 26 | ```javascript 27 | foo 28 | ``` 29 | 30 | 3. Binary Operators 31 | 32 | ```javascript 33 | +, -, *, /, ==, ===, !=, !==, <, <=, >, >= 34 | ``` 35 | 36 | 4. Unary Operators 37 | 38 | ```javascript 39 | !, - 40 | ``` 41 | 42 | 5. Conditional Expression, a.k.a. the ternary operator 43 | 44 | ```javascript 45 | test ? consequent : alternate 46 | ``` 47 | 48 | 6. Arrow Function Expression 49 | - Notice that we didn't implement the traditional `function` syntax. Arrow functions FTW! 50 | 51 | ```javascript 52 | (x) => x + 1 53 | 54 | (x) => { 55 | const y = x + 100; 56 | return y * y; 57 | } 58 | ``` 59 | 60 | 7. Call Expression 61 | 62 | ```javascript 63 | foo(1, 2, 3) 64 | ``` 65 | 66 | 8. Variable Declaration Statement 67 | - Notice that we only support `const` for now and there's NO mutation (assignment) in our language. 68 | - That means we can initialize stuff once and only once 69 | - And of course `const foo;` is not valid JavaScript 70 | - If you are familiar with Scheme/OCaml, then `const LHS = RHS` behaves just like a `letrec`. 71 | 72 | ```javascript 73 | const foo = 12; 74 | 75 | const fact = (x) => x < 2 ? 1 : x * fact(x - 1); 76 | ``` 77 | -------------------------------------------------------------------------------- /examples/tonic/tonicExample.js: -------------------------------------------------------------------------------- 1 | const { interp, setLexical } = require('the-super-tiny-interpreter'); 2 | 3 | const code = ` 4 | const adder = (x) => (y) => x + y; 5 | const x = 100; 6 | const add3 = adder(3); 7 | log(add3(5)); 8 | `; 9 | 10 | setLexical(true); 11 | 12 | interp(code); // should log `8` 13 | 14 | setLexical(false); 15 | 16 | interp(code); // should log 105 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "the-super-tiny-interpreter", 3 | "version": "1.0.3", 4 | "description": "Explain what a closure is by writing a JavaScript interpreter in JavaScript", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "clean": "rimraf lib coverage", 8 | "build:babel": "babel src --out-dir lib --ignore __tests__", 9 | "build": "npm run clean && npm run lint && npm test && npm run build:babel", 10 | "test": "jest", 11 | "coverage": "jest --coverage", 12 | "coveralls": "npm run coverage && cat ./coverage/lcov.info | coveralls", 13 | "lint": "eslint src", 14 | "prepublish": "npm run build" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/keyanzhang/the-super-tiny-interpreter.git" 19 | }, 20 | "keywords": [], 21 | "author": "Keyan Zhang (http://keya.nz)", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/keyanzhang/the-super-tiny-interpreter/issues" 25 | }, 26 | "homepage": "https://github.com/keyanzhang/the-super-tiny-interpreter#readme", 27 | "devDependencies": { 28 | "babel-cli": "^6.7.5", 29 | "babel-core": "^6.7.6", 30 | "babel-eslint": "^6.0.2", 31 | "babel-jest": "^14.1.0", 32 | "babel-plugin-transform-flow-strip-types": "^6.7.0", 33 | "babel-polyfill": "^6.7.4", 34 | "babel-preset-es2015": "^6.6.0", 35 | "babel-preset-stage-0": "^6.5.0", 36 | "coveralls": "^2.11.9", 37 | "eslint": "^3.2.2", 38 | "eslint-config-airbnb-base": "^5.0.1", 39 | "eslint-plugin-babel": "^3.2.0", 40 | "eslint-plugin-import": "^1.6.0", 41 | "jest-cli": "^14.1.0", 42 | "rimraf": "^2.5.2" 43 | }, 44 | "dependencies": { 45 | "babel-polyfill": "^6.9.0", 46 | "babylon": "^6.7.0", 47 | "fbjs": "^0.8.0", 48 | "immutable": "^3.8.0" 49 | }, 50 | "jest": { 51 | "automock": false, 52 | "testPathDirs": [ 53 | "/src" 54 | ] 55 | }, 56 | "tonicExampleFilename": "examples/tonic/tonicExample.js" 57 | } 58 | -------------------------------------------------------------------------------- /src/Closure.js: -------------------------------------------------------------------------------- 1 | import invariant from 'fbjs/lib/invariant'; 2 | 3 | import { batchExtendEnv, extendEnv } from './Environment'; 4 | import { CLOSURE_TYPE_FLAG } from './typeFlags'; 5 | 6 | /* 7 | * As we've said, 8 | * a closure is just a combination of a function definition and an environment snapshot. 9 | * Here we represent a closure simply as a plain object. 10 | */ 11 | const makeClosure = (params, body, defineTimeEnv) => ({ 12 | type: CLOSURE_TYPE_FLAG, 13 | params, 14 | body, 15 | env: defineTimeEnv, 16 | }); 17 | 18 | /* 19 | * This is a little bit tricky and please feel free to ignore this part. 20 | * 21 | * Our arrow functions can be recursively defined. For instance, 22 | * in `const fact = (x) => (x < 2 ? 1 : x * fact(x - 1));` 23 | * we need to reference `fact` in the body of `fact` itself. 24 | * 25 | * If we create a "normal" closure for the function above, the `fact` (pun intended) 26 | * in the body will be unbound. 27 | * 28 | * A quick fix is to update the environment with a reference to the closure itself. 29 | * For more information, check http://www.cs.indiana.edu/~dyb/papers/fixing-letrec.pdf 30 | */ 31 | const makeRecClosure = (id, params, body, defineTimeEnv) => { 32 | const closure = makeClosure(params, body, defineTimeEnv); 33 | const updatedEnvWithSelfRef = extendEnv(id, closure, defineTimeEnv); 34 | closure.env = updatedEnvWithSelfRef; 35 | return closure; 36 | }; 37 | 38 | /* 39 | * `applyClosure` is where the function invocation (computation) happens. 40 | * For demonstration purposes, we pass an additional `callTimeEnv` and an `isLexical` flag 41 | * so you can toggle the behavior between lexical and dynamic bindings. 42 | * 43 | * As you can see, there's no black magic™. The resolving behavior is simply determined 44 | * by which environment you want the interpreter to use. 45 | * 46 | * Some people call dynamic binding "late binding". That makes sense, cuz `callTimeEnv` 47 | * is definitely fresher than `defineTimeEnv`. We just have too many names though. 48 | */ 49 | const applyClosure = (evaluator, closure, vals, callTimeEnv, isLexical = true) => { 50 | invariant(closure.type === CLOSURE_TYPE_FLAG, `${closure} is not a closure`); 51 | 52 | const { params, body, env: defineTimeEnv } = closure; 53 | 54 | if (!isLexical) { 55 | // Dynamic scope. 56 | // `callTimeEnv` is the latest binding information. 57 | const envForTheEvaluator = batchExtendEnv(params, vals, /* 🙋 HEY LOOK AT ME 🙋 */ callTimeEnv); 58 | return evaluator(body, envForTheEvaluator); 59 | } 60 | 61 | // Lexical closure yo. 62 | // `defineTimeEnv` is the one that got extracted from the closure. 63 | const envForTheEvaluator = batchExtendEnv(params, vals, /* 🙋 HEY LOOK AT ME 🙋 */ defineTimeEnv); 64 | return evaluator(body, envForTheEvaluator); 65 | }; 66 | 67 | export { 68 | makeClosure, 69 | makeRecClosure, 70 | applyClosure, 71 | }; 72 | -------------------------------------------------------------------------------- /src/Environment.js: -------------------------------------------------------------------------------- 1 | /* 2 | * An environment is just a mapping from names to values. 3 | * 4 | * For instance, after we evaluate `const foo = 12;`, 5 | * the environment contains a mapping that looks like `{ foo: 12 }`. 6 | * 7 | * Here we use the immutable map from Immutable.js. The immutable model fits with 8 | * our implementation and its API is pretty nice. 9 | */ 10 | import { Map as iMap } from 'immutable'; 11 | 12 | /* 13 | * An empty map to start with. 14 | */ 15 | const emptyEnv = iMap({ 16 | undefined: void 0, // `void 0` is always `undefined` 17 | }); 18 | 19 | /* 20 | * get(name) 21 | * throws if the name cannot be found 22 | */ 23 | const lookupEnv = (name, env) => { 24 | if (!env.has(name)) { 25 | throw new Error( 26 | `Uncaught ReferenceError: ${name} is not defined. env snapshot: ${env.toString()}`, 27 | ); 28 | } 29 | 30 | return env.get(name); 31 | }; 32 | 33 | /* 34 | * set(name, value) 35 | */ 36 | const extendEnv = (name, val, env) => env.set(name, val); 37 | 38 | /* 39 | * Batch set. Nothing fancy. 40 | */ 41 | const batchExtendEnv = (names, vals, env) => { 42 | if (names.length !== vals.length) { 43 | throw new Error( 44 | `unmatched argument count: parameters are [${names}] and arguments are [${vals}]`, 45 | ); 46 | } 47 | 48 | return names.reduce( 49 | (newEnv, name, idx) => { 50 | const val = vals[idx]; 51 | return extendEnv(name, val, newEnv); 52 | }, 53 | env, 54 | ); 55 | }; 56 | 57 | export { 58 | emptyEnv, 59 | lookupEnv, 60 | extendEnv, 61 | batchExtendEnv, 62 | }; 63 | -------------------------------------------------------------------------------- /src/Interp/ExpressionInterp.js: -------------------------------------------------------------------------------- 1 | /* 2 | * https://github.com/estree/estree/blob/master/spec.md#expressions 3 | */ 4 | 5 | import { 6 | lookupEnv, 7 | } from '../Environment'; 8 | 9 | import { 10 | makeClosure, 11 | makeRecClosure, 12 | applyClosure, 13 | } from '../Closure'; 14 | 15 | import { CLOSURE_TYPE_FLAG, NATIVE_FUNC_FLAG } from '../typeFlags'; 16 | 17 | import Prims from '../Prims'; 18 | 19 | import Options from '../Options'; 20 | 21 | import { statementInterp } from './StatementInterp'; 22 | 23 | const expInterp = (exp, env) => { 24 | switch (exp.type) { 25 | case 'NullLiteral': { 26 | return null; 27 | } 28 | 29 | case 'NumericLiteral': 30 | case 'BooleanLiteral': { 31 | return exp.value; 32 | } 33 | 34 | case 'BlockStatement': { 35 | return statementInterp(exp, env); 36 | } 37 | 38 | case 'Identifier': { 39 | const { name } = exp; 40 | 41 | // @TODO document this 42 | if (Object.keys(Prims).includes(name)) { 43 | return Prims[name]; 44 | } 45 | 46 | return lookupEnv(name, env); 47 | } 48 | 49 | case 'ArrowFunctionExpression': { 50 | const { body, params } = exp; 51 | const names = params.map((obj) => obj.name); 52 | 53 | if (exp.extra && exp.extra.isLambda) { 54 | const { name: selfId } = exp.extra; 55 | return makeRecClosure(selfId, names, body, env); 56 | } 57 | 58 | return makeClosure(names, body, env); 59 | } 60 | 61 | case 'CallExpression': { 62 | const { callee, arguments: rawArgs } = exp; 63 | // here we recur on both sides 64 | const vals = rawArgs.map((obj) => expInterp(obj, env)); 65 | const closureOrFunc = expInterp(callee, env); 66 | 67 | // @TODO document this 68 | switch (closureOrFunc.type) { 69 | case CLOSURE_TYPE_FLAG: { 70 | return applyClosure(expInterp, closureOrFunc, vals, env, Options.isLexical); 71 | } 72 | case NATIVE_FUNC_FLAG: { 73 | return closureOrFunc.func.apply(null, vals); 74 | } 75 | default: { 76 | throw new Error(`unsupported ~closure type ${closureOrFunc.type}`); 77 | } 78 | } 79 | } 80 | 81 | case 'UnaryExpression': { 82 | const { argument, operator } = exp; 83 | // @TODO what's the `prefix` property here? 84 | 85 | switch (operator) { 86 | case '!': { 87 | return !expInterp(argument, env); 88 | } 89 | case '-': { 90 | return -expInterp(argument, env); 91 | } 92 | default: { 93 | throw new Error(`unsupported UnaryExpression operator ${operator}`); 94 | } 95 | } 96 | } 97 | 98 | case 'BinaryExpression': { 99 | const { left, operator, right } = exp; 100 | switch (operator) { 101 | case '+': { 102 | return expInterp(left, env) + expInterp(right, env); 103 | } 104 | case '-': { 105 | return expInterp(left, env) - expInterp(right, env); 106 | } 107 | case '*': { 108 | return expInterp(left, env) * expInterp(right, env); 109 | } 110 | case '/': { 111 | return expInterp(left, env) / expInterp(right, env); 112 | } 113 | case '==': { 114 | return expInterp(left, env) == expInterp(right, env); // eslint-disable-line eqeqeq 115 | } 116 | case '===': { 117 | return expInterp(left, env) === expInterp(right, env); 118 | } 119 | case '!=': { 120 | return expInterp(left, env) != expInterp(right, env); // eslint-disable-line eqeqeq 121 | } 122 | case '!==': { 123 | return expInterp(left, env) !== expInterp(right, env); 124 | } 125 | case '<': { 126 | return expInterp(left, env) < expInterp(right, env); // eslint-disable-line eqeqeq 127 | } 128 | case '<=': { 129 | return expInterp(left, env) <= expInterp(right, env); 130 | } 131 | case '>': { 132 | return expInterp(left, env) > expInterp(right, env); // eslint-disable-line eqeqeq 133 | } 134 | case '>=': { 135 | return expInterp(left, env) >= expInterp(right, env); 136 | } 137 | default: { 138 | throw new Error(`unsupported BinaryExpression operator ${operator}`); 139 | } 140 | } 141 | } 142 | 143 | case 'ConditionalExpression': { 144 | const { alternate, consequent, test } = exp; 145 | return expInterp(test, env) ? expInterp(consequent, env) : expInterp(alternate, env); 146 | } 147 | 148 | default: { 149 | throw new Error(`unsupported expression type ${exp.type}`); 150 | } 151 | } 152 | }; 153 | 154 | export { 155 | expInterp, 156 | }; 157 | -------------------------------------------------------------------------------- /src/Interp/StatementInterp.js: -------------------------------------------------------------------------------- 1 | /* 2 | * https://github.com/estree/estree/blob/master/spec.md#statements 3 | */ 4 | 5 | import invariant from 'fbjs/lib/invariant'; 6 | 7 | import { expInterp } from './ExpressionInterp'; 8 | import { extendEnv } from '../Environment'; 9 | 10 | const statementInterp = (exp, env) => { 11 | switch (exp.type) { 12 | case 'Program': // yeah, this is hacky but it works 13 | case 'BlockStatement': { 14 | let currentEnv = env; 15 | 16 | for (let i = 0; i < exp.body.length; i++) { 17 | const currentExp = exp.body[i]; 18 | 19 | switch (currentExp.type) { 20 | case 'ExpressionStatement': { 21 | expInterp(currentExp.expression, currentEnv); // stuff like `log(something)` 22 | continue; 23 | } 24 | 25 | case 'ReturnStatement': { 26 | const { argument } = currentExp; 27 | 28 | return expInterp(argument, currentEnv); // early return! 29 | } 30 | 31 | case 'VariableDeclaration': { 32 | const { kind, declarations } = currentExp; 33 | 34 | invariant( 35 | kind === 'const', 36 | `unsupported VariableDeclaration kind ${kind}`, 37 | ); 38 | 39 | invariant( 40 | declarations.length === 1, 41 | `unsupported multiple (${declarations.length}) VariableDeclarations`, 42 | ); 43 | 44 | const { id, init } = declarations[0]; 45 | const { name } = id; 46 | 47 | if (init.type === 'ArrowFunctionExpression') { 48 | /* 49 | * TL;DR: it could be a `letrec`! 50 | * 51 | * A better way is to do a static analysis and to see whether the RHS 52 | * actually contains recursive definitions. 53 | * However, for the sake of simplicity, 54 | * we treat all RHS lambdas as potential self-referencing definitions, 55 | * a.k.a., `letrec`s. 56 | * 57 | * For more information, check the comments and definitions in `Closure.js` 58 | * and http://www.cs.indiana.edu/~dyb/papers/fixing-letrec.pdf 59 | */ 60 | init.extra = { isLambda: true, name }; 61 | } 62 | 63 | const val = expInterp(init, currentEnv); 64 | currentEnv = extendEnv(name, val, currentEnv); 65 | 66 | continue; 67 | } 68 | 69 | default: { 70 | throw new Error(`unsupported BlockStatement type ${currentExp.type}`); 71 | } 72 | } 73 | } 74 | 75 | return undefined; // `return` hasn't been called so we return `undefined` 76 | } 77 | 78 | default: { 79 | throw new Error(`unsupported statement type ${exp.type}`); 80 | } 81 | } 82 | }; 83 | 84 | export { 85 | statementInterp, 86 | }; 87 | -------------------------------------------------------------------------------- /src/Interp/__tests__/ExpressionInterp-test.js: -------------------------------------------------------------------------------- 1 | import { expInterp } from '../index'; 2 | import { parse } from '../../Parser'; 3 | 4 | import { 5 | emptyEnv, 6 | extendEnv, 7 | } from '../../Environment'; 8 | 9 | import { makeClosure } from '../../Closure'; 10 | import { setLexical } from '../../Options'; 11 | 12 | const parseAndGet1stExp = (code) => parse(code).body[0].expression; 13 | 14 | const evalCode = (code) => expInterp(parseAndGet1stExp(code), emptyEnv); 15 | const evalCodeWithEnv = (code, env) => expInterp(parseAndGet1stExp(code), env); 16 | 17 | describe('ExpressionInterp', () => { 18 | it('NullLiteral', () => { 19 | expect(evalCode('null')).toBe(null); 20 | }); 21 | 22 | it('undefined', () => { 23 | expect(evalCode('undefined')).toBe(void 0); 24 | }); 25 | 26 | 27 | it('NumericLiteral', () => { 28 | expect(evalCode('3.123')).toBe(3.123); 29 | expect(evalCode('424')).toBe(424); 30 | }); 31 | 32 | it('BooleanLiteral', () => { 33 | expect(evalCode('true')).toBe(true); 34 | expect(evalCode('false')).toBe(false); 35 | }); 36 | 37 | it('UnaryExpression', () => { 38 | expect(evalCode('!true')).toBe(!true); 39 | expect(evalCode('!false')).toBe(!false); 40 | expect(evalCode('!!0')).toBe(!!0); 41 | expect(evalCode('!!3')).toBe(!!3); 42 | expect(evalCode('-12')).toBe(-12); 43 | }); 44 | 45 | it('BinaryExpression', () => { 46 | expect(evalCode('1 + 1')).toBe(1 + 1); 47 | expect(evalCode('15 - 4')).toBe(15 - 4); 48 | expect(evalCode('15 * 4')).toBe(15 * 4); 49 | expect(evalCode('15 / 4')).toBe(15 / 4); 50 | expect(evalCode('15 + 4 + 12')).toBe(15 + 4 + 12); 51 | expect(evalCode('15 + 4 * 12 - 28 / 15')).toBe(15 + 4 * 12 - 28 / 15); 52 | 53 | expect(evalCode('3 < 12')).toBe(3 < 12); 54 | expect(evalCode('3 > 12')).toBe(3 > 12); 55 | expect(evalCode('3 <= 12')).toBe(3 <= 12); 56 | expect(evalCode('3 >= 12')).toBe(3 >= 12); 57 | 58 | expect(evalCode('3 === 3')).toBe(3 === 3); // eslint-disable-line no-self-compare 59 | expect(evalCode('3 === 1 + 2')).toBe(3 === 1 + 2); // eslint-disable-line yoda 60 | expect(evalCode('1 == true')).toBe(1 == true); // eslint-disable-line eqeqeq 61 | expect(evalCode('100 == true')).toBe(100 == true); // eslint-disable-line eqeqeq 62 | 63 | expect(evalCode('100 != false')).toBe(100 != false); // eslint-disable-line eqeqeq 64 | expect(evalCode('100 !== false')).toBe(100 !== false); 65 | expect(evalCode('100 !== 12')).toBe(100 !== 12); 66 | }); 67 | 68 | it('ConditionalExpression', () => { 69 | expect(evalCode('3 > 4 ? 12 : 0')) 70 | .toBe(3 > 4 ? 12 : 0); // eslint-disable-line no-constant-condition 71 | }); 72 | 73 | it('Identifier', () => { 74 | // @TODO figure out a consistent way to match specific error messages 75 | expect(() => { evalCode('x'); }).toThrow(); 76 | expect(() => { evalCodeWithEnv('x', emptyEnv); }).toThrow(); 77 | 78 | const env0 = extendEnv('x', 12, emptyEnv); 79 | expect(evalCodeWithEnv('x', env0)).toBe(12); 80 | 81 | expect(evalCodeWithEnv('x + 3', env0)).toBe(12 + 3); 82 | }); 83 | 84 | it('ArrowFunctionExpression', () => { 85 | expect(evalCodeWithEnv('() => 3', emptyEnv)).toEqual(makeClosure( 86 | [], 87 | parseAndGet1stExp('3'), 88 | emptyEnv, 89 | )); 90 | 91 | expect(evalCodeWithEnv('() => 3 + 12', emptyEnv)).toEqual(makeClosure( 92 | [], 93 | parseAndGet1stExp('3 + 12'), 94 | emptyEnv, 95 | )); 96 | 97 | expect(evalCodeWithEnv('(x) => fact(x + 12)', emptyEnv)).toEqual(makeClosure( 98 | ['x'], 99 | parseAndGet1stExp('fact(x + 12)'), 100 | emptyEnv, 101 | )); 102 | }); 103 | 104 | it('CallExpression', () => { 105 | const env0 = extendEnv('x', 12, emptyEnv); 106 | expect(evalCodeWithEnv('((x) => x + 12)(12)', env0)).toBe(((x) => x + 12)(12)); 107 | }); 108 | 109 | it('arrow block', () => { 110 | const env0 = extendEnv('x', 24, emptyEnv); 111 | expect(evalCodeWithEnv('((x) => { return x + 12; })(12)', env0)) 112 | .toBe(((x) => { return x + 12; })(12)); // eslint-disable-line arrow-body-style 113 | expect(evalCode('(() => {})()')) 114 | .toBe((() => {})()); // eslint-disable-line arrow-body-style 115 | expect(evalCode('((x) => {})(12)')) 116 | .toBe(((x) => {})(12)); // eslint-disable-line arrow-body-style, no-unused-vars 117 | 118 | expect(evalCode('((x) => { 123; return x + 12; })(12)')) 119 | .toBe(((x) => { 123; return x + 12; })(12)); // eslint-disable-line no-unused-expressions 120 | }); 121 | 122 | it('should shadow variable bindings currently', () => { 123 | const env0 = extendEnv('x', 24, emptyEnv); 124 | expect(evalCodeWithEnv('((x) => { return x + 12; })(12)', env0)) 125 | .toBe(((x) => { return x + 12; })(12)); // eslint-disable-line arrow-body-style 126 | 127 | expect(evalCodeWithEnv('(() => { const x = 120; return x + 12; })()', env0)) 128 | .toBe((() => { const x = 120; return x + 12; })()); // eslint-disable-line arrow-body-style 129 | 130 | expect(evalCodeWithEnv('(() => { const x = 120; const y = 24; return y + 12; })()', env0)) 131 | .toBe((() => { 132 | const x = 120; // eslint-disable-line no-unused-vars 133 | const y = 24; 134 | return y + 12; 135 | })()); // eslint-disable-line arrow-body-style 136 | }); 137 | 138 | it('should make correct closures', () => { 139 | expect(evalCode(`(() => { 140 | const adder = (x) => (y) => x + y; 141 | const add3 = adder(3); 142 | return add3(39); 143 | })()`)).toBe((() => { 144 | const adder = (x) => (y) => x + y; 145 | const add3 = adder(3); 146 | return add3(39); 147 | })()); 148 | 149 | expect(evalCode(`(() => { 150 | const x = 100; 151 | const y = 200; 152 | const adder = (x) => (y) => x + y; 153 | const add3 = adder(3); 154 | return add3(39); 155 | })()`)).toBe((() => { 156 | const x = 100; // eslint-disable-line no-unused-vars 157 | const y = 200; // eslint-disable-line no-unused-vars 158 | const adder = (x) => (y) => x + y; // eslint-disable-line no-shadow 159 | const add3 = adder(3); 160 | return add3(39); 161 | })()); 162 | 163 | expect(evalCode(`(() => { 164 | const times2 = (x) => x * 2; 165 | const times4 = (x) => times2(times2(x)); 166 | const times4Add1 = (x) => times4(x) + 1; 167 | 168 | return times4Add1(39); 169 | })()`)).toBe((() => { 170 | const times2 = (x) => x * 2; 171 | const times4 = (x) => times2(times2(x)); 172 | const times4Add1 = (x) => times4(x) + 1; 173 | 174 | return times4Add1(39); 175 | })()); 176 | }); 177 | 178 | it('should support recursion (letrec)', () => { 179 | expect(evalCode(`(() => { 180 | const fact = (x) => (x < 2 ? 1 : x * fact(x - 1)); 181 | return fact(5); 182 | })()`)).toBe((() => { 183 | const fact = (x) => (x < 2 ? 1 : x * fact(x - 1)); 184 | return fact(5); 185 | })()); 186 | }); 187 | 188 | it('should support early return statements', () => { 189 | expect(evalCode(`(() => { 190 | const fact = (x) => (x < 2 ? 1 : x * fact(x - 1)); 191 | return fact(5); 192 | const foo = 12; 193 | const bar = 140; 194 | return foo + bar; 195 | })()`)).toBe((() => { 196 | const fact = (x) => (x < 2 ? 1 : x * fact(x - 1)); 197 | return fact(5); 198 | const foo = 12; // eslint-disable-line no-unreachable 199 | const bar = 140; // eslint-disable-line no-unreachable 200 | return foo + bar; // eslint-disable-line no-unreachable 201 | })()); 202 | }); 203 | 204 | it('should return undefined when there\'s no return', () => { 205 | expect(evalCode(`(() => { 206 | const fact = (x) => (x < 2 ? 1 : x * fact(x - 1)); 207 | const bar = 140; 208 | bar; 209 | })()`)).toBe((() => { 210 | const fact = (x) => (x < 2 ? 1 : x * fact(x - 1)); 211 | const bar = 140; 212 | bar; // eslint-disable-line no-unused-expressions 213 | })()); 214 | }); 215 | 216 | it('should support dynamic scope', () => { 217 | setLexical(false); 218 | 219 | expect(evalCode(`(() => { 220 | const adder = (x) => (y) => x + y; 221 | const x = 100; 222 | const add3ButActuallyAdd100 = adder(3); 223 | return add3ButActuallyAdd100(5); 224 | })()`)).toBe(100 + 5); 225 | 226 | expect(evalCode(`(() => { 227 | const x = 100; 228 | const y = 200; 229 | const adder = (x) => (y) => x + y; 230 | const add3 = adder(3); 231 | return add3(39); 232 | })()`)).toBe(100 + 39); 233 | 234 | setLexical(true); 235 | }); 236 | 237 | it('should support `log` as a native func call', () => { 238 | spyOn(console, 'log'); 239 | 240 | expect(evalCode(`(() => { 241 | const fact = (x) => (x < 2 ? 1 : x * fact(x - 1)); 242 | log(fact(5)); 243 | log(256); 244 | const foo = 12; 245 | const bar = 140; 246 | log(bar); 247 | return foo + bar; 248 | })()`)).toBe((() => { 249 | const foo = 12; 250 | const bar = 140; 251 | return foo + bar; 252 | })()); 253 | 254 | expect(console.log.calls.count()).toEqual(3); // eslint-disable-line no-console 255 | expect(console.log.calls.argsFor(0)).toEqual([120]); // eslint-disable-line no-console 256 | expect(console.log.calls.argsFor(1)).toEqual([256]); // eslint-disable-line no-console 257 | expect(console.log.calls.argsFor(2)).toEqual([140]); // eslint-disable-line no-console 258 | }); 259 | }); 260 | -------------------------------------------------------------------------------- /src/Interp/__tests__/StatementInterp-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import { statementInterp } from '../index'; 4 | import { parse } from '../../Parser'; 5 | 6 | import { 7 | emptyEnv, 8 | } from '../../Environment'; 9 | 10 | import { setLexical } from '../../Options'; 11 | 12 | const evalScript = (code) => statementInterp(parse(code), emptyEnv); 13 | 14 | describe('StatementInterp', () => { 15 | it('should print out all `log`s', () => { 16 | spyOn(console, 'log'); 17 | 18 | expect(evalScript(` 19 | const fact = (x) => (x < 2 ? 1 : x * fact(x - 1)); 20 | log(fact(5)); 21 | log(256); 22 | const foo = 12; 23 | const bar = 140; 24 | log(bar); 25 | `)).toBe(undefined); 26 | 27 | expect(console.log.calls.count()).toEqual(3); 28 | expect(console.log.calls.argsFor(0)).toEqual([120]); 29 | expect(console.log.calls.argsFor(1)).toEqual([256]); 30 | expect(console.log.calls.argsFor(2)).toEqual([140]); 31 | }); 32 | 33 | it('should support dynamic scope', () => { 34 | spyOn(console, 'log'); 35 | 36 | setLexical(false); 37 | 38 | evalScript(`(() => { 39 | const adder = (x) => (y) => x + y; 40 | const x = 100; 41 | const add3ButActuallyAdd100 = adder(3); 42 | log(add3ButActuallyAdd100(5)); 43 | })()`); 44 | expect(console.log.calls.argsFor(0)).toEqual([100 + 5]); 45 | 46 | evalScript(`(() => { 47 | const x = 100; 48 | const y = 200; 49 | const adder = (x) => (y) => x + y; 50 | const add3 = adder(3); 51 | log(add3(39)); 52 | })()`); 53 | expect(console.log.calls.argsFor(1)).toEqual([100 + 39]); 54 | 55 | expect(console.log.calls.count()).toEqual(2); 56 | 57 | setLexical(true); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/Interp/index.js: -------------------------------------------------------------------------------- 1 | export { expInterp } from './ExpressionInterp'; 2 | export { statementInterp } from './StatementInterp'; 3 | -------------------------------------------------------------------------------- /src/Options.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This guy allows us to toggle the lexical/dynamic behavior at runtime. 3 | */ 4 | 5 | const Options = { 6 | isLexical: true, 7 | }; 8 | 9 | const setLexical = (x) => { 10 | Options.isLexical = !!x; 11 | }; 12 | 13 | export { setLexical }; 14 | export default Options; 15 | -------------------------------------------------------------------------------- /src/Parser.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Writing a parser is way beyond the scope of this interpreter. 3 | * Let's just borrow it from babel. 4 | */ 5 | import { parse as babylonParse } from 'babylon'; 6 | import { isObject, mapFilterObject } from './utils'; 7 | 8 | const defaultOptions = { 9 | sourceType: 'script', 10 | }; 11 | 12 | const astStripList = [ 13 | 'start', 14 | 'end', 15 | 'loc', 16 | 'comments', 17 | 'tokens', 18 | 'extra', 19 | 'directives', 20 | 'generator', 21 | 'async', 22 | ]; 23 | 24 | /* 25 | * We don't care about line numbers nor source locations for now -- let's clean them up. 26 | * The correct way to implement this AST traversal is to use the visitor pattern. 27 | * See https://github.com/thejameskyle/babel-handbook/blob/master/translations/en/plugin-handbook.md#traversal 28 | */ 29 | const cleanupAst = (target) => { 30 | if (isObject(target)) { 31 | return mapFilterObject(target, (val, key) => { 32 | if (astStripList.includes(key)) { 33 | return false; 34 | } 35 | 36 | const newVal = cleanupAst(val); 37 | return [key, newVal]; 38 | }); 39 | } else if (Array.isArray(target)) { 40 | return target.map(cleanupAst); 41 | } 42 | 43 | return target; 44 | }; 45 | 46 | const parse = (code, options = defaultOptions) => { 47 | const originalAst = babylonParse(code, options); 48 | return cleanupAst(originalAst).program; // we don't care about `File` type, too 49 | }; 50 | 51 | export { parse }; 52 | -------------------------------------------------------------------------------- /src/Prims.js: -------------------------------------------------------------------------------- 1 | import { NATIVE_FUNC_FLAG } from './typeFlags'; 2 | 3 | const wrapNativeFunc = (params, func) => ({ 4 | type: NATIVE_FUNC_FLAG, 5 | params, 6 | func, 7 | }); 8 | 9 | const logFunc = wrapNativeFunc( 10 | ['x'], 11 | (x) => { console.log(x); }, // eslint-disable-line no-console 12 | ); 13 | 14 | const Prims = { 15 | log: logFunc, 16 | }; 17 | 18 | export default Prims; 19 | -------------------------------------------------------------------------------- /src/__tests__/Environment-test.js: -------------------------------------------------------------------------------- 1 | import { 2 | emptyEnv, 3 | lookupEnv, 4 | extendEnv, 5 | batchExtendEnv, 6 | } from '../Environment'; 7 | 8 | describe('Environment', () => { 9 | it('has an empty env that contains `undefined`', () => { 10 | expect(lookupEnv('undefined', emptyEnv)).toBe(void 0); 11 | expect(emptyEnv.size).toBe(1); 12 | }); 13 | 14 | it('should extend and lookup stuff correctly', () => { 15 | const env = extendEnv('foo', 999, emptyEnv); 16 | expect(lookupEnv('foo', env)).toBe(999); 17 | }); 18 | 19 | it('should support shadowing correctly', () => { 20 | const data = [ 21 | ['foo', 0], 22 | ['foo', 13], 23 | ]; 24 | 25 | const env = data.reduce( 26 | (res, [name, val]) => extendEnv(name, val, res), 27 | emptyEnv, 28 | ); 29 | 30 | expect(lookupEnv('foo', env)).toBe(13); 31 | }); 32 | 33 | it('should batch extend stuff correctly', () => { 34 | const env = extendEnv('foo', 999, emptyEnv); 35 | const keys = ['foo', 'bar', 'james', 'huang']; 36 | const vals = [1, 2, 3, 4]; 37 | const extendedEnv = batchExtendEnv(keys, vals, env); 38 | 39 | expect(extendedEnv.size - emptyEnv.size).toBe(4); 40 | expect(lookupEnv('james', extendedEnv)).toBe(3); 41 | expect(lookupEnv('foo', extendedEnv)).toBe(1); 42 | }); 43 | 44 | it('should throw when arguments.length !== parameters.length', () => { 45 | const env = extendEnv('foo', 999, emptyEnv); 46 | const keys = ['foo', 'bar', 'james', 'huang', 'yeah']; 47 | const vals = [1, 2, 3]; 48 | expect(() => { batchExtendEnv(keys, vals, env); }).toThrow(); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/__tests__/utils-test.js: -------------------------------------------------------------------------------- 1 | import { isObject, mapFilterObject } from '../utils'; 2 | 3 | describe('isObject', () => { 4 | it('should work on trivial stuff', () => { 5 | expect(isObject(null)).toBe(false); 6 | expect(isObject(undefined)).toBe(false); 7 | expect(isObject([])).toBe(false); 8 | expect(isObject([1, 2, 3])).toBe(false); 9 | 10 | expect(isObject({})).toBe(true); 11 | expect(isObject({ a: 3 })).toBe(true); 12 | 13 | expect(isObject(() => {})).toBe(false); 14 | expect(isObject(function noop() {})).toBe(false); // eslint-disable-line prefer-arrow-callback 15 | }); 16 | 17 | it('should work on class instances', () => { 18 | // because the AST produced by babylon is not a plain object 19 | class Foo { 20 | constructor(name) { 21 | this.name = name; 22 | } 23 | } 24 | 25 | expect(isObject(new Foo('James'))).toBe(true); 26 | }); 27 | }); 28 | 29 | describe('mapFilterObject', () => { 30 | it('should return an empty object when the predicate always return false', () => { 31 | expect(mapFilterObject({ a: 2 }, () => false)).toEqual({}); 32 | }); 33 | 34 | it('should map and filter correctly', () => { 35 | expect(mapFilterObject( 36 | { a: 2, b: 20 }, 37 | (val, key) => (val > 10 ? [key, val + 100] : false), 38 | )).toEqual({ 39 | b: 120, 40 | }); 41 | 42 | expect(mapFilterObject( 43 | { a: 2, b: 20 }, 44 | (val, key) => (val > 10 ? [`${key}-postfix`, val + 100] : [key, val]), 45 | )).toEqual({ 46 | a: 2, 47 | 'b-postfix': 120, 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import { statementInterp } from './Interp'; 3 | import { setLexical } from './Options'; 4 | import { parse } from './Parser'; 5 | import { emptyEnv } from './Environment'; 6 | 7 | const interp = (code) => statementInterp(parse(code), emptyEnv); 8 | 9 | export { 10 | interp, 11 | setLexical, 12 | }; 13 | -------------------------------------------------------------------------------- /src/typeFlags.js: -------------------------------------------------------------------------------- 1 | export const CLOSURE_TYPE_FLAG = Symbol('CLOSURE_TYPE_FLAG'); 2 | export const NATIVE_FUNC_FLAG = Symbol('NATIVE_FUNC_FLAG'); 3 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const isObject = (x) => !!(x && typeof x === 'object' && !Array.isArray(x)); 2 | 3 | /* 4 | * fn acts as both a mapFn and a predicate 5 | */ 6 | const mapFilterObject = (obj, fn) => { 7 | const keys = Object.keys(obj); 8 | const result = {}; 9 | 10 | keys.forEach((key) => { 11 | const val = obj[key]; 12 | const fnResult = fn(val, key); // `fn` returns `false` for removal, or a tuple of `[ key, val ]` 13 | if (fnResult) { 14 | const [newKey, newVal] = fnResult; 15 | result[newKey] = newVal; 16 | } 17 | }); 18 | 19 | return result; 20 | }; 21 | 22 | export { 23 | isObject, 24 | mapFilterObject, 25 | }; 26 | --------------------------------------------------------------------------------