Comptime string comparison failing test

Hello everyone!

Can you help me understand why this test does not pass? (master branch)

Context: I wanted to make sure a struct (eg a user generated struct or a reificated struct) has just attributes named “required” and “commands”. If the struct has a field called “hello” the test should not pass.

Test 1: I iterate over the fields and check with std.mem.eql

const std = @import("std");

const Target = struct {
    required: u32 = 0,
    commands: u32 = 0,
    // imagine a user has written an invalid field here!
};

test "Checking with an if (this test _should_ pass)" {
    const info = @typeInfo(Target);
    
    // let's iterate over the struct to see if just required or commands are valid
    inline for (info.@"struct".fields) |field| {
        const name = field.name;
        
        var hasRequired = false;
        var hasCommands = false;

        if (std.mem.eql(u8, name, "required")) {
            hasRequired = true; 
        } else if (std.mem.eql(u8, name, "commands")) {
            hasCommands = true;
        } else {
            @compileLog("Field Bytes:");
            inline for (name) |c| { @compileLog(c); }
            @compileLog("Expected Bytes:");
            inline for ("required") |c| { @compileLog(c); }
            @compileError("I've found an invalid comparison: " ++ name);
        }
    }
}

Output: it fails, but isn’t the string the same???

bug.zig:45:13: error: I've found an invalid comparison: required
            @compileError("I've found an invalid comparison: " ++ name);
            ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Compile Log Output:
@as(*const [12:0]u8, "Field Bytes:")
@as(u8, 114)
@as(u8, 101)
@as(u8, 113)
@as(u8, 117)
@as(u8, 105)
@as(u8, 114)
@as(u8, 101)
@as(u8, 100)
@as(*const [15:0]u8, "Expected Bytes:")
@as(u8, 114)
@as(u8, 101)
@as(u8, 113)
@as(u8, 117)
@as(u8, 105)
@as(u8, 114)
@as(u8, 101)
@as(u8, 100)

At this point I was super paranoid, so I implemented a one by one string comparison function

fn verboseStrEq(comptime field_name: []const u8, comptime target: []const u8) bool {
    if (field_name.len != target.len) return false;
    
    for (field_name, 0..) |char, i| {
        if (char != target[i]) {
            @compileLog("MISMATCH AT INDEX:", i);
            @compileLog("  Field char:", char);
            @compileLog("  Target char:", target[i]);
            return false;
        }
    }
    return true;
}

test "Doing it with a custom string to see why it did not pass" {
    const info = @typeInfo(Target);

    inline for (info.@"struct".fields) |field| {
        const name = field.name;
        
        var hasRequired = false;
        var hasCommands = false;

        if (std.mem.eql(u8, name, "required")) {
            hasRequired = true; 
        } else if (std.mem.eql(u8, name, "commands")) {
            hasCommands = true;
        } else {
            @compileLog("Checking field:", name);
            if (verboseStrEq(name, "required")) {
                 @compileLog("Match!");
            } else {
                 @compileLog("No Match!");
                 @compileError("Comparison failed despite looking identical");
            }
        }
    }
}

Output: if just got me even more confused, how the f*ck is does this match and not match within the same run???

bug.zig:70:18: error: Comparison failed despite looking identical
                 @compileError("Comparison failed despite looking identical");
                 ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Compile Log Output:
@as(*const [18:0]u8, "MISMATCH AT INDEX:"), @as(usize, [runtime value])
@as(*const [13:0]u8, "  Field char:"), @as(u8, [runtime value])
@as(*const [14:0]u8, "  Target char:"), @as(u8, [runtime value])
@as(*const [15:0]u8, "Checking field:"), @as([:0]const u8, "required"[0..8])
@as(*const [6:0]u8, "Match!")
@as(*const [9:0]u8, "No Match!")

The only reasonable behaviour (and in essence is doning the same but at runtime if I am not wrong?) happens in this last text, where it just passes.

test "With field parent it works" {
    const hasRequired = @hasField(Target, "required");
    const hasCommands = @hasField(Target, "commands");

    try std.testing.expect(hasRequired);
    try std.testing.expect(hasCommands);
}

This feels like a bug to me but I don’t understand why nor how happens. If you also think it is I can report it to codeberg, but I want to make sure I am not doing something deeply wrong.

Thank you :slight_smile:

const std = @import("std");

const Target = struct {
    required: u32 = 0,
    commands: u32 = 0,
    // imagine a user has written an invalid field here!
};

test "Checking with an if (this test _should_ pass)" {
    const info = @typeInfo(Target);
    
    // let's iterate over the struct to see if just required or commands are valid
    inline for (info.@"struct".fields) |field| {
        const name = field.name;
        
        var hasRequired = false;
        var hasCommands = false;

        if (comptime std.mem.eql(u8, name, "required")) {
            hasRequired = true; 
        } else if (comptime std.mem.eql(u8, name, "commands")) {
            hasCommands = true;
        } else {
            @compileLog("Field Bytes:");
            inline for (name) |c| { @compileLog(c); }
            @compileLog("Expected Bytes:");
            inline for ("required") |c| { @compileLog(c); }
            @compileError("I've found an invalid comparison: " ++ name);
        }
    }
}

You can pass the test by adding the comptime keyword before the two std.mem.eql.

Reason: Zig did not automatically set the memory comparison here to be executed at compile time, so you need to explicitly specify that it should be executed at compile time. Since these memory comparisons are executed at runtime, Zig did not eliminate this else branch, so the related CompileLog needs to be printed.

6 Likes

You can use @hasField to check if they are valid fields.

const hasRequired = @hasField(Target, "required");
const hasCommands = @hasField(Target, "commands");
3 Likes

Good idea. That plus a check for exactly 2 fields would do it.

Yeah, it’s at the bottom of my post! Thank you :))

Hell yeah, that’s it! Do you know why does zig don’t do this automatically if you are inside an inline, shouldn’t that make the whole context comptime?

Yes I asked myself the same question multiple times in that exact same context (inline for + std.mem.eql)

because inline != comptime.

inline unrolls loops, switch prongs, and in lines functions. This does propagate the comptimeness of captures/arguments, which can then be used for comptime evaluation, but it does not force it.

Functions won’t be called at comptime unless they are in a context that forces it, this can be from:

  • the use of comptime.
  • in the initial value of a global const/var.
  • default field values, or if the function returns a comptime only type (most often type).

The reason for that behaviour is:

a) it would greatly increase compile times, comptime is slow.
Also, LLVM might do that for us, if it thinks its worth it.

b) it simplifies dealing with functions that can only be called at runtime, instead of the compiler having to unwind itself, it can just assume the programmer did an oopsie, since it’s the programmers choice if it’s called at comptime.

8 Likes

Actually it makes a lot of sense when you put it this way. I could just added a comptime { // my code snippet } keyword with all the code in it if I really wanted to be fully comptime. Also makes sense to minimize what should be ran on compile time also, that’s why in case of doubt (as the behavior of std.mem.eql in this example) will be made run time!

Super clear explanation thank you very much :))

3 Likes

I think it’s a bit nicer to lift the comptime here:

fn validateTarget(Target: type) void {
    const info = @typeInfo(Target);

    comptime {
        // let's iterate over the struct to see if just required or commands are valid
        for (info.@"struct".fields) |field| {
            const name = field.name;

            var hasRequired = false;
            var hasCommands = false;
            if (std.mem.eql(u8, name, "required")) {
                hasRequired = true;
            } else if (std.mem.eql(u8, name, "commands")) {
                hasCommands = true;
            } else {
                @compileLog("Field Bytes:");
                for (name) |c| {
                    @compileLog(c);
                }
                @compileLog("Expected Bytes:");
                for ("required") |c| {
                    @compileLog(c);
                }
                @compileError("I've found an invalid comparison: " ++ name);
            }
        }
    }
}

const TargetStruct = struct {
    required: u32 = 0,
    commands: u32 = 0,
    // try uncommenting this:
    // other: u32 = 0,
};

test "with function" {
    validateTarget(TargetStruct);
    try std.testing.expect(true); // irrelevant tbh...
}

I’d suggest not putting fancy comptime stuff directly inside a test block, either. No big deal, it amounts to a function body with no arguments, but if you put it in a function call, you can use comptime on the arguments, or on the function call. More flexible that way.

1 Like