1.0.6 • Published 4 months ago

arith2js v1.0.6

Weekly downloads
-
License
ISC
Repository
-
Last release
4 months ago

Open in Codespaces

Práctica: Traducción de expresiones aritméticas a JavaScript

Introducción

Enunciado de la práctica arith2js

En la presente práctica se pretende implementar un traductor de expresiones aritméticas a JavaScript. Para ello se utilizará la herramienta Jison que es un generador de analizadores sintácticos (parsers) a partir de una gramática y un analizador léxico.

Primeros pasos

Instalar Dependencias

npm install

Código

Crear el fichero de gramática

%{
const { 
 L,
 buildLiteral, 
 buildBinaryExpression, 
 buildRoot,
 buildUnaryExpression,
 buildCallExpression
} = require('./ast-build.js');
%}
%token 
%left '+' '-'
%left '*' '/'
%right '**'
%left '!'
%nonassoc UMINUS
%%
es: e EOF { return buildRoot($1, L(@1, yy)); }
;

e:
   e '-' e     { $$ = buildBinaryExpression($1, $2, $3, L(@2, yy)); }
 | e '+' e     { $$ = buildBinaryExpression($1, $2, $3, L(@2, yy)); }
 | e '*' e     { $$ = buildBinaryExpression($1, $2, $3, L(@2, yy)); }
 | e '/' e     { $$ = buildBinaryExpression($1, $2, $3, L(@2, yy)); }
 | '(' e ')'   { $$ = $2; }
 | e '**' e    { $$ = buildCallExpression('power', [$1, $3], L(@2, yy)); }
 | e '!'       { $$ = buildCallExpression('factorial', [$1], L(@2, yy)); }
 | '-' e %prec UMINUS { $$ = buildUnaryExpression($1, $2, L(@1, yy)); }
 | N           { $$ = buildLiteral($1, L(@1, yy)); }
;

Crear el fichero de construcción del AST en el que se encuentran las funciones que construyen los nodos del árbol sintáctico abstracto

function buildRoot(child, loc) {
  return {
    "type": "Program",
    "start": [loc.start.line - 1] + loc.start.column,
    "end": [loc.end.line - 1] + loc.end.column,
    "loc": loc,
    "range": [
      [loc.start.line - 1] + loc.start.column,
      [loc.end.line - 1] + loc.end.column
    ],
    "body": [
      {
        "type": "ExpressionStatement",
        "start": [loc.start.line - 1] + loc.start.column,
        "end": [loc.end.line - 1] + loc.end.column,
        "loc": loc,
        "range": [
          [loc.start.line - 1] + loc.start.column,
          [loc.end.line - 1] + loc.end.column
        ],
        "expression": child
      }
    ],
    "sourceType": "script"
  };
}

function L(jloc, { input, offsets }) {
  return {
    "start": {
      "line": jloc.first_line,
      "column": jloc.first_column
    },
    "end": {
      "line": jloc.last_line,
      "column": jloc.last_column
    }
  };
}

function computeLineOffets(input) {
  let offsets = [];
  let lines = input.split("\n");
  let offset = 0;
  for (let i = 0; i < lines.length; i++) {
    offsets.push(offset);
    offset += lines[i].length + 1;
  }
  return offsets;
}

function buildLiteral(raw, loc) {
  return {
    "type": "Literal",
    "start": [loc.start.line - 1] + loc.start.column,
    "end":  [loc.end.line - 1] + loc.end.column,
    "loc": loc,
    "range": [
      [loc.start.line - 1] + loc.start.column,
      [loc.end.line - 1] + loc.end.column
    ],
    "value": Number(raw),
    "raw": raw
  };
}

function buildBinaryExpression(left, op, right, loc) {
  return {
    "type": "BinaryExpression",
    "start": [loc.start.line - 1] + loc.start.column,
    "end": [loc.end.line - 1] + loc.end.column,
    "loc": loc,
    "range": [
      [loc.start.line - 1] + loc.start.column,
      [loc.end.line - 1] + loc.end.column
    ],
    "operator": op,
    "left": left,
    "right": right
  };
}

function buildUnaryExpression(op, argument, loc) {
  return {
    "type": "UnaryExpression",
    "start": [loc.start.line - 1] + loc.start.column,
    "end": [loc.end.line - 1] + loc.end.column,
    "loc": loc,
    "range": [
      [loc.start.line - 1] + loc.start.column,
      [loc.end.line - 1] + loc.end.column
    ],
    "operator": op,
    "prefix": true,
    "argument": argument
  };
}

function buildCallExpression(callee, args, loc) {
  return {
    "type": "CallExpression",
    "start": [loc.start.line - 1] + loc.start.column,
    "end": [loc.end.line - 1] + loc.end.column,
    "loc": loc,
    "range": [
      [loc.start.line - 1] + loc.start.column,
      [loc.end.line - 1] + loc.end.column
    ],
    "callee": {
      "type": "Identifier",
      "start": [loc.start.line - 1] + loc.start.column,
      "end": [loc.end.line - 1] + loc.end.column,
      "loc": loc,
      "range": [
        [loc.start.line - 1] + loc.start.column,
        [loc.end.line - 1] + loc.end.column
      ],
      "name": callee
    },
    "arguments": args
  };
}

module.exports = {
    buildRoot,
    buildBinaryExpression,
    buildUnaryExpression,
    buildLiteral,
    computeLineOffets,
    L,
    buildCallExpression
};

Crear el fichero con la función transpile que se encargará de generar el código JavaScript a partir del AST

#!/usr/bin/env node
const fs = require('fs/promises');

const p = require("./calc").parser;

const estraverse = require("estraverse"); 
const exportedSupportIdentifiers = Object.keys(require("./support-lib.js")); // [ power, factorial ]

const escodegen = require('escodegen');
const { renderFile } = require('template-file'); // handlebars.js mustache.js ...

const computeLineOffets = require('./ast-build').computeLineOffets;

async function generateCode(dependencies, ast) {
  let code = escodegen.generate(ast);
  code = `console.log(${code.slice(0, -1)});`;
  if (dependencies.length === 0) {
    return code;
  }
  let root = __dirname.replace(/\/src$/, '');
  return renderFile(`${root}/src/template.js`, { root, code, dependencies });
}

const findUsedFunctions = function (ast) {
  const usedSupportFunctions = new Set();
  estraverse.traverse(ast, {
    enter: function (node, _ ) {
      if (node.type === "CallExpression" && node.callee.type === "Identifier" && exportedSupportIdentifiers.includes(node.callee.name)) {
        usedSupportFunctions.add(node.callee.name);
      }
    },
  });
  return Array.from(usedSupportFunctions);
}

async function writeOutput(outputFile, output, options) {
  let finalOutput = output;
  if (options?.verbose) {
    console.log(finalOutput);
  }
  if (!outputFile) {
    return finalOutput;
  }
  await fs.writeFile(outputFile, finalOutput);
  return finalOutput;
}

module.exports = async function transpile(inputFile, options) {
  let outputFile = options?.output || "./out/output.js";
  try {
    let input = await fs.readFile(inputFile, 'utf-8')
    let offsets = computeLineOffets(input);           

    p.yy = { input, offsets };      
    const ast = p.parse(input);

    let dependencies = findUsedFunctions(ast);        

    const output = await generateCode(dependencies, ast);
    
    return await writeOutput(outputFile, output, options);
  } catch (e) {
    console.error(e.message);
  }
};

Crear el fichero de ejecución bin/calc2js.mjs

#!/usr/bin/env node

import { program } from "commander";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { version } = require("../package.json");
import transpile from "../src/transpile.js";
import run  from "../src/run.js";


program
  .version(version)
  .showSuggestionAfterError(true);

program
  .command("transpile")
  .argument("<filename>", "calc file to transpile")
  .option("-o, --output <filename>", "output file")
  .option("-v, --verbose", "show generated code")
  .description("Transpile a calc file to JavaScript")
  .action((filename, options) => {
    transpile(filename, options); // Options es: { output: "archivosalida", verbose: true/false }
  });
  
program.parse(process.argv);

Ejecutar el programa

Para ejecutar el programa se debe ejecutar el siguiente comando que transpilará el archivo test/test1.calc a JavaScript

node bin/calc2js.mjs transpile test/test1.calc -o out.js

Si queremos que se ejecute el código generado, podemos usar la opción -v para que se muestre el código y pasarlo por pipeline a node

node bin/calc2js.mjs transpile test/test1.calc -o out.js -v | node

Adiciones

Con lo anterior tendríamos suficiente para ejecutar algunos archivos con operaciones básicas, pero necesitamos importar algunas funciones que no están definidas en el código que hemos escrito. Para ello, se ha creado un fichero support-lib.js que contiene las funciones power y factorial.

Crear el fichero support-lib.js

let gamma = require('math-gamma');
const power = (base, exponent) => Math.pow(base, exponent);
const factorial = (number) => gamma(number + 1);

module.exports = { 
  power, 
  factorial 
};

Crear el fichero template.js

Se usará template-file para añadir las dependencias solo cuando se necesiten

const { {{ dependencies }} } = require('{{root}}/src/support-lib.js');
{{code}}

Función run

Es incómodo tener que ejecutar el código generado con node, por lo que se ha creado una función run que se encarga de ejecutar el código generado

const transpile = require("./transpile.js");

module.exports = async function run(inputFile, options) {
  const code = await transpile(inputFile, options);
  eval(code);
};

Ahora se puede ejecutar con el siguiente comando

node bin/calc2js.mjs run test/test1.calc

Añadiendo la opción -v se mostrará el código generado

node bin/calc2js.mjs run test/test1.calc -v

Testing con Mocha

Se ha creado un fichero test.js en el que se automatiza la ejecución de los tests

const transpile = require("../src/transpile.js");
const assert = require('assert');
const fs = require("fs/promises");

require('dotenv').config();
const JSComparison = process.env.JSComparison === 'true';
const REMOVE_OUTPUTS = process.env.REMOVE_OUTPUTS === 'true';

const Tst = require('./test-description.js');

const Test = Tst.map(t => ({
  input: __dirname + '/data/' + t.input,
  output: __dirname + '/data/' + t.output,
  expected: __dirname + '/data/' + t.expected,
  correctOut: __dirname + '/data/' + t.correctOut,
})
)

const removeRequires = /require\(["'][\S]+["']\)/g;

function removeSpaces(s) {
  return String(s).replace(/\s/g, '').replace(removeRequires, 'require("support-lib")');
}

async function outputProgramIsAsExpected(t, outputjs) {
  let expectedjs = await fs.readFileSync(t.expectedjs, 'utf-8')
  
  assert.equal(removeSpaces(outputjs), removeSpaces(expectedjs));
  if (REMOVE_OUTPUTS) fs.unlinkSync(t.output);
  return outputjs;
}

async function outputRunExpected(outputjs, expectedout) {
  expectedout = await fs.readFile(expectedout, 'utf8');
  oldLog = console.log;
  let result = '';
  console.log = (...s) => result += s.join(' ');
  eval(outputjs);
  assert.equal(removeSpaces(result), removeSpaces(expectedout));
  console.log = oldLog;
  return result;
}

async function main() {
  for (let i = 0; i < Test.length; i++) {
    let [t, ft] = [Test[i], Test[i].input];
    it(`Test ${i + 1}: ${ft}`, async () => {
      let outputjs = await transpile(ft, t.output);
      if (JSComparison) await outputProgramIsAsExpected(t, outputjs);
      await outputRunExpected(outputjs, t.correctOut);
    });
  }
}

main();

Para ejecutar los tests se debe ejecutar el siguiente script

npm run test
npm test

Se pueden añadir más tests en el fichero test-description.js y creando su entrada su código js esperado y su salida esperada en archivos dentro del directorio test/data/ del repo

Conclusiones

Con la implementación de la práctica se ha conseguido traducir expresiones aritméticas a JavaScript. Se ha utilizado Jison para generar el parser y se ha creado un AST con las funciones necesarias para construirlo. Se ha añadido soporte para funciones power y factorial que no están definidas en el código. Se ha creado un fichero template.js para añadir las dependencias solo cuando se necesiten. Se ha creado una función run que se encarga de ejecutar el código generado. Se ha automatizado la ejecución de los tests con Mocha.

Yo haciendo la práctica

Referencias

1.0.6

4 months ago