Files
ww/CLAUDE.md
Hojun-Cho 2c33228b7e ww: rename toolchain to w-prefix + hare-style build/run/test driver
Plan 9-style w-prefix on the per-arch tools, disambiguating from the
real Plan 9 6c/6a/6l in ref/plan9front/:

    cmd/wwc/      → cmd/wcc/        libwwc.a → libwcc.a
    cmd/6{c,a,l}  → cmd/w6{c,a,l}   binary names too
    test/wwc/     → test/wcc/       6 test files w/ w6 prefix
    selfhost/cmd  mirror in lockstep
    bootstrap/amd64/{w6c,w6a,w6l}   snapshot binaries (gitignored)
    WW_6{C,A,L}   → WW_W6{C,A,L}    env-var overrides

Plan 9 source-tree refs ("Plan 9 6c shape", ref/plan9front/, etc.)
preserved. Hare-style driver, both C and ww sides:

    ww test [path]   discover *_test.ww in a directory module, run
                     each; single-file mode for `ww test foo.ww`
    Module-by-name   `ww build foo` resolves to foo.ww or foo/foo.ww
                     via search path (cwd : -I dirs : $WW_LIB)
    Default-to-cwd   `ww build` / `ww test` build the cwd module
    Run pass-through `ww run path arg1 arg2` reaches the program

lib/os: getcwd (79) and getdents64 (217) syscalls power `.` resolution
and directory enumeration on the ww side.

Makefile: wwstage tool deps now include lib/os/os.ww (+ lib/strconv
for wwdump_ww) so lib/* edits force their rebuild instead of leaving
stale binaries — surfaced when test 995 first failed against a stale
w6c_ww built before the lib/os additions.

Test 993 byte-identical parity gate (C-side ww vs ww-side ww_ww on a
build corpus) stays green; all 19 tests pass.
2026-05-11 13:49:27 +09:00

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 *.ww files (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/wcc/ — frontend library (lex, parse, check). Builds libwcc.a. Not a binary.
    • cmd/w6c/ — amd64 compiler. Reads .ww, writes .s (Plan 9 amd64 asm).
    • cmd/w6a/ — amd64 assembler. Reads .s, writes .o (ELF, for C interop).
    • cmd/w6l/ — amd64 linker. Reads .o and .a, writes a static ELF binary.
    • cmd/ww/ — user-facing driver (Hare's hare(1) / Plan 9's cc(1) analogue). Orchestrates w6c → w6a → w6l. Adding a new target later means a new triple (e.g. w7c/w7a/w7l for arm64). The frontend library wwc is shared.

Hard rules (do not violate)

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

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

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

  4. Static linking by default. A ww binary is self-contained, like Go. Dynamic linking is opt-in (-shared, -l).

  5. C FFI is first-class, Hare-style. A body-less fn declaration 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 separate extern "c" marker is needed.

  6. 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 (w6c for amd64) reads .ww and writes Plan 9-style target assembly (.s); the per-target assembler (w6a) writes ELF objects; the per-target linker (w6l) produces a static binary. Each tool uses Plan 9 cc's in-memory Prog/Adr shapes — read ref/plan9front/sys/src/cmd/cc/, cmd/w6c/, cmd/w6a/, cmd/w6l/ before writing your own.

  7. 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 9 Bio, Hare io::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 []u8 and let the caller cast.

    Tagged unions are allowed, but only as the Hare-style error idiom: (T | error) (and a few sentinel kin like nomem). Pattern-matched with match. 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.

  8. Tests run after every change. make test is the truth. A change without a green make test is not a change.

  9. Prototype in C, then self-host. The C bootstrap toolchain (libwcc, w6c, w6a, w6l, ww) is throwaway scaffolding. Its job is to compile enough of ww to 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 export keyword. No capitalization rule.
  • Module paths use .. So does field access. Compiler disambiguates.
  • Constants: def NAME: T = lit; (compile-time).
  • Variables: let name: T = expr; or let 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 point is fn 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 :=, no make, no new. Allocation is the built-in expression form (Hare-style):
    • alloc(point { x = 1, y = 2 }) returns *point
    • alloc([0u8...], 16) returns []u8 of len/cap 16
    • free(p) releases a pointer or slice
  • C FFI: a body-less fn is 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:

  1. 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.
  2. Hare tagged-union model. A function returns (T | E1 | E2 | ...). Callers match on 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 export keyword. 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 just tinfo to 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 libwcc, w6c, w6a, w6l, 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
    w6c             amd64 compiler
    w6a             amd64 assembler
    w6l             amd64 linker
  lib/
    libwcc.a       frontend library (linked into w6c)
    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:

  1. Compiler unit tests (test/wcc/): C, table-driven. Lex, parse, typecheck, IR-gen, codegen each have their own table.
  2. 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.
  3. 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 test green.
  • Produce no new warnings (-Wall -Wextra -Wpedantic in C; the ww typechecker is strict by default).

Rob Pike rules (kept on the wall)

  1. You can't tell where a program will spend its time. Measure.
  2. Measure. Don't tune for speed without numbers.
  3. Fancy algorithms are slow when n is small, and n is usually small.
  4. Fancy algorithms are buggier and harder to implement. Prefer simple.
  5. Data dominates. Get the structures right and the code follows.
  6. There is no rule 6.

Applied to this project:

  • w6c is a single-pass-ish recursive-descent parser into a typed AST, walked into a Prog list, 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, Adr modeled on Plan 9 cc. Read ref/plan9front/sys/src/cmd/cc/cc.h and 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. Read sys/src/cmd/cc/ (the shared frontend library), sys/src/cmd/8c/ and sys/src/cmd/w6c/ (per- target compilers), sys/src/cmd/8a/ (assembler), sys/src/cmd/8l/ (linker) for toolchain organization, Prog/Adr data 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 by wwfmt (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: w6c: fix const fold for i64, lib/fmt: handle %v for slices, cc: typo. Body only when the why is not obvious from the diff. No Co-Authored-By trailer, no "Generated with" footer, no emoji.