An open API service indexing awesome lists of open source software.

https://github.com/leifericf/clj-zig

Interactive Zig as a Clojure library.
https://github.com/leifericf/clj-zig

clojure ffi jvm panama systems-programming zig

Last synced: 1 day ago
JSON representation

Interactive Zig as a Clojure library.

Awesome Lists containing this project

README

          

# clj-zig

[![CI](https://github.com/leifericf/clj-zig/actions/workflows/ci.yml/badge.svg)](https://github.com/leifericf/clj-zig/actions/workflows/ci.yml)
![Zig 0.16](https://img.shields.io/badge/Zig-0.16-f7a41d?logo=zig&logoColor=white)
![Java 22+](https://img.shields.io/badge/Java-22%2B-007396?logo=openjdk&logoColor=white)

**clj-zig is an experiment, a proof of concept.** It begins with a question: what would it look and feel like to bring systems-level programming into Clojure, and can that be done while keeping the data-oriented, REPL-driven workflow Clojure developers work in? Can a Clojure developer reach for Zig where native code is the right tool, in a form that stays idiomatic Clojure, without restricting what the native side can do?

This project explores how far those questions can be carried.

Stay in the REPL, define a function in a familiar shape, and drop into Zig only where native performance, explicit layout, comptime, or low-level control earns its keep.

The name is descriptive rather than clever: `clj` for Clojure, `zig` for the Zig that backs each function.

## Core idea

```clojure
(defnz add
"Adds two signed integers."
[x :i64
y :i64
:ret :i64]
"return x + y;")

(add 20 22)
;; => 42
```

A normal Clojure function. The body is real Zig. The signature vector is a Clojure data contract describing the boundary.

## A wider tour

The signature vector is the whole contract. Widen it one type at a time.

```clojure
;; A slice arrives as a primitive array; :const makes it read-only.
(defnz sum
[xs [:slice :const :f64]
:ret :f64]
"var t: f64 = 0; for (xs) |x| t += x; return t;")

(sum (double-array [1.0 2.0 3.0]))
;; => 6.0
```

Named boundary types cross by value. A `defrecordz` returns a Clojure record:

```clojure
(defrecordz Point [x :f64 y :f64])

(defnz midpoint
[a Point
b Point
:ret Point]
"return .{ .x = (a.x + b.x) / 2.0, .y = (a.y + b.y) / 2.0 };")

(midpoint (->Point 0.0 0.0) (->Point 4.0 6.0))
;; => #user.Point{:x 2.0, :y 3.0}
```

A `defenumz` member bridges to a keyword. clj-zig copies an `[:owned [:slice T]]` return into a vector and frees the native memory; a `[:handle T]` is an opaque native resource the caller frees. Errors cross as data and allocations stay explicit. The [Boundary Contract](docs/03-boundary-contract.md) lists the full type vocabulary.

## Bigger bodies and C interop

A body can also live in a real `.zig` file instead of a string. The file is ordinary Zig with full editor and `zig fmt` support; the generated wrapper calls its `pub fn`. The descriptor can link C libraries too, so a body may `@cImport` a C header directly:

```clojure
(defnz hypotenuse
[a :f64 b :f64 :ret :f64]
{:zig/file "hyp.zig" :c/link ["m"]})
```

```zig
// hyp.zig
const c = @cImport({ @cInclude("math.h"); });
pub fn hypotenuse(a: f64, b: f64) f64 {
return c.sqrt(a * a + b * b);
}
```

The file path resolves next to the source file, then on the classpath. See [ADR 26](docs/adr/26-external-zig-source-files.md) and [ADR 27](docs/adr/27-compile-options-c-interop.md).

## Binding a prebuilt library

A program also reaches libraries it did not compile and that have no Zig body to wrap: the platform's windowing or input library, a system framework, libc, the graphics loader. `clj-zig.foreign` is a small foreign-function toolkit for binding one directly: open it, describe a signature as data, bind a cached downcall, optionally hand native code a Clojure callback.

```clojure
(require '[clj-zig.foreign :as foreign])

(let [lib (foreign/library-lookup
(foreign/resolve-library {:env ["LIBFOO"]
:candidates ["/opt/homebrew/lib/libfoo.dylib"]
:default "/opt/homebrew/lib/libfoo.dylib"}))
add (foreign/downcall lib "foo_add" foreign/c-int [foreign/c-int foreign/c-int])]
(foreign/call add (int 20) (int 22))) ;; => 42
```

`downcall` caches its handle per distinct signature, so a per-frame caller invokes the cached handle directly and does no linker work; `upcall-stub` adapts a Clojure fn to a C function pointer for callback-driven APIs; `read-utf8-bounded` reads a NUL-terminated string out of untrusted foreign memory under a hard cap. See [ADR 37](docs/adr/37-foreign-function-toolkit.md) and [ADR 38](docs/adr/38-synchronous-upcall-stubs.md).

## A namespace of native functions

A Clojure namespace is a Zig namespace. Name a function without a body and clj-zig takes the body from the `pub fn` of the same name in the `.zig` file beside the namespace's source: `app/geometry.clj` pairs with `app/geometry.zig`. Shared imports, helpers, and types live once in that file, and `zig-deps` declares the namespace's C link flags so each function inherits them:

```clojure
(ns geometry
(:require [clj-zig.core :refer [defnz zig-deps]]))

(zig-deps {:c/link ["m"]}) ;; link libm for the whole namespace

(defnz hypotenuse) ;; signature and body from geometry.zig's pub fn hypotenuse
(defnz circle-area)
```

```zig
//! clj-zig: geometry
const c = @cImport({ @cInclude("math.h"); });
fn square(x: f64) f64 { return x * x; }

pub fn hypotenuse(a: f64, b: f64) f64 { return c.sqrt(square(a) + square(b)); }
pub fn circle_area(r: f64) f64 { return 3.141592653589793 * square(r); }
```

With no signature, the boundary contract is inferred from the `pub fn` prototype: `pub fn hypotenuse(a: f64, b: f64) f64` gives `[a :f64 b :f64 :ret :f64]`. A Zig type fixes the shape, but a returned `[]T` or `*T` carries no ownership or handle policy in its type. A function returning one needs an explicit signature declaring `[:owned ...]` or `[:handle ...]`; until then it reports `:clj-zig/contract-policy-needed`.

Each function still compiles to its own content-addressed library, so redefining one recompiles only that one and a failed compile keeps the last good binding. A kebab-case name maps to its snake_case `pub fn` (`circle-area` to `circle_area`); the optional `//! clj-zig: ` header asserts the file belongs to the namespace. A body file may `@import` sibling and subdirectory `.zig` files, which are reproduced and compiled alongside it. See [ADR 28](docs/adr/28-namespace-as-zig-namespace.md) and [ADR 29](docs/adr/29-multi-file-zig-imports.md).

## Distributing a library

A library built with clj-zig ships its native code precompiled, so a
consumer adds the dependency and calls functions with no Zig toolchain and
no build step. At release, `clj-zig.bake/bake!` cross-compiles each `defnz`
in a namespace for the target matrix into the resource tree the jar
carries:

```clojure
(clj-zig.bake/bake! {:ns 'com.example.widgets/native :out "resources"})
```

At load, a consumer's `defnz` resolves its baked library from the classpath
by target, namespace, name, and content hash, extracts it into the cache,
and binds it without invoking Zig. The hash uses the pinned Zig version, so
the consumer reproduces the hash the author baked under without a toolchain;
a platform the author did not bake is a clean miss, compiled locally when a
toolchain is present. The default matrix is seven targets across Linux,
macOS, and Windows; a function linking a third-party C library is baked for
the host only. `examples/build.clj` shows bake, jar, and deploy. See
[ADR 31](docs/adr/31-distribute-precompiled-artifacts.md) and the
[Installation and Distribution](docs/09-installation-and-distribution.md)
guide.

## Inspect and redefine

Every function is an ordinary Var carrying its spec, source, and build status:

```clojure
(zig/spec #'sum) ;; the normalized boundary contract, as data
(zig/generated-source #'sum) ;; the full Zig wrapper
(zig/source #'sum) ;; the body you wrote
```

Redefine like any `defn`, and a fresh library compiles. When a new body fails to compile, the diagnostic prints and the last good binding stays callable.

## Pipeline

```text
Clojure form
-> signature data
-> normalized boundary contract
-> generated Zig wrapper
-> Zig compilation
-> native library loading
-> ordinary Clojure Var
```

## Examples

The [`examples/`](examples/) directory holds small, runnable programs. Load one
in a REPL and evaluate its `(comment ...)` block. The basics cover each boundary
type one file at a time. Four go further, into work that is hard or impossible
from the JVM:

- [`simd.clj`](examples/simd.clj): explicit SIMD over `@Vector` registers.
- [`memory_layout.clj`](examples/memory_layout.clj): a packed native buffer mutated in place, no allocation, no GC.
- [`bit_ops.clj`](examples/bit_ops.clj): sub-byte packing and single-instruction bit intrinsics.
- [`inline_asm.clj`](examples/inline_asm.clj): inline assembly, with the bodies in sibling `.zig` files.

And two show a namespace backed by a co-located `.zig`:

- [`cinterop.clj`](examples/cinterop.clj): imports a C header with `@cImport` and links a C library, its body in a sibling `.zig` file.
- [`geometry.clj`](examples/geometry.clj): bodyless functions sourced from the co-located `geometry.zig`, with `zig-deps` linking libm for the whole namespace.
- [`multifile.clj`](examples/multifile.clj): a body split across two `.zig` files, the first `@import`ing the second.

## Reading order

1. [Vision Brief](docs/01-vision-brief.md): what clj-zig is, who it serves, what counts as success.
2. [Interface Design](docs/02-interface-design.md): `defnz` and the family of z-suffixed forms.
3. [Boundary Contract](docs/03-boundary-contract.md): how values cross and the type vocabulary.
4. [REPL and Execution Model](docs/04-repl-and-execution-model.md): redefinition, caching, diagnostics.
5. [Composability and Builders](docs/05-composability-and-builders.md): data-level reuse and macros.
6. [Proof-of-Concept Plan](docs/06-proof-of-concept-plan.md): scope, phases, acceptance tests.
7. [Design Principles and Decisions](docs/07-design-principles-and-decisions.md): the principles; decisions are ADRs in [docs/adr/](docs/adr/README.md).
8. [Test Strategy](docs/08-test-strategy.md): how generative and exhaustive testing prove the boundary, layered on the example suite.
9. [Installation and Distribution](docs/09-installation-and-distribution.md): the consumer and author flows, baking, and the toolchain bootstrap.

## Requirements

- **Java 22 or newer.** clj-zig uses the finalized Foreign Function & Memory
API (JEP 454); `--enable-preview` is not required. The only flag the JVM
needs is `--enable-native-access=ALL-UNNAMED`, which the `:test` alias sets.
- **Zig 0.16, for an author.** clj-zig shells out to `zig` to compile
generated source. It uses a `zig` on the path, or fetches a pinned one on
first use (see below). A consumer of a library whose native code is
already baked needs no Zig.
- **Clojure CLI** (`deps.edn`, not Leiningen).

Development runs on JDK 26. If your shell's default JDK is older (for example
through sdkman), point the Clojure CLI at JDK 26 for one invocation using
`JAVA_CMD`:

```bash
JAVA_CMD="$(/usr/libexec/java_home -v 26)/bin/java" clojure -M:test
```

## Installation

Add clj-zig to your `deps.edn` and open native access at runtime. Until a
release is on Clojars, depend on it from git, pinning a commit:

```clojure
{:deps {io.github.leifericf/clj-zig {:git/sha ""}}
:aliases
{:dev {:jvm-opts ["--enable-native-access=ALL-UNNAMED"]}}}
```

Once published, depend on the released version from Clojars instead. The git
coordinate (`io.github.leifericf/clj-zig`) and the Clojars coordinate
(`com.leifericf/clj-zig`) differ; releases are dated:

```clojure
{:deps {com.leifericf/clj-zig {:mvn/version "2026.06.17-alpha1"}}
:aliases
{:dev {:jvm-opts ["--enable-native-access=ALL-UNNAMED"]}}}
```

That one JVM flag is the only step native access requires; a running JVM
cannot grant it to itself, so clj-zig cannot remove it. Without it, clj-zig
reports `:clj-zig/native-access-disabled` naming the flag.

An author also needs a `zig` compiler. clj-zig uses one on the path, or
fetches a pinned Zig into `.clj-zig/zig//` on first use and reuses
it, so installing Zig by hand is optional. On macOS with Homebrew:

```bash
brew install zig clojure/tools/clojure
brew install --cask temurin
```

A consumer of a library whose native code is baked needs no Zig at all. The
[Installation and Distribution](docs/09-installation-and-distribution.md)
guide covers the consumer flow, the author bake-and-publish flow, and the
toolchain bootstrap.

## Running the tests

```bash
clojure -M:test
```

Pure-core tests (signature, type, spec, source) run on any JDK 22+. The shell
tests compile and load native code, so they need `zig` on the path and JDK 22+.

## Non-goals for the proof of concept

- No Zig-to-Clojure callbacks.
- No embedded JVM from Zig.
- No arbitrary Clojure object marshalling.
- No hiding of Zig's type system.
- No production packaging before the REPL experience is proven.
- No DSL that pretends to be Zig but is not.

## Acknowledgements

clj-zig grew out of several conversations with my friend
[@teodorlu](https://github.com/teodorlu) in the Norwegian Clojure community.

## License

Released under the [MIT License](LICENSE).