Self-Describing Modules: Build Configuration That Can't Get Out of Sync

· 14 min read

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:

  1. Each module is truly self-contained - Declares exactly what IT needs
  2. No coordination required - Modules don’t need to know about each other
  3. Delete-safe - Remove graphics.kz, storage.kz still has everything it needs
  4. Grep shows truth - grep sqlite3 storage.kz finds the dependency immediately
  5. 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:


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.