14 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 tagged unions, no closures, no
lambdas. Five 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. - 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
Plan 9 model. An error is a string. Empty means OK. Hare uses tagged unions for errors; we don't have unions, so we drop down to the plainer Plan 9 thing.
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, tagged unions, closures, lambdas. Ever. See hard rule #7.
- 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.