Welcome!
My name is Bridger Brown, and I am a software engineer. This website was built to display my programming language interpreter that I built from scratch using TypeScript and Jest. Here you can learn about what an interpreter is, how it works, test it out in an in-browser terminal, check out the code files, and learn about how all of this was made.
What is an interpreter?
When we write code, there is a crucial component in our programming language that allows our code to be properly read, analyzed, translated, and finally executed. There are different methods used for this translation process, but the two most common ones are through an interpreter or a compiler.
The main difference between them being that interpreters execute and translate code line-by-line, whereas compilers translate the entire file of code before executing its instructions. Examples of interpreted languages are JavaScript, Python, Ruby, or PHP, and examples of compiled languages are C, C++, Java, or Rust. Why use one over the other? Well I wont get into the differences too much here, but it just comes down to what is most appropriate for the task. Interpreted languages tend to be easier and faster to develop, whereas compiled will have better raw performance. JavaScript is interpreted and makes up nearly all of the web whereas C is compiled and is used for more intensive programs like operating systems, server-side applications, or video game engines.
How does it work?
An interpreter is broken down into different distinct components, each responsible for a stage of the interpretation process.
Lexer
The first step is the 'lexer’. Our program needs to ‘tokenize’ each character of our text, defining them under a name depending on the character. For example, ‘=‘ will be tokenized as ‘EQUALS’ and all numeric characters will be categorized under the ‘INT’ integer token. The lexers job is to run through our line of code one character at a time, categorizing characters into their appropriate token groups. This also means finding identifiers like 'fn' for functions, 'true'/'false' for booleans, etc.
Parser
Once we've identified the tokens, we need to represent them into a kind of hierarchical structure to actually give these tokens semantic meaning. Thats where our 'parser' comes in. A parser creates a syntactic structure by defining tokens within an 'abstract syntax tree' (AST). Take a look at this example:
let a = 1;
This let statement is made up of an identifier ('let'), another identifier for its name ('a'), and an expression for its value ('1'). All of these then make up the statement 'LetStatement'. Why is the '1' an expression? Because everything to the right of the equals needs to be evaluated like an expression. The value of our let statement could be a math expression or even a function. This is why our AST becomes so important-- creating this relationship between our tokens allows for the proper evaluation.
LetStatement {
name: Identifier { value: 'a', token: { type: 'IDENT', literal: 'a' } },
value: IntegerLiteral { token: { type: 'INT', literal: '1' }, value: 1 }
}
Evaluator
Now that we've identified, organized, and put relation to our input comes the actual evaluation! In the evaluator, we will take our structured AST, identify the precedence of our expressions to evaluate them in the right order, and execute the matching functionalities. The actual logic of the evaluation is pretty straightforward, you're just identifying what type of evaluation needs to happen and when, but there's some other essential steps here.
In order to represent the values of our AST items along the way, we implement an object-oriented system which will also allow us to track their types, ie. a boolean value (true/false) or a string value ("hello"). This ensures not only that the evaluator executes the proper evaluations on the expected data types but keeps our interpreter as organized as possible and help prevent unwanted runtime errors.
Next, we will need to implement an ‘environment’ to manage scoping, tracking what we've evaluated and allowing independent evaluation within inner scopes, like function bodies. We can't have functions without scopes!
Finally, this is where we can extend our interpreter with built-in functions we would normally see in our favorite programming languages, so I added the built-in functions 'len' for accessing the length of an element, 'push' for pushing items to arrays, and even 'prnt' for console logging!
When all of the evaluator has completed its step by step evaluation process, the output is returned and we’ve finished our interpreter process!
Examples
>> let math = (1 + 2 * 2) / 5; return math; // input
1 // output
>> if (1 < 2) { return true } else { return false }; // input
true // output
>> let a = 5; let b = 2; let multiply = fn(x, y) { x * y }; multiply(a, b); // input
10 // output
What can it interpret?
Building a full-fledged interpretering programming language would obviously be a huge task! So for this project, I’ve defined enough for the fundamentals of any programming language with evaluation of functions, if statements, let statements, return statements, arrays, booleans, and mathematic expressions. After all, the purpose of this project was to challenge myself by learning about the overall process, components, and complexities of an interpreter, rather than to provide as much functionality as possible.
How do I test it out?
You can test out the interpreter in the terminal page here, where you can submit lines of code to be analyzed. There is a tutorial outlining the basic language syntax to follow, or you can try generating random lines to be analyzed.