Source: egg.js

/* eslint-disable no-cond-assign */
"use strict";
// Project defined at https://eloquentjavascript.net/12_language.html
// DONE: put all egg code into an egg object (namespace).
// DONE: complete exercises named as todo
//        - DONE arrays
//        - DONE SkipSpace includes comments
//        - DONE explain closures
//        - DONE implement set
// DONE: Write mocha tests in test.js
// TODO: Complete jsdoc documentation (see namespace documentation in assignment)
// DONE: Create jsdoc webpage / tutorial
// DONE: Create Try-it-now style input and output box in index.html
// DONE: change print implementation to print to the screen

/**
 * Egg parser and runner that writes program output to an HTML element
 *
 * @class Egg
 */
class Egg {

  /**
   *Creates an instance of Egg.
   * @param {string} outputId the HTML Textarea Element ID to send program output.
   * @memberof Egg
   */
  constructor(outputId) {
    this.outputId = outputId;

    /*************************
     * TOP SCOPE INITIALIZED *
     *************************/
    this.topScope = Object.create(null);

    this.topScope.true = true;
    this.topScope.false = false;
    // DONE: implement arrays
    this.topScope.array = (...args) => {
      return args;
    };

    this.topScope.length = (arrayName) => arrayName.length;

    this.topScope.element = (arrayName, n) => arrayName[n];

    for (let operator of ["+", "-", "*", "/", "==", "<", ">"]) {
      this.topScope[operator] = Function("a, b", `return a ${operator} b;`);
    }
    this.topScope.print = value => {
      let element = document.getElementById(this.outputId);
      if(!(element instanceof HTMLTextAreaElement)){ 
        throw ReferenceError("Cannot write to element not of type HTMLTextAreaElement.");
      }

      element.value += value + "\n";
      // console.log(value);
      return value;
    };

    /*****************************
     * SPECIAL FORMS INITIALIZED *
     *****************************/
    this.specialForms = Object.create(null);
    // eslint-disable-next-line id-length
    this.specialForms.do = (args, scope) => {
      let value = false;
      for (let arg of args) {
        value = this.evaluate(arg, scope);
      }
      return value;
    };
    // eslint-disable-next-line id-length
    this.specialForms.if = (args, scope) => {
      if (args.length != 3) {
        throw new SyntaxError("Wrong number of args to if");
      } else if (this.evaluate(args[0], scope) !== false) {
        return this.evaluate(args[1], scope);
      } else {
        return this.evaluate(args[2], scope);
      }
    };
    this.specialForms.while = (args, scope) => {
      if (args.length != 2) {
        throw new SyntaxError("Wrong number of args to while");
      }
      while (this.evaluate(args[0], scope) !== false) {
        this.evaluate(args[1], scope);
      }

      // Since undefined does not exist in Egg, we return false,
      // for lack of a meaningful result.
      return false;
    };
    this.specialForms.define = (args, scope) => {
      if (args.length != 2 || args[0].type != "word") {
        throw new SyntaxError("Incorrect use of define");
      }
      let value = this.evaluate(args[1], scope);
      scope[args[0].name] = value;
      return value;
    };
    /* DONE: add form 'set', like 'define', but only sets existing values.
    If no binding found, throw a 'ReferenceError'.
    See 'Fixing scope'.*/
    this.specialForms.set = (args, scope) => {
      if (args.length != 2 || args[0].type != "word") {
        throw new SyntaxError("Incorrect use of set");
      }
      let value = this.evaluate(args[1], scope);
      let found;
      try {
        found = Object.prototype.hasOwnProperty.call(scope, args[0].name);
        while (!found) {
          scope = Object.getPrototypeOf(scope);
          found = Object.prototype.hasOwnProperty.call(scope, args[0].name);
        }
      }
      catch (e) {
        throw new ReferenceError("The variable is not defined.");
      }
      scope[args[0].name] = value;
      return value;
    };
    /********************************************************************************************************
     * DONE: The way we have defined fun allows functions in Egg to reference the surrounding scope,
     * allowing the function’s body to use local values that were visible at the time the function was defined, 
     * just like JavaScript functions do.
     * 
     * The following program illustrates this: function f returns a function that adds its argument 
     * to f’s argument, meaning that it needs access to the local scope inside f to be able to use binding a.
     *      do(
     *        define(f, fun(a, fun(b, +(a, b)))),
     *        print(f(4)(5))
     *      )
     * 
     * Go to the definition of the fun form and explain which mechanism causes this to work 
     * Answer: functions are created in a scope context. That scope is used to create the local scope of the 
     * function and therefore maintains a reference to the outer scope through the prototype chain of the 
     * local scope variable.
     ********************************************************************************************************/
    
     this.specialForms.fun = (args, scope) => {
      if (!args.length) {
        throw new SyntaxError("Functions need a body");
      }
      let body = args[args.length - 1];
      let params = args.slice(0, args.length - 1).map(expr => {
        if (expr.type != "word") {
          throw new SyntaxError("Parameter names must be words");
        }
        return expr.name;
      });
      let theEgg = this;
      return function () {
        if (arguments.length != params.length) {
          throw new TypeError("Wrong number of arguments");
        }
        let localScope = Object.create(scope);
        // eslint-disable-next-line id-length
        for (let i = 0; i < arguments.length; i++) {
          localScope[params[i]] = arguments[i];
        }
        return theEgg.evaluate(body, localScope);
      };
    };


  }
  /******************
   * CORE FUNCTIONS *
   ******************/
  /**
   * Parse, evaluate, and excute code in Egg Program.
   *
   * @param {string} program Egg code
   * @returns {any} the final value evaluated in a program
   * @memberof Egg
   */
  run(program) {
    return this.evaluate(this.parse(program), Object.create(this.topScope));
  }
  /**
   * Evaluate an expression
   *
   * @param {Object} expr object with properties type and value
   * @param {Object} scope the scope of the expression being evaluated
   * @returns {any} the value an expression evaluates to
   * @memberof Egg
   */
  evaluate(expr, scope) {
    if (expr.type == "value") {
      return expr.value;
    } else if (expr.type == "word") {
      if (expr.name in scope) {
        return scope[expr.name];
      } else {
        throw new ReferenceError(
          `Undefined binding: ${expr.name}`);
      }
    } else if (expr.type == "apply") {
      let { operator, args } = expr;
      if (operator.type == "word" &&
        operator.name in this.specialForms) {
        return this.specialForms[operator.name](expr.args, scope);
      } else {
        let op = this.evaluate(operator, scope);
        if (typeof op == "function") {
          let theEgg = this;
          return op(...args.map(arg => theEgg.evaluate(arg, scope))); 
        } else {
          throw new TypeError("Applying a non-function.");
        }
      }
    }
  }
  /**
   * Convert program string into object representation
   *
   * @param {string} program Egg code string
   * @returns {Object} expression representation of egg code
   * @memberof Egg
   */
  parseExpression(program) {
    program = this.skipSpace(program);
    let match, expr;
    if (match = /^"([^"]*)"/.exec(program)) {
      expr = { type: "value", value: match[1] };
    } else if (match = /^\d+\b/.exec(program)) {
      expr = { type: "value", value: Number(match[0]) };
    } else if (match = /^[^\s(),#"]+/.exec(program)) {
      expr = { type: "word", name: match[0] };
    } else {
      throw new SyntaxError("Unexpected syntax: " + program);
    }

    return this.parseApply(expr, program.slice(match[0].length));
  }
  // DONE: implement skip over #comments\n
  /**
   * Trim comments and whitespace before code 
   *
   * @param {string} string Egg code
   * @returns {string} Egg code with first character of string not whitespace, nor comment.
   * @memberof Egg
   */
  skipSpace(string) {
    let firstNonSpace = string.search(/\S/);
    if (firstNonSpace == -1) return "";
    let trimmedString = string.slice(firstNonSpace);

    if (trimmedString.startsWith("#")) {
      let endOfComment = trimmedString.search("\n");
      if (endOfComment == -1) {
        return "";
      } else if (endOfComment == trimmedString.length) {
        return "";
      } else {
        return this.skipSpace(trimmedString.slice(endOfComment + 1));
      }
    } else {
      return trimmedString;
    }

  }
  /**
   * Runs applications in Egg and returns the resulting expression
   *
   * @param {Object} expr Expression representation of Egg code
   * @param {string} program Egg code
   * @returns {Object} Expression representation of Egg code
   * @memberof Egg
   */
  parseApply(expr, program) {
    program = this.skipSpace(program);
    if (program[0] != "(") {
      return { expr: expr, rest: program };
    }

    program = this.skipSpace(program.slice(1));
    expr = { type: "apply", operator: expr, args: [] };
    while (program[0] != ")") {
      let arg = this.parseExpression(program);
      expr.args.push(arg.expr);
      program = this.skipSpace(arg.rest);
      if (program[0] == ",") {
        program = this.skipSpace(program.slice(1));
      } else if (program[0] != ")") {
        throw new SyntaxError("Expected ',' or ')'");
      }
    }
    return this.parseApply(expr, program.slice(1));
  }
  /**
   * Convert Egg code into first expression representation
   *
   * @param {string} program Egg code
   * @returns {object} Expression representation of Egg code
   * @memberof Egg
   */
  parse(program) {
    let { expr, rest } = this.parseExpression(program);
    if (this.skipSpace(rest).length > 0) {
      throw new SyntaxError("Unexpected text after program");
    }
    return expr;
  }

}