Creating Your Own JavaScript Syntax

Creating Your Own JavaScript Syntax

Featured on Hashnode

Header by Maggie Appleton

Today we are going to create our own syntax in JavaScript. For simplicity sake and easy understanding, we will stick to a single javascript structure. Variable Declaration. We are going to implement a new syntax for declaring variables in JavaScript. The new syntax definition will be below.

// `set` and `define` to replace `let` and `const`

set name as "Duncan";
// let name = "Duncan";

define k as 1024;
// const k = 1024;

With the syntax, we could split the input and replace set and define with let and const respectively but everyone can do that. Let's try something else.

A compiler.

Don't get too scared, it will be a very small and tiny one. For simplicity, our compiler will only support numbers, strings, boolean and null.

The Compiler

Different compilers work in different ways but break down to the three primary stages:

  • Parsing : takes the raw code and turning it into an abstract representation known as an Abstract Syntax Tree (AST)
  • Transformation : takes the abstract representation and transforms and modifies it into another abstract representation of the target language.
  • Code Generation : takes the transformed abstract representation and generates the new code based on the given abstract representation.

Parsing

Parsing also gets broken down into two stages. Lexical Analysis (lexing/ tokenizing) and Syntactic Analysis. Lexical Analysis takes the raw code and turn each character it into a token with the lexer/tokenizer. The tokenizer returns an array of all the tokens for a given syntax.

// Given the code
set age as 18;

The tokenizer will return the array below.

[
  { type: 'keyword', value: 'set' },
  { type: 'name', value: 'age' },
  { type: 'ident', value: 'as' },
  { type: 'number', value: '18' },
];

Tokens are an array of tiny little objects that describe an isolated piece of the syntax.

Each token is an object with a type and value property. The type holds the type of the current character or set of characters being passed. value property stores the value of the character being passed.
Syntactic Analysis then takes the tokens and transforms them with a parser function to an abstract representation of the tokens in relation to each other. Usually, we would have two ASTs where one is from our language and the other is for the target language, but for simplicity again, we will build a single AST modify the same one to produce a different AST.

The parser will return the object below.

// Abstract Syntax Tree for `set age as 18;`
{
  type: "Program",
  body: [
    {
      type: "VariableDeclaration",
      kind: "set",
      declarations: [
        {
          type: "VariableDeclarator",
          id: { type: "Identifier", name: "age" },
          init: { type: "NumberLiteral", value: 18 },
        },
      ],
    },
  ],
}

Transformation

The next stage for our compiler is transformation. Taking the AST and transforming it into a totally new AST for any programming language or just modifying the same one. We won't generate a new AST, we will just modify it. On our AST, we have at each level an object with a type property. These are known as AST Node. These nodes have defined properties on them that describe one isolated part of the tree.

// We have a Node for a "NumberLiteral"
{
  type: "NumberLiteral",
  value: 18,
}

// A Node for a "VariableDeclarator"
{
  type: "VariableDeclarator",
  id: { ...object },
  init: { ...object },
}

Fortunately for us, we are doing only one thing with our AST, that is Variable Declaration. Let's see how we will modify our AST.

At the VariableDeclaration node, we have a kind property that contains the current keyword being used. So we will traverse the tree and visit each node until have a Node with type of VariableDeclaration and set the kind property to what keyword we want. let or const

// AST for `set age as 18;`
{
  type: "Program",
  body: [
    {
      type: "VariableDeclaration",
      kind: "set", // <- `kind` will be changed to `let` or `const`
      declarations: [ [Object] ],
    },
  ],
}

// AST after transforming it
{
  type: "Program",
  body: [
    {
      type: "VariableDeclaration",
      kind: "let", // <<<<<<<: Changed from `set`
      declarations: [ [Object] ],
    },
  ],
}

Code Generation

Now that we have our new AST, we can now generate our code. Our new AST has everything we need. The keyword, the variable name and the value assigned to the variable. The name and value can be found in the VariableDeclarator node.

Now that's it. A general idea of compilers and how they work. Not all compilers work like this but most certainly do. That's backbone and skeleton of our compiler. If our compiler was a website, all the above will be the HTML.

Let's write some code. 😋

We won't use any external libraries, we will write everything from scratch😺. Also, you must have Node.js installed on your local system. Use any text editor or IDE of your choice.

Create a new directory and run npm init -y and create a new javascript file with any filename of your choice.

In general, we will have 5 main functions in our code

Compiler

tokenizer

We will first declare a tokenizer function with a parameter of input, the inital code we are going to pass to our compiler as a string. Then initialize a current and tokens variable. current for the current location in the input and tokens will be an array that will hold the tokens for each individual token. Then we will add a;and awhitespace character to the end.

const tokenizer = (input) => {
  let tokens = [];
  let current = 0;

  // Add the semicolon to the end of the input if one was not provided
  // Then add whitespace to the end of the input to indicate the end of the code
  if (input[input.length - 1] === ';') {
    input += ' ';
  } else {
    input = input + '; ';
  }
};

After the initial declarations in the tokenizer, we come to the main part. We will have a while loop that will loop over all the characters in the input and while there is a character available, we will check for the type of the character and add it to a token and add the token to the tokens array.

const tokenizer = (input) => {
  // ...
  while (current < input.length - 1) {
    // We get the current character first
    const currentChar = input[current];

    // Now, we test for the types of each character.
    // We check for Whitespaces first
    // Regex to check for whitespace
    const WHITESPACE = /\s+/;
    if (WHITESPACE.test(currentChar)) {
      // If the current character is a whitespace, we skip over it.
      current++; // Go to the next character
      continue; // Skip everything and go to the next iteration
    }

    // We need semicolons They tell us that we are at the end.
    // We check for semicolons now and also if the semicolon is at the last but one position
    // We only need the semicolons at the end. Any other position means there
    // An error
    if (currentChar === ';' && currentChar === input[input.length - 2]) {
      // If the current character is a semicolon, we create a `token`
      let token = {
        type: 'semi',
        value: ';',
      };

      // then add it to the `tokens` array
      tokens.push(token);
      current++; // Go to the next character
      continue; // Skip everything and go to the next iteration
    }
  }
};

We now have check in place for semicolons and whitespaces but there are four more to go. Our compiler supports strings, numbers, booleans and null. We will now check for the following types. Remmember we are dealing with single characters so we willl need to put some checks in place else we will be pushing single characters as tokens Still in the while loop

const tokenizer = (input) => {
  // ...
  while (current < input.length - 1) {
    const currentChar = input[current];
    //...

    // Now we will check for Numbers
    const NUMBER = /^[0-9]+$/; // Regex to check if character is a number
    // If we use the same method above for the semicolons,
    // We create a number `token` and add it to `tokens`, we end up with a token for
    // each single number character instead of the number as a whole.
    // For example, if we have a number value of `123`, then our tokens will be
    //
    // [
    //   { type: 'number', value: 1 },
    //   { type: 'number', value: 2 },
    //   { type: 'number', value: 3 },
    // ]
    //
    // Instead of
    //
    // [
    //   { type: 'number', value: 123 },
    // ]
    // which we don't want.
    // So we create a `number` variable and check if the next character is a number.
    // If the next character is a number, we add it to the `number` variable
    // Then add the `number` variable's value as the value in our `token`
    // The add the `token` to our `tokens` array
    if (NUMBER.test(currentChar)) {
      let number = '';

      // Check if the next character is a number
      while (NUMBER.test(input[current++])) {
        number += input[current - 1]; // Add the character to `number`
      }

      // Create a token with type number
      let token = {
        type: 'number',
        value: parseInt(number), // `number` is a string to we convert it to an integer
      };

      tokens.push(token); // Add the `token` to `tokens` array
      continue;
    }
  }
};

Now that we have numbers underway, the next on our list is strings, booleans and null values. If we used the same approach for the semicolon and add a token for every character, we could face the same problem where we won't the full token value so we will a different approach similiar to the number check.

Strings will be easy to tackle with first. Each string starts and ends with a " so based on the same approach for numbers, we check if a character is a ", If it is, we will add every value that comes after the quote(") until we meet another quote indicating the end of the string.

const tokenizer = (input) => {
  // ...
  while (current < input.length - 1) {
    const currentChar = input[current];
    //...

    // Check if character is a string
    if (currentChar === '"') {
      // If the current character is a quote, that means we have a string
      // Initialize an empty strings variable
      let strings = '';

      // Check if the next character is not a quote
      while (input[++current] !== '"') {
        // If it is not a quote, it means we still have a string
        strings += input[current]; // Add it to the `strings` variable
      }

      // Create a token with property type string and a value with the `strings` value
      let token = {
        type: 'string',
        value: strings,
      };

      tokens.push(token); // Add the `token` to the `tokens` array
      current++;
      continue;
    }
  }
};

The last check and we are done with our tokenizer. The check for letters. booleans, null and the keywords, set and define all have characters that will test true for letters so we will use the same approach as the numbers. If the current character is a letter, we will add it to a new variable and check of the next character is also a letter until we meet a non-letter character then we will return.

const tokenizer = (input) => {
  // ...
  while (current < input.length - 1) {
    const currentChar = input[current];
    //...

    // Check if the character is a letter
    const LETTER = /[a-zA-Z]/; // Regex to check if it is a letter
    if (LETTER.test(currentChar)) {
      // If the current character is a letter we add it to a `letters` variable
      let letters = currentChar;

      // Check if the next character is also a letter
      while (LETTER.test(input[++current])) {
        // We add it to the `letters` variable if it is
        letters += input[current];
      }

      // ...
      // See below..
    }
  }
};

At this point, we have our letters value but we cannot add it to the tokens array yet. Each token must have a type and a value but for letters, they could be different. Our letters could be true || false which will have a type of boolean or the letters could be set || define which could have a type of keyword, so we need another check to check the letters and assign thier token the respective type.

const tokenizer = (input) => {
  // ...
  while (current < input.length - 1) {
    const currentChar = input[current];
    //...

    const LETTER = /[a-zA-Z]/;
    if (LETTER.test(currentChar)) {
      // ...
      //
      // Still in the letter check
      // At this point, we have a value for our `letters` so we check for thier types.
      //
      // We first check if the `letters` is `set` or `define` and we assign the `token` a type `keyword`
      if (letters === 'set' || letters === 'define') {
        // Add a `token` to the `tokens` array
        tokens.push({
          type: 'keyword',
          value: letters,
        });

        continue; // We are done. Start the loop all over again
      }

      // If the letter is `null`, assign the `token` a type `null`
      if (letters === 'null') {
        tokens.push({
          type: 'null',
          value: letters,
        });
        continue;
      }

      // If the letter is `null`, assign the `token` a type `ident`
      if (letters === 'as') {
        tokens.push({
          type: 'ident',
          value: letters,
        });
        continue;
      }

      // If the letter is `true` or `false`, assign the `token` a type `boolean`
      if (letters === 'true' || letters === 'false') {
        tokens.push({
          type: 'boolean',
          value: letters,
        });
        continue;
      }

      // If we don't know the `letters`, it is the variable name.
      // Assign the `token` a type `name`
      tokens.push({
        type: 'name',
        value: letters,
      });

      continue; // Start the loop again
    }
  }
};

At this point, we are done checking but if the character isn't recognized the our while loop will be stuck so we need some error checking in place and finally return the tokens from the tokenizer.

const tokenizer = (input) => {
  // ...
  while (current < input.length - 1) {
    // ....
    //
    // If the character reaches this point, then its not valid so we throw a TypeError
    // with the character and location else we will be stuck in an infinite loop
    throw new TypeError('Unknown Character: ' + currentChar + ' ' + current);
  }

  // Return the `tokens` from the `tokenizer`
  return tokens;
};

We are done with the tokenizer. All the code at this point can be found here.

// You can test your tokenizer with
const tokens = tokenizer('set isEmployed as false');

// [
//   { type: 'keyword', value: 'set' },
//   { type: 'name', value: 'isEmployed' },
//   { type: 'ident', value: 'as' },
//   { type: 'boolean', value: 'false' },
//   { type: 'semi', value: ';' },
// ]

parser

Now that the heavy lifting has been done for us in the tokenizer, we move to the parser. The parser takes the tokens produced by the tokenizer and modifies them into an AST. Out parser will have a walk function. The walk function will take the current token and return the AST Node for that specific token.

If we had a token

{
  type: "number",
  value: 1024
}

The AST Node will be:

{
  type: "NumberLiteral",
  value: 1024
}

The code for our parser

const parser = (tokens) => {
  // We will declare a `current` variable to get the current `token`
  let current = 0;

  // Then our parser will have a walk function
  const walk = () => {};
};

The walk function will be a recursive function. We first get the current token, check the type of the token and return an AST Node based on the type.

const parser = (tokens) => {
  // ...
  const walk = () => {
    // Get the current `token` with the `current` variable
    let token = tokens[current];

    // From here, we will check for the `type` of each token and return a node.
    if (token.type === 'number') {
      // Our token is a `number`,
      // We increase the current counter
      current++;
      // We create a type `NumberLiteral` and the value as the token's `value`
      let astNode = {
        type: 'NumberLiteral',
        value: token.value,
      };

      // We return the node
      return astNode;
    }

    // We will take the same steps for the `boolean`, `null` and `string` token types
    // Check the value, Increment the counter, return a new node
    // Check for a string token
    if (token.type === 'string') {
      current++;
      let astNode = {
        type: 'StringLiteral',
        value: token.value,
      };
      return astNode;
    }

    // Check for boolean token
    if (token.type === 'boolean') {
      current++;
      let astNode = {
        type: 'BooleanLiteral',
        value: token.value,
      };
      return astNode;
    }

    // Check for null token
    if (token.type === 'null') {
      current++;
      let astNode = {
        type: 'NullLiteral',
        value: token.value,
      };
      return astNode;
    }
  };
};

We have checks for null, boolean,string and number token types. Let's focus on the remaining ones, keyword, name, semi and ident. ident will always have a value of as so we won't need a node for it. We will just skip it. semi also indicates the end of the code so we will ignore it too. We will focus on the keyword and name

const parser = () => {
  // ...
  const walk = () => {
    let token = tokens[current];
    // ...

    // We now check for the `keyword` token type
    // The presence of a `keyword` token type indicates that we are declaring a variable,
    // So the AST node won't be the same as that of `number` or `string`.
    // The node will have a `type` property of `VariableDeclaration`, `kind` property of the keyword
    // and a `declarations` property which is an array for all the declarations
    if (token.type === 'keyword') {
      // New AST Node for  `keyword`
      let astNode = {
        type: 'VariableDeclaration',
        kind: token.value, // The keyword used. `set` or `define`
        declarations: [], // all the variable declarations.
      };

      // At this stage, we don't need the `keyword` token again. It's value has been used at the astNode.
      // So we increase the current and get the next token
      // Obviously the next one will be the `name` token and we will call the `walk` function again
      // which will have a token type of `name` now and the returned results will be pushed into
      // the declarations array

      token = tokens[++current]; // Increase the `current` token counter and get the next token.

      // Check if there is a token and the next token is not a semicolon
      while (token && token.type !== 'semi') {
        // if the token is not a semicolon, we add the result of `walk` again into
        // the AST Node `declarations` array
        astNode.declarations.push(walk());

        // We then go to the next token
        token = tokens[current];
      }

      // From here, we don't need the semicolon again, so we remove it from the
      // `tokens` array
      tokens = tokens.filter((token) => token.type !== 'semi');

      // Then we return the AST Node
      return astNode;
    }

    // The last is the `name` token type
    // The `name` token type will have a node of type `VariableDeclarator` and an
    // `id` which will also be a another node with type `Identifier` and an
    // `init` with the type of the value.
    // If the token type is a name, we will increse `current` by two to skip the next value after
    // `name` which is `ident` and we don't need it.
    if (token.type === 'name') {
      current += 2; // Increase by 2 to skip `ident`

      // Declare a new AST Node and recursively call the `walk` function again
      // Which the result will be placed in the `init` property
      let astNode = {
        type: 'VariableDeclarator',
        id: {
          type: 'Identifier',
          name: token.value,
        },
        init: walk(), // Call `walk` to return another AST Node and the result is assigned to `init`
      };

      // Return the AST Node
      return astNode;
    }

    // We throw an error again for an unknown type
    throw new Error(token.type);
  };
};

We are done with the walk function, but the function is just declared in the parser, it's not being used by the parser so we have to use it.

const parser = () => {
  // ..
  const walk = () => {
    // ...
  };

  // We will now declare our AST. We have been building the nodes,
  // so we have to join the AST as one.
  // The type of the AST will be `Program` which will indicate the start of the code
  // And a `body` property which will be an array that will contain all the other AST we have generated.
  let ast = {
    type: 'Program',
    body: [],
  };

  // We then check if there are token's in the `tokens` array and add thier Node to the main AST
  while (current < tokens.length) {
    ast.body.push(walk());
  }

  // Final return of the parse function.
  return ast;
};

There you have it, the parser in the flesh. You can use the test case for the tokenizer above and pass the tokens to the parser and log the results for yourself. You can get all the code up to this point here

traverser

It's time for our traverser. The traverser will take the ast from the parser and a visitor. The visitor will have objects with names of the various AST Node types and each object will have an enter method. While traversing the AST, when we get to a node with a matching visitor object, we call the enter method on that object.

// Example Visitor
let visitor = {
  VariableDeclaration: {
    enter() {},
  },
};
// Declaring the `traverser`
const traverser = (ast, visitor) => {};

The traverser will have two main methods, traverseArray and traverseNode. traverseArray will call traverseNode on each node in a node array. traverseNode will take an node and it's parent node and call the visitor method on the node if there is one.

const traverser = (ast, visitor) => {
  // `traverseArray` function will allow us to iterate over an array of nodes and
  // call the `traverseNode` function
  const traverseArray = (array, parent) => {
    array.forEach((child) => {
      traverseNode(child, parent);
    });
  };
};

Now that we have the traverseArray, we can proceed to the main traverseNode function.

const traverser = (ast, visitor) => {
  // ...

  // In the `traverseNode`, will get the  node `type` object and call the `enter`
  // method if the object is present
  // Then recursively call the `traverseNode` again on every child node
  const traverseNode = (node, parser) => {
    // Get the node object on the visitor passed to the `traverser`
    let objects = visitor[node.type];

    // Check if the node type object is present and call the enter method
    // with the node and the parent
    if (objects && objects.enter) {
      methods.enter(node, parent);
    }

    // At this point, we will call the `traverseNode` and `traverseArray` methods recursively
    // based on each of the given node types
    switch (node.type) {
      // We'll start with our top level `Program` and call the `traverseArray`
      // on the `body` property to call each node in the array with  `traverseNode`
      case 'Program':
        traverseArray(node.body, node);
        break;

      //We do the same to `VariableDeclaration` and traverse the `declarations`
      case 'VariableDeclaration':
        traverseArray(node.declarations, node);
        break;

      // Next is the `VariableDecalarator`. We traverse the `init`
      case 'VariableDeclarator':
        traverseNode(node.init, node);
        break;

      // The remaining types don't have any child nodes so we just break
      case 'NumberLiteral':
      case 'StringLiteral':
      case 'NullLiteral':
      case 'BooleanLiteral':
        break;

      // We throw an error if we don't know the `type`
      default:
        throw new TypeError(node.type);
    }
  };

  // We now start the `traverser` with a call to the `traverseNode` with the
  // `ast` and null, since the ast does not have a parent node.
  traverseNode(ast, null);
};

That's it for our traverser. You can get all the code up to this point here.

transformer

Next is our transformer which will take the AST and modify the AST and return it. Our transformer will have a visitor object and it will traverse the AST passed as an argument with the visitor and return the modified AST

Since we are only dealing with Variable Declaration's, our visitor will have only one object,VariableDeclaration and will change the value of the kind to the respective equivalent.

const transformer = (ast) => {
  // We will start by creating the `visitor` object
  const visitor = {
    // Then we will create the `VariableDeclaration` object in the `visitor`
    VariableDeclaration: {
      // Here, we will have the `enter` method which will take the `node` and the `parent`
      // Although we won't use the parent (Simplicity)
      enter(node, parent) {
        // Check if the VariableDeclaration has a `kind` property
        // If it has, we change based on the previous one
        // `set` -> `let`
        // `define` -> `const`
        if (node.kind) {
          if (node.kind === 'set') {
            node.kind = 'let'; // Set it to `let`
          } else {
            node.kind = 'const';
          }
        }
      },
    },
  };
};

That's it for our visitor. Although we could have done more, like things not related to variable declaration. We could have added a NumberLiteral object to multiply every number by 2 or another method to make every string in a String uppercase. visitor is where the mutations and the modifications take place.

let visitor = {
  // Multiply every number by 2
  NumberLiteral: {
    enter(node) {
      if (typeof node.value === 'number') {
        node.value *= 2;
      }
    },
  },

  // Uppercase every string value
  StringLiteral: {
    enter(node) {
      if (typeof node.value === 'string') {
        node.value = node.value.toUpperCase();
      }
    },
  },
};

We are done with the visitor but not the whole transformer. We need to use the visitor we created with the traverser to modify our AST and return the modified AST

const transformer = (ast) => {
  // ...visitor

  // We will call the `traverser` with the `ast` and the `visitor`
  traverser(ast, visitor);

  // Finally we return the AST, which has been modified now.
  return ast;
};

We are done with the transformer, you can get all the code up to this point here.

You can test your transformer with an ast generated by the parser and compare the difference.

generator

We are done with two phases of our compiler, Parsing and Transformation. It's left with the last phase, Code Generation. We will only have one function for this phase, generator.

The generator will be recursively call itself at each node till we get a giant string of all the values. At each node, we will either return a call to another child node or return a value if the node has no children.

const generator = (ast) => {
  // Let's break things down by the `type` of the `node`.
  // Starting with the smaller nodes to the larger ones
  switch (node.type) {
    // If our node `type` is either `NumberLiteral`,`BooleanLiteral` or `NullLiteral`
    // we just return the value at that `node`.
    case 'NumberLiteral':
    case 'BooleanLiteral':
    case 'NullLiteral':
      return node.value; // 18

    // For a `StringLiteral`, we need to return the value with quotes
    case 'StringLiteral':
      return `"${node.value}"`;

    // For an `Identifier`, we return the `node`'s name
    case 'Identifier':
      return node.name; // age

    // A `VariableDeclarator` has two more `node`'s so we will call the `generator`
    // recursively on the `id` and `init` which in turn will return a value.
    // `id` will be called with the `generator` with type `Identifier` which will return a name
    // `init` will be called with the `generator` with any of the Literals and will also return a value.
    // We then return the results of these values from the VariableDeclarator
    case 'VariableDeclarator':
      return (
        generator(node.id) + // age
        ' = ' +
        generator(node.init) + // 18
        ';'
      ); // age = 18;

    // For `VariableDeclaration`,
    // We will map the `generator` on each `node` in the `declarations`
    // The `declarations` will have the `VariableDeclarator` which in turn has `id` and `init`
    // which when the generator is called on will return a value
    // In total, we will return the `kind` of node with
    // a joined string of what we had from mapping the declarations
    case 'VariableDeclaration':
      return (
        node.kind + // let
        ' ' +
        node.declarations.map(generator).join(' ') // age = 18
      ); // let age = 18;

    // If we have a `Program` node. We will map through each node in the `body`
    // and run them through the `generator` and join them with a newline.
    case 'Program':
      return node.body.map(generator).join('\n'); // let age = 18;

    //  We'll throw an error if we don't know the node
    default:
      throw new TypeError(node.type);
  }
};

Finally, we are done with our generator and all the three stages. You can get all the code up till this point here.

compiler

Congratulations if you really made it this far. There's only one thing left to do. We need to link all the functions we created and combine it into one single function. We'll name it as the compiler

const compiler = (code) => {
  // Take the code and convert it into token
  const token = tokenizer(code);

  // Take the tokens and parse the into an AST
  const ast = parser(tokens);

  // Modify the ast into a new one
  const mast = transformer(ast);

  // Generate the code from the modified AST
  const output = generator(mast);

  // Return the new compiled code
  return output;
};

We can now test our baby compiler

let code = 'set age as 18;';
let _code = 'define name as "Duncan"';
const js = compiler(code);
const _js = compiler(_code);

console.log(js); // let age = 18;
console.log(_js); // const name = "Duncan";

Conclusion

Congratulations once again on making it to the end 🥳🥳🥳. In view of the fact that we wrote all this, it's kind of useless. No one will use it in the real world and also, if we used it in a real javascript code, we will get all sort of errors, unless of course we had a way to use it in the real world. I am planning on building a babel plugin so please check back in a few weeks. I learnt a lot. I hope you did. Thank you for reading. If you face any errors or have any questions, you can find me on twitter.