Self-Documenting Compiler Flags: When Your Compiler Tells You How to Use It

· 8 min read

The Problem: How Do You Document Flags Without Hardcoding Them?

We’re building a compiler. Compilers need flags:

koruc --fusion --ccp input.kz

And they need help text:

$ koruc --help

Koru Compiler

Flags:
  --fusion    Enable event fusion optimization for zero-cost abstractions
  --ccp       Enable Compiler Communication Protocol for AI observability
  --verbose   Enable verbose compiler output and debug logging

Simple enough, right? Most compilers have a giant match statement somewhere:

if (std.mem.eql(u8, arg, "--help")) {
    std.debug.print("Flags:
", .{});
    std.debug.print("  --fusion    Enable event fusion optimization
", .{});
    std.debug.print("  --ccp       Enable Compiler Communication Protocol
", .{});
    std.debug.print("  --verbose   Enable verbose compiler output
", .{});
}

This is fine.

Except it’s not.

Because we’re not building just any compiler. We’re building a metacircular compiler.

A compiler that can:

  • Compile itself
  • Profile its own performance
  • Generate its own build system
  • Document its own flags

Hardcoding flag documentation violates the principle: The compiler should know itself by reading itself.

The Constraint: It Must Be Metacircular

Here’s what we needed:

  1. Flags must be declared in Koru code - Not hardcoded in Zig
  2. The compiler must discover them by parsing itself - Not from external config files
  3. The documentation must live with the feature - Not in a separate README
  4. Users can extend flags without touching the compiler - Import a module, get its flags

In other words: The compiler needs to tell YOU how to use it, by reading its own source code.

We didn’t know if this was even possible.

The Exploration: Where Do We Put the Metadata?

We knew the flags would be declared somewhere in compiler.kz (the Koru standard library that implements the compiler).

But how do you encode flag metadata in executable code?

Attempt 1: Comments?

// FLAG: fusion
// DESCRIPTION: Enable event fusion optimization
// TYPE: boolean

NO. Comments don’t exist in the AST. The parser throws them away. We’d need a separate documentation parser. That’s two parsers. Gross.

Attempt 2: String Constants?

pub const FUSION_FLAG = FlagDeclaration{
    .name = "fusion",
    .description = "Enable event fusion optimization",
    .type = "boolean",
};

NO. This is just hardcoding with extra steps. And now you need a Zig constant evaluator to run at compile time. Even grosser.

Attempt 3: …What if the declaration IS an event?

Wait.

What if declaring a flag is itself an event invocation with the metadata as parameters?

~compiler:flags.declare {
  "name": "fusion",
  "description": "Enable event fusion optimization",
  "type": "boolean"
}

This is interesting. The metadata is now in the AST as an event invocation. The compiler can discover it by walking its own AST.

But there’s a problem: How do you prevent this from executing at runtime?

The Insight: [norun] + Source Parameters = Discoverable Metadata

Koru has a feature for compile-time events: events with Source parameters.

event flags.declare { source: Source }

The Source type means “capture this code block as a string literal.” The event receives the source text, not evaluated code.

This is used for macros and AST transformations. But we realized: It’s also perfect for embedded metadata.

Here’s the key insight:

  1. Declare the event with [norun] - The frontend sees it but doesn’t execute it
  2. Use Source parameter - The JSON gets captured as a string in the AST
  3. Walk the AST during --help - Find all compiler:flags.declare invocations
  4. Parse the JSON - Extract name, description, type
  5. Print the help text - Self-documented!

The annotation:

~[comptime|norun]pub event flags.declare { source: Source }
  • [comptime] - This runs during compilation, not at runtime
  • [norun] - Don’t execute it, just preserve it in the AST for discovery
  • pub - Other modules can invoke this to declare their own flags
  • source: Source - Captures the code block as a string

The Solution: 15 Lines of Pure Elegance

Here’s the actual code in compiler.kz:

// ============================================================
// COMPILER FLAGS - Self-documenting feature flags
// ============================================================
// Modules declare their compiler flags with inline documentation.
// The frontend collects these during --help to build flag documentation.
//
// Usage:
//   ~compiler:flags.declare {
//     "name": "fusion",
//     "description": "Enable event fusion optimization",
//     "type": "boolean"
//   }
//
// [norun] annotation prevents auto-execution - --help reads from AST instead.
// This makes help metacircular - discovered by parsing, not hardcoded!

~[comptime|norun]pub event flags.declare { source: Source }

~flags.declare {
  "name": "fusion",
  "description": "Enable event fusion optimization for zero-cost abstractions",
  "type": "boolean"
}

~flags.declare {
  "name": "ccp",
  "description": "Enable Compiler Communication Protocol for AI observability",
  "type": "boolean"
}

~flags.declare {
  "name": "verbose",
  "description": "Enable verbose compiler output and debug logging",
  "type": "boolean"
}

That’s it.

15 lines to declare three compiler flags. Self-documenting. Discoverable. Beautiful.

The Discovery: AST-Walking in 60 Lines

When you run koruc --help, the compiler:

  1. Parses the full program AST (both standard library AND user code)
  2. Walks every item looking for flows that invoke compiler:flags.declare
  3. Extracts the Source parameter (the JSON string)
  4. Parses the JSON to get name, description, type
  5. Prints the formatted help text

This is crucial: The compiler doesn’t just parse its own code. It parses THE ENTIRE PROGRAM - including your code. Because in Koru, there is no distinction between compiler code and user code. They’re parsed by the same parser, walked by the same AST traversal, discovered by the same mechanism.

Here’s the actual discovery code from main.zig:

/// Collect all compiler.flags.declare invocations from AST
fn collectFlagDeclarations(allocator: std.mem.Allocator, program: *const ast.Program) ![]FlagDeclaration {
    var flags = try std.ArrayList(FlagDeclaration).initCapacity(allocator, 4);

    // Walk FULL program AST (compiler code + user code - no distinction!)
    for (program.items) |item| {
        if (item == .flow) {
            const flow = item.flow;
            // Check if this is compiler.flags.declare
            if (flow.invocation.path.segments.len == 3 and
                std.mem.eql(u8, flow.invocation.path.segments[0], "compiler") and
                std.mem.eql(u8, flow.invocation.path.segments[1], "flags") and
                std.mem.eql(u8, flow.invocation.path.segments[2], "declare"))
            {
                // Extract source parameter (the JSON)
                for (flow.invocation.args) |arg| {
                    if (std.mem.eql(u8, arg.name, "source")) {
                        const flag = try parseFlagDeclaration(allocator, arg.value);
                        try flags.append(allocator, flag);
                    }
                }
            }
        }
    }

    return flags.toOwnedSlice(allocator);
}

The compiler parses itself and tells you what it knows.

No Boundary Between Compiler and User Code

This is the deepest metacircular truth:

The compiler doesn’t have special privileges.

When the compiler walks the AST looking for compiler:flags.declare, it walks the entire program AST. Your code. The standard library. Imported modules. All of it. There’s no separate “compiler code path” and “user code path.”

The compiler.kz standard library that implements the compiler? It’s parsed by the same parser that parses your input.kz. Its events are discovered by the same AST walk. Its flags are collected by the same mechanism.

If you write:

~compiler:flags.declare {
  "name": "my-flag",
  "description": "My custom compiler flag",
  "type": "boolean"
}

…in your code, it gets discovered exactly the same way as the compiler’s built-in flags. Not through special registration. Not through import magic. Through the universal mechanism of AST-walking.

This is metacircularity in action: The compiler treats its own code the same way it treats your code. Because they’re both just Koru code. Parsed by the same parser. Walked by the same traversal. Discovered by the same mechanisms.

There is no boundary.

What This Unlocks

1. Extensible Flag System

Here’s the magic: Users can declare compiler flags in their own code using the exact same mechanism.

There’s no privilege. No special API. No registration. You write the same event invocation:

// my_optimizer.kz
~[comptime]import "$std/compiler"

~compiler:flags.declare {
  "name": "inline-threshold",
  "description": "Maximum function size for inlining",
  "type": "number"
}

event optimize(ast) {
  // Use the flag value here
}

When you run koruc --help, it discovers YOUR flags alongside the compiler’s flags. Not because it special-cases imported modules. Not because you registered them. But because the compiler parses the full program AST - compiler code and user code together, without distinction.

Your flags.declare invocation gets discovered by the exact same AST walk that finds the compiler’s flags. The mechanism is universal.

2. Living Documentation

The flag declaration IS the documentation. They can’t get out of sync because they’re the same thing:

~flags.declare {
  "name": "fusion",
  "description": "Enable event fusion optimization for zero-cost abstractions",
  "type": "boolean"
}

Want to update the help text? Edit the declaration. Want to add a flag? Add a declaration. One source of truth.

3. IDE Integration (Future)

Because flags are discoverable from the AST, IDEs can:

  • Show all available flags with autocomplete
  • Display flag descriptions on hover
  • Validate flag values against declared types

All without hardcoding flag lists in editor plugins.

4. Machine-Readable Metadata

The JSON format means tools can programmatically query available flags:

$ koruc --list-flags --json
[
  {
    "name": "fusion",
    "description": "Enable event fusion optimization for zero-cost abstractions",
    "type": "boolean"
  },
  ...
]

Build systems, CI pipelines, and other tools can discover capabilities dynamically.

The Philosophy: Code That Knows Itself

This feature exemplifies Koru’s metacircular philosophy:

The compiler is not a black box. It’s a self-describing system.

When you ask koruc --help, you’re not reading hardcoded strings. You’re reading the compiler’s understanding of itself.

The compiler:

  • Parses the full program (compiler code + user code as equals)
  • Walks the unified AST
  • Discovers capabilities from everywhere
  • Tells you what it knows

This is metacircular development. The tool knows itself by examining itself. And it knows your code by examining it the exact same way.

And the mechanism is general:

  • compiler:requires - Declares build dependencies by parsing source
  • compiler:flags.declare - Declares compiler flags by parsing source
  • compiler:passes.* - The compilation pipeline described in Koru, discoverable by parsing

The pattern is consistent: Declarative metadata embedded in executable code, discovered through self-parsing.

The Journey: From Uncertainty to Elegance

We started not knowing if this was even possible.

We explored several bad approaches:

  • Comments (thrown away by parser)
  • Constants (need runtime eval)
  • External config files (not metacircular)

Then we realized: What if declarations are events?

And the pieces fell into place:

  • [norun] - Don’t execute, just preserve in AST
  • Source parameters - Capture code blocks as strings
  • JSON in source - Machine-readable metadata
  • AST-walking - Discover by parsing yourself

The result is 15 lines of elegant declarations that:

  • Document themselves
  • Extend naturally
  • Integrate with tooling
  • Embody metacircular principles

The Collaboration: AI-First Development

This feature emerged from human-AI pair programming:

  • Human: “We need compiler flags, but hardcoding them feels wrong.”
  • AI: “What if flags are declared as events?”
  • Human: “But they’d execute at runtime…”
  • AI: “What about [norun] with Source parameters?”
  • Human: “Oh. OH. That’s brilliant.”

The implementation took one afternoon. The testing revealed edge cases. The iteration made it robust.

Neither human nor AI would have reached this solution alone.

The human brought domain intuition about metacircular systems. The AI brought systematic exploration of the language’s feature space.

Together, we found an elegant solution that:

  • Uses existing features in novel combinations
  • Requires no new syntax
  • Works naturally with the AST
  • Embodies the metacircular philosophy

This is what AI-first development unlocks: Faster exploration of design spaces, with human judgment guiding the search.

The Punchline

We needed --help for compiler flags.

We could have hardcoded a list of strings.

Instead, we made the compiler discover its own flags by parsing the full program AST.

The flags are:

  • Declared in Koru code (yours and the compiler’s, treated identically)
  • Self-documenting
  • Extensible by users (with zero privilege separation)
  • Machine-readable
  • Integrated with the AST

The compiler tells YOU how to use it. And it discovers YOUR extensions the same way it discovers its own.

And the mechanism is simple:

  • [norun] events don’t execute, they’re discovered
  • Source parameters capture code as strings
  • JSON embeds machine-readable metadata
  • AST-walking discovers declarations

One feature. Infinite extensibility.

This is what happens when you build metacircular systems instead of static tools.

This is what happens when you ask “How can code know itself?” instead of “Where should I hardcode this?”

This is what happens when humans and AI collaborate on design problems.

We built a compiler that documents itself by reading itself.

The help text is alive.

The future is metacircular.


Want to try Koru? Check out the language guide or read about metacircular compiler development in more detail. The compiler is open source, the tests are real, and the philosophy is infectious.

This is an AI-first project. Every feature is designed through human-AI collaboration. If that excites you, join us.