jazz/compiler.js

"use strict";

import events from "events";
import * as ast from "./ast.js";

/**
 * Compile-time error raised when AST nodes cannot be compiled.
 */
class CompileError extends Error {
  /**
   * Create a compile error.
   *
   * @param {string} filename: template filename
   * @param {number|string} row: source row number
   * @param {number|string} col: source column number
   * @param {string} message: compile error details
   */
  constructor(filename, row, col, message) {
    super(message);
    this.name = "CompileError";
    this.filename = filename;
    this.row = row || "?";
    this.col = col || "?";
  }

  /**
   * Format this compile error into a readable location-prefixed string.
   *
   * @returns {string} formatted error string
   */
  toString() {
    return `${this.filename}:${this.row.toString()}:${this.col.toString()}: ${this.message.toString()}`;
  }
}

/**
 * Executable program generated from a Jazz template AST.
 */
class Program {
  /**
   * Create a program wrapper.
   *
   * @param {Function} fn: generated executable function
   * @param {Array<string>} globals: global variable names in call order
   */
  constructor(fn, globals) {
    this.fn = fn;
    this.globals = globals;
  }

  // Kept for backward compatibility.
  /**
   * Execute the program against a namespace and callback.
   *
   * @param {Object} namespace: values referenced by template globals
   * @param {Function} cb: standard cb(result) callback
   * @returns {*} return value from generated function
   */
  eval(namespace, cb) {
    return this.process(namespace, cb);
  }

  /**
   * Execute the program against a namespace and callback.
   *
   * @param {Object} namespace: values referenced by template globals
   * @param {Function} cb: standard cb(result) callback
   * @returns {*} return value from generated function
   */
  process(namespace, cb) {
    const args = [cb].concat(this._namespaceToArgs(namespace));
    return this.fn.apply(this.fn, args);
  }

  /**
   * Convert namespace values into argument list matching globals order.
   *
   * @param {Object} namespace: values keyed by global variable names
   * @returns {Array<*>} argument values in globals order
   */
  _namespaceToArgs(namespace) {
    const args = [];
    for (let i = 0; i < this.globals.length; i += 1) {
      args.push(namespace[this.globals[i]]);
    }
    return args;
  }
}

/**
 * JavaScript compiler that converts Jazz AST nodes into executable code.
 */
class CompilerJS {
  /**
   * Create a JavaScript compiler instance.
   */
  constructor() {
    this.debug = false;
    this.nextVar = 0;
  }

  _prologue(globals) {
    let code = "";
    code += "(function() {\n";
    code += "var __jazz_cb = arguments[0];\n";
    code += "var __jazz_emitter = new events.EventEmitter();\n";
    code += "var __jazz_expected = 0;\n";
    code += "var __jazz_seen     = 0;\n";
    code += "var __jazz_output = [];\n";
    code += "var __jazz_ready = false;\n";
    code += "function __jazz_check_complete() {\n";
    code += "  if (__jazz_ready && __jazz_expected == __jazz_seen) {\n";
    code += "    __jazz_cb(__jazz_output.join(''));\n";
    code += "  }\n";
    code += "}\n";

    code += "__jazz_emitter.addListener('data', function(pos, chunk) {\n";
    code += "  __jazz_output[pos] = chunk;\n";
    code += "  __jazz_seen++;\n";
    code += "  __jazz_check_complete();\n";
    code += "});\n";

    for (let i = 0; i < globals.length; i += 1) {
      const argIndex = i + 1;
      code += `var ${globals[i]} = arguments[${argIndex}];\n`;
    }

    return code;
  }

  _epilogue() {
    let code = "";
    code += "__jazz_ready = true;\n";
    code += "__jazz_check_complete();\n";
    code += "})";
    return code;
  }

  /**
   * Compile the AST root node into an executable program.
   *
   * @param {ast.AST} astRoot: parsed Jazz AST
   * @returns {Program} executable program instance
   */
  compile(astRoot) {
    let code = this._prologue(astRoot.globals);
    code += this._compileSuite(astRoot.root);
    code += this._epilogue();
    return new Program(eval(code), astRoot.globals);
  }

  _newVar() {
    const name = `__jazz_var${this.nextVar}`;
    this.nextVar += 1;
    return name;
  }

  _compileSuite(suite) {
    let code = "{\n";
    for (let i = 0; i < suite.body.length; i += 1) {
      code += this._compileStatement(suite.body[i]);
    }
    code += "}\n";
    return code;
  }

  _compileStatement(stmt) {
    let code = "";

    if (stmt.constructor === ast.IfStmt) {
      code += this._compileIfStmt(stmt);
    } else if (stmt.constructor === ast.ForEach) {
      code += this._compileForEach(stmt);
    } else if (stmt.constructor === ast.Echo) {
      code += this._compileEcho(stmt);
    } else if (stmt.constructor === ast.Call) {
      code += this._compileCall(stmt);
    } else if (stmt.constructor === ast.Suite) {
      code += this._compileSuite(stmt);
    } else if (stmt.constructor === ast.GetArr) {
      code += this._compileEcho(stmt);
    } else {
      throw new CompileError(
        "!FIXME!",
        stmt.row,
        stmt.col,
        `unknown statement type: ${stmt.type}`,
      );
    }

    return code;
  }

  _compileEcho(stmt) {
    let code = "";
    const slotVar = this._newVar();
    code += `var ${slotVar} = __jazz_expected++;\n`;
    code += `__jazz_emitter.emit('data', ${slotVar}, ${this._compileExpr(stmt.value)});\n`;
    return code;
  }

  _compileCall(stmt) {
    let code = "";
    const slotVar = this._newVar();

    code += `var ${slotVar} = __jazz_expected++;\n`;
    code += `(${this._compileExpr(stmt.expr)})(`;
    for (let i = 0; i < stmt.args.length; i += 1) {
      code += this._compileExpr(stmt.args[i]);
      code += ", ";
    }
    code += `function(data) { __jazz_emitter.emit('data', ${slotVar}, data); });\n`;

    return code;
  }

  _compileIfStmt(stmt) {
    let code = "if (";
    code += this._compileExpr(stmt.expr);
    code += ") ";
    code += this._compileSuite(stmt.suite);
    if (stmt.orelse) {
      code += ` else ${this._compileStatement(stmt.orelse)}`;
    }
    return code;
  }

  _compileForEach(stmt) {
    const exprVar = this._newVar();
    const indexVar = this._newVar();
    const lengthVar = this._newVar();
    const countVar = this._newVar();
    let code = `var ${exprVar} = ${this._compileExpr(stmt.expr)};\n`;

    code += `if (${exprVar} instanceof Array) {\n`;
    code += `var ${lengthVar} = ${exprVar} ? ${exprVar}.length : 0;\n`;
    code += `for (var ${indexVar} = 0, ${countVar} = 1; ${indexVar} < ${lengthVar}; ${indexVar}++, ${countVar}++) `;
    code += "(function() {\n";
    code += `  var __index = ${indexVar};\n`;
    code += `  var __count = ${countVar};\n`;
    code += `  var ${stmt.ident.name} = ${exprVar}[${indexVar}];\n`;
    code += `  ${this._compileSuite(stmt.suite)}`;
    code += "})();\n";
    code += "}\n";

    code += "else {\n";
    code += `var ${countVar} = 1;\n`;
    code += `for (var ${indexVar} in ${exprVar}) `;
    code += "(function() {\n";
    code += `  if (${exprVar}.hasOwnProperty(${indexVar})) {\n`;
    code += `  var __index = ${indexVar};\n`;
    code += `  var __count = ${countVar};\n`;
    code += `  var ${stmt.ident.name} = {'key': __index, 'value': ${exprVar}[__index]};\n`;
    code += `  ${this._compileSuite(stmt.suite)}\n`;
    code += `  ${countVar}++;\n`;
    code += "  }\n";
    code += "})();\n";
    code += "}\n";

    return code;
  }

  _compileHash(hash) {
    let code = "{";
    const body = hash.body;

    for (let i = 0; i < body.length; i += 1) {
      const element = body[i];
      code += this._compileExpr(element[0]);
      code += ":";
      code += this._compileExpr(element[1]);
      if (i < body.length - 1) {
        code += ",";
      }
    }

    code += "}";
    return code;
  }

  _compileExpr(expr) {
    if (expr.constructor === ast.Ident) {
      return expr.name;
    }

    if (expr.constructor === ast.GetAttr) {
      if (expr.array) {
        return `(${this._compileExpr(expr.expr)})[${this._compileExpr(expr.id)}]`;
      }
      return `(${this._compileExpr(expr.expr)}).${this._compileExpr(expr.id)}`;
    }

    if (expr.constructor === ast.And) {
      return `((${this._compileExpr(expr.left)}) && (${this._compileExpr(expr.right)}))`;
    }

    if (expr.constructor === ast.Or) {
      return `((${this._compileExpr(expr.left)}) || (${this._compileExpr(expr.right)}))`;
    }

    if (expr.constructor === ast.Not) {
      return `(!(${this._compileExpr(expr.expr)}))`;
    }

    if (expr.constructor === ast.BinOp) {
      return `((${this._compileExpr(expr.left)})${expr.op}(${this._compileExpr(expr.right)}))`;
    }

    if (expr.constructor === ast.Str) {
      return `"${expr.value
        .replace(/\\/g, "\\\\")
        .replace(/\r/g, "\\r")
        .replace(/\n/g, "\\n")
        .replace(/\t/g, "\\t")
        .replace(/"/g, '\\"')}"`;
    }

    if (expr.constructor === ast.Num) {
      return expr.value;
    }

    if (expr.constructor === ast.Bool) {
      return expr.value;
    }

    if (expr.constructor === ast.Empty) {
      const compiledExpr = this._compileExpr(expr.expr);
      let statement = "(";
      statement += `!${compiledExpr} || `;
      statement += `(${compiledExpr} instanceof Array && ${compiledExpr}.length == 0)`;
      statement += ")";
      return statement;
    }

    if (expr.constructor === ast.Hash) {
      return this._compileHash(expr);
    }

    if (expr.constructor === ast.SyncCall) {
      let code = "";
      const argsLength = expr.args.length;
      code += `(${this._compileExpr(expr.expr)})(`;
      for (let i = 0; i < argsLength; i += 1) {
        code += this._compileExpr(expr.args[i]);
        if (i !== argsLength - 1) {
          code += ", ";
        }
      }
      code += ")";
      return code;
    }

    if (expr.constructor === ast.GetArr) {
      return `(${this._compileExpr(expr.expr)})[${this._compileExpr(expr.index)}]`;
    }

    throw new CompileError(
      "!FIXME!",
      expr.row,
      expr.col,
      `unknown expression type: ${JSON.stringify(expr)}`,
    );
  }
}

/**
 * Create a default JavaScript compiler.
 *
 * @returns {CompilerJS} compiler instance
 */
function createCompiler() {
  return new CompilerJS();
}

export { CompileError, Program, CompilerJS, createCompiler, events };

export default {
  CompileError,
  Program,
  CompilerJS,
  createCompiler,
};