Open-source project
justinmeza/lci avatar
justinmeza/lci

lci: a LOLCODE interpreter that compiles down to machine code

A LOLCODE interpreter written in C.

822 stars111 forksCGPL-3.0

At a glance

What is it?
A C implementation of LOLCODE with three execution stages, a tree-walking interpreter, a register machine that resolves dynamic scoping at compile time, and a JIT that emits untagged AArch64 or x86-64 code when a dataflow pass can prove every value is an integer or a boolean.
Who is it for?
Adopt lci if you want a conforming LOLCODE implementation that runs on any system with an ANSI C compiler, that you can read as literate documentation, or that exposes C libraries to LOLCODE through CAN HAS bindings. Do not adopt it as a general purpose language, since it implements one esoteric specification, and do not expect the JIT on anything but AArch64 or x86-64 with executable memory.
Can I use it commercially?
Yes, with conditions. GPL-3.0 is a copyleft licence: if you distribute software that includes it, you must release that software's source code under the same licence. Running it internally without distributing it does not trigger that obligation.
Is it still maintained?
Yes. The repository last received commits 42 days ago.
What is it written in?
Mainly C, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Three stages that share one set of semantics

lci is a LOLCODE interpreter written in C, and the description sets out four goals: correct, portable, fast and precisely documented. What makes it interesting is that the same program can run at three different levels of sophistication, and that the level is chosen per procedure rather than per program.

The three stages are described as a tree-walking interpreter, a register machine, and a code generator. The interpreter in `interpreter.c` still defines what every construct means and still runs anything the other two stages decline, including bindings to C libraries. The register machine in `vm.c` compiles the parse tree into bytecode once, resolving names to registers or to scope slots ahead of time. The code generator in `jit.c` compiles a procedure to AArch64 or x86-64 machine code when a dataflow pass can prove that every value it handles is an integer or a boolean.

The design principle that makes this coherent is stated plainly: a program passes through as many stages as it can and drops back a stage whenever the next one cannot represent what it needs to do. All three share one set of semantics, one representation of values and one kind of scope object, so a procedure running in one stage can call a procedure running in another. That last property is the hard part, and it is what keeps the fallback from turning into a correctness problem.

The repository layout reflects the pipeline: `lexer.c`, `tokenizer.c` and `parser.c` in front, then `interpreter.c`, `vm.c` and `jit.c` for the three stages, with `intern.c` for interning, `unicode.c`, `error.c`, `binding.c` and `inet.c` around them and `main.c` at the entry point.

The register machine has to solve dynamic scoping at compile time

The interesting technical problem in the register machine is scope. LOLCODE is dynamically scoped: a function called with `I IZ` runs with its caller's scope as its parent, so a name the callee reads may resolve to a variable the caller declared.

That is hostile to a register allocator. The compiler's approach is a whole-program analysis that works out which names no procedure ever reads from outside the one that declares them. Only those become registers. Everything else stays in the scope object.

The safety property is the important part, and it comes from how failure is handled. A construct the analysis does not recognise leaves every name in its scope object, so adding to the language costs speed rather than correctness. That is the right way round for an esoteric language with a small and stable spec: an unrecognised construct can only slow the program down, never change what the program means.

The JIT applies a similar idea at the type level. When a dataflow pass proves every value a procedure handles is an integer or a boolean, the generated code carries no tags, allocates nothing and tests nothing at run time. The single check happens on entry, where a caller whose arguments do not match falls back to stage 2. Targets other than AArch64 and x86-64, and systems that refuse to make memory executable, run stages 1 and 2 instead.

Deep recursion is handled explicitly in all three stages, which check the remaining stack and stop with the message "Recursion too deep" rather than running into the ground.

The benchmark table is honest about where it loses

The README publishes a benchmark table measured on an Apple M2 Max, best of several runs, with process startup included. The comparison is against lulz 0.1.1, which compiles LOLCODE to Lua and runs it on LuaJIT, and against CPython running an equivalent Python program.

The tree-walking versus VM plus JIT columns are the interesting ones. fib(30) goes from 1,682.4 ms to 5.4 ms, a 309x speed-up, and ackermann(3,7) from 3,745.9 ms to 4.7 ms, which the table reports as 789x. The loop benchmark of 5 million iterations improves by a factor of 23.

For scale, the same fib(30) written in C and compiled with `-O2` takes 5.0 ms on that machine, so the JIT result of 5.4 ms is close to hand-written C.

What the documentation is careful about is the loss. String building is the one benchmark lci loses, and the reason given is specific rather than vague: `SMOOSH` copies the whole string each time round the loop, while CPython grows its buffer in place. So lci is 189.1 ms against 91.3 ms for its own VM and JIT path, and 22.5 ms for CPython.

The `n/a` entries are explained too. They mean the implementation does not accept a construct the program uses. lulz 0.1.1 does not implement `IM IN YR ... UPPIN YR` loops, `SMOOSH`, or `SMALLR OF`/`BIGGR OF`, which is why two rows are blank for it.

One caveat to keep in mind: the tree-walking baseline is lci as it was before the execution engine existed, not against some other tree-walking implementation.

Building it and running the test suite

The prerequisites are CMake and Python for the test driver, with Python 3 preferred since the build looks for `python3` and then `python`. Unit tests ship with the source, and conformance to the LOLCODE specification is treated as the primary correctness goal.

The easy route on Linux and macOS is an install script:

bash
$ ./install.py -h          # show install options
$ ./install.py             # install to default prefix
$ ./install.py --prefix="/home/foo/opt"  # custom prefix

The manual route is the standard CMake sequence, with the test and documentation steps optional:

bash
$ cmake .                  # configure
$ make                     # build
$ make install             # install
$ ctest                    # run tests (optional)
$ make docs                # build docs (optional)

Memory testing is enabled with a single configure flag:

bash
$ cmake -DPERFORM_MEM_TESTS:BOOL=ON .

The documentation itself is generated with Doxygen from literate code, which is one of the four stated design goals. For a project whose whole purpose is teaching a whimsical language, having the implementation readable as prose is not decoration.

Three environment variables exist for working on the engine rather than running programs: `LCI_NOJIT=1` skips stage 3 and runs everything on the register machine, `LCI_DUMP=1` prints each procedure's bytecode and whether it reached stage 3, and `LCI_DUMP_ASM=<file>` writes the generated machine code out for a disassembler.

CAN HAS bindings are how LOLCODE reaches C libraries

lci includes a binding system that exposes C libraries to LOLCODE through the `CAN HAS <library>?` form. Four libraries are documented in the visible portion of the README.

STDIO covers file I/O with `STDIO'Z OPEN` for opening a file with a mode such as `"r"` or `"w"`, `STDIO'Z LUK` to read up to a given number of bytes, `STDIO'Z SCRIBBEL` to write a string, `STDIO'Z CLOSE`, `STDIO'Z DIAF` to check for a file error and return TROOF, and `STDIO'Z AGEIN` to rewind to the beginning of a file. A full example reads a file and prints it:

text
HAI 1.4
  CAN HAS STDIO?

  I HAS A file ITZ STDIO'Z OPEN "hello.txt" AN "r"
  I HAS A contents ITZ STDIO'Z LUK file AN 1024
  VISIBLE contents
  STDIO'Z CLOSE file
KTHXBYE

SOCKS covers networking: `SOCKS'Z RESOLV` for a DNS lookup returning an IP string, `SOCKS'Z BIND` to open a TCP socket, using `"ANY"` for all interfaces, `SOCKS'Z LISTN` to accept an incoming connection, `SOCKS'Z KONN` to connect out, `SOCKS'Z CLOSE`, `SOCKS'Z PUT` to send data and return the byte count, and `SOCKS'Z GET` to receive up to a given amount.

The source tree confirms the shape of the binding system: `binding.c` and `binding.h` hold the mechanism, and `inet.c` and `inet.h` hold the networking support behind the SOCKS library.

A consequence worth noting is that the tree-walking interpreter is not optional in practice. It is the stage that runs anything the VM and JIT decline, and bindings to C libraries are explicitly listed among those things. So the fallback path is a live code path, not a theoretical safety net.

One implementation, two spec versions, separate tags

The default branch of the repository is `future`, and the README carries a pointer for anyone who wants the LOLCODE 1.3 specification implementation, which lives on a separate `v1.3` tag. The current tree therefore targets a later spec than 1.3, and choosing between them is a matter of picking the tag rather than the branch.

That split is worth internalising before you evaluate the interpreter. A conformance claim against one spec version says little about the other, and the unit tests bundled with the source are the evidence for the version in the tree you are reading.

On the licensing, the repository is GPL-3.0, with the COPYING file at the root. That is the one point where the choice of interpreter has consequences beyond curiosity. GPL-3.0 is a strong copyleft licence, and linking lci into a program you distribute carries obligations that MIT or BSD licensing would not. For an interpreter used as a standalone tool, the obligations are limited, but anyone embedding it should read the licence before deciding.

The project is maintained by Justin J. Meza, and the support channels are the lci-general group on Google Groups for help and the GitHub issue tracker for bugs. The homepage for the project is lolcode.org, and the README notes that the project's homepage is there too.

The repository is not archived and the last push was on 2026-09-11, so the work on the engine is live rather than frozen.

Editorial conclusion

Adopt lci if you want a conforming LOLCODE implementation that runs on any system with an ANSI C compiler, that you can read as literate documentation, or that exposes C libraries to LOLCODE through CAN HAS bindings. Do not adopt it as a general purpose language, since it implements one esoteric specification, and do not expect the JIT on anything but AArch64 or x86-64 with executable memory. Verify first which LOLCODE spec version you need, because the 1.3 implementation lives on a separate v1.3 tag, and read the performance table with care, since lci loses to CPython on string building.

Frequently asked questions

What is lci and which LOLCODE spec version does it implement?

lci is a LOLCODE interpreter written in C, and the default branch is future, targeting a later specification than 1.3. The LOLCODE 1.3 implementation is on the separate v1.3 tag.

How do I build and install lci?

On Linux and macOS you can run `./install.py` for a default prefix or `./install.py --prefix="/home/foo/opt"` for a custom one, or do it manually with `cmake .`, `make`, `make install`, and optionally `ctest` and `make docs`. Prerequisites are CMake and Python, with Python 3 preferred.

How does the lci JIT decide what to compile to machine code?

A dataflow pass has to prove that every value the procedure handles is an integer or a boolean. When it can, the generated code carries no tags, allocates nothing and tests nothing at run time, with a single check on entry where a mismatched caller falls back to the register machine. Only AArch64 and x86-64 are targeted.

Which C libraries can LOLCODE programs use through lci?

The binding system exposes C libraries via CAN HAS, with STDIO for file I/O and SOCKS for networking documented in detail, alongside two further libraries. The functions are named in the style of STDIO'Z OPEN and SOCKS'Z KONN.

How does lci handle LOLCODE's dynamic scoping?

The register machine runs a whole-program analysis that finds which names no procedure ever reads from outside the one that declares them, and only those become registers. A construct the analysis does not recognise leaves every name in the scope object, so adding to the language costs speed rather than correctness.

Where does lci lose against other implementations?

String building, because SMOOSH copies the whole string each time round the loop while CPython grows its buffer in place. On the published benchmark lci takes 189.1 ms tree-walking and 91.3 ms on the VM plus JIT path, against 22.5 ms for CPython.

Official sources

  1. Issues
  2. justinmeza/lci on GitHub
  3. License: GPL-3.0
  4. Project website
  5. README
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/justinmeza-lci.svg)](https://hysenlabs.com/projects/justinmeza-lci)

Was this useful?