A custom JIT compiler for a simplified imperative language, built with modern C++ and LLVM.
A custom Just-In-Time (JIT) compiler for a simplified imperative language built using modern C++ and the LLVM compiler infrastructure (complete pipeline from source code to native machine code execution).
Example program that recursively calculates the Fibonacci numbers:
# Compute the x'th fibonacci number recursively.
def fib(x) {
if (x < 3) {
1
}
else {
fib(x-1)+fib(x-2)
}
}
# This expression will compute the 40th number.
fib(40)
This project is referred to in the source code as both a “Decaf” compiler and a compiler for “LaSIL” (which stands for LASA Simplified Imperative Language). The reasons for this are twofold. Firstly, the primary inspiration for this project was the “Decaf Language Reference” used in CS 432 and CS 630 at James Madison University (though the language in its current form shares several differences and is less advanced). Moreover, I first started working on this project as a part of my CS independent study during my time at LASA high school. In order to pay homage to both Decaf and my high school (where I learned a great deal), I’ve decided to preserve the source code as is, despite the naming inconsistencies.
KaleidoscopeJIT to dynamically compile and execute LLVM IR on the fly.The compiler’s core components are modularized in the src/ directory:
src/
├── AST.hpp
├── CodeGenerator.cpp
├── CodeGenerator.hpp
├── FileHandler.cpp
├── FileHandler.hpp
├── JIT.cpp
├── JIT.hpp
├── KaleidoscopeJIT.hpp
├── Lexer.cpp
├── Lexer.hpp
├── Logger.cpp
├── Logger.hpp
├── Parser.cpp
├── Parser.hpp
└── main.cpp
The core architecture is as follows:
Lexer: Reads source files and generates a stream of language tokens.Parser: Consumes tokens to build an Abstract Syntax Tree (AST).CodeGenerator: Traverses the AST and emits optimized LLVM IR using several passes.Logger: Extensive logging subsystem with helper functions to pretty print the list of tokens generated by the Lexer and recursively output the AST.JIT: Manages the LLVM execution engine for runtime compilation.This project is configured for macOS (Apple Silicon/ARM64) environments. Ensure you have the following installed:
CMakeLists.txtbrew install libedit)This is how I installed LLVM using homebrew for a working build (as of 05/13/2026):
brew install llvmIf you need to have llvm first in your PATH, run:
echo 'export PATH="/opt/homebrew/opt/llvm/bin:$PATH"' >> ~/.zshrcFor compilers to find llvm you may need to set the following two flags:
export LDFLAGS="-L/opt/homebrew/opt/llvm/lib"export CPPFLAGS="-I/opt/homebrew/opt/llvm/include"For CMake to find llvm you may need to set: export CMAKE_PREFIX_PATH="/opt/homebrew/opt/llvm"
The build will likely break as LLVM gets updated. Consequently, prepare to change a few lines of code or downgrade your version of LLVM. As mentioned, this was last tested for version 22.1.5 on MacOS (installed via homebrew).
Navigate into the project directory:
cd LLVM-Compiler
Create a build directory and configure the project with CMake:
mkdir build && cd build
cmake ..
Build the executable:
make
By default, the compiled executable (decaf_cc) acts as a test runner for the Catch2 suite.
To run the built-in compiler tests:
./decaf_cc
Note: The primary entry point in src/main.cpp is currently configured for testing. To compile and execute standalone .decaf source files, you can uncomment the CLI file-parsing logic in main.cpp.
Running the test suite against the Fibonacci example above walks through lexing, parsing, unoptimized IR generation, and then LLVM’s optimization passes:
➜ build git:(main) ✗ ./decaf_cc
Debug Info: Abstract Syntax Tree
if/else statement:
└binary operation: <
└variable: x
└number: 3
└number: 1
└binary operation: +
└function call: fib
└binary operation: -
└variable: x
└number: 1
└function call: fib
└binary operation: -
└variable: x
└number: 2
Debug Info: Unoptimized function
define double @fib(double %x) {
entry:
%cmptmp = fcmp ult double %x, 3.000000e+00
%booltmp = uitofp i1 %cmptmp to double
%ifcond = fcmp one double %booltmp, 0.000000e+00
br i1 %ifcond, label %then, label %else
...
}
Debug Info: Optimized function
define double @fib(double %x) {
entry:
%cmptmp = fcmp ult double %x, 3.000000e+00
br i1 %cmptmp, label %ifcont, label %else
else:
%subtmp = fadd double %x, -1.000000e+00
%calltmp = call double @fib(double %subtmp)
%subtmp1 = fadd double %x, -2.000000e+00
%calltmp2 = call double @fib(double %subtmp1)
%addtmp = fadd double %calltmp, %calltmp2
br label %ifcont
ifcont:
%iftmp = phi double [ %addtmp, %else ], [ 1.000000e+00, %entry ]
ret double %iftmp
}
===============================================================================
All tests passed (1 assertion in 1 test case)
The actual terminal output has a lot more color and formatting as a result of the extensive logging, so it is unfortunate I can’t show that here in full. Also, please note the presence of a bunch of junk print statements left over from when I was debugging the parser. As this is an old project and I am now more focused on projects related to my electrical/computer engineering major, it may be a while before I revisit this project or clean this up, if ever.