Skip to content
p7gPublic

About

WIP statically-typed general-purpose programming language

Resources

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

🦃 Turkey

A small procedural language with an ML-style type system.

Turkey brings type inference, algebraic data types, and first-class functions to code with familiar loops, early returns, and mutable state. The aim is to make straightforward programs easy to write, while keeping the type system strong enough to support reusable abstractions.

Philosophy

  • Write the algorithm directly. Evaluation is strict, functions take ordinary argument lists, and loops and mutation are part of everyday code.
  • Let the compiler connect the types. Types are checked statically and inferred across functions. Add annotations where they help explain an interface.
  • Make alternatives explicit. Model choices with algebraic data types, handle them with pattern matching, and compose Option and Either with ?.
  • Build power in the library. Operators, iteration, indexing, and generic functions are defined through type classes. User-defined types can participate in the same syntax as the standard library.

A taste

Parse a few rectangles and add up their areas:

type Rectangle = Rectangle { width : Int, height : Int }

fun parseRectangle(text) {
    let (width, height) = String.splitOnce(text, "x")?
    Some(Rectangle {
        width = Int.parse(width)?,
        height = Int.parse(height)?
    })
}

fun area(rect) = rect.width * rect.height

fun main() {
    var total = 0
    for text in ["3x4", "5x2", "oops"] {
        match parseRectangle(text) {
            Some(rect) -> {
                total = total + area(rect)
            }
            None -> print("Skipping: " + text)
        }
    }
    print(total)
}
Skipping: oops
22

The compiler infers parseRectangle : fun(String) -> Option Rectangle. Each ? unwraps a successful result; a missing separator or invalid integer makes the parser produce None. The caller handles both cases explicitly. Tuple destructuring, named record fields, and an ordinary mutable accumulator work together without type annotations on the functions.

Try it

The compiler is written in Turkey. On an arm64 Mac running macOS 14.4 or later (the runtime sleeps on os_sync_wait_on_address), with a C compiler installed, it builds from the assembly committed in bootstrap/ (for Linux, see below):

sh scripts/build.sh

That leaves a compiler in build/stages/stage2. Save the example as rectangles.gob, then compile and run it:

build/stages/stage2 build -o rectangles.s rectangles.gob
cc -o rectangles rectangles.s
./rectangles

On Linux, link with $TURKEY_CC (below) and add -lm, since glibc keeps the maths functions the library calls out of libc.

Run the compiler from the repository root, where it finds lib/. It has two commands. build writes the program to -o, in the format the file's extension names (only assembly, .s, for now) or -f says; -o - -f asm writes it to standard output. check compiles as far as the last stage that can reject a program and writes nothing. Either takes --dump STAGE,... to print what a stage produced: build/stages/stage2 check --dump types rectangles.gob shows the inferred types, and running the compiler with no arguments lists the stages. build --timings reports on standard error how long each phase took, how the backend's time is spread over functions, and how much of the optimizer's work could run in parallel.

A program's tasks (System.Task, System.Parallel) run on one worker thread per performance core -- every core, on a Mac without performance levels or on Linux -- and the compiler is such a program: it compiles in parallel, and emits the same bytes however many workers it has. TURKEY_WORKERS=N sets the number of workers; TURKEY_WORKERS=1 runs tasks interleaved on the program's own thread, in a fixed order. The test suite sets it to 1 for everything it runs unless a test asks for more, since it runs a process per core already.

The compiler that builds it is bootstrap/, which holds its own arm64 assembly for macOS and for Linux, gzip'd. scripts/build.sh builds for whichever of the two the C compiler links for. sh scripts/build.sh --fixpoint checks the fixed point: the compiler built from today's source emits exactly the assembly it was built from. scripts/bump-bootstrap.sh replaces the committed compiler, which is rare and deliberate; both scripts explain themselves at the top.

The C compiler, and running on another machine

Two environment variables choose the toolchain, for scripts/build.sh and for the tests alike. Each is split into words the way a shell would split it.

  • TURKEY_CC is the C compiler that assembles and links a program. Unset, it is cc.
  • TURKEY_RUN is a prefix for running a program that compiler linked, or a built compiler. Unset or empty, the program runs directly.

On x86-64 Linux, the arm64 output runs under qemu-user. Link it statically, because a dynamically linked arm64 binary would need an arm64 libc to load. Set up the machine with:

apt-get update
apt-get install -y \
    qemu-user-static \
    gcc-aarch64-linux-gnu libc6-dev-arm64-cross \
    python3-pytest python3-pytest-xdist
B=/proc/sys/fs/binfmt_misc
if [ -d $B ] && [ ! -e $B/register ]; then
    mount -t binfmt_misc binfmt_misc $B 2>/dev/null
fi
if [ -w $B/register ]; then
    cat /usr/lib/binfmt.d/qemu-aarch64.conf > $B/register 2>/dev/null || true
fi

Then set TURKEY_CC="aarch64-linux-gnu-gcc -static" and leave TURKEY_RUN empty. The last step of the setup registers qemu with the kernel's binfmt_misc, so an arm64 binary runs as ./program, with no prefix, mounting binfmt_misc first where the container has not. That registration is lost when a machine is restored from a snapshot, so the repository's .claude/settings.json runs the same line as a SessionStart hook. On macOS the hook does nothing. Where binfmt_misc cannot be written, set TURKEY_RUN=qemu-aarch64-static instead. pytest comes from apt here, because Ubuntu 24.04 refuses a system-wide pip install.

With those set, sh scripts/build.sh builds the compiler for arm64 Linux from bootstrap/arm64-linux.s.gz, and --target arm64-linux makes a compiler on any platform emit Linux assembly.

The whole test suite runs and passes this way, pytest -m bootstrap and the fixed point included, and the goldens in tests/programs/ are the same on both platforms. It is slow: qemu runs the compiler about fifteen times slower than an arm64 Mac, so one self-compile takes about 26 minutes rather than 105 seconds. On a 4-core x86-64 machine sh scripts/build.sh takes about 27 minutes and --fixpoint about 80. python3 -m pytest takes about 75 minutes once boot is built and two hours from a cold cache; the few tests that compile the compiler's own source are most of that.

On an arm64 Linux machine none of that is needed: cc links for it and the programs run directly. CI does this on GitHub's arm64 runners (.github/workflows/linux.yml), building from the bootstrap once under gcc and once under clang, and running the suite and the fixed point split across jobs by scripts/ci-shard.sh.

One group of tests depends on the C compiler, and skips, saying why, where it cannot do what it needs: under TURKEY_CC="clang --target=aarch64-linux-gnu -static", the UBSan probes in test_allocators.py and test_runtime_gc.py. Linking -fsanitize=undefined statically needs compiler-rt built for aarch64, which Ubuntu's clang does not ship. gcc's cross toolchain has libubsan.a, so under gcc they run.

Project status

Turkey is an experimental language under active development. The compiler is written in Turkey and compiles itself, with an arm64 backend that emits assembly directly. The standard library is written in Turkey over a small set of runtime primitives. A Python implementation served as the reference while the compiler was ported to Turkey, and was retired once the tests ran against the self-hosted one.

For more depth:

The tests are Python. Install their dependencies with python3 -m pip install -e '.[dev]', then run python3 -m pytest tests -q. The tests compile their programs with boot, which is built from the committed bootstrap on first use; TURKEY_BOOT points them at one you built yourself instead.

The suite runs in parallel by default, and three commands cover it:

python3 -m pytest tests -q            # everything but the fixed point
python3 -m pytest tests -q -m bootstrap   # the fixed point alone
python3 -m pytest tests -q -m ''      # both, in one run

To run both, use the last one rather than the first two in turn. The fixed point is a chain nothing can parallelize -- boot compiling the compiler, linking the result, and that compiling it again, about two minutes a step -- and in one run it overlaps the rest of the suite instead of starting after it. On a 16-core arm64 Mac, after a change to src/ (so boot is rebuilt), the combined run takes about nine minutes. The build of boot and everything the tests compute from it are cached in $TMPDIR by content hash, so a second run with nothing changed is quicker; -n0 runs serially, for debugging.

To check the heap while debugging a program, compile it with boot build --gc-verify. Each collection independently checks object bounds, traced references, shadow-stack and native roots, marking completeness, region bookkeeping before and after sweeping, and the generational invariants: an old object holding a young one must have been remembered by the write barrier. Failures report heap verifier: and terminate the process with exit status 1 before proceeding to the next collection phase. Run it with TURKEY_GC_STRESS=1 to collect and verify at every allocation. At the compiler's own scale that does not finish; TURKEY_GC_STRESS=N collects at random intervals averaging N allocations instead, and TURKEY_GC_STRESS_SEED picks the sequence (1 by default), so a failure can be repeated and several seeds put the collections at different allocations. Under stress one collection in eight is full and the rest are minor; TURKEY_GC_GENERATIONAL=0 makes every collection full, to tell a collector bug from a missed write barrier. Verification scans the allocated heap and uses temporary native memory for a region index. A program compiled without --gc-verify has no verifier in it at all, and says so on stderr if it is run with TURKEY_GC_VERIFY set. It checks the roots and layouts the compiler supplies, so behavioral stress tests are still needed to catch references omitted from those descriptions.

boot build --gc-stats compiles in the collector's accounting: a line per collection and a summary at exit, on stderr. Counting sends every allocation through the runtime rather than the code compiled in line for it, so a stats build's times are slower than a plain one's.

About

WIP statically-typed general-purpose programming language

Resources

Stars

3 stars

Watchers

0 watching

Forks

Contributors

Languages