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
| Field | Type | Required | Default |
|---|---|---|---|
kind | #Kind | yes | none |
root | string | yes | none |
deps | [...string] | no | [] |
profile | #Profile | no | "debug" |
link | #Link | no | "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.
link
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.
profile | optimize in the spec | Zig |
|---|---|---|
"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"
| Value | build.zig call | Artifact | Installed to |
|---|---|---|---|
"exe" | addExecutable | name | zig-out/bin/ |
"static" | addLibrary(.linkage = .static) | libname.a | zig-out/lib/ |
"shared" | addLibrary(.linkage = .dynamic) | libname.dylib / libname.so | zig-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.