Skip to content

Releases: ZigoteProject/zsharp

Z# 0.7.0

Choose a tag to compare

@ZigoteProject ZigoteProject released this 05 Nov 13:56

The second public release: what the flagship and the first users asked for, from 0.7's plan
(ROADMAP), without that plan's first item, the checks on machines (below). The compiler is fuzzed in
every run of the suite (the same 300 programs each time) and builds programs as long and as deep as
generated code writes them; a stack overflow is a panic, a throw disposes the uses it passes over,
every value has debug info, and -g keeps the lines of an optimized build. Two memory errors 0.6.2
had are fixed: a value lent to a read or a call that another of its arguments freed first, and a
weak reference taking a later object for the one it pointed to. For the chip: constants in flash,
values placed in named sections (.noinit, a DMA region), fixed-point numbers, weak references and
arenas, and a file's tests run on it. Beside them: type providers (JSON, CSV, SQL, SVD, C headers
and providers of your own), properties with shrinking and zs fuzz, zs bench and zs test --coverage, workspaces, zs vendor and a lock that hashes its dependencies, and track S's small
items (Int's checked, saturating and bit operations, ISO 8601, UDP and name lookup), with ten
Advent of Code puzzles and three small tools as its exit tests.

Archives for Linux, macOS and Windows on x86-64 and arm64, each built on Linux x86-64 by
src/release.sh, whose compiler builds itself to the same bytes. Linux x86-64's suite passes in
the dev container (Fedora 44, LLVM 23), the chips' programs under QEMU and wasm32's under Node
among it, tests/pgo.sh and bench/host too. On Ubuntu 24.04 those two fail on that machine's
tools: tests/pgo.sh on LLVM 20's profile runtime, and bench/host's fib at 5.3% over its
instruction baseline (2,464,794 against 2,339,907, past the 5% the check allows). CI's Linux x86-64 job, in the dev container, failed two more on 2026-11-05, both fixed
here: tests/debug/kinds.zs, whose DWARF LLVM 23's llvm-dwarfdump --verify rejected for a
.debug_str_offsets entry inside a string GNU ld had merged by its tail (the one error the check
now lets by; lld's output has none), and the tree-sitter grammar, which didn't parse a typed _
parameter (let check () (_: Source)). What 0.7's plan put first is undone (ROADMAP, 0.7's item 1):
the flagship isn't flashed to a Nucleo-F401RE yet (it runs under QEMU's STM32F405, which models none
of the board's GPIO), the suite hasn't passed on the five CI runners (at fbf956d, on 2026-11-05,
Linux x86-64's passed and the others' failed: 23 and 24 checks on the Macs, the same 7 as before on
Linux arm64, and the Windows suites printed nothing; what the Macs and Linux arm64 failed on is
addressed after it, not yet confirmed in CI), and no archive has been tried on a clean machine, so
no macOS or Windows build has run on a real machine. Linux arm64's suite under QEMU and Windows
x64's under Wine, which 0.6.2 ran, haven't been run for 0.7, nor has the stress run over the pool,
tasks and Alt under ThreadSanitizer (the suite's four programs under ThreadSanitizer pass). Of the
plan's other items, the fuzzer's seed isn't rotated per run yet (it's 1 unless ZS_FUZZ_SEED says),
and [<Align>] on a type, Seq pipelines as one loop (track S, item 9) and fixed point's vector
forms are left.

Upgrading from 0.6.2

What a 0.6.2 program, project or script may have to change, and what behaves differently:

  • A negative integer literal is a signed integer: let a : u8 = -1, a -1 passed where a u8 is
    wanted and a pattern | -1 at an unsigned type are errors (expected u8, got int; a negative pattern is a signed integer; u8 has no -1), where 0.6.2 negated the positive literal at run
    time, which overflowed, and matched a pattern's value wrapped to the type with nothing said
    (| -1 at u8 matched 255). To rewrite: the value itself (let a : u8 = 255, | 255).
  • A one-case union over plain values has no tag (decision 66): type UserId = UserId of int is
    an int, 8 bytes on a 64-bit target, where 0.6.2 put a 4-byte tag before it (16), so sizeof
    and alignof give its field's, and the records and arrays holding it are smaller. To
    rewrite:
    an assert on such a size.
  • A throw, or a handler clause that stops without resuming, runs Dispose.dispose on what the
    uses it passes over bound, newest first, before the clause runs; 0.6.2 freed them without it.
  • A stack overflow on Linux and macOS (in a program, not under a sanitizer) is a panic,
    panic: stack overflow (recursion too deep) with exit status 134, where it was a SIGSEGV (139
    in a shell).
  • A dependency's module under a directory named internal (at any depth, in either case) is the
    package's own: naming it from another package is an error that lists the package's public
    modules. To rewrite: the import, to one of those.
  • zs.lock gains a hash of each git dependency's files at the next build or fetch (a 0.6.2 lock
    is read as it is, and rewritten then). From then on a checkout edited under .zs-cache/deps/ is
    restored from its commit at the next build, and one that still differs is an error. To
    rewrite:
    a dependency patched in place, as a path dependency (zs add ../json).
  • Testing.Test has two more cases, Bench and Property. To rewrite: a match over a
    Test (a runner of your own), with their arms or | _ ->. A test binary's --list gives a
    fifth field, the entry's kind (test, bench or property).
  • With ZS_LEAKCHECK=1, a test that leaks at its exit fails under zs test; 0.6.2 reported the
    leak and passed it.
  • zs build -g --release, and -g with any -O level, is an optimized build that keeps the
    lines; 0.6.2 built it at -O0. -g alone is still -O0 but on the Cortex-M0+ and wasm32,
    where it's now the optimized build with lines.

Language

  • A negative integer literal is one constant, a signed integer: i32 -2147483648 on a 32-bit
    int target (a chip) was the positive literal, past the type's range, negated, and panicked
    with an overflow (tests/mcu/ints.zs). So let a : u8 = -1 is an error (it overflowed at run
    time), and so is a pattern | -1 at u8, which says so; -0x8000_0000_0000_0000 is i64's
    least, as a value and as a pattern (tests/err_neg_unsigned.zs, tests/err_neg_pattern.zs,
    tests/fold_edges.zs).
  • One-case unions without a tag (decision 66, built): a plain union with one case that has
    fields is the field itself (type UserId = UserId of int is an int in registers, arrays and
    records; sizeof<q15> is 2), or a struct of its fields; matching, equality, show, hash and
    the debug info follow (tests/onecase.zs). One over a heap value (Wrap of string) stays
    boxed, its count in the object. A [<CLayout>] record can't hold one yet (C's view of a
    one-case union is left).
  • F#'s list and array ranges: [ 1 .. 10 ], [ 1 .. 2 .. 9 ] (a step, negative too) and
    [| 0 .. n |], the list expression over the range, so one loop and the list built once, empty
    when the range is (tests/ranges.zs). The README called them not there yet.
  • Typed tuple parameters as F# writes them: let f (a: int, b: string) = … is one parameter of
    type int * string, called f (1, "x"); each part takes its type (the error says so when one
    is missing). The other not-there-yet of the README.
  • use under effects (track S, item 10): a throw, or any handler clause that stops without
    resuming, disposes what the uses it passes over bound, newest first, before the clause runs,
    as a cancelled task does (tests/effects/use.zs). It freed them without their Dispose.dispose.
    Code without effects compiles exactly as before. A resumption a clause keeps and never calls,
    and a stop inside a task { } body, still dispose nothing; a consumer that leaves a generator
    early (Seq.take, Seq.tryFind) stops it, and that disposes.
  • [<Section ".noinit">] and [<Align 32>] on a top-level value (0014 item 12, decision 69):
    a constant table, Buffer.create n x or ref x with constant arguments has its storage in
    the image, in the named section and so aligned (a table's data at a multiple of it after its
    header), with no startup code and nothing allocated. .noinit is neither copied nor zeroed by
    the chip's reset handler (runtime/boards/cortex-m.ld), so a boot counter or a crash log
    survives a reset; a push past a placed Buffer's room moves it to the heap and leaves the
    storage. On a function, a computed value or an alignment that isn't a power of two from 8 to
    4096, an error says what the attribute takes. A section named .noinit… (as the boards'
    scripts' *(.noinit*) takes it) holds a ref or Buffer of plain values only (a string or array
    there would point into memory a reset left), and not a constant (never loaded); a section's
    name is letters, digits, '.', '_' and '$'. The element may be any constant (a fixed-point
    number, a tuple, an option, a table), its own objects constants outside the section. On a
    chip, a ref's or Buffer's storage in a section the board's script doesn't name (a DMA region)
    is copied from flash at startup; a constant table alone in such a section is read-only and
    stays in flash, but one sharing it with a ref or Buffer (RAM, never loaded) or in .bss…
    (zeroed) is an error, as is a ref or Buffer in flash (.text…, .rodata…) or one in .bss…
    that doesn't start at zero (a plain union's first case does), and anything in the board's own
    .vectors or .ARM…. A chip's library has no startup, so its placed values mustn't need that
    copy or a .noinit ref's header (a host's or wasm32's loads its sections like any other). A
    placed Buffer's slots are dropped at exit whatever it did since (written, grown into the heap,
    shortened). A program with placed values builds as one module, so they're placed wherever
    they're declared (tests/placement.zs, tests/placement_drops.zs, tests/err_placement.zs,
    tests/mcu/placement_errors.sh; on b...
Read more

Z# 0.6.2

Choose a tag to compare

@ZigoteProject ZigoteProject released this 29 Oct 20:24

The first public release, for microcontrollers: firmware for a real board, the ST Nucleo-F401RE,
written as tasks on the runtime's loop, with interrupt handlers, registers as [<Bits>] records,
code checked not to touch the heap, and its log over RTT (examples/nucleo). Beside it: package
versions and the orphan rule for impls, Concurrent ML's events for tasks, lldb, doc tests,
zs doctor, allocation hints in the editor, and the guide's page for F# programmers.

Archives for Linux, macOS and Windows on x86-64 and arm64, each cross-built from Linux x86-64.
Linux x86-64's suite passes natively, Linux arm64's under QEMU (its compiler builds itself to
the same bytes), Windows x64's under Wine; the macOS builds and arm64 Windows are untested on
a real machine. The flagship runs under QEMU's STM32F405, not yet on the board itself.

Language

  • What an F# programmer writes in the first hour parses: let y = x + 1 in y * 2; a condition
    continued on a line starting with || or && (and |||, &&&, ^^^); a record on the line
    after its constructor (Some then { x = 1 } indented); match a, b with without
    parentheses. handle is a keyword only where a handler starts, so let handle x = … is a
    function. [<CLayout>] and the other attributes apply to a private type.
  • [<Unsafe>] on a function: only unsafe code may call it (Bytes.writer, which writes
    wherever its pointer points, is one).
  • A top-level Buffer of one type (let private seen: Buffer<int> = Buffer.empty, or a record
    holding one) is one Buffer, computed once at startup, so a push to it is seen at every use; it
    was a new Buffer at each use, and the push was lost. A generic one, Buffer.empty itself, is
    still a new Buffer at each use.
  • The orphan rule (K9): an impl written lives in its trait's package or its type's, so two
    packages can't both write one that a program using both would have twice. To rewrite: an
    impl of the library's trait for the library's type, or for a dependency's: wrap the type in a
    one-case union of your own. A hand-written impl used before it's checked (further on, or in a
    later module) is an error saying to write it before its first use; it was a wrong
    "does not implement" or "already implemented".
  • Patterns at the top level: let (width, height) = (800, 600) declares both names. A tuple of
    constants stays constants; anything else is computed once at startup. A local let's pattern
    takes an annotation (let (a, b): int * string = …), and a let whose pattern can fail says
    so: a let's pattern must match every value, and this one misses None.
  • Layout facts: sizeof<T>, alignof<T> and offsetof<T> "field" are int constants for the
    target, a [<CLayout>] type's as C lays it out. A top-level assert on constants is checked
    when compiling (assert (sizeof<Packet> = 16)), naming what it read when it fails; any other
    top-level assert runs at startup. The header zs build --lib writes checks each struct's size
    with _Static_assert.
  • C unions: a [<CLayout>] union has no tag, each case a view of the same bytes. It's built by
    case (the bytes after the case's field zero) and read by case name inside unsafe
    (unsafe d.U32); it can't be matched, compared or printed. It crosses to C by value as C
    passes a union on every target, sits in [<CLayout>] records, and is declared in --lib
    headers; debuggers show it as C's. Hovering a [<CLayout>] type shows its size and alignment.
  • The cycle warning (0021): a type whose objects can form a reference cycle through a ref,
    Buffer, Mutex or Channel (two ways back, or two types reaching each other), which
    counting never frees, is warned where it's declared, with the path and notes at the other
    types' fields, and the fixes: a weak back-pointer, [<Cyclic>] on a type whose cycles the
    program breaks, or [<NoWarn "cycle">].
  • A public constant that uses a private one of its module works from other modules (it was
    'x' is private to module M at the use).
  • A type private record's fields are private too: outside its module, code can't read, set,
    build or match them, even on a value it reached through a public record's field.
  • A task cancelled at a wait no longer leaks a value it took apart with a tuple pattern
    (let! (x, reply, nack) = …) before that wait.

Platforms

  • The flagship, examples/nucleo: firmware for the Nucleo-F401RE as a project (zs build there
    builds it for the board, zs test tests its shell on the computer), three tasks on the
    runtime's loop: the LED's, the button's (Irq.wait) and a serial shell fed by USART2's
    handler through an Spsc and a Signal, its registers [<Bits>] records, its log over RTT.
    ./test.sh runs it on QEMU's STM32F405, typing at its USART2.
  • Tasks on a microcontroller (0016): the same task { }, Task.run, sleep, both, all,
    race, timeout and scope as on a computer, on the embedded runtime's event loop: timers on
    SysTick, the core asleep (wfi) when nothing runs. Irq.wait n is a task that goes on once
    line n fires; Signal.set from a program's own handler wakes the task in Signal.wait.
    Runtime.setClock hz after changing the core's clock. Task moved from Std to Core (the same
    namespace, so nothing changes on a computer); on a chip, reaching Task.spawn,
    Task.blocking or Task.waitFor is a compile error naming the chain. examples/mcu/uart.zs
    receives a line in a task waiting for the UART's interrupt. Task.fromCallback makes a task
    of a callback, for a driver's own waits.
  • A real board, the ST Nucleo-F401RE (--target nucleo-f401, 0027): linked for its STM32F401RE
    (512 KB of flash at 0x08000000, 96 KB of RAM), with its interrupt lines by name
    ([<Interrupt "USART2">]), and output over RTT, a ring in RAM the debug probe reads while the
    chip runs. zs run --target nucleo-f401 flashes it with probe-rs, which shows the output and
    returns the exit status. With no probe attached, nothing waits: output that doesn't fit is
    dropped, and an exit leaves the core asleep (semihosting's breakpoint would fault there).
    Targets are found by name, so a board shares its core's triple: [target.nucleo-f401] and
    [c.nucleo-f401] are its own.
  • A chip's image keeps its symbol table (zig cc dropped it at -Os) and its entry point (it
    was 0, where a debugger's load jumps).
  • 32-bit Arm Linux (--target arm-linux-musleabihf) builds with 32-bit words, as its runtime
    has: it built with 64-bit ones, linking against a runtime that read them as 32.
  • Interrupt handlers (0012): [<Interrupt 38>], [<Interrupt "UARTRX0">] (the board's names,
    runtime/boards/<board>.irq) or [<Interrupt "SysTick">]/"HardFault"/… on a
    unit -> unit function make it the line's handler over the runtime's weak defaults. A handler
    may reach only what's Share (interrupt handler 'tick' uses the top-level value 'count' : ref<int>, …); Atomic is in Core now (masking interrupts on a Cortex-M0), and Spsc<'a> is a
    queue of one producer and one consumer that neither waits nor locks. Irq.enable, disable
    and pend.
  • Cpu (0012): wfi, wfe, sev, dmb, dsb, isb, nop, enableInterrupts,
    disableInterrupts, one instruction each, and Cpu.critical (fun () -> …), its body run with
    interrupts masked (it nests). On a computer the barriers are fences and the rest nothing; masking
    is an error there.
  • [<Bits>] records (0013): a record that is one unsigned integer, its fields ranges of its
    bits (bool, enums, u8 to u64, [<Width n>] and [<Bit n>], the first field
    attributes): { ue = true; te = true } is one constant, a read a shift and a mask,
    { r with f = v } a masked write keeping the gaps; compared as the integer, printed as the
    record. Bits.toU32, ofU32 and the other widths, raw and ofRaw.
  • [<NoAlloc>] and --no-heap (0012): a function that, or anything it calls, may allocate or
    free is an error naming the chain (reply is [<NoAlloc>], but reply -> describe builds a string); interrupt handlers and Cpu.critical's bodies are checked the same way.
    --no-heap checks the whole program, startup values included, and builds a chip's image with
    no heap.
  • A string built with + (an interpolation is one) is one allocation: its pieces are joined at
    once, where each + made a string of its own ($"{name}: {qty}\n" made five, and makes one
    beside qty's text). An empty literal piece is left out, and a piece alone is the string
    itself, so $"{s}" allocates nothing and passes [<NoAlloc>].
  • [<NoAlloc>] passes code that frees nothing: dropping a value matched to a case without
    fields (a static cell), an option's Some s keeping its string, and string of a string (its
    own text). Its errors name an operator as written (Ledger.<+, which showed as Ledger.) and
    a dropped local without the compiler's numbering ('o', not 'o.8').
  • A function that makes a span over a parameter (Array.fold f 0 xs over an array) borrows it.
  • Runtime.ticks on a chip counts a SysTick wrap its handler hasn't run for yet, and never goes
    back (it could, by a wrap, right at one).
  • An [<Export>] function returning unit compiles (the compiler panicked in its C wrapper).
  • C interop on a microcontroller, both ways: structs by value follow AAPCS (they were passed
    with x86-64's rules), hard and soft float; [<Link>] C sources are compiled for the chip; C
    callbacks work there. zs build --lib --target cortex-m4 makes an archive and its header for a
    C firmware, which takes the library's output, stop and ticks by defining zs_write,
    zs_stop and zs_ticks.
  • WebAssembly: [<Link>] C sources are linked in, and --lib -o lib.a makes an archive for a C
    program built for WebAssembly.
  • import c brings C unions in, a member a case named through its union (epoll_data.fd 3,
    unsafe ev.data.fd), and a str...
Read more

Z# 0.6.1

Choose a tag to compare

@ZigoteProject ZigoteProject released this 25 Oct 11:44

The first release for more than Linux: the compiler for Linux x86-64, macOS arm64 and
Windows x64, each built by the macOS one (src/release.sh).

Platforms

  • zs build --target takes aarch64-macos, x86_64-macos, x86_64-windows-gnu,
    aarch64-windows-gnu, aarch64-linux-gnu and wasm32 (freestanding, with a Node host):
    clang compiles the IR, zig cc the runtime and the link, no SDK.
  • The compiler runs on macOS and Windows. On Windows, a build without --target goes through
    mingw-w64; file names, arguments and the environment are UTF-8; paths work across drives.
  • The runtime's loop on epoll, kqueue and IOCP (wepoll); sockets, processes and pipes on Windows.
  • Cross builds are the same bytes each time; ThinLTO caches on macOS.

Compiler

  • Every mistake of a declaration is reported, not only its first.
  • The back half of the compiler runs on all cores; it builds itself faster than the F# compiler did.
  • Optimized builds drop what nothing reaches: a hello world is 17 KB, not 89.
  • import c works from a fresh clone, and for a target finds its system headers.
  • A project's modules are found from its main's folder, in any subfolder.

Editor

  • LSP: a function's signature at the end of its line; hover, references and definition of
    type names.

Library

  • Task.run waits for the spawned tasks it cancelled.

Fixes

  • A dependency's name can't reach outside .zs-cache/deps; a rev starting with - isn't
    passed to git as an option; MCP tools refuse symbolic links.
  • A file with a top-level expression reports a parse error instead of panicking.
  • The Windows runtime builds with newer mingw-w64 headers, which declare mkdtemp.

Other

  • MIT license; examples/logstat, a Z# tour for F# developers.

Z# 0.6

Choose a tag to compare

@ZigoteProject ZigoteProject released this 24 Oct 17:17

The compiler in Z#, beside the F# one (the last version with both). Stage 0 for building src/: ./dev fetches zs-0.6-linux-x86_64.tar.gz from here.