word

// small programs. native binaries. no dependencies.

word is a small programming language I built for single-file tools. It compiles straight to a native executable that carries its own runtime: no libc, no third-party libraries, and nothing to install on the machine that runs it.

The compiler, both assemblers and all three linkers are written in word, and they compile themselves. This page is the short version. The repo has the rest.

posts.w
// the three newest posts on my blog, over TLS 1.3
posts = parse(get("blog.ryankempt.com/index.json"))
i = 0
loop i < 3
    out(posts[i]["title"])
    i = i + 1
$ ./word run posts.w
Defeating Blue Team Labs
Huntress CTF 2025
I won the 2024 CyberDrain CTF!

That's the whole program. It downloads my blog's list of posts over a secure connection, reads it and prints the titles of the three newest. The encryption and the code that reads the list are both written in word, and they come built in, so there's nothing to install.

That's the whole program. It fetches my blog's search index over HTTPS, checks the certificate chain against the system's trust store, parses the JSON and prints the first three titles. The TLS 1.3 client and the JSON parser are both written in word, and neither one needs an import.

4targets from one binaryLinux x86-64 and arm64, Windows x64, macOS on Apple Silicon
20 KBhello worlda static Linux executable, with no libc
0dependenciesno MinGW, Xcode, binutils or LLVM, to build or to run
0.55 sto rebuild itselfall 46,910 lines of the toolchain, compiled, assembled and linked

A short tour.

Eleven small programs, and what each one prints.

explain it for

    Hello

    A program is a text file of instructions that run from the top down. This one has a single instruction. out prints whatever is between its brackets and then starts a new line, and the quotes mark Hello, word! as text to print as it is.

    A program is a file of statements that run top to bottom. There's no main to declare. out prints a value and a newline.

    hello.w
    out("Hello, word!")
    $ ./word run hello.w
    Hello, word!
    $ ./word build hello.w -o hello
    $ ./hello
    Hello, word!

    word run turns the file into a program your computer can run, and runs it. word build saves that program as a file instead, called hello here, and it runs on its own without word installed.

    word run builds for this machine and runs the result. word build writes the executable instead, and on Linux x86-64 this one is 20,480 bytes.

    Names and numbers

    width = 7 gives the number 7 a name, so the lines after it can use width wherever they need that number. A name like this is called a variable, because another = can store a different value in it later.

    Arithmetic works the way it does on paper. * multiplies and / divides, so 7 divided by 2 is 3.5, and 6 divided by 3 is 2.

    = declares a name the first time you use it and assigns to it after that. It's the only assignment operator, and there are no type declarations.

    A number is a 63-bit integer or a double. / gives you the exact answer: an integer when two integers divide evenly, and a float when they don't.

    numbers.w
    width = 7
    height = 2
    out(width * height)
    out(width / height)
    out(6 / 3)
    out("area: " . width * height)
    $ ./word run numbers.w
    14
    3.5
    2
    area: 14

    . joins things into one piece of text. The last line joins "area: " with the answer to width * height, so it prints area: 14.

    . joins values into text, and it renders a number the way out would.

    true, false, null and none

    3 > 2 is a question, and its answer is true. Questions like this are how a program decides what to do, and the answer is always true or false.

    number turns text into a number when it can. "wat" isn't a number, so the answer is none, which is how word says there was no answer. The fourth of these values, null, is the empty value in JSON data from the web.

    These four are values of their own. none means there was no answer, so a failed conversion can't be mistaken for 0.

    answers.w
    out(3 > 2)
    out(number("42") + 1)
    out(number("wat"))
    $ ./word run answers.w
    true
    43
    none

    if runs the lines indented under it only when its question comes out true. Give it something that isn't a question, like a plain number, and word won't build the program. It tells you what to write instead:

    Only true and false can be conditions. When the compiler can see that a condition isn't one, the build stops and says what to write instead:

    check.w
    n = 5
    if n
        out("five")
    $ ./word build check.w
    check.w:2:4: a condition must be true or false, not a number; compare it, as in 'x != 0'

    Functions and loops

    fib(n) defines a function: a named set of steps you can run whenever you need them. The indented lines are the steps, n is the number you hand it each time, and return hands the answer back.

    loop i < n repeats the lines indented under it for as long as i < n is true. Each pass adds 1 to i, so the loop runs n times and then stops.

    A function is a name, its parameters and an indented body. loop is the only loop: loop cond runs while the condition holds, and a bare loop runs until a break leaves it.

    fib.w
    fib(n)
        a = 0
        b = 1
        i = 0
        loop i < n
            t = a + b
            a = b
            b = t
            i = i + 1
        return a
    
    out(fib(30))
    $ ./word run fib.w
    832040

    Each pass also moves a and b one place along the Fibonacci sequence, 0, 1, 1, 2, 3, 5, 8 and so on, where each number is the sum of the two before it. The last line asks for fib(30) and prints it.

    if, else if and else work the way you'd expect, and blocks are indentation.

    Text and arraysText and arrays are regions

    Text in quotes, like "word", is a row of characters. array(5) makes a row of five numbers, all 0 to start with. word calls both kinds of row a region, and the same tools work on each.

    len counts what's in a region. copy takes part of one, and counting starts at 0, so copy("regionally", 2, 6) takes characters 2 to 5, which spell gion. squares[i] = i * i stores a number at position i.

    Strings and arrays are the same thing, called a region. A string literal is a region of code points, and array(n) is a region of n zeros. len measures one, copy(p, start, end) takes a slice, and every index is bounds-checked.

    regions.w
    name = "word"
    out(len(name))
    out("Hello, " . name . "!")
    out(copy("regionally", 2, 6))
    
    squares = array(5)
    i = 0
    loop i < 5
        squares[i] = i * i
        i = i + 1
    out(squares)
    
    total = 0
    loop n in squares
        total = total + n
    out(total)
    $ ./word run regions.w
    4
    Hello, word!
    gion
    [0,1,4,9,16]
    30

    loop n in squares goes through the array one number at a time and adds each one to total. If you ask for a position a region doesn't have, like the seventh of five, the program stops with an error. Step 7 shows one.

    Walking text gives you numbers, because a character is its code point: loop c in "hi" gives 104 and 105, and char(104) spells one.

    MapsMaps are JSON

    A map stores values under names, the way a contact on your phone keeps a number under "mobile". The names are called keys. person["name"] looks one up, and person["email"] = ... adds a new one.

    Printing a whole map shows it as JSON, the text format most websites use to send data. Asking for a key the map doesn't have gives none.

    The other aggregate is the map. {} holds keys and values, and it prints as JSON. An absent key gives none, and has tells an absent key from one that holds none.

    maps.w
    person = {name: "Ada", age: 36}
    person["email"] = "ada@example.com"
    
    out(person["name"])
    out(person)
    out(person["phone"])
    
    loop k in keys(person)
        out(k . " = " . person[k])
    $ ./word run maps.w
    Ada
    {"name":"Ada","age":36,"email":"ada@example.com"}
    none
    name = Ada
    age = 36
    email = ada@example.com

    keys(person) lists the keys in the order they were added, and the loop prints each one with its value. parse reads JSON into maps, which is how the program at the top of this page reads the list of posts on my blog.

    parse reads JSON into maps and arrays, and stringify writes it back out. Maps have no methods and no o.field syntax.

    Mistakes stop the programChecked, with the file and line

    Some mistakes only show up while a program runs, like asking for position 5 of an array that only has 3. word checks for them as it goes. When one happens, the program stops and prints the file, the line number and what went wrong, so you know where to look.

    Array bounds, integer overflow, division by zero and shift counts are all checked at run time. A failed check stops the program with the file, the line and exit status 70, so a bad index can't corrupt memory.

    bounds.w
    scores = array(3)
    out(scores[5])
    $ ./word run bounds.w
    bounds.w:2: index 5 out of bounds for a region of length 3

    Numbers have a limit too. The biggest whole number word can hold is 4,611,686,018,427,387,903, and going past it stops the program instead of giving a wrong answer:

    Integers don't wrap either. The largest one is 2^62 - 1, and one more stops the program:

    overflow.w
    big = 2305843009213693951 * 2 + 1
    out(big)
    out(big + 1)
    $ ./word run overflow.w
    4611686018427387903
    overflow.w:3: integer overflow

    Contracts

    A contract is a check that runs every time a function is called. divide:before runs just before divide does, and it can see the numbers divide was given. Dividing by zero has no answer, so this check answers false when b is 0, and the program stops and tells you which function it was.

    A function can carry a :before hook that checks its arguments, and an :after hook that checks its result. A hook answers true or false, and false stops the program with a message that gives the function's name.

    divide.w
    divide(a, b)
        return a / b
    
    divide:before
        if b == 0
            return false
        return true
    
    out(divide(10, 4))
    out(divide(1, 0))
    $ ./word run divide.w
    2.5
    divide.w:6: contract violation in divide

    divide(10, 4) passes the check and prints 2.5. divide(1, 0) never gets to run.

    Hooks only observe. They can't change the arguments or the result, and inside an :after hook the result is called result.

    Files and the network

    write saves text to a file, append adds to the end of one, and read loads a file back. read gives you the file's raw bytes, and decode turns them into text.

    split cuts the text into lines wherever there's a \n, the character that ends a line. lines[1] is the second line, since counting starts at 0. A file that isn't there reads as none, so the program can check for that.

    Four modules come with it: fs, json, net and txt. None of them needs an import, because the compiler knows which module each name comes from, and calling read is what brings fs in.

    notes.w
    write("notes.txt", "first line\n")
    append("notes.txt", "second line\n")
    
    lines = split(decode(read("notes.txt")), "\n")
    out(lines[1])
    out(read("missing.txt") == none)
    $ ./word run notes.w
    second line
    true

    get fetches a web page the way read loads a file. It connects over HTTPS, the secure kind of web connection, with code that's also written in word.

    get, post, put, delete and head speak HTTP and HTTPS. Behind them is a TLS 1.3 client written in word: DNS, the handshake, X25519, ChaCha20-Poly1305 and AES-GCM, and X.509 chain verification against the host's own trust store.

    The TLS stack is written from scratch and hasn't been audited. It's fine for fetching data. Don't use it to protect anything that matters, and read docs/SECURITY.md before you rely on it.

    Windows, Mac and LinuxOne binary, four targets

    A program has to be built for the kind of computer that runs it. word can build for Windows, for Macs with Apple chips and for Linux, from any of them, so you can make every version on your own machine.

    The compiler, the x86-64 and AArch64 assemblers and the ELF, PE and Mach-O linkers are all one binary, so any word builds for every target.

    $ ./word build -win hello.w -o hello.exe
    $ ./word build -arm64 hello.w -o hello-arm64
    $ ./word build -mac hello.w -o hello-mac
    $ file hello.exe hello-arm64 hello-mac
    hello.exe:   PE32+ executable for MS Windows 6.00 (console), x86-64, 5 sections
    hello-arm64: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, no section header
    hello-mac:   Mach-O 64-bit arm64 executable, flags:<NOUNDEFS|DYLDLINK|TWOLEVEL|PIE>

    file is a Linux command that tells you what kind of file something is, and here it shows one program built for three different systems. On a Mac, a program that uses the network builds and runs, but in version 1.0 its network calls answer none.

    No MinGW, no Xcode, no binutils and no LLVM. net works on Linux and Windows. On macOS a program that uses it builds, but in 1.0 every net verb answers none there.

    word is written in wordIt compiles itself

    The word compiler, the program that turns your .w files into programs, is written in word too: about 39,000 lines of it, in compiler/word.w. word verify builds the compiler again from that source and checks that the result matches the copy in the repo, byte for byte.

    compiler/word.w is about 39,000 lines of word. word verify rebuilds the committed Linux binary from it and checks that the result is byte-for-byte the same, so the binary proves it matches the source beside it.

    $ ./word verify
    word verify: OK - rebuilt word is byte-identical to the committed binary

    There's also a second compiler, written in Python, that builds the same bytes from the same source, so the check doesn't depend on word alone. docs/BOOTSTRAP.md has the details.

    A tampered binary could reproduce its own tampered self, so there's also a second compiler in bootstrap/, about 5,600 lines of Python that share no code with word. It rebuilds the same bytes from the same source. docs/BOOTSTRAP.md explains what that settles and what it doesn't.

    Get started.

    You'll need a terminal, the window where you type commands, and git to download the code. On Linux there's nothing else to install, because the word program is already in the repo, ready to run:

    The first line downloads word, the second writes the Hello program from step 1 into hello.w, and the third runs it. On Windows or a Mac, download word for your system from a release and use it the same way.

    On Linux x86-64 there's nothing to install. The word binary is committed to the repo with its executable bit set, so it runs straight from a fresh clone:

    On Windows, macOS or arm64 Linux, take the binary for your platform from a release, or build one with the Linux word: ./word build -win compiler/word.w -o word.exe makes a Windows word.exe, and -mac or -arm64 make the others.

    $ git clone https://github.com/rdkempt/word && cd word
    $ printf 'out("Hello, word!")\n' > hello.w
    $ ./word run hello.w
    Hello, word!

    Where to next.

    word is at 1.0. The spec is frozen for all of 1.x, so a program that compiles today will compile and behave the same on every 1.x release.