Two worked examples

Everything in 30-teaching.md and 40-backends.md exists because of two programs in this repository. Neither of them changes a single line in src/ or stage0/.

examples/apiexamples/lang
what it isan HTTP + SQLite todo APIa language, lx, with classes, generics and reference counting
what it teachesclass, interface, bool, strfn, single inheritance, virtual/override, interfaces, generics with where, ref, namespaces, automatic memory
size of the moduleoop.mc, 482 lines9 modules, 2,831 lines
built bymc build examples/apimc build examples/lang
checked bymake check-examplesmake check-lang

examples/api — a todos API in a language it teaches itself #

$ mc build examples/api
compiler build/mc-api.mc -> build/mc-api
compile main.mc -> build/api

Two steps: a compiler that understands class and interface, and then the server compiled by it. No make, no ld.

examples/api/
  mc-api.mc      this directory's compiler: <mc/core> + oop.mc + user_init()
  oop.mc         teaches `class` and `interface` through the parser's public API
  lib/rt.mc      a fixed arena, strings, strbuf, itoa/atoi
  lib/http.mc    sockets, HTTP/1.1 request and response
  lib/sqlite.mc  #dylib "/usr/lib/libsqlite3.dylib" + externs + wrappers
  main.mc        the API: routes, handlers, the database
  mc.toml        the whole build as data
  test.sh        brings the server up on a free port and hits every route

The compiler is twenty lines #

// mc-api.mc
#include <mc/core>
#include "oop.mc"

void user_init() {
    syntax("class", &oop_class);                 // top-level declaration
    syntax("interface", &oop_interface);         // top-level declaration
    type_alias("bool", TY_U8);                   // a new type, no new syntax
    type_alias("str", TY_UPTR);
}

That is the entire interface between the example and the compiler. oop.mc then consumes tokens with p_id, p_next, p_type, p_ident, parse_function, parse_block … and hands ordinary declarations back through top_add.

What a class turns into #

written in main.mcgenerated by oop.mc
interface Handler {type_alias("Handler", TY_UPTR)
i64 handle(self, Request req, Response res);#define HANDLER_HANDLE 0 and a dispatcher i64 handler_handle(uptr self, …) { return callp(ld64(ld64(self) + 0), self, …); }
class Todo {type_alias("Todo", TY_UPTR)
i64 id;#define TODO_ID 0 · todo_id(self) · set_todo_id(self, v)
bool done;#define TODO_DONE 16 + accessors using ld8/st8
str json(self) { … }uptr todo_json(uptr self) { … }self prepended to the parameters
}#define TODO_SIZE 24 and uptr todo_new(), calling rt_alloc(TODO_SIZE)
class TodoHandler : Handler {word 0 of the object reserved for the vtable; fields start at 8
} with an interfaceu8 todohandler_vt[8], todohandler_vt_init() filling it with &todohandler_handle, and todohandler_new()

main.mc's seven class/interface declarations expand into dozens of ordinary ones — accessors, dispatchers, vtables and constructors — and you can watch it happen:

$ examples/api/build/mc-api --dump-ast examples/api/main.mc | grep -E '^(FUNC|GLOBAL)'
...
FUNC type=i64 name=handler_handle
FUNC type=uptr name=todohandler_db
FUNC type=i64 name=todohandler_handle
GLOBAL val=8 type=u8 name=todohandler_vt
FUNC name=todohandler_vt_init
FUNC type=uptr name=todohandler_new

Here is the whole mechanism in miniature — an interface, a class that implements it, and a dispatch through the vtable, compiled by mc-api:

// expect-exit: 42
#include "../lib/rt.mc"

interface Greeter {
    i64 greet(self, i64 n);
}

class Doubler : Greeter {
    i64 base;
    i64 greet(self, i64 n) { return doubler_base(self) + n * 2; }
}

i64 main() {
    Doubler d = doubler_new();
    set_doubler_base(d, 10);
    Greeter g = d;                 // the concrete type is forgotten here
    return greeter_greet(g, 16);   // 10 + 32 = 42, through the vtable
}

Greeter g = d; is an ordinary uptr assignment; greeter_greet(g, 16) is callp(ld64(ld64(g) + 0), g, 16). There is no dynamic dispatch in the language — there is callp, and a module that generates the right table.

Diagnostics belong to the module #

An interface method the class does not implement is a compile error at the class line; an error about a specific member points at the member's line:

$ build/mc-api missing.mc -o /tmp/x.o      # a class that skips one interface method
missing.mc:8: interface method not implemented: name
$ build/mc-api dupfield.mc -o /tmp/x.o     # a class with two fields of the same name
dupfield.mc:6: duplicate #define

The second one is the core's own message, reached through def_add — a module that generates #defines gets the core's duplicate check for free.

The default compiler rejects the same source, which is the point: the syntax belongs to this directory, not to the language.

$ mc examples/api/main.mc -o /tmp/x.o
examples/api/main.mc:27: type expected in parameter          # `str s` — no such type in the core

The rest of it #

Routing is a linear (prefix, Handler) table walked in registration order, and the main loop never knows which handler it is calling. #dylib "/usr/lib/libsqlite3.dylib" plus thirteen externs give the SQLite bindings; mc.toml's [libs]/[externs] say the same thing from outside the source, and both are present on purpose — the #dylib wins for its own externs.

$ examples/api/build/api 8080 /tmp/todos.db &
api: port 8080, db /tmp/todos.db
$ curl -s -X POST --data-binary 'buy bread' localhost:8080/todos
{"id":1,"title":"buy bread","done":false}
$ curl -s localhost:8080/todos
[{"id":1,"title":"buy bread","done":false}]
$ curl -s -X DELETE localhost:8080/todos/1
{"deleted":1}

Known limits, all documented in examples/api/README.md: the runtime arena is fixed at 4 MiB and never returns memory, the server handles one connection at a time, and #dylib only works along the --exe path.


examples/lang — a whole language from a prelude #

$ mc build examples/lang
compiler build/mc-lang.mc -> build/mc-lang
compile main.lx -> build/lang-demo

lx has classes with single inheritance and virtual/override, interfaces, generics with where constraints and const parameters, ref parameters, namespaces with import/using, and automatic memory management by reference counting. None of that is in mc. It all lives in lang.mc, a module that runs inside the compiler during the parse and hands the core nothing but ordinary declarations.

// expect-exit: 0
// expect-stdout: 13
// expect-stdout: circle
#include "../lib/prelude.lx"

class Shape {
    virtual i64 area(self) { return 0; }
    virtual str name(self) { return "shape"; }
}

class Circle : Shape {
    i64 r;
    override i64 area(self) { return 3 * self.r * self.r; }
    override str name(self) { return "circle"; }
}

interface Printable { str show(self); }

class Box<T, const N: i64> : Printable where T : Shape {
    T items[N];
    i64 count;
    fn push(self, T s) { self.items[self.count] = s; self.count += 1; }
    fn total(self) -> i64 {
        i64 t = 0;
        for (i64 i = 0; i < self.count; i += 1) { t += self.items[i].area(); }
        return t;
    }
    str show(self) { return "box"; }
}

fn bump(ref i64 x) { x += 1; }

fn main() -> i64 {
    Box<Circle, 4> b = new Box<Circle, 4>();
    Circle c = new Circle();
    c.r = 2;
    b.push(c);
    i64 n = 0;
    bump(ref n);
    print(b.total() + n);          // 3*2*2 = 12, plus the bumped 1
    prints(c.name());              // virtual dispatch through Shape
    return 0;
}

What mc actually sees for that Box<Circle, 4>:

uptr Box__Circle__4_new();
void Box__Circle__4_push(uptr self, uptr s);
i64  Box__Circle__4_total(uptr self);
u8   Box__Circle__4_vt[24];
void Box__Circle__4_vt_init();
void Box__Circle__4_release(uptr self);

How each feature is built #

Nothing below is a special case in src/:

featuremechanism
class, interface, namespace, import, using, fnsyntax(word, &f) — top-level position
{, while, for, and every class or interface namesyntax_stmt(word, &f) — statement position
new, ref, generic functions, namespaced callssyntax_expr(word, &f) — expression position
a.b, a.b = c, a.m(x), a[i], a[i] = vsyntax_infix(word, prec, &f)
str, bool, and every class name used as a typetype_alias(name, TY_*)
the body of a genericp_skip_balanced + p_start
one instantiation per argument tuplep_push_source + p_subst_name/p_subst_int + p_depth
Holder<Bag<Num, 2>> closing on >>p_resplit_punct(1)

Three of those are worth a sentence each.

syntax_stmt("{") is what makes a release happen per scope. K_LBRACE is not a core keyword, so the module may own every statement-position block, nested ones included — and without recursing, because the core's parse_block consumes its own brace. That is also why while and for are handlers here rather than #rules: a rule's block hole is parsed by parse_block, which would walk straight past the module's block handler and with it every scope-exit release.

syntax_infix(".") is what makes member access typed. The handler receives the left operand already parsed and reads the member name itself, so what it emits depends on the static type the module recorded for that expression: a field becomes ldW/stW at an offset, a virtual method becomes callp(ld64(ld64(obj) + slot), obj, …), a plain method becomes a direct call. Member assignment works because = is deliberately not in the core's infix table: the Pratt loop has already stopped, the handler reads the =, and the core sees a plain expression statement.

Memory is reference counting with free lists, in lib/rt.mc — written in nothing but the core language, so the default mc compiles it unchanged. The acceptance test churns 100 000 objects inside a 4 MiB arena, which is what proves the deallocation is real.

The core is still there underneath #

i64/u8/uptr, if, loop, break N, ld64/st64, #include, #define, #rule all still work, and an .lx file may drop into the core whenever it wants to. Replace lang.mc with a different file and the same mc compiles a different language.

Reading the sources #

Both examples are meant to be read. Start at examples/api/oop.mc — it is the smaller one and it uses only syntax and type_alias. Then examples/lang/lang_expr.mc, which is where syntax_infix(".") and the generic instantiation live.

The one deviation on record: examples/lang/mc.toml sets [compiler].core = "lang_core.mc" instead of using the bundled <mc/core>, because the bundle's data array plus a module that size exhausted the arena at the time it was written. The mechanism that removes that cause is in place; the example was left exactly as it was verified.

Next #

How the compiler compiles itself, and the rules that keep it reproducible: 70-bootstrap.md.

Edit this page