Skip to content

Latest commit

 

History

59 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

λanguage

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 backends

examples/ 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.


lm

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
}

Types

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 time

How widths and signedness actually work

Two 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.000000

Because 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|.

Operators

||  &&  ==  !=  <  >  <=  >=  |  ^  &  <<  >>  +  -  *  /  %
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 == 1

This 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.

Assignment

= 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 twice

The 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.

Evaluation order

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.

Syntax

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

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 unsigned

for 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 and continue

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 value

That 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.

Memory

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 8

Signed 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.

Function pointers

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.

Strings

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.

Structs and arrays

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));   # 16

A 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 too

The 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.

Builtins

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.


The four backends

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 0
node 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.

Why four

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.

How fast are they

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.


The compiler in its own language

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.sh

The 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.


What is missing

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.

Where the backends stop agreeing

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 alloc does not fail.
  • f64 printing relies on C's %.6f and JS's toFixed(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. *i32 at 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.
  • for counts up by one only; there is no step clause and no reverse range. while covers the rest.

Legacy λanguage

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, cps
print("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.

What was fixed

  • FALSE was never defined. It was referenced from the parser, the CPS pass, the generator and the optimizer, so anything needing a default value (an if with no else, an empty {}) crashed. Now in legacy/ast.js.

  • gensym was missing from the optimizer, crashing opt_iife on nested IIFEs.

  • try/throw did not work. try had no catch clause and discarded its continuation; cps_throw was dispatched to but never written; js_try and js_throw returned undefined, emitting literal undefined into 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 a throw happens the frame holding a host try is 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.


Layout

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

About

A small language with four independent backends (C, WebAssembly, ARM64, a bytecode VM) that must agree byte for byte, and a compiler for it written in itself.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages