Let’s create a mini programming language!

In this tutorial, we will create a mini programming language. That compiles:

center,
left 100,
color blue,
top 10,
right 100,

to

_center();
_draw();
_left(100);
_draw();
_setColor("blue");
_draw();
_top(10);
_draw();
_right(100);
_draw();

Which we will use to draw interesting stuff on the HTML canvas.

So let's start!

Basic stuff

Before doing the code, we need to know the logic.

Lexing

First, we lex the code. This is the process of splitting the code into tokens and removing unnecessary characters.

Validating

Then, we validate the code by checking if the tokens are valid.

Compiling

Then, we compile the code to JavaScript.

Programming part

We will name our mini-language drawlang. Its syntax looks like this.

  • center, left, top, right, bottom, color are keywords.
  • , is also a keyword that draws.
  • Any color like red, blue, green, black, white, …etc. is a color.
  • Any number is a number.

Setting the basic stuff

First, I'll create an index.js file and add this code.

const fs = require("fs"); // File system

const code = `
center,
left 100,
color blue,
top 10,
right 100,
`; // Sample code to test during the tutorial

We import the fs module to read the file.

Creating the lexer

I'll create a function named tokenize and accepts a string as an argument.

function tokenize(code) // ...

Now keep to variables to store the tokens and keep track of the current position.

const tokens = [];
let i = 0;

And a utility function to add a token to the tokens array.

function addToken(type, value) {
    tokens.push({
        type,
        value,
    });
}

Now, we can start tokenizing the code. We will use a while loop to iterate over the code.

while (i < code.length) // ...

then we get the current character.

const char = code[i];

We will use a switch statement to determine the type of the token.

switch (
    char
    // ...
) {
}

We ignore all the whitespace characters.

case " ":
case "\t":
case "\n":
case "\r":
    i++;
    break;

If it's a comma, we add a COMMA token.

case ",":
    addToken("COMMA", char);
    i++;
    break;

Else, we check if it's a number or a keyword.

default:
    const isDigit = /\d/.test(char); // Returns true if it's a digit
    const isLetter = /[a-z]/i.test(char); // Returns true if it's a letter

    if (isDigit) {
        let number = ""; // Stores the number
        while (i < code.length && /\d/.test(code[i])) { // While the current character is a digit
            number += code[i];
            i++;
        }
        addToken("NUMBER", number); // Finally, we add the token
    } else if (isLetter) {
        let name = ""; // Stores the name
        while (i < code.length && /[a-z]/i.test(code[i])) { // While the current character is a letter
            name += code[i];
            i++;
        }
        addToken("NAME", name); // Finally, we add the token
    } else {
        throw new Error(`Unknown character: ${char}`); // 🤬 Error
    }
    break;

And we finally escape the while loop and return the tokens.

return tokens;

Now try adding this code and running node index.js and see the output.

const tokens = tokenize(code);
console.log(tokens);

Nice! We have created the lexer/tokenizer.

Validating and compiling to JavaScript

We will create a function named compile that accepts an array of tokens as an argument.

function compile(tokens) {
    // ...
}

Then add some variables to store the compiled code and keep track of the current position.

let i = 0;
let out = "";

let addCode = (code) => {
    out += code + "\n";
};

Again, we use a while loop to iterate over the tokens.

while (i < tokens.length) {
    // ...
    i++;
}

This time we create a function to get the current token.

const token = () => tokens[i];

And a function named expect to check if the next token is the expected one and if not, throw an error.

function expect(type) {
    if (tokens[++i].type !== type) {
        throw new Error(`Expected ${type}, got ${tokens[i].type}`);
    }
}

After that, we use a switch statement to determine the type of the token.

switch (token().type) // ...

If it's a COMMA, we add _draw() to the compiled code.

case "COMMA":
    addCode("_draw()");
    break;

Else, we check if it's a NAME token.

case "NAME":
/**
 * If the name is center, execute the center function
 */
if (token().value === "center") {
    addCode(`_center();`);
} else if (token().value === "color") {
    /**
     * If the name is color, expect a name and set the color
     */
    expect("NAME");
    addCode(`_setColor("${token().value}");`);
} else if (
    /**
     * If the name is left/right/top/bottom, expect a number and execute the corresponding function
     */
    token().value === "left" ||
    token().value === "right" ||
    token().value === "top" ||
    token().value === "bottom"
) {
    expect("NUMBER");
    const value = parseInt(token().value);

    addCode(`_${tokens[i - 1].value}(${value});`); // We get the token before the current one and use it to determine the function to call
} else {
    throw new Error(`Unknown name: ${token().value}`); // 🤬 Error
}
break;

At last, we finally escape the while loop and return the compiled code.

return out;

Now try adding this code and running node index.js and see the output.

const tokens = tokenize(code);
const compiled = compile(tokens);
console.log(compiled);

If you did it right, you should see the following output:

We'll write the code in a file named out.js so you can run it in the browser.

const tokens = tokenize(code);
const compiled = compile(tokens);

console.log("Compiled code successfully!");
fs.writeFileSync("out.js", compiled);

Nice! We now create a framework.js file that will contain the code for the framework.

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");

canvas.width = 900;
canvas.height = 900;

let x, y, lastX, lastY, color;
x = y = lastX = lastY = 0;
color = "transparent";

function setX(newX) {
    lastX = x;
    x = newX;
}

function setY(newY) {
    lastY = y;
    y = newY;
}

function _center() {
    setX(canvas.width / 2);
    setY(canvas.height / 2);
}

ctx.beginPath();

function _draw() {
    ctx.moveTo(lastX, lastY);
    ctx.lineTo(x, y);
    ctx.strokeStyle = color;
    ctx.stroke();
}

function _left(distance) {
    setX(x - distance);
}

function _right(distance) {
    setX(x + distance);
}

function _top(distance) {
    setY(y - distance);
}

function _bottom(distance) {
    setY(y + distance);
}

function _setColor(newColor) {
    color = newColor;
}

In the HTML file, we'll add the canvas and the script tag.

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Document</title>
    </head>

    <body>
        <canvas id="canvas"></canvas>

        <script src="framework.js"></script>
        <script src="out.js"></script>
    </body>
</html>

Now run node index.js and open the index.html file in your browser.

This code shows this

center,
left 100,
color blue,
top 10,
bottom 100,
right 100,
top 200,
right 100,

The end

If you need the code - here