Skip to content

Repository files navigation

ZV Logo

ZV Programming Language

ZV is a small, statically typed systems language I designed that compiles to LLVM IR. It is intentionally opinionated: I wanted a language that gives me explicit control over memory, resources, and hardware, keeps the syntax close to C, and treats native interop as a first-class concern rather than an afterthought.

The compiler is written in C# (.NET 10) using LLVMSharp. The frontend and a target-independent LLVM backend support normal hosted processes:

  • Hosted executable (-target x86-32-hosted|amd64-hosted) — compiled with Clang into a normal Windows or Linux process.
  • Shared library (-target x86-32-hosted|amd64-hosted ... lib) — compiled with Clang into a Windows DLL or Linux shared object, exposing only functions marked export.

Table of Contents


Contributing

Bug reports, feature requests, and pull requests are welcome. Open an issue or start a discussion on the GitHub repository — there are no special requirements or CLAs, just keep things constructive.


Philosophy

ZV is C minus the bullshit.

I wanted raw performance, fast execution time, and direct access to the hardware without the years of legacy bullshit that come with it: no C header bureaucracy, no implicit casts, no int booleans, no dangling pointers that, when chased down manually to track down bugs, only prove that nothing should be said to imply that a programming language from 1972 doesn't need its variables bound-checked. Memory and resource allocation are included in the code; the compiler will warn me if I do anything dumb, and the generated LLVM IR code still resembles my original source code enough for me to figure out what the machine is really doing.

I have no inclination to type up something resembling a parsing test for the syntax of modern systems programming languages, even those with followers like Rust and Zig. C syntax is unremarkable, and that is precisely the point.

Opening a file and handling the error in Rust is

let f: Result<File, std::io::Error> = File::open("missing.txt");

in ZV it's just

try {
    PTR<VOID> f = fopen("missing.txt", "r");
} catch (e) {
    print("Caught: %s", e.message);
}

and a heap-allocated array of ints in C++ is

std::vector<int> nums = {1, 2, 3, 4};

versus ZV's

INT32[] nums = [1, 2, 3, 4];

Neither of those Rust/C++ snippets is exotic, they're both ordinary code you'd write ten times a day, but the Result<T, E> and std::vector<T> wrapping is still there every single time.

Calling into a native library is the same story. Rust needs an unsafe block, an extern "C" binding, and a manual CString conversion just to call MessageBoxA:

use std::ffi::CString;

#[link(name = "user32")]
extern "C" {
    fn MessageBoxA(hwnd: *mut c_void, text: *const i8, caption: *const i8, utype: u32) -> i32;
}

let text = CString::new("hi").unwrap();
let caption = CString::new("ZV").unwrap();
unsafe { MessageBoxA(std::ptr::null_mut(), text.as_ptr(), caption.as_ptr(), 0) };

ZV's extern block reads like the Win32 header it's binding, and string conversion is a single cstr() call with no unsafe:

extern "user32.dll" {
    INT32 MessageBoxA(PTR<VOID> hwnd, CSTRING text, CSTRING caption, UINT32 type_val);
}

MessageBoxA(0, cstr("hi"), cstr("ZV"), 0);

Native interop is a first-class concern in ZV rather than afterthoughts bolted on with unsafe and FFI crates.

Propagating an error through a few calls is another everyday case. C makes you check a return value or errno after every call and bubble it up by hand:

FILE *f = fopen(path, "r");
if (!f) {
    fprintf(stderr, "open failed: %s\n", strerror(errno));
    return -1;
}
if (fseek(f, 0, SEEK_END) != 0) {
    fprintf(stderr, "seek failed: %s\n", strerror(errno));
    fclose(f);
    return -1;
}

Rust replaces the manual checks with ?, but every function in the chain now has to return Result<T, E> and thread the error type through its signature:

fn read_size(path: &str) -> Result<u64, std::io::Error> {
    let mut f = File::open(path)?;
    f.seek(SeekFrom::End(0))?;
    Ok(f.stream_position()?)
}

ZV lets the calls fail naturally and catches the failure once, wherever it's convenient, without changing every function signature along the way:

try {
    PTR<VOID> f = fopen(path, "r");
    fseek(f, 0, 2);
    INT64 size = ftell(f);
} catch (e) {
    print("Failed: %s", e.message);
}

The memory bugs that make C miserable to debug are the same handful every time, so ZV catches them structurally instead of relying on the programmer's memory of where every buffer ends.

An out-of-bounds write in C just corrupts whatever's next to the array, silently:

int a[4];
a[10] = 5;   // undefined behavior, no error, corrupts nearby memory

ZV bounds-checks array access by default, so the same mistake is a compile error for a constant index and a catchable exception for a variable one:

INT32[4] a;
a[10] = 5;          // compile error: index 10 out of bounds for length 4

INT32[] nums = INT32[10];
INT32 i = 100;
nums[i] = 1;        // runtime IndexOutOfBoundsException

Use-after-free is another classic: C happily lets you keep using a pointer after free(), and the bug only shows up later as a crash or corrupted data:

int *nums = malloc(10 * sizeof(int));
free(nums);
nums[0] = 1;   // undefined behavior, no diagnostic

ZV tracks whether a variable has been freed or moved and refuses to compile if it's used afterward:

INT32[] nums = INT32[10];
free(nums);
nums[0] = 1;   // compile error: 'nums' was already freed
free(nums);    // compile error: 'nums' was already freed

Because ZV frees owned heap allocations automatically at the end of their scope, most code never has to call free() at all, which removes the double free and use-after-free mistakes before they can be written, not just after.

But, again, I am no masochist. ZV includes things that, for reasons of convenience, I happen to want: built-in try/catch/throw exceptions so that I do not have to write every function in error-code chains, newtype for confusing units without paying the cost of a runtime wrapper, and cross-platform builtins such as print, len, cstr, get_timestamp, file I/O, threads, and mutexes—so that I do not have to repeatedly link and wrap libc/pthreads/Win32 by hand.


Quick Start

Install

Pre-built binaries are available on the Releases page. You do not need the .NET SDK to use a release build; only Clang is required to link executables and libraries.

Windows (MSI installer)

Two x64 MSI installers are provided:

  • ZV-Setup-x64.msi — installs for the current user only, into %LOCALAPPDATA%\ZV, and adds it to your user PATH. Does not require admin.
  • ZV-Setup-x64-AllUsers.msi — installs for all users, into C:\Program Files (x86)\ZV, and adds it to the system PATH. Requires admin.

Both MSIs are framework-dependent x64 packages and require the .NET 10 Runtime (x64) to already be installed. During setup you can opt in to installing the optional build tools: Scoop plus LLVM (which provides clang and lld). These tools are required if you want to compile ZV executables and DLLs.

Download the MSI you want and run it. Then open a new terminal and run:

zv checkdeps

Windows (portable zip)

Download ZV-win-x64.zip, extract it, and run the included installer:

.\setup\install.bat

This copies ZV.exe and the standard lib/ folder into %LOCALAPPDATA%\ZV and updates your user PATH. To uninstall, delete that folder and remove it from PATH.

Linux

Download ZV-linux-x64.tar.gz, extract it, and run:

./setup/install.sh

This installs the zv binary to ~/.local/bin and the standard library to ~/.local/share/zv/lib, then ensures ~/.local/bin is on your PATH. To uninstall, remove those paths.

Arch Linux users can use the provided AUR PKGBUILDs in packaging/aur/ instead:

  • zv-bin installs the latest release tarball (depends: clang, lld).
  • zv-git builds from source (makedepends: dotnet-sdk, git; depends: clang, lld).

The POSIX install script will also work on macOS if you build from source.

Standard library includes

Once installed, headers from the shipped lib/ folder can be included with angle brackets:

#include <lib/prng.zv>

Local project files still use quotes:

#include "common.zv"

You can add extra system include directories with the ZV_INCLUDE_PATH environment variable, using the platform path separator (; on Windows, : on Unix).

Prerequisites

  • Clang in your PATH (needed for hosted exe and lib linking)

To build the compiler itself from source you also need the .NET 10 SDK.

Once installed, run ZV checkdeps (or dotnet run -- checkdeps from source) to verify every tool the compiler needs is discoverable on PATH. See Checking Toolchain Dependencies.

Build and Test

dotnet build
dotnet test

Compile a ZV Program

# Compile a single file to LLVM IR (native hosted target by default)
dotnet run -- hello.zv

# Compile and link a hosted executable for the native target
dotnet run -- hello.zv -o hello.exe

# Compile and link a shared library (.dll on Windows, .so on Linux)
dotnet run -- mylib.zv -target x86-32-hosted -o mylib.dll lib

Project Layout

ZV/
├── Program.cs                    # CLI driver and target linkers
├── Compiler/
│   ├── Lexer/                    # Tokenizer
│   ├── Parser/                   # Recursive-descent parser
│   ├── AST/                      # AST node definitions
│   ├── Backend/                  # LLVM IR generator and builtins
│   │   ├── LlvmGenerator.cs
│   │   ├── LlvmGenerator.Builtins.cs
│   │   ├── LlvmGenerator.Curses.cs
│   │   ├── LlvmGenerator.Cpu.cs
│   │   ├── LlvmGenerator.Framebuffer.cs
│   │   ├── LlvmGenerator.Freestanding.cs
│   │   ├── LlvmGenerator.Ps2.cs
│   │   ├── LlvmGenerator.Serial.cs
│   │   └── LlvmGenerator.Vga.cs
│   └── Tests/                    # xUnit parser and backend tests
└── .github/workflows/            # CI build and release

CLI Usage

ZV <file or directory> [-o output] [-target <triple>] [-L libdir]... [-O|--optimize] [-copt O0|O1|O2|O3|Os|Oz|list] [-v|--verbose]
ZV checkdeps
Flag Description
-o Output path. .exe/no extension forces a linked executable on hosted targets.
-target <triple> Target triple such as x86-32-hosted or amd64-hosted. Defaults to a native hosted triple.
-L <dir> Add a directory to the linker's library search path. Repeatable.
-O, --optimize Run LLVM's in-process optimization pipeline (mem2reg, instcombine, simplifycfg, reassociate, gvn) before emitting. Opt-in; off by default.
-copt <level> Optimization level passed to clang as -O<level> when linking (O0, O1, O2, O3, Os, Oz). Defaults to O2. Use -copt list to print the available levels.
-v, --verbose Print each compiler stage (lexing/parsing per file, codegen, optimization passes, emission, linking) with timing, prefixed [verbose].

When a directory is passed, the compiler recursively scans for .zv files and compiles them as a single module. #include is also supported inside a file.

Checking Toolchain Dependencies

ZV checkdeps

Scans PATH for the external tools the compiler shells out to and reports which ones are available, without invoking any of them:

Tool Required Used for
clang Yes Compiling/linking hosted exe/lib targets.
llvm-readobj No Reading a DLL's export table (for extern "path/to.dll").
llvm-dlltool No Generating a Windows import library from a DLL's exports.

Exits with a non-zero status if a required tool is missing.


Language Syntax

ZV source files use the .zv extension. The grammar is intentionally small and close to C because I wanted the language to be immediately readable to anyone who knows C, without piling on the syntax extensions that make other systems languages feel like a different language every six months.

Comments and Literals

// Line comment
/* Block comment */

42          // INT32 literal
1_000_000   // digit separators are allowed in numeric literals
0xFF_FF     // hex literals may also use underscores
3.14        // FLOAT64 literal
1_000.000_001 // underscores work in floats too
"hello"     // STRING literal
'A'         // CHAR literal
true false  // BOOL literals
null        // null pointer value

Underscores are ignored when the literal is parsed, so 1_000_000 is exactly 1000000.

String escape sequences: \n, \t, \r, \\, \", \0.

Types

Primitive types are case-insensitive:

Category Types
Signed integers INT8, INT16, INT32, INT64, INT128
Unsigned integers UINT8, UINT16, UINT32, UINT64, UINT128
Floating point FLOAT32, FLOAT64
Other BOOL, CHAR, VOID
String STRING (UTF-8 bytes, immutable, length-aware: { i8*, i64 })
C string CSTRING (NUL-terminated i8*)
Wide string WSTRING (NUL-terminated UTF-16 i16*)
Typed pointer PTR<T> (pointer to T; PTR<VOID> is an opaque i8*)
Function pointer FUNCPTR<ReturnType(ParamType, ...)> (pointer to a function with that signature; see Function Pointers)
Dynamic arrays INT32[], CSTRING[], etc. (fat pointer: { T*, i64 })
Fixed-size arrays INT32[64], etc. ([64 x T] stack value)
User-defined struct Point { ... }

STRING is a length-aware UTF-8 value ({ i8*, i64 }) that does not assume a NUL terminator; CSTRING is a plain NUL-terminated i8* used for C interop; WSTRING is a NUL-terminated UTF-16 i16* used for Windows wide-character APIs. Dynamic arrays (T[]) are fat pointers to heap-allocated memory. Fixed-size arrays (T[N]) are stack-resident LLVM array values ([N x T]).

Variables

INT32 x = 10;
UINT64 big;
FLOAT32 pi = 3.14;
BOOL enabled = true;
STRING name = "ZV";
CSTRING ptr = cstr(name);
WSTRING wptr = wstr(name);
CHAR c = 'A';
CONST INT32 MAX = 100;   // Constant, requires initializer (lowercase `const` also works)

INT32[] nums = [1, 2, 3];

INT32[64] stackArr;           // fixed-size stack array, zero-initialized
INT32[64] filled = 5;         // all elements = 5
INT32[4] explicit = [1, 2, 3, 4];
INT32[] heapArr = INT32[64];  // heap array, zero-initialized
INT32[] heapFilled = INT32[64](7);

Global variables are emitted as LLVM globals; local variables live on the stack (alloca).

Functions

INT32 add(INT32 a, INT32 b) {
    return a + b;
}

@entry
UINT32 main(CSTRING[] args) {
    print("Hello, world!");
    return 0;
}

@entry marks the program entry point. For hosted targets it should accept CSTRING[] args and return an integer.

export INT32 add(INT32 a, INT32 b) {
    return a + b;
}

export marks a function as part of the public ABI of a shared-library build. It has no effect for executables. See Compilation Targets.

Control Flow

if (x > 0) {
    print("positive");
} else {
    print("non-positive");
}

while (x < 10) {
    x = x + 1;
}

do {
    x = x - 1;
} while (x > 0);

for (INT32 i = 0; i < 10; i = i + 1) {
    print(i);
}

break;
continue;
return;
return x;

switch (x) {
    case 1:
        print("one");
    case 2, 3:
        print("two or three");
        break;
    default:
        print("something else");
}

switch is one deliberate departure from C: a case does not fall through into the next one by default. Each case implicitly "breaks" at the end of its body - the classic missing-break bug simply can't happen - unless you opt into falling through explicitly with fallthrough;:

switch (x) {
    case 1:
        print("one");
        fallthrough;   // explicitly cascade into case 2's body
    case 2:
        print("one or two");
    default:
        print("default only runs for other values");
}

Multiple values can share a body with comma-separated cases (case 2, 3:), and stacked labels with no statements between them (case 2: case 3:) also share a single body. case values must be constant integer, BOOL, or CHAR literals (optionally negated), and the discriminant must be an integer, BOOL, or CHAR expression. break exits the nearest enclosing switch or loop (whichever is innermost); continue always targets the nearest enclosing loop, skipping over an enclosing switch, exactly as in C.

Operators

Precedence Operators Description
Highest ++, -- (postfix) Postfix increment/decrement
++, -- (prefix), -, !, ~ Prefix increment/decrement, unary minus, logical not, bitwise NOT
as Type cast
*, /, % Multiplication, division, modulo
+, - Addition, subtraction
<, <=, >, >= Comparisons
==, != Equality
& Bitwise AND
^ Bitwise XOR
| Bitwise OR
<<, >> Bitwise shift left, shift right (logical, not sign-extending)
&& Logical AND (short-circuiting)
|| Logical OR (short-circuiting)
?: Ternary conditional
Lowest =, +=, -=, *=, /= Assignment and compound assignment
INT32 a = 5;
a++;              // postfix: returns old value, increments a
++a;              // prefix: increments a, returns new value

UINT32 flags = 0xFF;
UINT32 inverted = ~flags;         // bitwise NOT

BOOL ok = (x > 0) && (y < 100); // short-circuiting
INT32 sign = (x < 0) ? -1 : 1;  // ternary
INT32 mask = flags & 0x0F;        // bitwise AND
INT32 bits = flags | 0x80;        // bitwise OR
INT32 toggle = flags ^ 0x01;      // bitwise XOR
UINT32 shifted = flags << 3;      // shift left
UINT32 unshifted = flags >> 3;    // shift right (logical: zero-filled, regardless of signedness)

total += value;                 // compound assignment: total = total + value
i -= 1;
scale *= 2;
count /= 4;

&& and || are short-circuiting: the right-hand side is only evaluated if needed. The ternary operator ?: also only evaluates the branch that is taken. Compound assignments (+=, -=, *=, /=) desugar to a regular assignment with the corresponding arithmetic operator.

Structs and Arrays

struct Point {
    INT32 x;
    INT32 y;
}

packed struct Compact {
    INT8 a;
    INT8 b;
}

VOID demo() {
    Point p;
    p.x = 10;
    p.y = 20;
    print(p.x + p.y);

    INT32[] values = [1, 2, 3, 4];
    values[0] = 100;
    print(len(values));   // returns INT64
}

Struct literals

A struct value can be built with a named-field literal, either with an explicit type name or, when the target type is already known from context (a variable or field declaration), in a shorter bare-brace form:

struct Vec2 {
    FLOAT32 x;
    FLOAT32 y;
}

struct Sprite {
    CSTRING name;
    Vec2 position;
    Vec2 scale;
}

// Explicitly typed - can be used anywhere (call arguments, return values, ...)
// since the literal carries its own type.
Sprite a = Sprite {
    name = cstr("player"),
    position = Vec2 { x = 320.0, y = 240.0 },
    scale = Vec2 { x = 1.0, y = 1.0 }
};

// Bare-brace form - the type is inferred from the declared type of the
// variable/field being initialized, so it can be omitted, including for
// nested fields.
Sprite b = {
    name = cstr("enemy"),
    position = { x = 0.0, y = 0.0 },
    scale = { x = 1.0, y = 1.0 }
};

Fields not mentioned in a literal are left zero-initialized (see Ownership: move and copy for why this matters for struct fields that own heap memory).

Arrays

ZV has two deliberately different array kinds. That is an intentional design choice: stack arrays and heap arrays have different lifetimes, performance characteristics, and ownership rules, and I want the type system to make that distinction visible instead of hiding it behind a single abstraction.

Dynamic arrays: T[]

A fat pointer { T*, i64 } to heap-allocated memory. The programmer owns the allocation. When an owning variable goes out of scope, the compiler inserts a matching free() automatically — this is deterministic scope-based cleanup, not garbage collection. Explicit free(x) is still allowed for early release and for values that outlive their declaring scope.

INT32[] nums = [1, 2, 3, 4];
INT32[] zeros = INT32[64];          // 64 zeroed heap elements
INT32[] sevens = INT32[64](7);      // 64 heap elements filled with 7

print(len(nums));   // INT64 length
// No explicit free needed here — 'nums', 'zeros', and 'sevens' are freed
// automatically when the block ends.

Dynamic arrays can be returned from functions; ownership is transferred to the caller, so the local variable is not freed. move() makes the transfer explicit:

INT32[] create_numbers() {
    INT32[] numbers = INT32[64];
    return numbers;          // ownership returned to caller
}

INT32[] create_more() {
    INT32[] more = INT32[64];
    return move(more);       // explicit transfer, same effect
}

Fixed-size arrays: T[N]

A value type that lives in the current stack frame. It is represented in LLVM as [N x T]. No free() is needed — the storage disappears when the scope ends.

INT32[64] numbers;                  // zero-initialized
INT32[64] values = 5;               // all elements = 5
INT32[4] explicit = [1, 2, 3, 4];   // exact count required

numbers[0] = 42;

Returning a fixed-size array from a function is an error because it would return a pointer to dead stack space.

Multidimensional arrays

Fixed-size arrays nest: each bracket adds an inner dimension, so T[W][H] is an array of H arrays of W elements. For example, INT32[3][2] is a 2-row by 3-column matrix (stored as [2 x [3 x i32]]).

INT32[3][2] matrix;                 // zero-initialized 2x3 matrix
INT32[3][2] matrix = [[1, 2, 3],
                     [4, 5, 6]];   // explicit nested initializer
INT32[3][2] filled = 7;             // every scalar element set to 7
INT32[3][2] partial = [[1, 2],      // missing slots are zero-filled
                       [3]];

matrix[0][1] = 10;                  // row 0, column 1
matrix[1][2] = matrix[0][0] + 5;

Whole fixed-size arrays can be copied by assignment, passed to functions, and queried with len(). Rows can be sliced and passed by reference.

VOID sumRow(INT32[3] row) { }

INT32[3][2] a = [[1, 2, 3], [4, 5, 6]];
INT32[3][2] b = a;                  // by-value copy
sumRow(a[0]);                        // pass first row by reference

print(len(a));                      // outer dimension: 2
print(len(a[0]));                   // inner dimension: 3

array_copy() works with fixed-size arrays as well, copying raw elements in a single block:

INT32[3] src = [1, 2, 3];
INT32[3] dst;
array_copy(dst, src);

INT32[3][2] m1 = [[1, 2, 3], [4, 5, 6]];
INT32[3][2] m2;
array_copy(m2, m1);

A contiguous heap-allocated matrix can be built as a dynamic array of fixed-size rows. Memory is one flat allocation; array_copy() can fill individual rows:

INT32[3][] rows = INT32[3][2];      // 2 rows of 3 columns, contiguous
rows[0][1] = 42;
array_copy(rows[1], [7, 8, 9]);
print(len(rows));                   // 2
print(len(rows[0]));                // 3

Dynamic arrays can also be jagged, where each row is its own allocation:

INT32[][] grid = [[1, 2, 3],
                 [4, 5, 6]];

grid[0][1] = 99;                    // row 0, element 1
print(len(grid));                   // rows: 2
print(len(grid[0]));                // length of row 0: 3

All forms are bounds-checked: an out-of-range index produces IndexOutOfBoundsException at runtime, or a compile error if the index is a provably out-of-bounds constant.

Casts

Casts use the as keyword:

CSTRING ptr = cstr(name);
STRING s = ptr as STRING;
INT32 small = big as INT32;
BOOL flag = x as BOOL;

Supported conversions:

  • Integer ↔ integer of a different width (truncation or sign-extension)
  • Integer ↔ floating point (FLOAT32/FLOAT64)
  • Floating point ↔ floating point of a different width
  • Pointer ↔ pointer (bitcast)
  • Integer ↔ pointer (only inside unsafe { ... })
  • Any integer, float, or pointer → BOOL (tests for non-zero / non-null)
  • BOOL → integer or floating point (true becomes 1, false becomes 0)
  • Dynamic array (T[]) → element pointer
  • Dynamic array (T[]) → dynamic array with a different element pointer type
  • STRING → CSTRING (raw reinterpretation: extracts the data pointer without copying or NUL-terminating it; prefer cstr(s), which makes a safe, NUL-terminated copy, unless you know the STRING's buffer is already NUL-terminated)
  • CSTRING → STRING (measures length with strlen)

Unsupported or nonsensical combinations (for example, casting a struct to an integer or an array value to a float) produce a compile-time error with a clear message describing the source and target types.

Extern Bindings

extern "user32.dll" {
    INT32 MessageBoxA(PTR<VOID> hwnd, CSTRING text, CSTRING caption, UINT32 type_val);
    INT32 msg_box(PTR<VOID> hwnd, CSTRING text, CSTRING caption, UINT32 type_val) = "MessageBoxA";
}

A bare library name (no / or \) is forwarded to the linker as -l<libname> (minus any .dll/.lib/.so suffix) and resolved through the linker's default search paths and any directories added with -L. The optional = "native_symbol" clause maps a ZV name to a different C symbol.

Linking a non-standard library

If the library name contains a path separator, it's treated as a path to a concrete file and passed straight to the linker instead of -l<name> — this is how you link against a DLL/.so that isn't installed anywhere on the system's default search path:

extern "./vendor/mylib.dll" {
    INT32 my_add(INT32 a, INT32 b);
}

Notes:

  • On Linux, a path to a .so links directly, since ELF shared objects carry their own symbol table that the linker can read.
  • On Windows, lld-link cannot link directly against a .dll — it needs the companion import library (.lib) that's normally produced alongside a DLL when it's built. If a .lib with the same name sits next to the .dll, it's used automatically. Otherwise, the compiler generates one on the fly from the DLL's export table (via llvm-readobj --coff-exports + llvm-dlltool, both of which ship with LLVM/Clang) and caches it as <dll>.generated.lib next to the DLL, regenerating it only if the DLL changes.
  • You can also point directly at an existing .lib (extern "./vendor/mylib.lib") to skip export-table generation entirely.

Function Pointers

A huge fraction of real-world C APIs take a callback: qsort's comparator, signal's handler, Win32's EnumWindows/WNDPROC, and so on. FUNCPTR<ReturnType(ParamType, ...)> is a pointer to a function of that exact signature, so those bindings can be written and called the same way as any other extern function instead of being special-cased:

INT32 add(INT32 a, INT32 b) {
    return a + b;
}

VOID demo() {
    FUNCPTR<INT32(INT32, INT32)> op = add;   // a bare function name decays to its address
    print("%d", op(3, 4));                    // real indirect call, checked against the signature
}

A bare reference to a top-level function (used as a value, not called) decays to its address, which is bitcast-compatible with any pointer type - including a generic PTR<VOID> callback parameter - via assignment, as, or a function call argument, the same way thread_spawn() takes a worker function internally:

extern "msvcrt.dll" {
    FUNCPTR<VOID(INT32)> signal(INT32 sig, FUNCPTR<VOID(INT32)> handler);
}

VOID on_interrupt(INT32 sig) {
    print("caught signal %d", sig);
}

signal(2, on_interrupt);

Calling through a FUNCPTR<...>-typed variable (as op(3, 4) does above) is a real indirect call: the argument count and types are checked against the declared signature at compile time, just like an ordinary function call.

Directives

#include "common.zv"       // textual include with cycle detection
#define BUFFER_SIZE 1024    // simple macro replacement

@entry
UINT32 my_main(CSTRING[] args) {
    return 0;
}
  • #include "path" — Textually includes another .zv file at this location. Includes are tracked recursively and cyclic includes are ignored to prevent infinite expansion.
  • #define NAME value — Simple textual macro replacement. Any identifier that matches NAME is replaced by value before parsing. There is no parameterization or conditional compilation; it is a straightforward token substitution.

#include Directives

#include is textual: the contents of the included file are expanded in place, exactly like C/C++. ZV supports two forms that differ in how the path is resolved.

Local includes use double quotes:

#include "common.zv"

The compiler searches relative to the directory containing the file that contains the #include, then relative to the current working directory. Use this for project-local headers.

System includes use angle brackets:

#include <lib/prng.zv>

The compiler searches the configured system include directories. By default this includes:

  • A lib/ folder next to the zv binary (portable install).
  • The per-user install location (%LOCALAPPDATA%\ZV on Windows, ~/.local/share/zv on Linux).
  • Common system-wide locations on Linux (/usr/lib/zv, /usr/share/zv, /usr/local/lib/zv, /usr/local/share/zv).
  • Any directory listed in the ZV_INCLUDE_PATH environment variable, using the platform path separator (; on Windows, : on Linux).

So after a normal install, #include <lib/prng.zv> finds the shipped standard library without any extra flags.

You can also jump from an #include to its target file in the language server (Ctrl+Click / Go to Definition).

Attributes

Attributes use the @ prefix and appear before the declaration they modify:

  • @entry — Marks the following function as the program entry point. For hosted targets it must accept CSTRING[] args and return an integer type.
  • @export — Marks a function as part of the public ABI (same as export keyword).
  • @packed — Marks a struct with no padding between fields (same as packed keyword).

Built-in Functions

Built-ins are recognized by name and do not need an extern declaration.

Hosted / General

Function Description
print(...) Print to stdout. If the first argument is a string literal it is used as a printf format; otherwise a format is inferred from the argument types. STRING values print as length-delimited UTF-8. When a literal format string is given, both the argument count and each argument's type are checked against the format specifiers at compile time (see Compiler Diagnostics) - a mismatch (wrong count, %d given a STRING, %s given a raw STRING instead of a CSTRING, ...) is a compile error instead of the garbage output/crash it would be in plain C.
len(s) Returns the length of a STRING or dynamic array (INT64).
cstr(s) Converts a STRING to a CSTRING by allocating a fresh, NUL-terminated heap copy of its bytes (a no-op passthrough if s is already a CSTRING). Bound directly to a variable it is owned and freed at end of scope; used inline it is freed automatically after the enclosing statement. See Strings.
wstr(s) Converts a STRING or CSTRING to a WSTRING by allocating a fresh, NUL-terminated UTF-16 copy using MultiByteToWideChar(CP_UTF8). Passing an existing WSTRING returns it unchanged. Lifetime is handled the same as cstr(). Currently only supported on Windows.
array_copy(dest, src) Copies all of src into the start of dest (both dynamic arrays of the same element type). Uses memmove, so aliased/overlapping arrays copy correctly. Throws ArrayCopyException at runtime if src is longer than dest.
array_copy(dest, dest_offset, src, src_offset, count) Copies count elements starting at src_offset in src into dest starting at dest_offset. Throws ArrayCopyException at runtime if either range is out of bounds. In both forms, mismatched element types, negative offsets/counts, or an out-of-range access that's provable from array literals at the call site are rejected at compile time.
alloc(INT64 size) Allocate memory with malloc (PTR<VOID>). Throws OutOfMemoryException if allocation fails.
free(value, ...) Free heap memory early. Accepts a pointer, a STRING, or a dynamic array (fat pointer); extracts the data pointer automatically. Owned dynamic arrays are normally freed automatically at the end of their scope, so free() is only needed for early release or non-owning pointers.
copy(x) Returns a bitwise copy of x; only valid for non-owning types. See Ownership.
move(x) Returns the value of x and invalidates the source variable (ownership transfer). See Ownership.
get_timestamp() Unix epoch seconds (INT64).
get_timestamp_ms() Unix epoch milliseconds (INT64).
Exception(STRING message) Constructs an Exception value carrying message. See Exception Handling.
respawn() Relaunches this program as a new process; returns a PROCESS. See Processes: respawn().
exit(INT32 code) Terminates the current process immediately with code (libc exit()).

File I/O (thin wrappers around C stdio):

Function Description
fopen(path, mode) Opens a file (PTR<VOID>), like C fopen. Throws FileOpenException on failure.
fclose(f) Closes a file handle. Throws FileCloseException on failure.
fread(buffer, size, count, f) Reads from a file into buffer, returns bytes read (INT64).
fwrite(buffer, size, count, f) Writes buffer to a file, returns bytes written (INT64).
fseek(f, offset, whence) Seeks within a file. Throws FileSeekException on failure.
ftell(f) Returns the current file position (INT64). Throws FileException on failure.
feof(f) Returns non-zero if the stream has reached end-of-file (INT32).
ferror(f) Returns non-zero if the stream's error indicator is set (INT32).
fgets(buf, n, f) Reads up to n-1 characters into buf, stopping at newline or EOF. Returns buf on success or null on EOF/error.
fputs(str, f) Writes a NUL-terminated string to a stream. Returns INT32; non-negative on success, EOF on error.
tmpfile() Creates a temporary read/write file that is deleted on fclose. Returns PTR<VOID>. Throws FileOpenException on failure.
memcpy(dest, src, count) Copies count bytes from src to dest. Both are PTR<VOID>/raw pointers.
memset(ptr, value, count) Fills the first count bytes of ptr with value (INT32).
remove(path) Deletes a file. Throws FileRemoveException on failure.
rename(oldPath, newPath) Renames/moves a file. Throws FileRenameException on failure.
mkdir(path, mode) Creates a directory. Throws DirectoryException on failure.
rmdir(path) Removes a directory. Throws DirectoryException on failure.
PTR<VOID> f = fopen("file.txt", "r");
fseek(f, 0, 2);
INT64 size = ftell(f);
fread(buffer, 1, size as UINT64, f);
fwrite(buffer, 1, size as UINT64, f);
fclose(f);
remove("old.txt");
rename("a.txt", "b.txt");
mkdir("dir", 511);
rmdir("dir");

Strings

ZV distinguishes between a native length-aware string and a C-style NUL-terminated string:

  • STRING — an immutable UTF-8 value represented as { i8* data, i64 len }. Its length is available in O(1) via len(s), and string literals produce STRING values. STRING does not guarantee a NUL terminator (e.g. a concatenation result generally isn't NUL-terminated), and its bytes may contain embedded NUL characters.
  • CSTRING — a plain, NUL-terminated i8* used at the C/native boundary, never automatically freed on its own. Convert a STRING to a CSTRING with cstr(s).
  • WSTRING — a NUL-terminated UTF-16 i16* used for Windows wide-character APIs. Convert a STRING or CSTRING to a WSTRING with wstr(s). Currently only supported when targeting Windows (it uses MultiByteToWideChar(CP_UTF8)).
Function Description
len(s) Returns the byte length of a STRING or dynamic array (INT64). O(1) for STRING.
cstr(s) Converts a STRING to a CSTRING by allocating a fresh, NUL-terminated heap copy of its bytes. Passing an existing CSTRING returns it unchanged (no allocation). See lifetime rules below.
wstr(s) Converts a STRING or CSTRING to a WSTRING by allocating a fresh, NUL-terminated UTF-16 copy. Passing an existing WSTRING returns it unchanged. Lifetime is handled the same as cstr().

STRING supports + for concatenation and == / != for content equality. Concatenation allocates a new buffer. When the result is assigned to a variable, it is owned and freed automatically at the end of that variable's scope; otherwise it should be freed explicitly with free() if it is kept.

cstr() / wstr() allocation lifetime

cstr(s) and wstr(s) both make defensive, NUL-terminated copies when s is a STRING (neither assumes the source buffer is NUL-terminated), so the new allocation needs an owner:

  • Bound directly to a CSTRING or WSTRING variable — CSTRING p = cstr(s); or WSTRING w = wstr(s); — the allocation is owned by the variable and freed automatically at the end of its scope, just like strdup()/str_concat().
  • Used inline without being bound to a variable — e.g. as a call argument, f(cstr(s)) — it is a ZV-managed temporary: the compiler frees it automatically once the statement that created it finishes evaluating, so it does not need to be freed manually.
  • Returned or stored somewhere else long-lived (e.g. into a struct field or an array) is not automatically tracked; free it explicitly with free() once you're done with it.
STRING name = "ZV";
print(len(name));            // 2
print(name == "ZV");         // true

STRING greeting = "Hello, " + name;
print(greeting);             // prints "Hello, ZV"
free(greeting);

C-string interop

When calling a native API, use CSTRING parameters and convert explicitly:

extern "user32.dll" {
    INT32 MessageBoxA(PTR<VOID> hwnd, CSTRING text, CSTRING caption, UINT32 type);
}

STRING message = "Hello from ZV";
MessageBoxA(0, cstr(message), cstr("ZV"), 0);

For Windows UTF-16 APIs, use WSTRING and wstr():

extern "user32.dll" {
    INT32 MessageBoxW(PTR<VOID> hwnd, WSTRING text, WSTRING caption, UINT32 uType);
}

STRING message = "Hello from ZV";
MessageBoxW(null, wstr(message), wstr("ZV"), 0);

Here, both cstr(message) and cstr("ZV") are ZV-managed temporaries: the compiler frees them automatically right after the MessageBoxA call statement finishes, so nothing needs to be freed by hand. If you want to keep a converted CSTRING or WSTRING around instead, bind it to a variable and it becomes owned:

CSTRING text = cstr(message);   // owned; freed automatically at end of scope
MessageBoxA(0, text, cstr("ZV"), 0);

For raw C-string manipulation, the following functions are also available on CSTRING values:

Function Description
strlen(s) Returns the length of s in bytes, scanning for NUL (INT64).
strcmp(a, b) Byte-wise comparison, like C strcmp (INT32: 0 if equal).
strncmp(a, b, n) Like strcmp, but compares at most n bytes.
strcpy(dest, src) Copies src (including the NUL terminator) into dest. dest must already have enough space. Returns dest.
strncpy(dest, src, n) Copies at most n bytes of src into dest. Returns dest.
strcat(dest, src) Appends src to the end of dest. dest must have enough space. Returns dest.
strncat(dest, src, n) Appends at most n bytes of src to dest. Returns dest.
strchr(s, ch) Returns a pointer to the first occurrence of ch in s, or null if not found.
strstr(haystack, needle) Returns a pointer to the first occurrence of needle in haystack, or null if not found.
strdup(s) Allocates and returns a new heap copy of s. Throws OutOfMemoryException on allocation failure; the caller owns the result.

Threads and Concurrency

These functions are available only for the hosted exe and lib targets. On Windows the compiler emits native Win32 thread APIs; on Linux and macOS it emits POSIX pthreads. The linker automatically pulls in the platform threading library.

All thread and mutex handles are opaque PTR values returned from the creation functions. They are not automatically garbage-collected: call thread_join and mutex_destroy to release them.

Function Description
thread_spawn(STRING fn, PTR arg) Starts a new OS thread that calls the ZV function named fn with arg. The worker must be declared as VOID fn(PTR arg). Returns a thread handle (PTR).
thread_join(PTR handle) Blocks until the thread exits and frees the handle.
thread_sleep_ms(INT32 ms) Suspends the calling thread for ms milliseconds.
mutex_create() Creates a new OS mutex and returns its handle (PTR).
mutex_lock(PTR handle) Acquires the mutex, blocking if it is already held.
mutex_unlock(PTR handle) Releases the mutex.
mutex_destroy(PTR handle) Destroys the mutex and frees the handle.
atomic_load_int8(PTR p) ... atomic_load_uint128(PTR p) Atomically reads the integer at p. Available for all integer types: int8, uint8, int16, uint16, int32, uint32, int64, uint64, int128, uint128.
atomic_store_<type>(PTR p, <type> v) Atomically writes v to the integer at p for the matching type.
atomic_add_<type>(PTR p, <type> v) Atomically adds v to the integer at p and returns the previous value.

These are raw primitives: you are responsible for avoiding data races. Use a mutex or atomics when multiple threads access the same memory.

// Atomic shared counter
VOID worker(PTR p) {
    atomic_add_int32(p, 1);
}

@entry
VOID main(CSTRING[] args) {
    PTR counter = alloc(4 as INT64);
    atomic_store_int32(counter, 0);

    PTR t1 = thread_spawn("worker", counter);
    PTR t2 = thread_spawn("worker", counter);

    thread_join(t1);
    thread_join(t2);

    print("counter = %d\n", atomic_load_int32(counter));
}
// Mutex-protected global counter
PTR mutex;
INT32 counter = 0;

VOID worker(PTR arg) {
    mutex_lock(mutex);
    counter = counter + 1;
    print("count: %d\n", counter);
    mutex_unlock(mutex);
}

@entry
VOID main(CSTRING[] args) {
    mutex = mutex_create();

    PTR t1 = thread_spawn("worker", null);
    PTR t2 = thread_spawn("worker", null);

    thread_join(t1);
    thread_join(t2);

    mutex_destroy(mutex);
}

Terminal UI (curses)

Available only for the hosted exe target. Links ncurses on Linux/macOS or pdcurses on Windows. All functions operate on the implicit stdscr window.

Function Description
curses_init() Initializes the screen (initscr). Must be called first.
curses_end() Restores the terminal (endwin). Must be called before exit.
curses_refresh() Flushes pending updates to the physical screen.
curses_clear() Clears the screen and forces a full repaint on the next refresh.
curses_erase() Clears the screen without forcing a full repaint.
curses_echo() / curses_noecho() Enables/disables echoing of typed characters.
curses_cbreak() / curses_nocbreak() Enables/disables cbreak mode (input available without waiting for newline).
curses_raw() Enables raw mode (disables signal generation for control characters).
curses_start_color() Initializes color support.
curses_move(row, col) Moves the cursor to (row, col).
curses_printw(fmt, ...) Prints a formatted string at the current cursor position.
curses_mvprintw(row, col, fmt, ...) Moves to (row, col) then prints a formatted string.
curses_addch(ch) Writes a single character at the current cursor position.
curses_getch() Reads a single character of input (INT32), blocking unless curses_nodelay is set.
curses_curs_set(visibility) Sets cursor visibility (0 = invisible, 1 = normal, 2 = very visible).
curses_keypad(enabled) Enables/disables interpretation of function/arrow keys as single key codes.
curses_nodelay(enabled) Enables/disables non-blocking curses_getch().
curses_init_pair(pair, fg, bg) Defines color pair pair with foreground/background colors.
curses_color_pair(pair) Returns the attribute value for color pair pair, for use with attron/attroff.
curses_attron(attrs) / curses_attroff(attrs) Turns the given attribute(s) on/off for subsequent output.
curses_box(verch, horch) Draws a border around the window using verch/horch as the vertical/horizontal characters.
curses_rows() Returns the terminal's row count (LINES).
curses_cols() Returns the terminal's column count (COLS).
curses_init();
curses_start_color();
curses_init_pair(1, 7, 0);
curses_attron(curses_color_pair(1));
curses_mvprintw(5, 5, "Hello from ZV!");
curses_attroff(curses_color_pair(1));
curses_refresh();
curses_getch();
curses_end();

Embedded Resources / Files

#embed directives bundle files into the compiled binary and can be looked up at runtime:

#embed "data.bin" resource
#embed "style.css" file "assets/style.css"

@entry
INT32 main(CSTRING[] args) {
    INT32 n = resource_count();
    for (INT32 i = 0; i < n; i++) {
        CSTRING name = resource_name(i);
        PTR<UINT8> data = resource_ptr(i);
        USIZE size = resource_size(i);
        print("%s: %d bytes", name, size as INT32);
    }

    INT32 m = file_count();
    for (INT32 i = 0; i < m; i++) {
        CSTRING path = file_name(i);
        PTR<UINT8> data = file_ptr(i);
        USIZE size = file_size(i);
        print("%s: %d bytes", path, size as INT32);
    }
    return 0;
}

Supported builtins:

Builtin Returns Description
resource_count() INT32 Number of #embed ... resource entries.
resource_name(i) CSTRING Name of the i-th resource.
resource_ptr(i) PTR<UINT8> Pointer to the i-th resource's data.
resource_size(i) USIZE Size in bytes of the i-th resource.
file_count() INT32 Number of #embed ... file entries.
file_name(i) CSTRING Path/name of the i-th file embed.
file_ptr(i) PTR<UINT8> Pointer to the i-th file embed's data.
file_size(i) USIZE Size in bytes of the i-th file embed.

If no #embed directives are present, the compiler emits no embed table or runtime helpers, keeping binaries unchanged.


Exception Handling

ZV has runtime exception handling via try/catch/throw. Exceptions are values of the built-in Exception type, which carries a message string.

Creating Exceptions

Exception err = Exception("something went wrong");
Exception HttpNotFound = Exception("HTTP request got 404");

throw

throw raises an exception. If no try/catch block is active the program prints the message to stderr and exits with code 1.

throw Exception("fatal error");

You can also throw a plain string literal directly, without wrapping it in Exception(...):

throw "file not found";

try / catch

try {
    PTR<VOID> f = fopen("missing.txt", "r");
} catch (e) {
    print("Caught: %s", e.message);
}

The variable declared in catch (e) (no type) is an Exception whose .message field contains the error string, and it catches any exception. catch (Exception e) behaves exactly the same way - Exception is the built-in catch-all type.

Custom exception types

Declare a named, catchable exception type with exception Name;:

exception NegativeBalanceException;

VOID withdraw(FLOAT64 amount) {
    if (amount < 0.0) {
        throw NegativeBalanceException("cannot withdraw a negative amount");
    }
}

Once declared, Name("description") constructs a tagged exception (just like the built-in Exception("...")), and catch (Name e) only catches exceptions of that type, leaving others to propagate:

try {
    withdraw(-5.0);
} catch (NegativeBalanceException e) {
    print("Rejected: %s", e.message);
} catch (Exception e) {
    print("Some other error: %s", e.message);
}

A try can have multiple catch clauses; they're tried in order, and a catch-all (catch (e) or catch (Exception e)) must come last if present. If no clause matches, the exception propagates to an enclosing try, or aborts the program if there is none - exactly like an unhandled throw.

You can give an exception type a default message with exception Name = <message>;, so it can be thrown/constructed without repeating it every time:

exception PoopException = Exception("the program shitted itself");

throw PoopException;              // uses the default message
throw PoopException("override");  // or supply your own

Under the hood, every runtime exception - built-in or user-declared - is just a message string with a "TypeName: description" prefix; catch matches against that prefix. That means throw "MyError: oops"; works even without an exception MyError; declaration, and if you do declare the type, catch (MyError e) will catch it the same way it would catch MyError("oops"). Using catch (MyError e) without declaring exception MyError; first is a compile error.

Runtime Exceptions from Builtins

Many built-in functions automatically throw runtime exceptions on failure. These are pre-declared exception types, so they can be both caught and constructed/thrown by name just like a custom exception type:

Builtin Exception
fopen FileOpenException: failed to open file
fclose FileCloseException: failed to close file
fseek FileSeekException: fseek failed
ftell FileException: ftell failed
remove FileRemoveException: failed to remove file
rename FileRenameException: failed to rename file
mkdir DirectoryException: failed to create directory
rmdir DirectoryException: failed to remove directory
alloc OutOfMemoryException: memory allocation failed

These can all be caught with try/catch, either generically or by type:

try {
    PTR<VOID> f = fopen("/nonexistent", "r");
} catch (FileOpenException e) {
    print("Couldn't open file: %s", e.message);
} catch (Exception e) {
    print("Other error: %s", e.message);
}

Processes: respawn()

respawn() is ZV's cross-platform alternative to fork(). A real fork() can't be given identical semantics on Windows - there is no way to duplicate a running process's address space there - so instead of a POSIX-only fork() with a different (thread- or re-exec-based) story on Windows, respawn() gives every platform the same, more restricted, contract:

PROCESS p = respawn();
if (p.child) {
    print("I'm the child");
    exit(0);
}
print("I'm the parent");

respawn() relaunches this same executable, with its original command-line arguments, as a brand new OS process (fork()+execvp() on Linux/macOS, _spawnvp() on Windows) and returns PROCESS { BOOL child } to the caller (child = false). The freshly launched process runs the program again from its entry point (@entry); when that process's code reaches a respawn() call, it recognizes it's the relaunched instance and returns PROCESS { child: true } immediately, without spawning anything further.

This is not a real fork() - there is no address-space duplication. Concretely:

  • Everything the program does before the respawn() call runs twice: once in the original process, and again from scratch in the newly-started process. Only put idempotent setup before the call.
  • The "child" does not inherit the parent's local variables, open sockets, or call stack at the point of the call - it starts over at @entry with the same args (respawn's internal marker argument is filtered out of args automatically).
  • Because of the above, respawn() cannot be used to fork off a worker per-iteration of a loop the way fork() classically is (e.g. one child per accepted socket connection) - the child would just re-run the whole program, including re-listening on its own socket, not take over a specific already-accepted connection. It's suited to patterns like "re-run the risky or heavy part of this program in an isolated process."
  • exit(code) (a thin wrapper over the C exit()) ends whichever process calls it; it's the usual way to end the child branch, matching the example above.

respawn() and exit() require a hosted OS process.


Safety: bounds checking and unsafe

I chose to bounds-check every array access by default. The runtime cost is tiny compared to the debugging time it saves, and when I genuinely need unchecked memory access I can put it inside an explicit unsafe { ... } block so the danger is visible in the source.

Array indexing is bounds-checked by default:

  • Fixed-size arrays (T[N]) — a constant (literal) out-of-range index is a compile-time error. A non-constant index gets a runtime check.
  • Dynamic arrays (T[]) — every index is checked at runtime against the array's length before the memory access happens.

A failed runtime check raises an IndexOutOfBoundsException through the same mechanism used by other runtime errors (see Exception Handling); if there is no enclosing try/catch, the program prints the error and exits.

INT32[4] a;
a[10] = 5;          // compile error: index 10 out of bounds for length 4

INT32[] nums = INT32[10];
INT32 i = 100;
nums[i] = 1;        // runtime IndexOutOfBoundsException

Raw pointers (PTR<VOID>, and any other pointer-typed value) carry no length information, so they can never be bounds-checked. Indexing a raw pointer, or converting between a pointer and an integer with as, therefore requires an explicit unsafe { } block. This keeps unchecked memory access visible in source, while safe ZV code (typed arrays, structs, references) is always protected:

VOID poke(PTR<VOID> p) {
    // p[0] = 1;             // compile error: requires 'unsafe'
    // UINT64 addr = p as UINT64; // compile error: requires 'unsafe'

    unsafe {
        p[0] = 1;
        UINT64 addr = p as UINT64;
    }
}

unsafe { } blocks may be nested and only affect the checks above; every other rule (types, struct access, etc.) still applies inside them.

Meaningful unsafe examples

unsafe is intended for the small amount of code that genuinely needs unchecked memory access — talking to hardware, parsing binary protocols, or implementing low-level data structures. The pointer casts themselves do not require unsafe; only the unchecked indexing and integer↔pointer conversions do.

Viewing an array's raw bytes

A fixed-size array can be decayed to a byte pointer. This is useful for serialization, hashing, or inspecting endianness:

VOID dump_first_bytes() {
    INT32[3] values = [10, 20, 30];

    // Cast to a byte pointer; the cast itself does not require unsafe.
    PTR<INT8> bytes = values as PTR<INT8>;

    unsafe {
        print(bytes[0] as INT32);   // 10 (first byte of values[0])
        print(bytes[4] as INT32);   // 20 (first byte of values[1])
        bytes[8] = 99;              // mutate values[2] through the alias
    }

    print(values[2]);               // 99
}

Manually allocated raw buffer

alloc() returns an owning PTR<VOID>. You can treat it as a byte buffer and free it when done:

VOID manual_buffer() {
    PTR<VOID> raw = alloc(8 as INT64);   // allocate 8 bytes

    unsafe {
        raw[0] = 0x41;                   // 'A'
        raw[1] = 0x42;                   // 'B'
        print(raw[0] as INT32);          // 65
        print(raw[1] as INT32);          // 66
    }

    free(raw);                           // early release of untyped memory
}

Pointer / integer round-trip

Some APIs hand back a numeric handle that must later be used as a pointer. The round-trip is allowed inside unsafe:

unsafe {
    INT32[3] values = [10, 20, 30];
    PTR<INT8> p = values as PTR<INT8>;

    UINT64 addr = p as UINT64;            // encode pointer as integer
    PTR<INT8> back = addr as PTR<INT8>;   // decode back to pointer
    print(back[4] as INT32);              // 20
}

Because raw pointer indexing is not bounds-checked, it is the programmer's responsibility to keep offsets within the intended object. Mistakes here can read or write adjacent memory without a runtime error.

ZV also tracks a basic ownership state for free()/move()'d variables: using a variable after it has been freed or moved away is a compile-time error, and so is freeing the same variable twice. Reassigning the variable makes it valid again.

INT32[] nums = INT32[10];
free(nums);
nums[0] = 1;   // compile error: 'nums' was already freed
free(nums);    // compile error: 'nums' was already freed

Because heap allocations are freed automatically when the owning variable goes out of scope, explicit free() is no longer required for the common case. It remains useful for early release or for values whose lifetime must end before their declaring scope exits:

VOID example() {
    INT32[] buffer = INT32[1024];
    // buffer is freed automatically at the end of this block
}

VOID early() {
    INT32[] buffer = INT32[1024];
    free(buffer);   // early release is still allowed
}

This tracking is flow-insensitive (it does not reason about if/while branches independently), so it is a simple, conservative approximation rather than a full borrow checker — it is meant to catch the common straight-line use-after-free/double-free/double-move mistakes.


Ownership: move and copy

I don't want a garbage collector, but I also don't want to chase manual-memory bugs in every program. ZV provides explicit ownership-transfer builtin functions, and the compiler tracks three states for every variable that owns a resource:

  • valid / owned — the variable holds a live value.
  • moved — ownership was transferred out via move().
  • freed — the resource was released via free().

A moved or freed variable cannot be used until it is reassigned.

move(x)

Returns the value of x and invalidates the source variable (ownership transfer). After move, the original variable is conceptually invalid and using it again (without reassigning it first) is a compile-time error.

The language-level guarantee is compile-time invalidation — the source is dead after a move. Runtime zeroing is an implementation detail for debugging safety, not the semantic meaning. A moved-from variable is invalid, not zero-valued.

INT32[] a = INT32[10];
INT32[] b = move(a);

a[0] = 1;          // compile-time error: 'a' was moved

move() is optional when returning a local value — return numbers; already transfers ownership out of the function the same way return move(numbers); does; the explicit form just documents intent.

It is also optional in plain assignment when the right-hand side is an owning variable. INT32[] b = a; is equivalent to INT32[] b = move(a); and invalidates a.

copy(x)

Returns a bitwise copy of x. The original remains valid.

copy() is only valid for trivially-copyable (non-owning) values. Copying an owned resource (such as a heap-allocated dynamic array) would silently create two owners of the same memory, causing double-free. The compiler rejects this at compile time:

INT32 a = 42;
INT32 b = copy(a);   // OK: INT32 is trivially copyable

INT32[] nums = INT32[10];
INT32[] dup = copy(nums);  // compile-time error: cannot copy owned variable

If a deep copy is needed for a resource-owning value, allocate explicitly:

INT32[] original = INT32[10];
INT32[] duplicate = INT32[10];   // allocate new memory, then copy elements

This keeps the distinction between bitwise copy, ownership transfer, and deep resource copy always explicit in the source.

Ownership through structs

A struct is a heap-owning ("owning") type if any of its fields is a dynamic array (T[]), a CSTRING, or another owning struct - transitively, to arbitrary depth:

struct A {
    INT32[] data;
}

struct B {
    A a;
    CSTRING name;
}

B owns memory (through a.data and name), so assigning into one of these fields is an ownership transfer, exactly like binding a fresh allocation to a variable. When an owning struct variable's scope ends (or it is free()'d or overwritten), the compiler recursively destroys its owning fields - A.data and B.name above - the same way it would free a plain T[] or CSTRING variable:

VOID demo() {
    B b;
    b.a.data = INT32[10];
    b.name = cstr("hello");
    // both b.a.data and b.name are freed automatically here.
}

Fields never assigned an allocation stay null/zero (struct locals are zero-initialized), and free(NULL) is always a safe no-op, so this is safe even if only some owning fields are ever populated.

Because an owning field is always considered owned by its containing struct, storing a borrowed/shared array or CSTRING pointer in one is not supported; allocate (or cstr()/strdup()) a fresh copy instead, or use a raw PTR<T> field with unsafe { } if you truly need a non-owning pointer.

Any assignment that reads an already-owned owning value — whether it is a plain variable (B b2 = b; or b2 = b;) or an owning field of an owned struct (INT32[] arr = foo.data; or bar.field = foo.data;) — is treated as an implicit move(). Ownership transfers to the left-hand side, and the source variable/field becomes invalid; using a moved-from variable again without reassigning it is a compile-time error. This guarantees that there is never more than one owner of any heap allocation and that an owning value can never become a dangling shallow alias when its source is destroyed.

copy() is rejected for any value that owns heap memory (an owned dynamic array, CSTRING, or owning struct). If you want two independent copies, allocate a new value and copy the contents explicitly, for example with array_copy() for arrays.

struct Foo {
    INT32[] data;
}

struct Point {
    INT32 x;
    INT32 y;
}

VOID demo() {
    Foo a;
    a.data = INT32[10];
    Foo b = a;          // implicit move: a is now invalid, b owns the data
    a.data[0] = 1;      // compile-time error: 'a' was moved

    Foo c = copy(b);    // compile-time error: cannot copy() owned variable 'b'

    Point p;
    Point q = copy(p);  // OK: Point has no owning fields
}

Type Aliases

type (transparent alias)

Creates a new name for an existing type. The alias is interchangeable with the original type.

type Size = UINT64;
type Str = STRING;

Size n = 1024;

newtype (distinct alias)

Creates a distinct type backed by an existing type. It shares the same representation as its underlying type at the IR level, but the compiler enforces that it is not interchangeable with that underlying type or with any other newtype - crossing the boundary always requires an explicit as cast.

newtype Celsius = FLOAT64;
newtype Fahrenheit = FLOAT64;

Celsius c = 20.0;      // OK: numeric literals may initialize a newtype directly
Fahrenheit f = 68.0;

c = f;                    // ERROR: distinct newtypes
Celsius x = f;            // ERROR: distinct newtypes
FLOAT64 y = c;            // ERROR: newtype -> underlying type is not implicit
FLOAT64 y = c as FLOAT64; // OK: explicit conversion to the underlying type
Celsius x2 = y as Celsius; // OK: explicit conversion from the underlying type

Fahrenheit f2 = c as Fahrenheit; // ERROR: casting between distinct newtypes
                                 // is rejected even with `as` - write a
                                 // conversion function instead.

The same rule applies to function parameters and return values:

VOID set_temperature(Celsius value) {
    ...
}

Fahrenheit f = 72.0;
set_temperature(f); // ERROR: Fahrenheit is not a Celsius

type aliases remain fully transparent and are unaffected by these checks.


Compilation Targets

Targets are written as triples: <arch>-<env>-<options...>. Supported forms are x86-32-hosted and amd64-hosted. The default target is a native hosted triple.

Hosted executable / shared library

  • Linked with Clang against user32, kernel32, msvcrt, and legacy_stdio_definitions on Windows, or the platform libc on Linux.
  • extern libraries are appended as -l<name>. Each one is printed to the console (e.g. Linking against native library 'shell32.dll' (-lshell32)), along with the full clang command line that is actually run, so it's always clear which DLLs/.so files the output depends on.
  • For a shared library, append lib to the target triple (e.g. -target x86-32-hosted lib). Functions marked export get external linkage and, on Windows, the LLVM dllexport storage class.

Examples

Hello World

@entry
UINT32 main(CSTRING[] args) {
    print("Hello, world!");
    return 0;
}

Arithmetic, Loops, and Arrays

@entry
UINT32 main(CSTRING[] args) {
    INT32[] nums = [1, 2, 3, 4, 5];
    INT32 total = 0;

    for (INT32 i = 0; i < len(nums) as INT32; i++) {
        total = total + nums[i];
    }

    print("total: %d", total);
    return 0;
}

Fixed-Size and Heap Arrays

INT32[] create_buffer() {
    INT32[] buf = INT32[64];
    return buf;   // ownership transferred to caller
}

@entry
UINT32 main(CSTRING[] args) {
    // Stack array: zero-initialized by default
    INT32[4] stack;
    stack[0] = 10;

    // Stack array filled with a value
    INT32[4] filled = 7;

    // Heap array with explicit initializer
    INT32[] heap = INT32[4](5);

    print("stack[0]: %d", stack[0]);
    print("filled[3]: %d", filled[3]);
    print("heap[0]: %d", heap[0]);
    print("heap len: %lld", len(heap));

    INT32[] owned = create_buffer();
    print("owned len: %lld", len(owned));

    // 'heap' and 'owned' are freed automatically when main ends.
    // Explicit free() is only needed for early release.
    return 0;
}

Structs

struct Point {
    INT32 x;
    INT32 y;
}

INT32 distance_squared(Point p) {
    return p.x * p.x + p.y * p.y;
}

@entry
UINT32 main(CSTRING[] args) {
    Point p;
    p.x = 3;
    p.y = 4;
    print("squared distance: %d", distance_squared(p));
    return 0;
}

Calling a Native Library

extern "user32.dll" {
    INT32 MessageBoxA(PTR<VOID> hwnd, CSTRING text, CSTRING caption, UINT32 type_val);
}

@entry
UINT32 main(CSTRING[] args) {
    MessageBoxA(0, "Hello from ZV", "ZV", 0);
    return 0;
}

Concurrent Counter with Atomics

VOID worker(PTR p) {
    atomic_add_int32(p, 1);
}

@entry
VOID main(CSTRING[] args) {
    PTR counter = alloc(4 as INT64);
    atomic_store_int32(counter, 0);

    PTR t1 = thread_spawn("worker", counter);
    PTR t2 = thread_spawn("worker", counter);

    thread_join(t1);
    thread_join(t2);

    print("counter = %d\n", atomic_load_int32(counter));
}

Mutex-Protected Global Counter

PTR mutex;
INT32 counter = 0;

VOID worker(PTR arg) {
    mutex_lock(mutex);
    counter = counter + 1;
    print("count: %d\n", counter);
    mutex_unlock(mutex);
}

@entry
VOID main(CSTRING[] args) {
    mutex = mutex_create();

    PTR t1 = thread_spawn("worker", null);
    PTR t2 = thread_spawn("worker", null);

    thread_join(t1);
    thread_join(t2);

    mutex_destroy(mutex);
}

Standard Library Helpers

The shipped lib/ folder contains higher-level helpers built on top of the builtins. Include them with #include <lib/<name>.zv>.

Module What it covers
lib/file.zv Whole-file read/write, streaming, binary primitive I/O, line handling, temp files
lib/path.zv Path joining, splitting, extension extraction, normalization, absolute checks
lib/math.zv Constants and per-width min/max/clamp/abs/sign/lerp/round/deg/rad helpers
lib/prng.zv Fast deterministic 64-bit LCG pseudo-random numbers
lib/secprng.zv Deterministic ChaCha20-based CSPRNG
lib/hex.zv Hexadecimal encoding and decoding for byte buffers and CSTRINGs
lib/hash/*.zv Checksums and hashes: adler32, crc32, djb2, fnv1a, md5, murmur3, sdbm, siphash, xxhash32
lib/win/*.zv Windows API bindings (kept separate from the portable helpers above)

File helpers (lib/file.zv)

#include <lib/file.zv>

Whole-file helpers, streaming, binary primitive I/O, and line handling.

  • readall(path) — read a whole text file into an owned CSTRING.
  • writeall(path, content) — write text to a file, truncating any existing content.
  • appendall(path, content) — append text, creating the file if it does not exist.
  • file_size(path) — return the size of a file in bytes, or -1 on error.
  • exists(path) — return true if the file can be opened for reading.
  • readallbytes(path) — read a whole file into an owned UINT8[].
  • readfilebytes(path) — read a whole file into a FileBytes { data, len }; caller must free(data).
  • file_open(path, mode) / file_close(fs) — open/close a FileStream (mode follows fopen).
  • file_read(fs, buf, len) / file_write(fs, buf, len) — raw byte I/O on a stream.
  • file_tell(fs) / file_seek(fs, off, whence) — stream positioning (whence: 0=set, 1=cur, 2=end).
  • file_eof(fs) / file_error(fs) — check stream state.
  • file_read_line(fs) — read one line into an owned CSTRING; newline is preserved.
  • file_write_line(fs, line) — write a CSTRING to a stream with fputs.
  • tmpfile_stream() — open a temporary read/write stream deleted automatically on close.
  • readlines(path) / file_lines_get(lines, i) / file_lines_free(lines) — read all lines into a FileLines view.
  • Binary primitive readers/writers (_le little-endian, _be big-endian): file_read_u8, file_read_i8, file_read_u16_le/_be, file_read_i16_le/_be, file_read_u32_le/_be, file_read_i32_le/_be, file_read_u64_le/_be, file_read_i64_le/_be, file_read_f32_le/_be, file_read_f64_le/_be, and the matching file_write_* functions.
  • file_read_cstring(fs, len) — read exactly len bytes into a NUL-terminated CSTRING.

Path helpers (lib/path.zv)

#include <lib/path.zv>

Small path-manipulation helpers; results are owned CSTRINGs.

  • path_join(a, b) — join two path components with a separator when needed.
  • path_dir(p) — directory portion of a path (. if none).
  • path_file(p) — file-name portion of a path.
  • path_ext(p) — file extension, or empty string if none.
  • dirname(p), basename(p), extname(p), join(a, b) — standard aliases for the above.
  • is_absolute(p) — true for POSIX /, Windows drive, or UNC paths.
  • normalize(p) — collapse ./.., remove duplicate separators, and use / separators.

Math helpers (lib/math.zv)

#include <lib/math.zv>

Numeric constants and per-width numeric helpers.

  • Constants: PI, TAU, E, DEG2RAD, RAD2DEG.
  • Integer helpers for each signed width (i32, i64, i128): min_*, max_*, clamp_*, abs_*, sign_*.
  • Unsigned helpers for each width (u32, u64, u128): min_*, max_*, clamp_*.
  • Float helpers for f32/f64: min_*, max_*, clamp_*, abs_*, sign_*, lerp_*.
  • Conversion/angle helpers: floor_i32/floor_i64, ceil_i32/ceil_i64, round_i32/round_i64, deg2rad_f32/deg2rad_f64, rad2deg_f32/rad2deg_f64.

Pseudo-random helpers (lib/prng.zv)

#include <lib/prng.zv>

Fast, deterministic 64-bit LCG generator. Not cryptographically secure.

  • prng_seed(seed) — seed the generator.
  • Width-specific outputs: prng_u8/16/32/64, prng_i8/16/32/64, prng_bool.
  • Unit-float outputs: prng_f32() and prng_f64() in [0, 1].
  • Ranged variants: prng_*_range(min, max). Integer ranges are inclusive; float ranges are half-open [min, max).

Secure PRNG helpers (lib/secprng.zv)

#include <lib/secprng.zv>

Deterministic, platform-independent ChaCha20-based CSPRNG. Still requires a proper entropy source for production security.

  • secprng_seed(seed) — seed the generator.
  • Width-specific outputs: secprng_u8/16/32/64, secprng_i8/16/32/64, secprng_bool.
  • Unit-float outputs: secprng_f32() and secprng_f64() in [0, 1).
  • Ranged variants: secprng_*_range(min, max). Integer ranges are inclusive; float ranges are half-open [min, max).

Hex helpers (lib/hex.zv)

#include <lib/hex.zv>

Encode byte buffers and CSTRINGs to hex, and decode hex back to UINT8[].

  • hex_encode(data, len) — encode len bytes from PTR<UINT8> into a lowercase CSTRING.
  • hex_encode_upper(data, len) — same, using uppercase A-F.
  • hex_encode_cstring(s) — encode a CSTRING to lowercase hex.
  • hex_encode_bytes(data) — encode a UINT8[] to lowercase hex.
  • hex_decode(s) — decode a hex CSTRING into an owned UINT8[]. Accepts an optional 0x/0X prefix and whitespace between digits. Throws on invalid characters or an odd number of hex digits.

Hash helpers (lib/hash/*.zv)

#include <lib/hash/<name>.zv>

Checksum and hash functions; most take a (PTR<UINT8> data, INT64 len) buffer and many also offer a _cstring variant.

  • adler32(data, len) / adler32_cstring(s) — Adler-32 checksum.
  • crc32(data, len) / crc32_cstring(s) / crc32_update(crc, data, len) / crc32_byte(crc, b) — CRC-32.
  • djb2(s) — DJB2 string hash.
  • fnv1a_32(data, len) / fnv1a_cstring_32(s) — FNV-1a 32-bit.
  • md5(data, len, out) / md5_cstring(s, out) — MD5; out is a PTR<UINT8> to a 16-byte buffer.
  • murmur3_32(data, len, seed) / murmur3_cstring_32(s, seed) — MurmurHash3 32-bit.
  • sdbm(s) — SDBM string hash.
  • siphash_2_4(data, len, key) / siphash_cstring_2_4(s, key) — SipHash-2-4; key is a UINT8[16].
  • xxhash32(data, len, seed) / xxhash32_cstring(s, seed) — xxHash 32-bit.

Compiler Diagnostics: Errors and Warnings

Most mistakes ZV can detect (bad types, use-after-free, out-of-bounds constant indices, print() format mismatches, ...) are hard compile errors, in keeping with catching bugs structurally instead of at runtime. A smaller set of things are non-fatal warnings: almost certainly mistakes, but not blockers, so compilation still succeeds:

  • Unreachable code — a statement that can never execute because an earlier one in the same block always returns, throws, breaks, or continues.
  • A non-void function that doesn't return a value on all code paths — falling off the end of a non-void function is undefined behavior in C; ZV still compiles it (inserting a trap so the module stays valid) but warns about it instead of silently producing garbage if that path is ever actually reached.

Warnings are printed to the console as warning: [file:line:col] message alongside the usual build output, and are surfaced as "warning"-severity diagnostics through the language server (ZV --lsp), so they show up as squiggles in an editor the same way errors do.

Development

  • dotnet build — Build the compiler.
  • dotnet test — Run the parser and backend test suite.
  • ZV --lsp — Run the language server over stdio.

Language Server

ZV --lsp runs a stdio-based LSP server for use with editors and the VS Code extension. It supports:

  • Full document synchronization (textDocument/didOpen, didChange, didClose)
  • Diagnostics (lex/parse errors and compile errors) published via textDocument/publishDiagnostics
  • textDocument/references for finding symbol usages
  • textDocument/definition for jumping to a symbol declaration, or jumping to the target file of an #include directive (local "..." and system <...> includes)

VS Code Extension

A first-party VS Code extension lives in ZVCodeExtension/. It provides syntax highlighting, language-server diagnostics, and a command to compile the current .zv file. The compiler is auto-detected on PATH (looks for ZV or ZV.exe), or you can set the absolute path with the zv.executablePath setting.

cd ZVCodeExtension
npm install
npm run compile

Open ZVCodeExtension/ in VS Code and press F5 to launch an Extension Development Host window. To build a .vsix archive:

cd ZVCodeExtension
npm run package
code --install-extension zvcode-0.1.1.vsix

About

ZV programming langauge

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages