Is there something like Rust's cargo expand for Zig's comptime

So I am trying to wrap my head around comptime in Zig, and I am finding the line between compile time and runtime a bit blurry and confusing.

I think the main issue is, because it is exactly just Zig code, it get’s a bit confusing to note mentally the logic that runs at compile time vs the one that runs at runtime and how to think about the difference.

I was then wondering if there is a way to see the output of the logic that runs at compile time, without running.

Something like cargo expand GitHub - dtolnay/cargo-expand: Subcommand to show result of macro expansion which allows to see the output of the code generation that happens when using Rust macro’s.

Is there something similar in Zig?

Plus any tips on how to approach comptime in Zig?

For example approaching this code

const std = @import("std");
const config = @import("config");

pub fn main() !void {
    inline for (.{ u8, u16, u32, u64 }) |T| {
        std.debug.print("{} ", .{@typeInfo(T).Int.bits});
    }
}

This is supposed to be a for loop that runs at compile time, but does have a run time representation because it also runs at runtime and it prints the result"

 zig build run
8 16 32 64 %    

Is it possible to see the output of the comptime logic?

While Rust’s Macro System and Zig’s Comptime are both useful for meta programming, they are kinda different.

In Rust the macros are ways to programmatically generate source code, that will then be piped into the AST before compilation begins. My understanding of comptime is different. Comptime is Code that you are saying “Run this at compile time, and then use the output”. Most of what is done in compile time will disappear from the compiled output.

That being said, I think there is room for a similar tool like you are asking that, rather than showing what comptime expands too, what is eliminated after comptime completes.

I don’t have the knowledge of the compiler, and when comptime executes to know how that could be done, or if it could be done.

2 Likes

Another example. From the ziglings

    inline while (i < instructions.len) : (i += 3) {

        // This gets the digit from the "instruction". Can you
        // figure out why we subtract '0' from it?
        const digit = instructions[i + 1] - '0';

        // This 'switch' statement contains the actual work done
        // at runtime. At first, this doesn't seem exciting...
        switch (instructions[i]) {
            '+' => value += digit,
            '-' => value -= digit,
            '*' => value *= digit,
            else => unreachable,
        }
        // ...But it's quite a bit more exciting than it first appears.
        // The 'inline while' no longer exists at runtime and neither
        // does anything else not touched directly by runtime
        // code. The 'instructions' string, for example, does not
        // appear anywhere in the compiled program because it's
        // not used by it!
        //
        // So in a very real sense, this loop actually converts
        // the instructions contained in a string into runtime
        // code at compile time. Guess we're compiler writers
        // now. See? The wizard hat was justified after all.
    }

I get that the while loop will execute at compile time, but in the comment, of the code within it, it says:

        // This 'switch' statement contains the actual work done
        // at runtime. At first, this doesn't seem exciting...

How come, if the while loop executes at compile time, within it’s body is then also code that executes at Runtime. How does that work?

The comment above is fairly accurate. A feature like this doesn’t really work for Zig, because comptime isn’t acting on the AST, so we can’t really recover Zig code from it. Implementation-wise, comptime is essentially an interpreter for Zig code. When executing at runtime, it’s essentially an interpreter which emits runtime instructions instead of actually doing the computation. In your specific example of inline for, the loop condition is interpreted at comptime, so the compiler itself is looping 4 times (over each of those types); and the body of the loop is being “interpreted” in a runtime sense, so it emits runtime code for each iteration rather than actually trying to comptime-eval std.debug.print.

To try and understand the execution of comptime code, note the existence of @compileLog, which allows you to print arbitrary values at comptime. Try replacing your std.debug.print call with @compileLog(@typeInfo(T).Int.bits) for example.

6 Likes

If Zig gets a custom debugger in the future, it seems to me it could be possible to create a mode to run the compiler “interactively” allowing the user to inspect the compiler and step through custom special breakpoints to gain insight into what is happening. Maybe part of that could be to have a version of the comptime interpreter that triggers “breakpoints” that make it stepable and report info to an attached or spawned interface, that can then be used to step through significant points in its execution. This is pure speculation on my part, but I imagine something like that should be possible.

But I guess that would still only give you a somewhat abstract view into what is happening that is quite different from providing a view of an imaginary “expanding” of the code

But I don’t know how much would be required to implement it, probably also only makes sense once incremental compilation is done?

I guess what I describe would basically be a @compileDebugger() and that would spawn a custom debugger gui/tui, or maybe a comptime @breakpoint().

3 Likes