19th Jul 2021
5 mins

Dynamically generating code using AST

#Express#NodeJs#Syntax Tree

Hello there! In this blog We will write custom code generators that uses AST. But before that, just a bit of information for  those who are coming to this post directly I wanted to bring this to your attention that this is the last post in the blog series of “AST”.

Till now we have covered few tools to work with AST, writing scripts to perform code analysis on the fly and writing custom eslint plugin to have our own custom eslint rule. Now last but not the least “Code Generators”. As you would have already seen that some of  today’s cool kids(JS frameworks) are giving you some sort of cli tool to generate their framework specific code in which with the help of few input you can actually generate a lot of code. For eg. Angular gives you Angular CLI with the help of which you can generate a new angular project or add new components to an already existing angular project. Have you ever wondered how these JS based generators are written? If the answers is No then,

Let’s make some cool code generator. For the example I have used a simple express server with a router file that looks something like this-

const express = require('express');
const router = express.Router();

router.get('/', function (req, res) {
    res.send('Welcome to the App.');
});

module.exports = router;
router.js

And now we will write a simple CLI tool that can take router file, route path and route method as input and add a very basic route to given router file. Since this generator is going to make us somewhat more lazy so lets call this generator as Lazy-nator. For creating this CLI tool follow below steps -

Step 1. Create a directory called lazy-nator using mkdir lazy-nator

Step 2. Now go inside this directory and initialise a node project here using

cd lazy-nator and then,

yarn init -y

Step 3. After this goto package.json and add "bin": "./cli.js" line. In my case the package.json file looks something like this -

{
  "name": "lazy-nator",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "bin": "cli.js", 
  "dependencies": {
    "enquirer": "^2.3.6",
    "escodegen": "^2.0.0",
    "esprima": "^4.0.1"
  }
}
package.json

Step 4. Now create a cli.js file which will be responsible to handle all command ine interfacing.

#!/usr/bin/env node

const { prompt } = require('enquirer');
const { readdirSync } = require('fs');
const { join } = require('path');
const { generateRoute } = require('./route-generator');


const fileList = readdirSync('./').filter(file => file.endsWith('.js'));

const questions = [{
    type: 'select',
    name: 'routeFile',
    message: 'Select your router file',
    initial: 1,
    choices: fileList.map(name => ({name}))
}, {
    type: 'input',
    name: 'routePath',
    message: 'What is path for the route?'
}, {
    type: 'select',
    name: 'routeMethod',
    message: 'Specify method for the route',
    initial: 1,
    choices: ['POST', 'GET']
}];

prompt(questions).then(answers => {
    const { routeFile, routePath, routeMethod} = answers;
    generateRoute(join(process.cwd(), routeFile), routePath, routeMethod);
    console.log(`Method '${routeMethod}' with path '${routePath}' added to file '${routeFile}'`);
})
cli.js

As you can see I am using a library called enquirer, this library helps us to create wonderful cli based prompts. Using this I am able to create a cli prompt that asks router file, route path and http method.

Once we are able to get above information, we can start working on our code generator logic. I’ve created another file called route-generator.js for this purpose.

const esprima = require('esprima');
const escodegen = require('escodegen');
const { readFileSync, writeFileSync } = require('fs');
const routerNode = require('./nodes/route-node');

const generateRoute = (filePath, path, method) => {
    const routePath = path.startsWith('/') ? path : `/${path}`;
    const routeMethod = method.toLowerCase();
    const file = readFileSync(filePath, {encoding: 'UTF-8'});
    const ast =  esprima.parseScript(file);
    ast.body.splice(ast.body.length - 2, 0, routerNode(routePath, routeMethod));
    writeFileSync(filePath, escodegen.generate(ast));
}


module.exports = {
    generateRoute
}
route-generator.js

This file exports a method called generateRoute that accepts three arguments router file path, http route path and http method for that route. Since now we have already written router file we can easily use esprima(refer to this) to parse the content of the router file to get the syntax tree. Now when we have this syntax tree I can easily update the content of the AST to add our new route. A little trick I use to exactly know where and what I need to add, is that I use https://astexplorer.net and just parse the code i wanted to add to AST. As we can see I have imported a file called route-node which is nothing but the AST snippet (that we wanted to add to code) in template format.

module.exports = (path, method) => ({
    "type": "ExpressionStatement",
    "expression": {
        "type": "CallExpression",
        "callee": {
            "type": "MemberExpression",
            "computed": false,
            "object": {
                "type": "Identifier",
                "name": "router"
            },
            "property": {
                "type": "Identifier",
                "name": method
            }
        },
        "arguments": [
            {
                "type": "Literal",
                "value": path,
                "raw": `'${path}'`
            },
            {
                "type": "FunctionExpression",
                "id": null,
                "params": [
                    {
                        "type": "Identifier",
                        "name": "req"
                    },
                    {
                        "type": "Identifier",
                        "name": "res"
                    }
                ],
                "body": {
                    "type": "BlockStatement",
                    "body": [
                        {
                            "type": "ExpressionStatement",
                            "expression": {
                                "type": "CallExpression",
                                "callee": {
                                    "type": "MemberExpression",
                                    "computed": false,
                                    "object": {
                                        "type": "Identifier",
                                        "name": "res"
                                    },
                                    "property": {
                                        "type": "Identifier",
                                        "name": "send"
                                    }
                                },
                                "arguments": [
                                    {
                                        "type": "Literal",
                                        "value": `Hello from path ${path}`,
                                        "raw": `'Hello from path ${path}'`
                                    }
                                ]
                            }
                        }
                    ]
                },
                "generator": false,
                "expression": false,
                "async": false
            }
        ]
    }
});
nodes/route-node.js

Once above part is done we are ready with our code generator, since it is a cli module to test it locally we have to perform module linking process. So that it can be used in other projects -

Now in the lazy-nator directory run yarn link. This will link your current local module to system bin so that your lazy-nator command can be accessed globally (For npm use $ npm link). While working on this project I’ve noticed that in my system Yarn was not able to change the file permissions of cli.js which result in following error while running the command.

$ lazy-nator
zsh: permission denied: lazy-nator
console output

If you are also facing the same issue try changing permission of your bin file(cli.js in this case) using following command. $ chmod +x cli.js.

Now goto your node express project directory and run lazy-nator where the router file is present. I am using a sample project called lazynator-test.

$ lazy-nator
? Select your router file … 
  db.js
  index.js
❯ routes.js

? What is path for the route? ›  /example

? Specify method for the route … 
  POST
❯ GET

✔ Select your router file · routes.js
✔ What is path for the route? · /example
✔ Specify method for the route · GET

Method 'GET' with path '/example' added to file 'routes.js'

console output

After above command is successful we can see our updated routes.js file.

const express = require('express');
const router = express.Router();
router.get('/example', function (req, res) {
    res.send('Hello from path /example');
});
router.get('/', function (req, res) {
    res.send('Welcome to the App.');
});
module.exports = router;
routes.js

So yeah, generating code from custom written generator is this simple. Above code based can be downloaded from this Github repo. And here is the links to other blogs from the same series -

  1. Into the world of Abstract Syntax Tree
  2. Writing a Python Slayer in Javascript using AST
  3. Creating your very own custom ESLint plugin

I hope this blog series is helpful to you. And please feel free to drop your comments/ feedback.

…Over and Out.


Attributions:

Photo by Alex Knight on Unsplash