Schema Reference

schema.cue in full:

package build

#Kind:    "exe" | "static" | "shared"
#Profile: "debug" | "release"
#Link:    "abi" | "import"

#Module: {
	kind:     #Kind
	root:     string
	deps: [...string] | *[]
	profile:  #Profile | *"debug"
	link:     #Link | *"abi"

	if kind == "shared" {
		link: "abi"
	}
}

#Profiles: {
	debug: {
		optimize: "Debug"
	}
	release: {
		optimize: "ReleaseFast"
	}
}

profiles: #Profiles

That is the whole type system. You rarely edit it. Everything below describes how each part behaves.

#Module

FieldTypeRequiredDefault
kind#Kindyesnone
rootstringyesnone
deps[...string]no[]
profile#Profileno"debug"
link#Linkno"abi"

#Module is a CUE definition, which makes it closed. Any field not in that table is rejected.

A module is declared by unifying with it:

name: #Module & { ... }

The CUE field name becomes the module name, which becomes the artifact name. core produces libcore.a; app produces app.


kind

Required. One of "exe", "static", "shared". Decides which std.Build call build.zig makes and where the artifact lands.

package build

hello: #Module & {
	kind: "exe"
	root: "src/main.zig"
}
$ ./gen_build_spec.sh && zig build && ls zig-out/bin
Generated build_spec.zig
hello

Change one word:

hello: #Module & {
	kind: "static"
	root: "src/main.zig"
}
$ ./gen_build_spec.sh && zig build && ls zig-out/lib
Generated build_spec.zig
libhello.a

An unrecognised value is rejected before anything is generated:

$ cue export -e build
app.kind: 3 errors in empty disjunction:
app.kind: conflicting values "exe" and "dylib":
    ./project.cue:4:6
    ./project.cue:5:8
    ./schema.cue:3:11
    ./schema.cue:7:12
app.kind: conflicting values "shared" and "dylib":
    ./project.cue:4:6
    ./project.cue:5:8
    ./schema.cue:3:30
    ./schema.cue:7:12
app.kind: conflicting values "static" and "dylib":
    ./project.cue:4:6
    ./project.cue:5:8
    ./schema.cue:3:19
    ./schema.cue:7:12

One error per branch of the disjunction. The last file:line in each block is the schema rule; the first is your declaration.

Omitting kind gives a different error, because the field has no default:

$ cue export -e build
build.modules.app.kind: incomplete value "exe" | "static" | "shared":
    ./export.cue:10:14

root

Required. The root source file, as a path relative to the directory holding build.zig.

protocol: #Module & {
	kind:    "static"
	root:    "src/protocol.zig"
	profile: "release"
}

CUE checks the type and nothing else. It never touches the filesystem, so a path that does not exist passes validation. From examples/02-lib-and-app with root pointed at a file that is not there:

$ cue export -e build
{
    "modules": {
        "mathlib": {
            "kind": "static",
            "root": "src/nope.zig",
            "deps": [],
            "optimize": "Debug"
        },
        "calc": {
            "kind": "exe",
            "root": "src/calc.zig",
            "deps": [
                "mathlib"
            ],
            "optimize": "ReleaseFast"
        }
    }
}

The spec tests catch it, which is why they exist:

$ zig build test
test
+- run test 4/5 passed, 1 failed
error: 'spec_test.test.every module root exists on disk' failed: missing root for module 'mathlib': src/nope.zig

Without them you get the compiler's version:

$ zig build
install
+- install mathlib
   +- compile lib mathlib Debug native 1 errors
error: failed to check cache: 'src/nope.zig' file_hash FileNotFound

A non-string is rejected outright:

$ cue export -e build
app.root: conflicting values 42 and string (mismatched types int and string):
    ./project.cue:4:6
    ./project.cue:6:8
    ./schema.cue:8:12

deps

Optional, [...string], default []. Each entry is the name of another module. For each one, build.zig calls linkLibrary.

mathlib: #Module & {
	kind: "static"
	root: "src/mathlib.zig"
}

calc: #Module & {
	kind:    "exe"
	root:    "src/calc.zig"
	deps:    ["mathlib"]
	profile: "release"
}

A dependency is a link edge. It gives you no Zig import. This is the single thing most likely to surprise you.

$ zig build
src/calc.zig:3:25: error: no module named 'mathlib' available within module 'root'
const mathlib = @import("mathlib");
                        ^~~~~~~~~

Symbols cross the boundary the way they would from a C library:

// src/mathlib.zig
pub export fn mathlib_add(a: i32, b: i32) i32 {
    return a + b;
}

// src/calc.zig
extern fn mathlib_add(a: i32, b: i32) i32;
$ ./zig-out/bin/calc
calc built as ReleaseFast
  add(2, 3)         = 5
  mul(6, 7)         = 42
  clamp(15, 0, 10)  = 10

Order does not matter. build.zig creates every compile step before it resolves any dependency, so a module may depend on one declared later in the file.

Multiple dependencies and repeated dependents are both fine:

gateway: #Module & {
	kind:    "exe"
	root:    "src/gateway.zig"
	deps:    ["protocol", "codec"]
	profile: "release"
}

CUE checks the type but cannot check the names. deps is [...string], and CUE has no view of which modules exist. A wrong name gets through validation and kills build.zig during configuration:

$ zig build
thread 868007 panic: attempt to use null value
/path/to/azazel/build.zig:43:44: 0x1002a0033 in build (build)
            step.linkLibrary(built.get(dep).?);
                                           ^

Read that panic as "a name in deps is not in export.cue's _modules". The spec tests cannot help here, because they run after configuration.

A wrong type is caught:

$ cue export -e build
app.deps: 2 errors in empty disjunction:
app.deps: conflicting values "mathlib" and [...string] (mismatched types string and list):
    ./project.cue:4:6
    ./project.cue:7:8
    ./schema.cue:9:8
app.deps: conflicting values "mathlib" and [] (mismatched types string and list):
    ./project.cue:4:6
    ./project.cue:7:8
    ./schema.cue:9:23

Cycles are not rejected by CUE either. build_spec_test.zig runs Kahn's algorithm over the declared edges and fails if any module is left unresolved.


Optional. One of "abi" or "import". Default "abi". It controls how a module is consumed by the things that depend on it.

"abi" is the original model. The module is compiled to its own artifact and linked over the C ABI. Symbols cross the edge as pub export fn on the dependency and extern fn on the dependent. This is what you need for a shared library with a stable ABI, and for linking C or C++. A shared module is always abi; the schema forces it.

"import" merges the module into each dependent as a plain Zig module. The dependent reaches it with @import("<name>"), the same way it would reach any Zig package. There is no separate artifact and no link step: the dependency compiles as part of whatever imports it.

core: #Module & {
	kind: "static"
	root: "src/core.zig"
	link: "import"
}

app: #Module & {
	kind: "exe"
	root: "src/main.zig"
	deps: ["core"]
}
// src/core.zig
pub fn add(a: i32, b: i32) i32 {
	return a + b;
}

// src/main.zig
const core = @import("core");
pub fn main() void {
	_ = core.add(2, 3);
}

The source contract differs between the two. An abi dependency exports C-ABI symbols and the dependent declares them extern. An import dependency is ordinary Zig (pub fn) and the dependent @imports it. Switching a module's link means writing its edge the matching way.

Prefer import for pure Zig-to-Zig dependencies. It rebuilds much faster because the whole graph is one compilation, so Zig caches and re-links once instead of validating and linking one artifact per module. The gap grows with the module count. On a 150-module graph, a one-module change rebuilds in a few hundred milliseconds under import against several seconds under abi. Keep abi where the boundary is real: shared libraries, and edges that cross into C or C++.

At large scale the two flat models trade places. All abi re-validates and re-links a graph of hundreds of artifacts on every build. All import keeps the graph small but turns each incremental into a recompile of the whole program, so its cost grows with the codebase. The answer is to combine them: group modules into clusters, each an import graph behind one abi module, and link the clusters to each other over the ABI. A change then recompiles one cluster and relinks, which stays flat as the project grows. On a 2000-module graph, clustered builds rebuild a one-module change in about two seconds and build clean in about ten, against roughly two minutes and ninety seconds for the all-abi model. See examples/06-clusters.


profile

Optional, #Profile, default "debug". It is a name, and #Profiles maps it to a Zig OptimizeMode.

profileoptimize in the specZig
"debug""Debug".Debug
"release""ReleaseFast".ReleaseFast

Per module, so one build can mix them:

gateway: #Module & {
	kind:    "exe"
	root:    "src/gateway.zig"
	deps:    ["protocol", "codec"]
	profile: "release"
}

worker: #Module & {
	kind: "exe"
	root: "src/worker.zig"
	deps: ["protocol"]
}
$ ./zig-out/bin/gateway
gateway (ReleaseFast)
  magic       = 0xA2A2
  frame bytes = 12
  verify      = true
  tampered    = false

$ ./zig-out/bin/worker
worker (Debug)
  magic    = 0xA2A2
  job      = resize:1920x1080
  checksum = 574250979

An unknown profile is rejected:

$ cue export -e build
app.profile: 2 errors in empty disjunction:
app.profile: 3 errors in empty disjunction:
app.profile: conflicting values "debug" and "turbo":
    ./project.cue:4:6
    ./project.cue:7:11
    ./schema.cue:4:11
    ./schema.cue:10:12
app.profile: conflicting values "release" and "turbo":
    ./project.cue:4:6
    ./project.cue:7:11
    ./schema.cue:4:21
    ./schema.cue:10:12

profile is the only field with an indirection. Nothing else in the schema goes through a lookup table, which is what makes profiles the natural place to extend.


#Kind

#Kind: "exe" | "static" | "shared"
Valuebuild.zig callArtifactInstalled to
"exe"addExecutablenamezig-out/bin/
"static"addLibrary(.linkage = .static)libname.azig-out/lib/
"shared"addLibrary(.linkage = .dynamic)libname.dylib / libname.sozig-out/lib/

All three in one build:

$ ls zig-out/bin zig-out/lib
zig-out/bin:
gateway
worker

zig-out/lib:
libcodec.dylib
libprotocol.a

The names follow the target. Cross-compiling the same project.cue:

$ zig build -Dtarget=x86_64-linux --prefix zig-out-x
$ ls zig-out-x/bin zig-out-x/lib
zig-out-x/bin:
gateway
worker

zig-out-x/lib:
libcodec.so
libprotocol.a

$ file zig-out-x/bin/gateway
zig-out-x/bin/gateway: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, with debug_info, not stripped

Adding a fourth kind means editing #Kind in schema.cue and adding a case to the switch in build.zig. Everything else follows.


#Profile and #Profiles

#Profile: "debug" | "release"

#Profiles: {
	debug: {
		optimize: "Debug"
	}
	release: {
		optimize: "ReleaseFast"
	}
}

profiles: #Profiles

#Profile is the set of names you may write. #Profiles maps each name to a std.builtin.OptimizeMode spelling. export.cue performs the lookup:

optimize: profiles[v.profile].optimize

The generator already understands all four Zig modes: Debug, ReleaseFast, ReleaseSafe, ReleaseSmall. Adding a profile takes two edits to schema.cue.

Add the name:

#Profile: "debug" | "release" | "small"

Add the mapping:

#Profiles: {
	debug: {
		optimize: "Debug"
	}
	release: {
		optimize: "ReleaseFast"
	}
	small: {
		optimize: "ReleaseSmall"
	}
}

Use it:

hello: #Module & {
	kind:    "exe"
	root:    "src/main.zig"
	profile: "small"
}
$ ./gen_build_spec.sh && zig build && ./zig-out/bin/hello
Generated build_spec.zig
hello from azazel (optimize=ReleaseSmall)

ReleaseSafe works the same way:

$ ./gen_build_spec.sh && zig build && ./zig-out/bin/hello
Generated build_spec.zig
hello from azazel (optimize=ReleaseSafe)

A mode outside those four dies in the generator, at the lookup table:

$ ./gen_build_spec.sh
Traceback (most recent call last):
  File "<string>", line 10, in <module>
KeyError: 'SuperFast'
$ echo $?
1

That leaves a truncated build_spec.zig behind, since the header is written before the module list. Fix #Profiles and rerun; the file is rewritten from scratch each time.


export.cue

export.cue is the second half of declaring a module, and the half people forget.

package build

_modules: {
	"core": core
	"app":  app
}

build: modules: {
	for k, v in _modules {
		(k): {
			kind:     v.kind
			root:     v.root
			deps:     v.deps
			optimize: profiles[v.profile].optimize
		}
	}
}

_modules is the list of modules that get built. The comprehension below it projects each one into the shape the generator reads, resolving profile to optimize on the way.

project.cue describes modules. export.cue decides which of them exist as far as the build is concerned. A module in one and not the other is dropped with no warning:

$ cue vet
$ echo $?
0

$ ./gen_build_spec.sh
Generated build_spec.zig
pub const modules = [_]Module{
    .{
        .name = "app",
        .kind = .exe,
        .root = "src/main.zig",
        .deps = &.{},
        .optimize = .Debug,
    },
};

Order in _modules is the order in the generated array. It has no effect on linking.