C bootstrap (phases 0-9):
cmd/wwc, cmd/6c, cmd/6a, cmd/6l, cmd/ww, rt, lib/*.
ww-side self-host (phase 10):
selfhost/cmd/wwc — ww-cgen frontend; bootstrap fixed point.
selfhost/cmd/6a — assembler; byte-identical to C 6a (test 991).
selfhost/cmd/6l — linker w/ archive (.a) support; byte-identical
to C 6l (test 992).
selfhost/cmd/ww — driver (build/run/version); byte-identical to
C ww (test 993).
make test: 15/15. make bootstrap: ww2.s == ww3.s, ww2.o == ww3.o,
ww2 == ww3 byte-identical, with the full ww-tooled chain.
15 KiB
ww
A small systems language. Plan 9 in spirit and code style, Hare in syntax, API, and FFI, no GC, CSP at the end.
This file is the contract for the work. Read PLAN.md for the schedule.
Identity
- Name:
ww - Source extension:
.ww - Module: a directory of
*.wwfiles (Hare-style layout) - Toolchain: Plan 9-organized. One library + one binary per role
per target architecture. Plan 9 uses a single digit per arch
(8=386, 6=amd64, 5=arm, 7=arm64, 9=power); we adopt that.
cmd/wwc/— frontend library (lex, parse, check). Buildslibwwc.a. Not a binary.cmd/6c/— amd64 compiler. Reads.ww, writes.s(Plan 9 amd64 asm).cmd/6a/— amd64 assembler. Reads.s, writes.o(ELF, for C interop).cmd/6l/— amd64 linker. Reads.oand.a, writes a static ELF binary.cmd/ww/— user-facing driver (Hare'share(1)/ Plan 9'scc(1)analogue). Orchestrates6c → 6a → 6l. Adding a new target later means a new triple (e.g.7c/7a/7lfor arm64). The frontend librarywwcis shared.
Hard rules (do not violate)
-
No garbage collector. Ever. Memory is allocated and freed by the programmer. The compiler may insert defer-style cleanup, never a tracing/reference-counting collector.
-
No
map. A built-in growable hash table is unsafe without GC (rehashing invalidates pointers). Users may build their own; it is not a language type. -
No complex runtime. The runtime is a few hundred lines. It owns: process startup, syscalls trampoline, panic/abort, and (later) the CSP scheduler. It does not own memory beyond a tiny bump arena for startup.
-
Static linking by default. A
wwbinary is self-contained, like Go. Dynamic linking is opt-in (-shared,-l). -
C FFI is first-class, Hare-style. A body-less
fndeclaration imports the symbol;@symbol("name")overrides the linker name. We must be able to bind libcrypto/libtls/ncurses cleanly and link them statically into the final image. The platform calling convention (SysV amd64 on Linux) is the C ABI, so no separateextern "c"marker is needed. -
Plan 9 toolchain. No LLVM, no QBE, no external IR. We do not invent a portable SSA IR. We follow Plan 9: the per-target compiler (
6cfor amd64) reads.wwand writes Plan 9-style target assembly (.s); the per-target assembler (6a) writes ELF objects; the per-target linker (6l) produces a static binary. Each tool uses Plan 9 cc's in-memoryProg/Adrshapes — readref/plan9front/sys/src/cmd/cc/,cmd/6c/,cmd/6a/,cmd/6l/before writing your own. -
No generics, no interfaces, no closures, no lambdas. Four forms of bloat we refuse. Polymorphism, when genuinely needed, is a struct of function pointers plus a
ctx: *void(Plan 9Bio, Hareio::stream). All functions are declared at file scope; function values are pointers to those named functions. No capturing. No anonymous function literals. The compiler does no virtual dispatch; users build vtables explicitly when they want them. If a function needs to work on multiple types, write it multiple times, or operate on[]u8and let the caller cast.Tagged unions are allowed, but only as the Hare-style error idiom:
(T | error)(and a few sentinel kin likenomem). Pattern-matched withmatch. Propagated with postfix?. Asserted with postfix!. They are not an open extension point — no enum methods, no virtual dispatch through the tag, no nesting beyond what the error idiom needs. If you find yourself reaching for a discriminated record, use a struct with a tag field instead. -
Tests run after every change.
make testis the truth. A change without a greenmake testis not a change. -
Prototype in C, then self-host. The C bootstrap toolchain (
libwwc,6c,6a,6l,ww) is throwaway scaffolding. Its job is to compile enough ofwwto compile the ww reimplementations of itself. Do not over-engineer the C side.
Type system
Hare-style integer names. Fixed width, explicit signedness:
i8 i16 i32 i64 signed
u8 u16 u32 u64 unsigned
uint int register width (target-defined)
uintptr pointer-width unsigned
f32 f64 IEEE 754
bool one byte
rune i32, a Unicode code point
str immutable utf-8 view: { *u8, len }
void zero-sized
Composite:
*T pointer (may be nil)
[N]T fixed array
[]T slice: { *T, len, cap }
struct { x: i32, y: i32 } aggregate (Hare shape)
fn(arg: T) ret function pointer (file-scope only)
chan T CSP channel (last phase)
No map. No interface. No union. No exceptions. No generics.
No closures.
Polymorphism, when truly needed, is a struct of function pointers
plus a ctx: *void. See lib/io/stream.ww for the canonical shape.
This is how Plan 9 Bio and Hare io::stream work. It is plain
data, easy to read, and the compiler does nothing magic for it.
Syntax
Hare-shaped. Trailing semicolons. = after function and type
signatures. The one departure from Hare: module paths use .
instead of ::. Both module navigation and field access use the
same dot — the compiler resolves by name lookup.
use io;
use fmt;
use os;
def MAX_LINE: i32 = 4096;
type point = struct {
x: i32,
y: i32,
};
export fn move(p: *point, dx: i32, dy: i32) void = {
p.x += dx;
p.y += dy;
};
export fn distance(a: point, b: point) f64 = {
let dx: f64 = (a.x - b.x): f64;
let dy: f64 = (a.y - b.y): f64;
return math.sqrt(dx*dx + dy*dy);
};
export fn main() void = {
let p: point = point { x = 0, y = 0 };
move(&p, 3, 4);
for (let i: i32 = 0; i < MAX_LINE; i += 1) {
fmt.println(i);
};
};
Lexical rules:
- Statements end in
;. No automatic insertion. - Function bodies follow
=:fn f() T = { ... };. - Type definitions follow
=:type p = struct { ... };. - Visibility is the
exportkeyword. No capitalization rule. - Module paths use
.. So does field access. Compiler disambiguates. - Constants:
def NAME: T = lit;(compile-time). - Variables:
let name: T = expr;orlet name = expr;(inferred). - Struct literal:
point { x = 0, y = 0 }(Hare uses=). - Type cast:
expr: T. - Pointers are nullable. Compare with
== nil. - No methods. A function on
pointisfn move(p: *point, ...). Plan 9 cc has no methods; neither do we. - No closures, no lambdas, no anonymous functions. A function value is a pointer to a named, file-scope function.
- No
:=, nomake, nonew. Allocation is the built-in expression form (Hare-style):alloc(point { x = 1, y = 2 })returns*pointalloc([0u8...], 16)returns[]u8of len/cap 16free(p)releases a pointer or slice
- C FFI: a body-less
fnis an external symbol.@symbol("name")overrides the linker name:@symbol("malloc") fn c_malloc(n: u64) *void; @symbol("free") fn c_free(p: *void) void; - Errors are plain strings. See "Errors" below.
Errors
Two idioms, picked by the API author:
- Plan 9 model. An error is a string. Empty means OK. Functions
that can fail return
(T, error). Use this when there are only one or two error sources and the caller usually wants to format the message and move on. - Hare tagged-union model. A function returns
(T | E1 | E2 | ...). Callersmatchon it, or propagate with postfix?, or assert non-error with!. Use this when errors are structured (have payload) or when a caller routinely wants to handle one specific error kind.
Both are first-class. Pick whichever fits; do not mix in a single return type.
type error = str;
def eEOF : error = "eof";
def eShortRead : error = "short read";
Functions that can fail return (T, error). The T is zero-valued
when the error is non-empty:
export fn open(name: str) (*file, error) = {
if (name == "") {
return nil, "open: empty name";
};
let fd: i32 = sys.open(name, sys.oRdonly, 0);
if (fd < 0) {
return nil, sys.errstr();
};
return alloc(file { fd = fd, name = name }), "";
};
let f, err = open("/tmp/x");
if (err != "") {
fmt.eprintln(err);
os.exit(1);
};
Wrapping is string concatenation: fmt.errorf("open %s: %s", name, err). Comparison is plain string compare. Sentinel errors are
package-level defs.
Why a string and not a struct? Because Plan 9 used errstr for
thirty years and the world did not end. Strings are concrete,
allocation-free when literal, and carry arbitrary detail without
inviting a type hierarchy. Hare's ? postfix and match for
errors are also unavailable to us by rule #7.
Naming
Plan 9 taste lowered to ww. No CamelCase anywhere in ww source.
- Package names: short, lowercase, one word.
fmt,io,bio. - Identifiers: lowercase, words run together.
newbuf,tcpsock,parsefile. Underscores allowed but discouraged. - Visibility: the
exportkeyword. Not first-letter case. - Types: lowercase, like everything else (
point,lexer,node). - Constants (
def): UPPER_SNAKE for tunables (MAX_LINE,NHASH); lowercase for ordinary ones (eEOF,eShortRead). - Files: short, descriptive, lowercase.
lex.c,parse.c,ir.c,lex.ww,parse.ww. - C-side struct typedefs in the bootstrap mirror Plan 9 (capitalized
is the C convention there):
Node,Sym,Type,Prog,Adr. In ww source the same shapes are lowercase (node,sym,prog,adr; the type-info struct is justtinfoto avoid the keyword).
Standard library
Hare layout, Plan 9 names where they exist. Initial cut:
lib/
types/ integer limits, type info
bytes/ byte slice ops
strings/ str ops
fmt/ printf-family
io/ reader/writer/closer interfaces
bufio/ buffered io (Plan 9 'bio' equivalent)
bio/ alias of bufio for Plan 9 muscle memory
os/ process, fs, args, env
os/exec/ run subcommands
errors/ error type, sentinel values
sort/ sort.Slice, sort.Search
strconv/ number<->string
path/ path manipulation
encoding/ hex, base64, utf8
hash/ crc32, fnv, sha256
net/ dial, listen
time/ monotonic + wall clock
sync/ (post-CSP) mutex, once, waitgroup
C bindings live under lib/c/:
lib/c/
libc/ malloc, printf, etc. (when calling out)
tls/ libtls (or BearSSL) bindings
crypto/ libcrypto bindings
curses/ ncurses bindings
Bindings are thin: one .ww file per C header section, marked
extern "c", no wrapping logic in the binding layer itself. Higher
ergonomics live in a sibling pure-ww package.
Build
POSIX make, no autotools, no cmake.
make # builds libwwc, 6c, 6a, 6l, ww, stdlib
make test # runs all tests (toolchain + stdlib)
make install # installs to $PREFIX (default /usr/local)
make clean
Target layout under out/:
out/
bin/
ww user-facing driver
6c amd64 compiler
6a amd64 assembler
6l amd64 linker
lib/
libwwc.a frontend library (linked into 6c)
libwwrt.a runtime archive (linked into final binaries)
<pkg>.a precompiled stdlib modules
obj/... intermediate .s, .o per package
Static by default. ww build foo.ww produces a statically linked
ELF. Dynamic is ww build -shared or per-library -l.
Testing
Three tiers, all driven by make test:
- Compiler unit tests (
test/wwc/): C, table-driven. Lex, parse, typecheck, IR-gen, codegen each have their own table. - Language tests (
test/lang/*.ww): each file is a single ww program with a comment header declaring expected exit code and expected stdout. The harness (test/run) compiles and runs. - Stdlib tests (
lib/*/+test.ha-style, here*_test.ww): in-tree tests per module. Hare convention, just renamed.
Every change must:
- Add or update a test that exercises the change.
- Leave
make testgreen. - Produce no new warnings (
-Wall -Wextra -Wpedanticin C; the ww typechecker is strict by default).
Rob Pike rules (kept on the wall)
- You can't tell where a program will spend its time. Measure.
- Measure. Don't tune for speed without numbers.
- Fancy algorithms are slow when n is small, and n is usually small.
- Fancy algorithms are buggier and harder to implement. Prefer simple.
- Data dominates. Get the structures right and the code follows.
- There is no rule 6.
Applied to this project:
6cis a single-pass-ish recursive-descent parser into a typed AST, walked into aProglist, then printed as text. No parser generator, no LLVM, no SSA pass pipeline.- Optimizer is intentionally absent at first. Constant fold + dead code elim only. Add passes when a benchmark demands one.
- Data structures:
Node,Sym,Type,Prog,Adrmodeled on Plan 9 cc. Readref/plan9front/sys/src/cmd/cc/cc.hand the per-target headers (cmd/8c/gc.h,cmd/8a/,cmd/8l/) before inventing a new shape.
What is in ref/
ref/hare/— the Hare distribution. Read for syntax, FFI (@symbol), stdlib layout, type names, error idioms.ref/plan9front/— 9front. Readsys/src/cmd/cc/(the shared frontend library),sys/src/cmd/8c/andsys/src/cmd/6c/(per- target compilers),sys/src/cmd/8a/(assembler),sys/src/cmd/8l/(linker) for toolchain organization,Prog/Adrdata shapes, mkfile style, and naming.
When in doubt: copy Hare for surface syntax and stdlib, copy Plan 9 for toolchain organization and compiler internals.
What we will NOT build
- A package manager. Modules are directories. Vendoring is
cp -r. - A formatter beyond
wwfmt(one canonical style, no options). - A language server in phase 0. Plain editors are fine.
- Generics, interfaces, closures, lambdas. Ever. See hard rule #7.
Tagged unions are allowed but ONLY for the Hare-style error idiom
(
(T | error)+match+?+!). No general-purpose enums or open extension points. - An async/await coloring. CSP is the concurrency story; a function is a function.
Working agreement (for the assistant)
When making changes:
- Prefer editing existing files to creating new ones.
- Run
make test(or the smallest relevant slice) after any code change. Report results. - Match the file's existing style. C files: Plan 9 style (tabs,
K&R, short names; see
ref/plan9front/sys/src/cmd/cc/). ww files: Hare-shaped, formatted bywwfmt(one canonical style). - If a design question is non-obvious, propose two options before writing code.
- Keep diffs small. One concern per change.
- Commits: Plan 9 style. Subject is one short lowercase line,
prefixed with the affected area:
6c: fix const fold for i64,lib/fmt: handle %v for slices,cc: typo. Body only when the why is not obvious from the diff. NoCo-Authored-Bytrailer, no "Generated with" footer, no emoji.