Self-Describing Modules: Build Configuration That Can't Get Out of Sync
The Build Configuration Problem
You’re adding a new feature. It needs SQLite. You write the code:
#include <sqlite3.h>
void save_data(const char* path) {
sqlite3 *db;
sqlite3_open(path, &db);
// ... your code
} Compile. Link error. Oh right—forgot to update the build file.
Open CMakeLists.txt (separate file):
target_link_libraries(myapp sqlite3) Or Cargo.toml (separate file):
[dependencies]
rusqlite = "0.30" Or build.zig (separate file):
exe.linkSystemLibrary("sqlite3"); The dependency lives somewhere else. Easy to forget. Easy to get out of sync. Delete the feature? Better remember to clean up the build file too.
What if the code could just declare what it needs?
Koru’s Solution: build:requires
In Koru, modules are self-describing:
~import "$std/build"
~std.build:requires {
exe.linkSystemLibrary("sqlite3");
}
~event save_data { path: []const u8, data: []u8 }
| saved {}
| error { msg: []const u8 }
~proc save_data {
const db = c.sqlite3_open(path);
// ... your code
return .{ .saved = .{} };
} The dependency is right there in the source file. Next to the code that needs it. Compile this file, and the compiler automatically generates:
✓ Found 1 compiler requirement(s)
✓ Generated build.zig The build configuration is extracted from your source code. You literally cannot have a module that uses SQLite without declaring the SQLite dependency. It’s impossible to forget.
How It Works
1. Declare Dependencies In Code
Use ~std.build:requires with an implicit Source block:
~import "$std/build"
~std.build:requires {
exe.linkSystemLibrary("sqlite3");
exe.linkSystemLibrary("curl");
} That { ... } block? It’s not executed at runtime. It’s captured as raw Zig build code and passed to the compiler as a Source parameter.
Note: The collector also recognizes ~compiler:requires for compiler-internal build dependencies, but ~std.build:requires is the user-facing API.
2. Compiler Walks the AST
During compilation, CompilerRequiresCollector walks the AST looking for all ~compiler:requires invocations:
// In src/compiler_requires.zig
pub fn checkFlowForRequires(flow: *const ast.Flow) !void {
if (std.mem.eql(u8, module_qualifier, "compiler") and
std.mem.eql(u8, event_name, "requires"))
{
// Found one! Extract the Source parameter
const source_code = arg.value;
try self.requirements.append(source_code);
}
} Every module’s requirements are collected automatically.
3. Generate build.zig With Struct Namespacing
Each requirement is wrapped in its own struct scope to prevent variable name conflicts:
const std = @import("std");
pub fn build(__koru_b: *std.Build) void {
const __koru_target = __koru_b.standardTargetOptions(.{});
const __koru_optimize = __koru_b.standardOptimizeOption(.{});
const __koru_exe = __koru_b.addExecutable(.{
.name = "app",
.root_module = __koru_b.createModule(.{
.root_source_file = __koru_b.path("backend_output_emitted.zig"),
.target = __koru_target,
.optimize = __koru_optimize,
}),
});
// Module: graphics
const graphics_build_0 = struct {
fn call(b: *std.Build, exe: *std.Build.Step.Compile) void {
_ = &b; _ = &exe;
exe.linkSystemLibrary("sqlite3");
}
}.call;
graphics_build_0(__koru_b, __koru_exe);
__koru_b.installArtifact(__koru_exe);
} Each module’s build code lives in an isolated struct. No variable conflicts. Clean separation.
Multiple Modules, Same Library
What if two modules both need SQLite?
// graphics.kz
~import "$std/build"
~std.build:requires {
exe.linkSystemLibrary("sqlite3");
exe.linkSystemLibrary("vulkan");
}
// storage.kz
~import "$std/build"
~std.build:requires {
exe.linkSystemLibrary("sqlite3"); // Same library!
} This generates:
const graphics_build_0 = struct {
fn call(b: *std.Build, exe: *std.Build.Step.Compile) void {
exe.linkSystemLibrary("sqlite3");
exe.linkSystemLibrary("vulkan");
}
}.call;
graphics_build_0(__koru_b, __koru_exe);
const storage_build_0 = struct {
fn call(b: *std.Build, exe: *std.Build.Step.Compile) void {
exe.linkSystemLibrary("sqlite3"); // Duplicate!
}
}.call;
storage_build_0(__koru_b, __koru_exe); Does this cause problems? No! We tested it:
✅ BUILD SUCCEEDED WITH DUPLICATE LINKS! Zig’s build system and linker handle duplicate linkSystemLibrary() calls gracefully. The same library gets linked once.
Why this is actually better:
- Each module is truly self-contained - Declares exactly what IT needs
- No coordination required - Modules don’t need to know about each other
- Delete-safe - Remove graphics.kz, storage.kz still has everything it needs
- Grep shows truth -
grep sqlite3 storage.kzfinds the dependency immediately - Refactor-friendly - Move code between files, dependencies follow
This isn’t a bug—it’s a feature. Self-describing modules stay self-describing.
Real-World Example: Game Engine
// graphics.kz
~import "$std/build"
~std.build:requires {
const raylib = b.dependency("raylib", .{
.target = target,
.optimize = optimize,
});
exe.linkLibrary(raylib.artifact("raylib"));
exe.linkSystemLibrary("vulkan");
}
~event render { scene: Scene }
| done {}
// audio.kz
~import "$std/build"
~std.build:requires {
exe.linkSystemLibrary("openal");
}
~event play { sound: Sound }
| done {}
// physics.kz
~import "$std/build"
~std.build:requires {
const bullet = b.dependency("bullet3", .{});
exe.linkLibrary(bullet.artifact("bullet"));
}
~event step { dt: f32 }
| done {}
// persistence.kz
~import "$std/build"
~std.build:requires {
exe.linkSystemLibrary("sqlite3");
}
~event save_state { state: GameState }
| saved {} Run koruc game.kz and get:
✓ Found 4 compiler requirement(s)
✓ Generated build.zig All dependencies automatically collected. All modules self-describing. Zero manual build file maintenance.
Comparison: Traditional Approaches
CMake (C/C++)
Code and build separated:
// graphics.cpp
#include <vulkan/vulkan.h>
void render() { ... } # CMakeLists.txt (separate file!)
find_package(Vulkan REQUIRED)
target_link_libraries(myapp Vulkan::Vulkan) Problems:
- Build knowledge separated from code
- Easy to forget when adding features
- Easy to leave stale entries when removing features
- Have to remember which CMakeLists.txt controls which source file
- Refactoring code doesn’t update build config
Cargo (Rust)
Better, but still separate:
// src/graphics.rs
use vulkano::...;
pub fn render() { ... } # Cargo.toml (separate file!)
[dependencies]
vulkano = "0.34" Problems:
- All dependencies in one file, scales poorly for large projects
- Can’t tell which module needs which dependency just by reading code
- Deleting a module doesn’t remove its dependency from Cargo.toml
- No per-module dependency isolation
Zig build.zig (Current Best)
Already better:
// graphics.zig
const vk = @import("vulkan");
pub fn render() void { ... } // build.zig (separate file!)
pub fn build(b: *std.Build) void {
const exe = b.addExecutable(...);
exe.linkSystemLibrary("vulkan");
} Problems:
- Still separate file for build configuration
- Manual tracking of what each module needs
- Easy to forget when adding new modules
- Build file doesn’t scale with modular code
Koru
Dependencies in source code:
// graphics.kz
~import "$std/build"
~std.build:requires {
exe.linkSystemLibrary("vulkan");
}
~event render {}
~proc render { ... } Benefits:
- ✅ Build requirement lives next to the code that needs it
- ✅ Impossible to forget (module won’t compile without import + declaration)
- ✅ Delete module = dependency declaration deleted automatically
- ✅ Grep shows you exactly what each module needs
- ✅ Refactor-safe—move code, dependencies follow
- ✅ Generated build.zig is debuggable, readable Zig code
Advanced: Custom Build Steps
You’re not limited to linkSystemLibrary(). You have full access to Zig’s build API:
~import "$std/build"
~std.build:requires {
// Compile shaders
const glsl = b.addSystemCommand(&.{
"glslangValidator",
"-V",
"shader.glsl",
"-o",
"shader.spv",
});
exe.step.dependOn(&glsl.step);
// Install assets
b.installFile("shader.spv", "shaders/shader.spv");
} Anything Zig’s build system supports, you can use. No special syntax. Just Zig build code, captured from your source.
Platform-Specific Dependencies
~import "$std/build"
~std.build:requires {
const builtin = @import("builtin");
if (builtin.target.os.tag == .macos) {
exe.linkFramework("Metal");
exe.linkFramework("MetalKit");
} else if (builtin.target.os.tag == .windows) {
exe.linkSystemLibrary("d3d12");
} else {
exe.linkSystemLibrary("vulkan");
}
} Conditional compilation? Native Zig code. Works exactly as you’d expect.
The Source Type
The magic is the Source type parameter:
~pub event std.build:requires { source: Source }
| added {} When you write:
~std.build:requires {
exe.linkSystemLibrary("sqlite3");
} That { } block is an implicit Source block. Just like FlowAST has implicit { flow } syntax:
~threading:spawn {
~work()
| done |> _
} The Source type captures raw code as a string. The compiler passes it to the event handler. The handler (in this case, the AST collector) extracts it and saves it for build.zig generation.
No parsing. No interpretation. Just text extraction.
This pattern works for ANY domain-specific language you want to embed:
~routes:define {
GET /api/users -> user.list
POST /api/users -> user.create
DELETE /api/users/:id -> user.delete
} The Source type is a primitive. You can build whatever abstractions you want on top of it.
Implementation: Three Files
1. compiler_requires.zig - AST Walker
pub const CompilerRequiresCollector = struct {
allocator: std.mem.Allocator,
requirements: std.ArrayList([]const u8),
pub fn checkFlowForRequires(self: *Self, flow: *const ast.Flow) !void {
// Check for compiler:requires or std.build:requires
const is_build_requires = (std.mem.eql(u8, mq, "std") and
flow.invocation.path.segments[0] == "build" and
flow.invocation.path.segments[1] == "requires");
const is_compiler_requires = (std.mem.eql(u8, mq, "compiler") and
flow.invocation.path.segments[0] == "requires");
if (is_build_requires or is_compiler_requires) {
// Extract source parameter
const source_copy = try self.allocator.dupe(u8, arg.value);
try self.requirements.append(source_copy);
}
}
}; Walks the AST, finds ~compiler:requires invocations, extracts Source parameters.
2. emit_build_zig.zig - Code Generator
pub fn emitBuildZig(
allocator: std.mem.Allocator,
requires: []const BuildRequirement,
output_path: []const u8,
) !void {
// Generate build.zig header
append(&buffer, &pos, "const std = @import("std");
");
append(&buffer, &pos, "pub fn build(__koru_b: *std.Build) void {
");
// ... setup code ...
// Wrap each requirement in struct scope
for (requires, 0..) |req, i| {
append(&buffer, &pos, "const module_build_");
append(&buffer, &pos, index_str);
append(&buffer, &pos, " = struct {
");
append(&buffer, &pos, " fn call(b: *std.Build, exe: *std.Build.Step.Compile) void {
");
// Inject user's build code
append(&buffer, &pos, req.source_code);
append(&buffer, &pos, " }
}.call;
");
append(&buffer, &pos, "module_build_");
append(&buffer, &pos, index_str);
append(&buffer, &pos, "(__koru_b, __koru_exe);
");
}
// Write to file
try file.writeAll(final_content);
} Takes collected requirements, wraps each in struct, generates build.zig.
3. main.zig - Integration
// Collect requirements from AST
var requires_collector = try CompilerRequiresCollector.init(allocator);
try requires_collector.collectFromSourceFile(&parse_result.source_file);
const requirements = requires_collector.getRequirements();
if (requirements.len > 0) {
try printStdout(allocator, "✓ Found {d} compiler requirement(s)
", .{requirements.len});
// Generate build.zig
try emit_build_zig.emitBuildZig(allocator, build_requirements.items, "build.zig");
try printStdout(allocator, "✓ Generated build.zig
", .{});
} Hooks into the compiler pipeline. Runs after parsing, before code generation.
That’s it. Three files. ~300 lines total. Self-describing modules.
Current Status: Shipping NOW
This isn’t vaporware. This is working today:
$ koruc myapp.kz
✓ Compiled myapp.kz → backend.zig
✓ Generated backend_output_emitted.zig (62479 bytes)
✓ Found 3 compiler requirement(s)
✓ Generated build.zig
$ zig build
✅ Build succeeded
$ ./zig-out/bin/app
Hello from self-describing modules! Tests passing: test 619
Implementation:
- compiler_requires.zig - AST collector
- emit_build_zig.zig - Code generator
- Integration in main.zig
Design Philosophy
Why Inline Declarations?
Locality of behavior. When you read a module, you should know:
- What it does
- What it depends on
- What it returns
Without jumping to separate configuration files.
Why Raw Zig Code?
Maximum flexibility. Don’t abstract away Zig’s build API. Don’t create a Koru-specific DSL for build configuration. Just let users write Zig build code directly.
When Zig adds new build features, they’re immediately available. Zero waiting for Koru to expose them.
Why Struct Namespacing?
Isolation without coordination. Each module uses natural variable names (b, exe) without worrying about conflicts. The generated code uses __koru_ prefixes in outer scope, so modules can use clean names.
Why Not Deduplicate?
Self-describing modules over optimization. If two modules need SQLite, they both declare it. Zig’s linker handles duplicates. The benefit: each module stands alone. Delete one, the other still works. No hidden dependencies.
Future: Standard Library Helpers
For common patterns, helpers could reduce boilerplate:
// Instead of raw build.requires:
~build:link_system_libs(libs: ["vulkan", "opengl", "x11"])
// Which expands to:
~std.build:requires {
exe.linkSystemLibrary("vulkan");
exe.linkSystemLibrary("opengl");
exe.linkSystemLibrary("x11");
} But this is optional. Power users can always drop down to raw ~std.build:requires.
Why This Matters
Build configuration drift is a solved problem in Koru.
You cannot:
- ❌ Forget to declare a dependency (module won’t compile)
- ❌ Leave stale dependencies (delete module = delete declaration)
- ❌ Wonder which module needs which library (grep the source)
- ❌ Have build config out of sync (it’s extracted from source)
You get:
- ✅ Self-describing modules
- ✅ Zero manual build file maintenance
- ✅ Refactor-safe dependencies
- ✅ Debuggable generated build.zig
- ✅ Full power of Zig’s build API
Try It
Want to see self-describing modules in action?
Clone the repo:
git clone https://github.com/korulang/koru
cd koru Run test 619:
./run_regression.sh 619 Check the generated build.zig:
cat build.zig See the code:
Welcome to Self-Describing Modules
Build configuration that can’t get out of sync. Because it’s not separate—it’s embedded in your source code.
Welcome to Koru. Where modules describe themselves.