Two languages in one repo.
legacy/: the original λanguage, a dynamically typed functional language that compiles through CPS to JavaScript. Still works, now with a test suite.src/: lm, a small low-level language with the same minimal syntax but machine-level semantics, and four independent backends.
npm test # everything
npm run bench # time the four backends against each other
node lc.js examples/01-hello.lm # start here
node lc.js --all tests/lm/sieve.lm # one program through all four backendsexamples/ is the place to start, with ten programs in
order, from hello world to a Mandelbrot set, where the comments carry the
reasoning and the code proves it runs.
npm test is 92 tests: 20 lm programs and 10 examples, each run through all
four backends and checked byte-for-byte against each other and against a
recorded expectation, plus 59 compile-error cases and 3 legacy λanguage
programs. A program that reads stdin gets it from a sibling .stdin file.
Static types, explicit memory, no closures, no garbage collector.
fn sieve(limit: i64) -> i64 {
let flags = alloc(limit + 1);
let mut i = 0;
while i <= limit {
flags[i] = 1;
i = i + 1;
}
# ...
count
}
fn main() -> i64 {
print(sieve(1000)); # 168
0
}| Type | Size | Meaning |
|---|---|---|
i8 i16 i32 i64 |
1 2 4 8 | signed integers; wrap on overflow |
u8 u16 u32 u64 |
1 2 4 8 | unsigned integers; wrap on overflow |
f32 f64 |
4 8 | IEEE single and double |
bool |
1 | true / false |
*T |
8 | pointer to T: a byte offset into flat memory, not a host address |
struct |
varies | a named layout, reached through a pointer (see below) |
[T; N] |
N×T | fixed-size array, and also a layout rather than a value |
fn(T…) -> R |
8 | function pointer, carried as a function index |
unit |
0 | no value (the default return type) |
as converts between them: 7 as f64, 7.9 as i64 (truncates toward zero),
300 as i8 (truncates to 44), -1 as u64 (the maximum), p as i64,
0 as *i64. Only the 64-bit integers convert to and from pointers; narrower
ones have to go through them.
Arithmetic keeps its operands' type rather than promoting, so i8 + i8 is an
i8 and wraps at eight bits, and f32 + f32 is an f32 rounded to single
precision. Mixing widths or signedness is an error; cast explicitly.
Unsigned values cannot be negated.
Numeric literals take the type the context asks for, and are range-checked against it, so constants read naturally without casts:
let a: i8 = -128; # fine
let b: i8 = 200; # error: 200 does not fit in i8 (-128..127)
let mut n: i32 = 0;
while n < 100 { n = n + 1; } # 100 and 1 are i32 here
let f: f32 = 0.1; # rounded to single precision at compile timeTwo ideas keep eight integer types from multiplying into eight code paths per backend.
Sizes describe memory, not registers. While a value is being computed it lives in a full 64-bit register or slot in canonical form (signed types sign-extended, unsigned types zero-extended). Width only matters at loads, stores and the results of sub-64-bit arithmetic. Calling conventions, local slots and comparisons stay uniform while memory stays byte-accurate.
f32 rides the same machinery: "putting the result back in canonical form"
just means rounding to single precision instead of masking to a width. Three
backends get that free from native f32 instructions; the bytecode VM has to
say Math.fround out loud, because JS numbers are doubles; the same shape as
u64 needing an explicit asUintN there and nowhere else.
Signedness is not a representation, it is a choice of operation. A u64 and
an i64 holding the same bits differ only in how you compare, divide, convert
to float, print and re-narrow them, since +, - and * are the same
instruction either way:
let big: u64 = 18446744073709551615;
print(big / 2); # 9223372036854775807, unsigned divide
print(big as i64 / 2); # 0, same bits, signed divide
print(big > 0); # true
print(big as i64 > 0); # false
print(big as f64); # 18446744073709551616.000000
print(big as i64 as f64);# -1.000000Because canonical unsigned values are non-negative, the bytecode VM needs no
unsigned opcodes at all: plain BigInt <, / and >> are the unsigned ones
(BigInt >> propagates the sign, so it is arithmetic on signed values and
logical on unsigned ones for free).
The same reasoning says which operations need re-narrowing afterwards and which
do not. &, |, ^ and >> preserve canonical form (every operand's high
bits already agree with its sign bit, so the result's do too), while <<, ~
and ordinary arithmetic can push bits out of range and must be remasked. % is
exempt as well, since |a % b| < |b|.
|| && == != < > <= >= | ^ & << >> + - * / %
unary: - ! ~ *
listed lowest to highest precedence. Bitwise binds tighter than comparison,
unlike C, so a & b == c means (a & b) == c, the reading people expect.
! is the logical negation on bool and ~ the bit complement on integers.
Shift amounts are reduced modulo the operand's width, which makes every shift defined rather than undefined:
print(1 << 65); # 2, since 65 & 63 == 1
print(1 << -63); # 2, negative counts wrap the same way
let b: u8 = 1;
print(b << 9); # 2, u8 so 9 & 7 == 1This is what 64-bit hardware already does; C leaves it undefined, and without
the rule the four backends would quietly drift apart. >> follows the operand's
signedness (arithmetic for signed, logical for unsigned), and the shift amount
is typed independently of the value being shifted.
= assigns, and every binary operator has a compound form: += -= *= /= %=,
&= |= ^=, <<= >>=. Assignment is a statement, not an expression.
t op= v is exactly t = t op v, except that an indexed target evaluates its
base and index once:
p[next()] += 1; # next() is called once, not twiceThe parser desugars that into a block with hidden temporaries, so no backend
has to know compound assignment exists. Reading a variable has no effect, so
p.x += 1 and p[i] += 1 need no temporaries at all; only the effectful parts
of a target get hoisted.
lm evaluates left to right, everywhere: binary operands, call arguments, and the address before the value in a store. The wasm, arm64 and VM backends get this for free from how they walk the tree. C does not, since it leaves argument and operand order unspecified, so the C backend binds operands to temporaries in order whenever the order is observable:
print_i(({ int64_t t0 = lm_tell(1); int64_t t1 = lm_tell(2); (t0) + (t1); }))"Observable" means one side can write memory and another can touch it.
Both halves matter: a load causes no effect of its own, but it can still observe
one, so p[0] + f() needs the same treatment as f() + g() when f stores
through p. Checking only for writes would leave exactly that case reordering
freely.
clang already happens to go left to right, but relying on that would make the agreement between the four backends accidental rather than guaranteed.
Expression-oriented. A block's value is its last expression when that expression has no trailing semicolon:
let v = { let a = 10; let b = 32; a + b }; # 42
let x = if n > 0 { 1 } else { 0 - 1 };let binds immutably; let mut allows assignment. # and // start comments.
A function returns either by yielding a tail expression or by returning on every
path, so if c { return a; } else { return b; } is a valid whole body.
for i in 0..n { ... }Half-open, so for i in 0..n runs n times. Both bounds are evaluated once,
before the first iteration and in source order. The loop variable is scoped to
the loop and immutable (only the loop's own step advances it), and it takes its
type from the bounds, so a typed bound makes the untyped literal adapt:
let limit: u8 = 200;
for k in 195..limit { ... } # k is u8, so the comparison is unsignedfor is a real node in all four backends rather than parser sugar, and the
reason is continue: desugaring for i in a..b { B } into
{ let mut i = a; while i < b { B; i += 1; } } would make continue skip the
increment and spin forever. continue has to land on the step, which only a
dedicated node can express.
break leaves the innermost enclosing loop; continue advances it, landing on
the condition test in a while, and on the step in a for. Both must be
statements:
while d < n {
if n % d == 0 { break; } # fine
d += 1;
}
while i < n {
i += 1; # progress first; the condition is re-tested,
if i % 2 == 0 { continue; } # not skipped, exactly as in C
total += i;
}
let v = 1 + { break; 2 }; # error: break cannot appear inside an
# expression that produces a valueThat restriction is not fussiness, it is what keeps the naive backends correct.
arm64 spills expression temporaries to the machine stack and the VM uses an
operand stack; branching out of a half-evaluated expression would strand those
values. For break that leaks once per enclosing iteration; for continue it
leaks on every iteration of its own loop, so a single loop would grow without
bound. At a statement boundary nothing is pending, so the restriction makes the
branch free. A trailing if still counts as a statement, since a
statement-position block discards its tail.
alloc(n) reserves n 8-byte cells from a bump allocator and returns a
*i64; cast the result for other widths. There is no free, no GC, and no
bounds checking: it is a flat 1 MiB region, and running off the end is your
problem.
let p = alloc(3);
p[0] = 10; # store
print(*p); # load; *p means p[0]
let q = p + 2; # scaled by element size, like C
print(q - p); # 2
let bytes = p as *i8;
bytes[0] = 1; # one byte, not eight
print(bytes + 1); # advances by 1; p + 1 would advance by 8Signed loads sign-extend and unsigned loads zero-extend; stores only care about
width. Writing 255 through a *u8 reads back as 255, and the same byte read
through a *i8 reads back as -1.
Pointers are offsets rather than real addresses specifically so that a printed pointer is identical across all four backends. That is what makes the differential test below meaningful.
A function's name, used without calling it, is a value of type
fn(T…) -> R:
fn ascending(a: i64, b: i64) -> i64 { a - b }
fn descending(a: i64, b: i64) -> i64 { b - a }
fn sort(p: *i64, n: i64, cmp: fn(i64, i64) -> i64) { ... cmp(p[j], p[j + 1]) ... }
sort(xs, 6, ascending);
sort(xs, 6, descending);They go anywhere a value goes: locals, parameters, struct fields, arrays.
struct Op { name: *u8, run: fn(i64, i64) -> i64 }
ops[0] = Op { name: "add", run: add };
let table = new([fn(i64, i64) -> i64; 3]);
*table = [add, sub, mul];The value is the function's index, not a machine address. The four backends
represent code addresses in completely different ways (a real C function
pointer, a wasm table slot, an arm64 code address, a VM function number), so
indexing through a table is what lets them all agree on what a function pointer
is, and on what add as i64 prints. It is the same decision that makes data
pointers byte offsets rather than host addresses.
Casting a function pointer to i64 reveals that index; the reverse is refused,
because an arbitrary integer is not a function.
There is no string type. A string literal is a *u8 pointing at
NUL-terminated bytes placed at the top of memory, so everything else is written
in the language itself:
fn strlen(s: *u8) -> i64 {
let mut n = 0;
while s[n] != 0 { n += 1; }
n
}
let s = "world";
puts(s);
print(strlen(s)); # 5
print(s[0]); # 119, which is 'w'Identical literals are stored once, so ("x" as i64) == ("x" as i64). Escapes
(\n, \t, \0, \\) are bytes, and non-ASCII text is UTF-8, so strlen
counts bytes rather than characters, which is what a language at this level should do.
Literals live in read-only-by-convention memory; copy into your own allocation
before mutating, as tests/lm/strings.lm does with strcpy, strcat, upper
and reverse.
The bytes sit at the top of memory precisely so the heap still starts at 8 and no pointer a program prints depends on how many literals it happens to contain.
struct Point { x: i64, y: i64 }
struct Node { value: i64, next: *Node }
struct Buf { len: i64, data: [u8; 8] }
struct Grid { cells: [[i32; 3]; 2] }
let p = new(Point { x: 3, y: 4 });
p.y += 4;
print(sizeof(Point)); # 16A struct (and a fixed-size array [T; N]) is a layout, not a value: it
describes offsets into memory and is always reached through a pointer, so p.x
auto-dereferences the way C's -> does. There are no struct or array locals,
parameters or return values.
That is a deliberate constraint rather than a shortcut. A wasm local is not addressable (it does not live in linear memory at all), so a struct local would have to be spilled to memory anyway. Working through pointers makes that explicit, and it sidesteps aggregate calling conventions in four backends.
Layout is C-style natural alignment: each field sits at the next offset that
suits its own alignment, and the struct is padded out to its widest member so an
array of them stays aligned. struct Odd { a: i8, b: i64, c: i16, d: f32 } is
24 bytes, not 15.
Everything else falls out of machinery that already existed. Pointer arithmetic
scales by sizeOf(pointee), so *Point walks 16 bytes at a time for free, and
ps[i].x is just an address computation (base, plus index times element size,
plus field offset), with a load only at the scalar leaf. Nested structs are
embedded, so o.nested.u adds two constant offsets and touches memory once.
Arrays compose with all of it: [Point; 4] inside a struct, [[i32; 3]; 2]
for two dimensions, b.data[i] and g.cells[r][c] to reach an element. Adding
them cost each backend a single line, because an array base is a place and so
contributes an address rather than a loaded value, which is exactly what struct
field access already needed.
Literals initialise storage rather than producing a value, since a bare
Point { x: 1, y: 2 } would be a struct value, which the language does not
have. They appear in exactly the places that have somewhere to write:
let p = new(Point { x: 1, y: 2 }); # allocate and fill
*p = Point { x: 3, y: 4 }; # fill something that exists
path.points[i] = Point { x: i, y: 0 };
b.data = [9, 8, 7, 6]; # arrays tooThe two kinds nest inside each other freely:
let g = new(Grid { cells: [[1, 2, 3], [4, 5, 6]] });
let path = new(Path { count: 2,
points: [Point { x: 1, y: 2 }, Point { x: 3, y: 4 }] });Struct fields may be written in any order, but both kinds are evaluated in the order written. Every field and every element must be given a value (an array literal has to match its length exactly), because leaving one out would silently publish whatever the allocator last had in that memory. All of it expands to one store per field or element, so no backend knows literals exist.
new(T) allocates one T and new(T, n) an array of them; sizeof(T) folds
to a constant at compile time. Both take a real type, so new([i64; 6]) and
sizeof([[i32; 3]; 2]) work. A struct may not contain itself by value; use a
pointer, as Node does.
print(x) writes one line, formatted by type (integers in decimal, signed or
unsigned as their type dictates; floats to six decimal places; bool as
true/false; *T as its offset). puts(s) writes NUL-terminated bytes from
memory and then a newline (like C's puts rather than fputs), so for a
literal it is the same as print, and print("...") is exactly that with the address
folded in at compile time.
Both output builtins end the line, so there is no way to put a label and a
number on one line without rendering the number to bytes yourself.
examples/06-strings.lm does it with a dozen-line
itoa.
new(T) / new(T, n) allocate structs, and sizeof(T) yields a type's size in
bytes as a compile-time constant.
read(p: *u8, n: i64) -> i64 and write(p: *u8, n: i64) -> i64 are the whole
of lm's I/O: raw bytes between stdin/stdout and the flat region, returning the
count moved. See tests/lm/io.lm.
main must return i64; it becomes the process exit code, masked to 8 bits.
All four consume the same typed AST. There is no separate IR: lm's control flow
is if, while and a statement-only break, which stays structured enough to
feed wasm's block/loop directly and simple enough for a naive register-free
arm64 walk.
Loop control flow is the only thing that costs the backends anything beyond a
case in a switch. C emits its own break, continue and for, and its for
already routes continue through the step, which is exactly what lm wants.
arm64 branches to a label; the VM emits a jump patched once the target is known.
wasm is the interesting one: its br counts relative label depth, and if
pushes a label just like block and loop, so the emitter tracks the label
stack and computes each distance. continue in a for is the awkward case,
because wasm cannot branch into the middle of a block to reach the step, so
the body gets a block of its own, and leaving it falls through to exactly the
right place:
block ;; break lands here
loop
<condition, br_if out>
block ;; continue lands here
if
if
if
br 3 ;; continue: out of three ifs, to the body block's end
...
end
<step> ;; ... which falls straight into it
br 0node lc.js -t c prog.lm # emit C, compile with clang
node lc.js -t wasm prog.lm # emit a .wasm binary, run under Node
node lc.js -t arm64 prog.lm # emit arm64 asm, assemble with clang
node lc.js -t vm prog.lm # emit bytecode, run the interpreter
node lc.js --emit -t arm64 prog.lm # show the generated code instead
node lc.js -o ./prog -t arm64 prog.lm # keep the native binary| Backend | File | What it does |
|---|---|---|
| C | src/backends/c.js |
Signed integers become int64_t and unsigned ones uint64_t, so C's own <, / and printf are already the right ones; sized memcpy helpers do the loads and stores over a flat MEM[]. Value-producing blocks become GNU statement expressions. |
| WebAssembly | src/backends/wasm.js |
Emits the binary format directly: LEB128, sections, structured control flow, and i64.load8_s/store8/extend8_s for widths. No WAT, and no wasm toolchain in package.json. |
| ARM64 | src/backends/arm64.js |
Real Darwin assembly. No register allocator: values live in x0/d0, temporaries spill to the stack, locals hang off x19. Widths use ldrsb/ldrsw/strb/sxtb/sxtw. |
| Bytecode VM | src/backends/bytecode.js, src/vm.js |
Stack machine. Arithmetic is on unbounded BigInts with an explicit SEXT/ZEXT after every operation, so wraparound matches the native backends exactly. |
Because they check each other. --all runs a program through every backend and
diffs the output:
$ node lc.js --all tests/lm/edges.lm
tests/lm/edges.lm
c exit=42 ok
wasm exit=42 ok
arm64 exit=42 ok
vm exit=42 ok
Four independent code generators agreeing on 64-bit wraparound, truncating division, short-circuit evaluation, width-accurate overflow at every size, signed-vs-unsigned division and comparison, and float formatting is far stronger evidence of correctness than any one of them matching a golden file.
Of the 20 programs in tests/lm/, six (basics, bytes, io, memory,
returns, sieve) check that ordinary code works. The other fourteen exist to hunt
for disagreements (edges, widths, unsigned, bits, compound, break,
continue, forloop, floats, structs, arrays, literals, strings and
funcptr), and they earned their keep. They caught an inverted || in the
bytecode compiler, a valid function the typechecker wrongly rejected, a C crash
on a trailing else-less if, an arm64 lsl computed from a non-power-of-two
element size, and a u64 literal that wasm encoded with a bit too many.
The ten examples run through the same harness, and found the bug that all 19
could not: a function taking *SomeStruct failed to typecheck when defined
after its caller, because signatures were resolved lazily per function, so the
call compared its argument against unresolved surface syntax and reported the
parameter type as *named. Every test program happens to define callees first.
Writing documentation in a natural order is a different kind of coverage than
writing tests, which is a good argument for keeping the examples under test.
npm run bench times all four on five programs, comparing their output before
timing anything. On an M1 Max, with process startup removed:
| run time (geomean) | compile time | |
|---|---|---|
C via clang -O2 |
1.06× | ~400ms |
| wasm | 1.36× | ~1ms |
| arm64 | 3.54× | ~220ms |
| bytecode VM | 3152× | ~0.2ms |
Those hold up across runs: independent repeats put arm64 at 3.54× and 3.55×, and moved the others by a few percent. Don't read that stability into the per-benchmark rows, though; at 1–9ms the fast three are close enough to the noise floor that individual ratios wobble.
The ordering is the point: the backends that produce the fastest code take the
longest to produce it, by three orders of magnitude. wasm lands within 40% of
clang -O2 because V8 optimises code that this project's emitter does not. The
arm64 gap is the register allocator it does not have: in matmul a fifth of
its instructions are stack spills. The VM's ~3000× is the price of BigInt
arithmetic, which is also exactly what makes it a trustworthy semantic
reference.
Benchmarking found two real bugs the test suite could not, because both produced
correct output: the C backend was silently emitting x86_64 (node is itself an
x86_64 binary here, so clang inherited the wrong target and the "native" backend
ran under translation), and clang was contracting a*b + c into a fused
multiply-add, rounding once where lm rounds twice.
Reproducing the first one later found a third bug, this time in the harness. It
measured the process-spawn floor using the C backend and subtracted that from
arm64 as well, so an inflated C floor drove arm64's corrected time negative,
where a clamp turned it into ~0 and made naive assembly look hundreds of times
faster than clang -O2. Floors are now measured per backend, an underflow aborts
the run, and every binary's target architecture is checked before it is timed.
bench/README.md has the reproduction and the numbers.
selfhost/ holds lm's compiler rewritten in lm, one stage at a time, each
checked against the JavaScript it replaces: the same token stream, the same
syntax tree, the same accept-or-reject on 95 programs, and the same C, byte for
byte, on every program under every backend. selfhost/emit-c.lm writes the same
3,800 lines for checker.lm's 84 KB that src/backends/c.js writes.
Then it compiles itself. selfhost/bootstrap.sh builds the emitter with the
JavaScript compiler, compiles the emitter again with the result, and once more
with that; the last two agree across 6,883 lines.
./selfhost/bootstrap.shThe fixpoint is the part a test suite cannot supply. Every test in tests/lm
runs a program the JavaScript compiler built, so a stage that disagreed with its
own output would pass all of them.
Writing the compiler in lm also turned out to be a different instrument from the
test suite, and it found different bugs: an ARM64 register lost across
memmove, a truncated pipe on stdout, a heap ceiling, a store that computed its
address before its value while the arena underneath it moved, and a parser that
decided Name { ... } was a struct literal by whether the name began with a
capital, so while R >= S { parsed one way in lm and another in JavaScript.
None were reachable from a program that fits on a screen, and the last one
needed a variable to be called S.
selfhost/README.md has the accounting, and
selfhost/LIMITS.md has what the language cost to write it
in.
A fair account of where this stops, since it stops at a deliberate point rather than a finished one.
I/O is stdin and stdout, and nothing else. read(p, n) and write(p, n)
move raw bytes to and from the flat region, returning how many moved; read
returns 0 at end of input. There is no open, no close, no paths, no argv,
no clock, no extern fn, and no math library, so a language with f32 and
f64 still cannot compute a square root.
That is a deliberate floor rather than an unfinished one. Two functions are
enough to drive a compiler by redirection, and every path beyond them would have
to be implemented four times. It is also the only feature where the four
backends genuinely cannot share an answer: C and arm64 go through libc's
fread/fwrite, the VM reads the host's fd 0, and wasm (which has no syscalls
at all) takes two more functions on its existing env import. write is the
only way to produce output that does not end in a newline, since print and
puts both terminate the line.
One subtlety that only shows up with four backends: arm64 uses fread/fwrite
rather than the raw read/write syscalls specifically so that write shares
printf's stdio buffer. Bypassing stdio would put the two in separate buffers and
let their output emerge in the wrong order.
The top level accepts only fn and struct. No constants, globals, type
aliases, enums, or unions, and no imports; one file is the whole program.
const is the biggest win for the least work: right now every magic number is a
bare literal or a local redeclared per function, which sits badly with a language
whose tests are full of masks and widths.
No address-of. &x does not exist; unary is -, !, *, ~. Pointers
come from alloc and new and nowhere else, so a local can never be pointed at.
You cannot write swap(&a, &b), and a function that might replace a caller's
value has to return it instead, which is why insert in
examples/08-linked-list.lm returns the new head.
This follows from aggregates not being values, which wasm forced, but & on a
scalar local is implementable by spilling that one local to memory.
Cheap frontend gaps that bite hardest. No hex or binary literals: 0xff
does not lex, which is disproportionately annoying in a language advertising bit
manipulation. No exponent notation for floats, so 1e16 must be written out. No
character literals. A call's callee must be a name, so table[i](1, 2) is
rejected and has to be bound to a local first, even though call_indirect and
its equivalents already exist in all four backends. No labeled break, so
escaping nested loops needs a flag. No match, no ternary, no do..while, no
rotates, no float %.
No slices. Every function taking a buffer takes a pointer and a length by
hand, and nothing checks that they agree. No generics, no optionals, no
runtime-length arrays, no const pointers, and no aggregate by value anywhere.
alloc never fails. Asking for more cells than the 1 MiB region holds
succeeds silently, and writing near the end of that allocation then behaves four
different ways: C and arm64 die quietly, wasm traps, the VM throws. A bounds
check in the bump allocator would turn four undefined behaviours into one defined
error. It is the only item here that affects correctness rather than
expressiveness.
If you want the most language per unit of work: hex literals, const, and an
arbitrary callee expression; all three are frontend-only.
Everything above is guaranteed identical across all four. These are the places it is not, which matters because backend agreement is this project's whole claim to correctness:
- Division by zero is unchecked in C and arm64, traps in wasm, and throws in the VM.
- Casting a float that is out of integer range is undefined in C, saturates on arm64, traps in wasm, and wraps in the VM: four behaviours, four backends.
- Running off the end of memory dies quietly under C and arm64, traps in
wasm, and throws in the VM. Nothing bounds-checks, and
allocdoes not fail. f64printing relies on C's%.6fand JS'stoFixed(6)agreeing. They do for ordinary values, but not necessarily for infinities or NaN.
And some plain limits, which are not divergences:
- Nothing enforces alignment.
*i32at an odd offset works on all four backends here, but that is a property of the targets, not a promise. - At most 8 arguments of each class (integer, float) per call, on arm64.
- No closures: a function pointer captures nothing, so anything it needs must be passed in or reachable through memory.
forcounts up by one only; there is nostepclause and no reverse range.whilecovers the rest.
The original: dynamically typed, closures, let/lambda, compiled through a
CPS transform and an optimizer into JavaScript.
node legacy/cli.js prog.lam
node legacy/cli.js --emit js prog.lam # also: ast, cpsprint("hello");
let (x = 2, y = 21) print(x * y);
let loop (n = 5) if n > 0 then { print(n); loop(n - 1) } else print("done");
It runs on a trampoline: GUARD throws a Continuation when the JS stack gets
too deep and Execute resumes from it, so 200,000-deep recursion is fine.
-
FALSEwas never defined. It was referenced from the parser, the CPS pass, the generator and the optimizer, so anything needing a default value (anifwith noelse, an empty{}) crashed. Now inlegacy/ast.js. -
gensymwas missing from the optimizer, crashingopt_iifeon nested IIFEs. -
try/throwdid not work.tryhad nocatchclause and discarded its continuation;cps_throwwas dispatched to but never written;js_tryandjs_throwreturnedundefined, emitting literalundefinedinto the output.They now lower entirely in the CPS pass into calls to three runtime primitives, so the optimizer and generator need no new node types. The handlers live in a heap array rather than using the host's
try/catch, and they have to, because the trampoline unwinds the real JS stack, so by the time athrowhappens the frame holding a hosttryis long gone:let (deep = lambda rec (n) if n == 0 then throw("bottom") else rec(n - 1)) print(try deep(100000) catch (e) { print("caught:", e); "recovered" }); -
The AST dumps on every run are gone, and the compiler is a library (
legacy/compile.js) with a CLI on top instead of one inline script.
lc.js lm compiler driver
src/lexer.js shared frontend
src/parser.js
src/types.js
src/check.js typechecker; annotates the AST for all backends
src/runtime.js constants and output formatting every backend must match
src/backends/ c.js, wasm.js, arm64.js, bytecode.js
src/vm.js bytecode interpreter
legacy/ the original CPS -> JS λanguage
examples/ 10 annotated programs, in reading order
tests/run.js harness: lm x 4 backends, examples, compile errors, legacy
tests/lm/ 20 programs, each run through every backend
differential/ four-backend runner, divergence classifier, instrument gate
tests/errors/ 59 programs that must fail to compile
bench/run.js timing harness; bench/*.lm are the five workloads