ClassyC is a C11 compiler with a small set of extensions that make
everyday systems code less painful: classes, UTF-8 String, JSON-like
dict, generic List / Map / Set, and a JIT that runs your program
the moment it compiles.
It is a heavily modified c2m front end on MIR. You can interpret, JIT, or AOT to a native binary. Ordinary C still compiles.
./bin/classyc -I include examples/readme-taste.cy -eg4 tracks, 2 of them stretch out
Led Zeppelin — Kashmir (508s)
Pink Floyd — Echoes (1412s)
mood=late artists=4
That file is examples/readme-taste.cy. It is a real program, not a
sketch.
- C11, minus complex numbers. Variable-length arrays are limited;
trailing flexible arrays (
T a[1]/T a[]) work. Atomics and_Thread_localare supported. - Classes with constructors, destructors,
new/delete, andthis. String— UTF-8,+concatenation, methods on values and literals ("MiXeD".lower()).dict— JSON-shaped maps and arrays, plus(User) dto bind a dict onto a class or struct.List<T>/Map<K,V>/Set<T>as stack values. Pipelines return values. Prefer that overnew/deletefor locals.- Lambdas that stay C-shaped: a non-capturing
=>is a function pointer; a capturing one is inlined only as a collection callback. - Exceptions and safety traps on by default (null, divide-by-zero,
OOB). Opt out with
-fno-exceptions. - JIT and AOT on the same front end. An LSP (
bin/classyc-lsp) and a DAP runner (bin/jitrunner) are in the tree.
ext/mir is a git submodule. Clone with --recurse-submodules, or:
git submodule update --init ext/mir
cmake -B build -S .
cmake --build build -jIn-tree cmake . && make also works. Either way the compiler lands at
bin/classyc. Linux gets bin/b2obj (ELF); macOS builds b2objmac
and aliases it as b2obj.
./install.sh # system install: /usr/local/{bin,include/classyc,lib,share/classyc}
# (sudo needed for the final `cmake --install` step)
./install.sh --user # per-user install: ~/.classyc/{bin,include/classyc,lib,share/classyc}
# no sudo needed; add ~/.classyc/bin to PATH as promptedBoth give you classyc, classyc-lsp, nmb, b2obj/b2objmac, and
classyc-aot (the AOT driver script, installed under that name) on PATH,
plus a namespaced header tree so #include "list.h" etc. just works with no
-I flags — classyc auto-discovers <prefix>/include/classyc relative to
its own binary. AOT builds (classyc-aot, --with-mir, -ffibers) work the
same way, installed or run straight from the repo.
Under the hood these are thin wrappers around plain CMake:
cmake -B build -S . -DCMAKE_INSTALL_PREFIX=/wherever
cmake --build build -j
cmake --install buildUninstall with ./uninstall.sh (or ./uninstall.sh --user), which just runs
the uninstall CMake target — removes exactly what install() put down, by
reading install_manifest.txt.
# JIT-compile and run (this is the usual loop)
./bin/classyc -I include examples/readme-taste.cy -eg
# Same, but lazy: generate a function the first time it is called
./bin/classyc -I include examples/readme-taste.cy -el
# Compile to binary MIR, then a native object, then a binary
./bin/classyc -I include -c examples/readme-taste.cy -o /tmp/taste.bmir
./bin/b2obj /tmp/taste.bmir /tmp/taste.o
# or, one shot:
./classyc-aot.sh -I include examples/readme-taste.cy -o /tmp/taste-I include is how list.h, map.h, set.h, chan.h, and friends
are found. Quoted includes also search the current directory, so some
examples write #include "include/httpclient.h" and still work from
the repo root.
| Flag | What it does |
|---|---|
-eg |
generate machine code and run |
-el |
lazy per-function generation |
-eb |
lazy per-basic-block generation |
-ei |
interpret MIR |
-c / -S |
emit .bmir or textual MIR |
-g |
source locations for gdb / jitrunner |
-On |
MIR + midopt level (-O2 is the default) |
-ffibers |
enable go / await |
-fno-exceptions |
no exceptions, no safety traps |
-l name / -L dir |
same idea as cc (-l sqlite3, not a path) |
-F dir / -framework name |
clang-style frameworks (<OpenGL/gl.h>, JIT-load OpenGL.framework; -lGL aliases that on Darwin) |
classyc is also a C compiler. The MIR c-test suite is
sh ext/mir/c-tests/runtests.sh ext/mir/c-tests/use-c2m-gen-O3 bin/classyc.
examples/readme-taste.cy — a record crate, not a tutorial in
disguise.
#include <stdio.h>
#include "list.h"
#include "map.h"
[[copyable_no_release]]
class Track {
String title;
String artist;
int seconds;
Track (String title, String artist, int seconds) {
this.title = title;
this.artist = artist;
this.seconds = seconds;
}
~Track () {}
int IsLong () { return seconds >= 360; }
String Label () { return f"{artist} — {title}"; }
};
int main (void) {
auto crate = List<Track> ();
crate.Add (Track ("Kashmir", "Led Zeppelin", 508));
crate.Add (Track ("Africa", "Toto", 295));
crate.Add (Track ("Tom Sawyer", "Rush", 276));
crate.Add (Track ("Echoes", "Pink Floyd", 1412));
auto epics = crate.Where ((Track t) => t.IsLong ());
printf ("%d tracks, %d of them stretch out\n", crate.Count (), epics.Count ());
for (auto t in epics)
printf (" %s (%ds)\n", t.Label (), t.seconds);
auto counts = Map<String, int> ();
for (auto t in crate)
counts[t.artist] = counts.GetOr (t.artist, 0) + 1;
dict night = { "mood": "late", "volume": 7 };
printf ("mood=%s artists=%d\n", (char *) night.mood, counts.Count ());
return 0;
}A few things that file is quietly showing:
- A class is a semicolon-terminated definition, like a struct.
this.fieldis only required when a parameter shadows the field. BaresecondsinIsLongis the member.[[copyable_no_release]]marks a class whose destructor does not free a unique resource.List<Track>storesTrackby value and relocates elements withmemcpy, so a destructor thatfrees would be a double-free.Stringfields are fine under this attribute; unique heap resources belong inList<T*>with.owns().auto crate = List<Track>()is a stack shell.~Listfrees the buffer at the}ofmain.Wherereturns another stackList.f"{artist} — {title}"is an f-string.dictis a tagged JSON value. A string leaf prints with(char *); a number leaf reads with(int)/(double).
String is a built-in UTF-8 type. length() counts code points,
not bytes. "héllo".length() is 5.
String s = " Schöne Grüße ";
s = s.trim().upper(); // SCHÖNE GRÜSSE
size_t n = "hello world".find("world"); // 6, or (size_t)-1
String path = "report.pdf".replace(".pdf", ".txt");
List<String> *parts = "a,b,c".split(",");
String back = parts->join("|"); // "a|b|c"
printf("%s\n", (char *)"MiXeD".lower()); // mixedreplace(needle, repl) is search-and-replace. replace(pos, len, repl)
is positional — handy next to find. Also: substr, starts_with,
ends_with, contains, equals, empty.
+ concatenates and will promote numbers and other scalars:
"rows: " + 128. Heap strings are tracked and reclaimed for you; see
Memory.
A dict is a tagged box: object, array, number, string, bool, or null.
dict cfg = {
"server": { "host": "localhost", "port": 8080 },
"debug": 1,
"logfiles": ["access.log", "error.log"]
};
printf("%s\n", (char *)cfg.server.host);
int port = (int)cfg.server.port;
cfg.retries = 5; // new key
dict d = json("{\"items\":[{\"name\":\"ada\",\"score\":42}]}");
printf("%s\n", (char *)d.items[0].name); // ada
int n = (int)d.items.length(); // 2 (alias: .count())
for (auto i, item in d.items)
printf("%d: %s\n", i, (char *)item.name);for-in looks at the runtime tag: an object yields (key, value), an
array yields (index, element). json(v) stringifies any box.
Array-literal assignment (d.tags = ["fast"];) is not implemented.
Build arrays with JSON, or with dict_create_array / dict_array_append.
class Address { String city; int zip; };
class User { String name; int age; Address addr; };
dict d = json(req.body);
User u = (User) d; // missing field → KeyException
User u2 = (User)? d; // missing fields stay 0 / NULLThat walk is driven by the declared members. Names must match keys
exactly. Scalars, String, nested class/struct values, and any
Add(T)-protocol collection field (List<T>* / Set<T>*, including
pointer-to-class elements like List<User*>*) all work. Map<K,V>*
fields are not bound yet.
cy-validate/val-020-json-binding.cy,
val-024-json-binding-collections.cy, and
val-058-json-bind-list-classptr.cy are the spec.
House style: stack shells, values in the buffer, pipelines that return values.
#include "list.h"
#include "map.h"
#include "set.h"
auto nums = List<int> ();
for (int i = 1; i <= 6; i++) nums.Add (i);
auto evens = nums.Where ((int x) => x % 2 == 0);
auto doubled = evens.Map ((int x) => x * 2);
auto top3 = nums.Take (3);
int found = nums.Find ((int x) => x == 4); // miss → 0 for scalars
auto tags = Set<String> ();
tags.Add ("c"); tags.Add ("c"); tags.Add ("rust");
printf ("%d\n", tags.Count ()); // 2 — String hashes by content
auto ages = Map<String, int> ();
ages["Ada"] = 36;
if (ages.Contains ("Ada"))
ages["Ada"] = ages["Ada"] + 1;list[i] and map[k] are real lvalues when GetMut exists, so
fleet[0].Boost(5) mutates the buffer. fleet.Get(0).Boost(5) mutates
a copy and throws the work away.
Map.Get(k) throws KeyException on a miss. Use GetOr(k, fallback)
or TryGet when absence is normal.
Bare assignment of List / Map / Set is rejected (they are
move-only). Transfer with move, or bind a by-value return.
A C array can become a list: names.ToList(), or
new List<String>(names). The compiler threads the length next to the
decayed T* so a constructor can ask items.count().
Going the other way: ToArray, CopyTo, ToJsonArray / ToJson,
FromJson. Map<String,V> has ToDict / ToJson and
Keys() / Values() as value Lists.
| Form | What it becomes | Where |
|---|---|---|
| Non-capturing | a static function, thin T(*)(…) |
anywhere a function pointer is legal |
| Capturing | open-coded at the call site | direct argument of a listed HOF only |
int is_even (int x) { return (x & 1) == 0; }
auto a = nums.Where (is_even);
auto b = nums.Where ((int x) => (x & 1) == 0); // same path
int thr = 3;
auto big = nums.Where ((int x) => x > thr); // captures thr
int flip = -1;
nums.Sort ((int a, int b) => flip * (a - b));
auto pred = (int x) => x > thr; // error — not a HOF argumentHOFs that accept a capturing literal today:
List:Where/Filter/Map/ForEach/Any/All/Find/Sort/SelectMap:Where/ForEach/Any/AllSet:Filter/ForEach/Any/All
No [=] / [&], no stored capturing callbacks. That last one needs
fat closures; collection pipelines do not. Design:
DOC/LAMBDA-CAPTURE.md.
auto library = List<Track*> ();
library.owns ();
library.Add (new Track ("Kashmir", "Led Zeppelin", 508));
// ~library deletes every Track
auto epics = library.Where ((Track *t) => t.seconds > 360);
// view — does not steal .owns(), does not free the TracksOne owner per heap object. Where / Copy / Take never copy
.owns(). Map has .ownsValues(), .ownsKeys(), and .owns().
class Point {
int x, y;
Point (int x, int y) { this.x = x; this.y = y; }
~Point () { printf ("~Point(%d,%d)\n", x, y); }
Point *withX (int v) { x = v; return this; }
int sum () { return x + y; }
};
Point *p = new Point (3, 4).withX (10);
defer delete p; // LIFO, also on return / break / continue
Point q = Point (1, 2); // stack: ~Point at scope exitnew T(args) { .field = value, ... } runs the constructor, then the
designators. The leading . is what separates that from collection
brace-init (new List<int>{1, 2, 3}, which calls Add).
defer is the cleanup you write. Heap Strings are not your
problem; objects you new are, unless you mark them owned.
auto x = 42; // int
auto d = { "name": "Ada", "age": 36 }; // dict
auto arr = { 1, 2, 3 }; // int[3]for (auto x in …) walks arrays, dict, List, Set, Map, and
string arrays. Two-var form: for (auto k, v in m) for dict and
Map. You can name the type and the element is coerced:
for (String s in d.tags) printf ("%s\n", s);
for (int n in d.xs) sum += n;On a dict object, a single auto variable is the key. Use two
variables when you want the value too.
interface Drawable { void draw (); }
class Circle impl Drawable { /* ... */ };
Any<Drawable> d = any<Drawable> (new Circle ());There is no extends / virtual. Structural interface + impl +
Any<I> is the dispatch story.
On by default. A null deref, a divide by zero, or an OOB slice becomes a catchable exception rather than a mysterious JIT crash.
try {
int *p = 0;
int v = *p;
} catch (NullException e) {
printf ("caught: %s\n", e.msg);
}
throw (OutOfBoundsException, "bad index");Built-in (no include): Exception, NullException,
OutOfBoundsException, ArithmeticException, RuntimeException,
KeyException (8), TypeException (7). User IDs start at 100:
enum { MyKeyError = 100 };
throw (MyKeyError, "key missing");Uncaught exceptions print and exit(1). CY_EXC_ABORT=1 if you
want a core. -fno-exceptions turns the whole mechanism off.
Opt-in: -ffibers. Without it, go and await are ordinary
identifiers.
#include "chan.h"
void worker (Chan<int> *ch) {
for (int i = 0; i < 100; i++) ch->send (i);
ch->close ();
}
int main (void) {
auto ch = new Chan<int> (16); // new Chan<int>() is rendezvous
go worker (ch);
int sum = 0, v = 0;
while (ch->recv (&v)) sum += v; // false after close+drain
add_scheduler (1); // run the scheduler on this thread
delete ch;
return sum == 4950 ? 0 : 1;
}go f(args) takes a plain function, arguments packed by value
(at most 8, integer or pointer). await; yields. add_scheduler(n)
with n > 1 starts n pthread workers. send after close throws;
so does a second close.
./bin/classyc -I include -ffibers examples/classy-go-chan.cy -egMore: DOC/FIBERS.md, DOC/TLS-IMPLEMENTATION.md,
DOC/CLASSY-ATOMICS.md.
#include "httpclient.h"
auto resp = Http.get ("https://pokeapi.co/api/v2/pokemon/ditto");
defer delete resp;
if (resp->ok ()) {
dict d = resp->asDict ();
printf ("#%d %s\n", (int)d.id, (char *)d.name);
}OpenSSL is loaded on demand; you do not link it. Full tour:
examples/classy-fetch.cy.
include/sqlite.h is a small wrapper: Sqlite.open, bound
execute / query → List<dict>*, (User) row binding,
transactions. Needs -l sqlite3. See examples/classy-restful.cy
and examples/classy-querybuilder.cy.
include/httpserve.h is a gunicorn-shaped request/response server.
examples/http-serve.c and examples/classy-http-app.c link as one
program.
Four layers, all optional except the String arena (which is just there):
Strings. Every heap String — from +, methods, json(),
join, or a helper that returns String — is tracked. The compiler
checkpoints at the start of an allocating function and at the top of
each loop iteration, and releases at the bottom / on continue /
on break. A returned String is kept for the caller. If you
assign a tracked string to a variable declared outside the loop,
per-iteration release is disabled for that loop (the function still
cleans up on return).
Collections. auto xs = List<T>() is RAII. Transforms return
values. Use new / owned auto when a pointer must escape.
owned / move / readonly. Opt a single-owner heap object
into the static checker. owned auto x = new Box(1); is released
exactly once at the end of x's scope. move x transfers ownership
and leaves x as a read-only view. readonly y borrows without
owning. Use-after-move and delete of a view are compile errors.
detach / unowned / attach. detach expr takes a String
or class pointer off the current arena ledger and yields the same
value — the explicit escape hatch. unowned on a declaration opts
that binding out of ownership tracking entirely. attach parses and
type-checks; it does not emit a runtime call yet.
Between check and gen, a CFG ownership pass warns about leaks and
errors on use-after-free / double-free for malloc/new bindings.
Useful flags: -fauto-release (insert defer free for definite
leaks), -fownership-report, -fcheck-whole-allocs (several .cy
files as one TU). -fno-ownership turns the analyzer off.
The long form lives in DOC/readme-ownership.md
and DOC/BY-VALUE.md.
./bin/classyc -I include -c -g examples/readme-taste.cy -o /tmp/taste.bmir
./bin/b2obj --dwarf4 /tmp/taste.bmir /tmp/taste.o
cc -g -o /tmp/taste /tmp/taste.o
gdb /tmp/tasteOr ./classyc-aot.sh -I include -g examples/readme-taste.cy -o /tmp/taste.
Line information is DWARF v4.
bin/nmb is nm for .bmir / .mir: symbols, trap sites, debug types.
./bin/nmb -slt examples/http_crud/http_crud.bmirHot-reload and DAP (Zed / VS Code) sit in bin/jitrunner:
bash src/jitrunner/build.sh
./bin/classyc -I include -c -g -o /tmp/prog.bmir examples/readme-taste.cy
./bin/jitrunner /tmp/prog.bmir
./bin/jitrunner /tmp/prog.bmir --watch
./bin/jitrunner --compile examples/readme-taste.cy --watch
./bin/jitrunner --dap-stdio /tmp/prog.bmir --mode interp
./bin/jitrunner --dap 4711--watch watches a .bmir or, with --compile, the source. It does
not take a directory plus --dap on the same line. Details:
src/jitrunner/JITRUNNER.md.
bin/classyc-lsp is the language server.
Curated, all .cy unless noted:
| File | What it is |
|---|---|
examples/readme-taste.cy |
the crate at the top of this page |
examples/classy-aurora-ops.cy |
by-value List<Ship> / Map, GetMut, GroupBy |
examples/classy-neon-grid.cy |
another value-first pipeline |
examples/classy-map.cy |
Map<K,V> |
examples/classy-lambda.cy |
thin lambdas + capturing HOFs |
examples/classy-docsearch.cy |
TUI search; capturing Where |
examples/classy-fetch.cy |
HTTPS + dict |
examples/classy-go-chan.cy |
fibers (-ffibers) |
examples/classy-restful.cy |
SQLite REST (-l sqlite3) |
examples/classy-querybuilder.cy |
QueryBuilder<T> |
examples/classy-exceptions.cy |
try / catch / throw |
examples/classy-safety.cy |
default-on traps |
The rest of examples/ is fair game. From the repo root:
./examples/run-examples.shBehavior claimed here is supposed to stay true in
cy-validate/ (64 programs):
sh cy-validate/run-validate.shRegressions: sh bugs/run-bugs.sh. Rough edges:
cy-validate/SHORTCOMINGS.md.
Deeper notes, all under DOC/:
BY-VALUE.md,
GENERICSMEM.md,
LAMBDA-CAPTURE.md,
FIBERS.md,
GEN-OPT.md.
pre → parse → check → ownership → midopt → gen → MIR
↘ JIT (-eg / -el / -eb / -ei)
↘ .bmir → b2obj → native
The parser is hand-written and stays close to the C11 grammar. Check does types, monomorphization, and most const folding. Ownership is the leak/UAF lattice. Midopt prunes dead class methods and proves some safety elisions before any MIR exists. MIR does the SSA work (GVN/CCP, DCE, LICM, …).
String and dict helpers are small C runtimes imported during
generation. List / Map / Set are headers that the compiler
monomorphizes.
- No
extends/super/virtual. Useinterface+Any<I>. - Capturing lambdas cannot be stored or returned.
- Generic functions infer type arguments (
Max(3, 5)).Max<int>(3, 5)and signatures likeList<T> Sort<T>(List<T>)are not there yet. dictcannot be assigned an array literal.- JSON bind does not yet fill
Map<K,V>*fields. attachis a reserved statement with no runtime effect.- Exception names exist only at compile time; the runtime stores integer ids.
- This is a working compiler with a validation suite, not a promise
that every C corner or every collection edge is done. Read
SHORTCOMINGS.mdbefore betting a service on a dark corner.
Contributions, bug reports, and slightly-too-ambitious programs are welcome.
Built on MIR. Original c2mir by Vladimir Makarov.