Backlog/lib/sdl3/research/zig-skills.md

21 KiB

Zig 0.15 Skills and Gotchas

This document tracks common issues, syntax changes, and gotchas encountered when working with Zig 0.15 during the SDL3 parser implementation.

Work Summary

Successfully implemented a fully functional SDL3 C header parser in Zig that converts SDL3 C headers into idiomatic Zig bindings. The parser uses a simplified text-matching approach (no full C parser) and successfully handles:

  • Opaque types: typedef struct SDL_GPUDevice SDL_GPUDevice;pub const GPUDevice = opaque {};
  • Enums: C enums with value detection → Zig enums with camelCase values
  • Structs: C structs with field parsing → Zig extern structs with converted types
  • Flags: C typedef + #define flags → Zig packed structs
  • Functions: C function declarations → Zig inline wrapper functions with proper casts

Key Achievements:

  • 4 core modules: patterns.zig (700+ lines), naming.zig (130+ lines), types.zig (88 lines), codegen.zig (339 lines)
  • All 14 tests passing
  • Zero memory leaks after fixes
  • Successfully parses test headers and generates valid Zig code
  • Proper handling of SDL naming conventions (SDL_GPUDevice → GPUDevice, SDL_CreateGPUDevice → createGPUDevice)

Major Bugs Fixed:

  1. Memory leaks from readLine() allocations - fixed with defer
  2. Function name conversion bug (gPUSupportsShaderFormats) - fixed acronym lowercasing logic
  3. Enum/struct parsing broken - fixed matchPrefix + readLine interaction
  4. Brace characters appearing in parsed values - added brace line filtering

Build System Changes

root_module vs root_source_file

Issue: In Zig 0.15, the build system API changed from root_source_file to root_module.

Old (pre-0.15):

const parser_exe = b.addExecutable(.{
    .name = "sdl-parser",
    .root_source_file = b.path("parser.zig"),
    .target = target,
    .optimize = optimize,
});

New (0.15+):

const parser_exe = b.addExecutable(.{
    .name = "sdl-parser",
    .root_module = b.createModule(.{
        .root_source_file = b.path("parser.zig"),
        .target = target,
        .optimize = optimize,
    }),
});

Solution: Use root_module = b.createModule(.{...}) instead of passing fields directly.

ArrayList API Changes

Allocator Required for All Methods

Issue: ArrayList methods now require passing the allocator explicitly, not just at initialization.

Old:

var list = std.ArrayList(u8).init(allocator);
try list.append('x');
const slice = list.toOwnedSlice();
list.deinit();

New (0.15+):

var list = try std.ArrayList(u8).initCapacity(allocator, initial_capacity);
try list.append(allocator, 'x');
const slice = try list.toOwnedSlice(allocator);
list.deinit(allocator);

Solution: Pass allocator to append(), toOwnedSlice(), deinit(), and use initCapacity() instead of init().

Files affected:

  • naming.zig: All ArrayList operations
  • patterns.zig: String building and result accumulation

Reserved Keywords

opaque is Reserved

Issue: opaque is a reserved keyword in Zig and cannot be used as an identifier.

Error:

// ❌ This fails
fn scanOpaque() !?OpaqueType {
    if (condition) |opaque| {  // ERROR: 'opaque' is an identifier
        return opaque;
    }
}

// ❌ This also fails
fn writeOpaque(self: *Self, opaque: OpaqueType) !void {
                             ^~~~~~  // ERROR: expected '{', found ':'
}

// ❌ Even in tests
test "opaque type" {
    const opaque = OpaqueType{...};  // ERROR: expected 'an identifier', found 'opaque'
}

Solution: Use alternative names like opaque_type, opaque_decl, opaque_val:

// ✅ Correct
fn scanOpaque() !?OpaqueType {
    if (condition) |opaque_decl| {
        return opaque_decl;
    }
}

fn writeOpaque(self: *Self, opaque_type: OpaqueType) !void {
    // ...
}

test "opaque type" {
    const opaque_type = OpaqueType{...};
}

Files affected:

  • patterns.zig:92: Capture variable renamed to opaque_decl
  • codegen.zig:44: Capture variable renamed to opaque_decl
  • codegen.zig:53: Parameter renamed to opaque_type
  • codegen.zig:247: Test variable renamed to opaque_type

Compiler Strictness

Unused Variables are Errors

Issue: Zig 0.15 treats unused variables as compilation errors, not warnings.

Error:

const value = getSomething();  // ERROR: unused local variable

Solutions:

  1. Use the variable
  2. Assign to _ if intentionally unused:
    _ = getSomething();
    
  3. Prefix with underscore for parameters:
    fn callback(_unused: u32) void {}
    

Error Unions Must be Handled

Issue: Functions returning error unions must have errors explicitly handled.

Error:

const line = self.readLine();  // ERROR: error union not handled
                               // readLine() returns ![]const u8

Solution: Use try or explicit error handling:

const line = try self.readLine();  // ✅ Correct

// Or handle explicitly
const line = self.readLine() catch |err| {
    return err;
};

Files affected:

  • patterns.zig: All readLine() calls needed try

Memory Management

Arena Allocator for Tests

Issue: Using direct allocation in tests can cause memory leak errors that are hard to track.

Problem:

test "something" {
    var gpa = std.testing.allocator;
    const name = try gpa.alloc(u8, 10);
    // ... use name ...
    // If test fails before freeing, GPA reports leak
}

Solution: Use arena allocator for test allocations:

test "something" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const allocator = arena.allocator();

    const name = try allocator.alloc(u8, 10);
    // ... use name ...
    // Arena automatically frees everything on deinit
}

Files affected:

  • patterns.zig: Test for opaque typedef uses arena

Type System

Const Pointer to Array vs Slice

Issue: &array creates a const pointer to array, not a slice.

Error:

const decls = [_]Declaration{...};
function(&decls);  // ERROR: expected '[]Declaration', found '*const [1]Declaration'

Solution: Use array slice syntax &array for mutable or decls[0..] for explicit slice:

const decls = [_]Declaration{...};
function(decls[0..]);  // ✅ Explicit slice

// Or if function accepts const:
function(&decls);  // Works if function parameter is '[]const Declaration'

Parser Implementation Challenges

matchPrefix() Consumes Input

Problem: After calling matchPrefix("typedef enum "), the scanner position has moved past the prefix. Calling readLine() immediately after will read from the new position, not from the start of the line.

Example:

// Source: "typedef enum SDL_GPUPrimitiveType {"
if (self.matchPrefix("typedef enum ")) {  // pos moves to after "typedef enum "
    const line = try self.readLine();     // Reads "SDL_GPUPrimitiveType {"

    // BUG: Trying to tokenize expecting "typedef enum name"
    var iter = std.mem.tokenizeScalar(u8, line, ' ');
    _ = iter.next(); // Expects "typedef" - NOT THERE
    _ = iter.next(); // Expects "enum" - NOT THERE
    const name = iter.next(); // Gets "SDL_GPUPrimitiveType" but after skipping non-existent tokens
}

Solution: Don't use readLine() after matchPrefix(). Instead, scan forward to find landmarks (like opening brace), extract what you need from the source slice directly:

if (self.matchPrefix("typedef enum ")) {
    // Find the opening brace
    const name_start = self.pos;
    while (self.pos < self.source.len and self.source[self.pos] != '{') {
        self.pos += 1;
    }

    // Extract name from the slice between matchPrefix and '{'
    const name_slice = std.mem.trim(u8, self.source[name_start..self.pos], " \t\n\r");
    var iter = std.mem.tokenizeScalar(u8, name_slice, ' ');
    const name = iter.next() orelse return null;

    // Now we're positioned at '{', which is what readBracedBlock() expects
    const body = try self.readBracedBlock();
}

Files affected:

  • patterns.zig:scanEnum() - Fixed to scan for '{' instead of using readLine()
  • patterns.zig:scanStruct() - Applied same fix

readBracedBlock() Returns Full Source Including Braces

Problem: readBracedBlock() returns the entire source from current position to the end of the closing brace, including the braces themselves and any typedef name after the closing brace.

Example:

// Source at position: "{ VALUE1, VALUE2 } TypeName;"
const body = try self.readBracedBlock();
// body = "{ VALUE1, VALUE2 } TypeName;"
//         ^                 ^^^^^^^^^^ - includes closing brace and typedef name

When splitting by newlines and parsing, you get lines like:

  • "{"
  • "VALUE1,"
  • "VALUE2"
  • "} TypeName;"

Solution: Filter out lines that start with braces when parsing the body:

var lines = std.mem.splitScalar(u8, body, '\n');
while (lines.next()) |line| {
    const trimmed = std.mem.trim(u8, line, " \t\r");
    if (trimmed.len == 0) continue;
    if (std.mem.startsWith(u8, trimmed, "//")) continue;
    if (std.mem.startsWith(u8, trimmed, "/*")) continue;
    if (std.mem.startsWith(u8, trimmed, "{")) continue;  // ← Filter opening brace
    if (std.mem.startsWith(u8, trimmed, "}")) continue;  // ← Filter closing brace + typedef name

    // Now parse the actual content
    if (try self.parseEnumValue(trimmed)) |value| {
        try values.append(self.allocator, value);
    }
}

Files affected:

  • patterns.zig:scanEnum() - Added brace filtering for enum values
  • patterns.zig:parseStructField() - Added brace filtering for struct fields

Memory Leaks from Temporary Allocations

Problem: Functions that allocate memory (like readLine()) return owned slices that must be freed. In loops, forgetting to free these causes memory leaks.

Example:

// BUG: Memory leak
while (!self.isAtEnd()) {
    const line = try self.readLine();  // Allocates
    try func_text.appendSlice(self.allocator, line);
    // line is never freed - LEAK!

    if (std.mem.indexOfScalar(u8, line, ';')) |_| break;
}

Solution: Use defer to ensure allocation is freed even if loop breaks early:

while (!self.isAtEnd()) {
    const line = try self.readLine();
    defer self.allocator.free(line);  // ← Always freed when scope exits

    try func_text.appendSlice(self.allocator, line);

    if (std.mem.indexOfScalar(u8, line, ';')) |_| break;
}

Files affected:

  • patterns.zig:scanFunction() - Added defer for readLine() calls

Leading Acronym Lowercasing in Function Names

Problem: Simply lowercasing the first character of a function name doesn't work well with leading acronyms.

Example:

  • SDL_GPUSupportsShaderFormats → strip "SDL_" → GPUSupportsShaderFormats
  • Lowercase first char → gPUSupportsShaderFormats (should be gpuSupportsShaderFormats)

Solution: Lowercase the entire leading acronym (consecutive uppercase letters) until you hit a lowercase letter that starts a new word:

pub fn functionNameToZig(c_name: []const u8, allocator: Allocator) ![]const u8 {
    const without_prefix = stripSDLPrefix(c_name);
    var result = try allocator.dupe(u8, without_prefix);

    // Lowercase leading acronyms (e.g., "GPUSupports" -> "gpuSupports")
    var i: usize = 0;
    while (i < result.len and std.ascii.isUpper(result[i])) : (i += 1) {
        // If we have at least 2 uppercase letters and the next char is lowercase,
        // we've found the end of the acronym (e.g., "GPUs" -> "gpu" + "Supports")
        if (i > 0 and i + 1 < result.len and std.ascii.isLower(result[i + 1])) {
            // Don't lowercase this last uppercase letter - it starts the next word
            break;
        }
        result[i] = std.ascii.toLower(result[i]);
    }

    return result;
}

Results:

  • GPUSupportsShaderFormatsgpuSupportsShaderFormats
  • CreateGPUDevicecreateGPUDevice
  • GPUTextureFormatgpuTextureFormat

Files affected:

  • naming.zig:functionNameToZig() - Implemented acronym lowercasing logic

Best Practices

  1. Always handle error unions: Use try or explicit catch
  2. Avoid reserved keywords: Check keyword list before naming variables
  3. Use arena allocators in tests: Simplifies cleanup and prevents leak errors
  4. Pass allocators explicitly: Don't assume methods have access to allocator
  5. Prefer explicit slices: Use array[0..] instead of relying on coercion
  6. Check build API docs: Build system API changes between versions
  7. Use defer for cleanup: Especially in loops where early breaks can skip cleanup
  8. Don't mix matchPrefix with readLine: Scanner position management requires care
  9. Filter implementation details from parsed data: Braces, keywords, etc. aren't part of semantic content

Common Error Messages

Error Message Likely Cause Solution
expected 'an identifier', found 'opaque' Using reserved keyword Rename variable
expected type '[]T', found '*const [N]T' Array vs slice mismatch Use array[0..]
error union not handled Missing try or catch Add error handling
unused local variable Variable declared but not used Use it or assign to _
expected 2 arguments, found 1 ArrayList API change Pass allocator explicitly
root_source_file field doesn't exist Old build API Use root_module

Const Arrays in Tests Must Be Mutable for Slicing

Issue: When passing array slices to functions, the array must be declared with var not const, even if you're not modifying the array itself.

Error:

test "generate opaque type" {
    const decls = [_]Declaration{...};
    const output = try CodeGen.generate(allocator, decls[0..]);
    // ERROR: expected type '[]Declaration', found '*const [1]Declaration'
    // ERROR: cast discards const qualifier
}

Solution: Declare the array with var:

test "generate opaque type" {
    var decls = [_]Declaration{...};  // ✅ Use var
    const output = try CodeGen.generate(allocator, decls[0..]);
}

Files affected:

  • codegen.zig: All test arrays changed from const to var

Standard Library API Changes

ArrayList.writer() Requires Allocator

Issue: The writer() method on ArrayList now requires an allocator parameter.

Error:

var list = std.ArrayList(u8).initCapacity(allocator, 256);
try list.writer().print("Hello", .{});
// ERROR: member function expected 1 argument(s), found 0

Solution: Pass the allocator to writer():

var list = std.ArrayList(u8).initCapacity(allocator, 256);
try list.writer(allocator).print("Hello", .{});  // ✅ Pass allocator

Files affected:

  • codegen.zig: All writer() calls updated to pass allocator

std.io.getStdOut() Moved

Issue: std.io.getStdOut() no longer exists in Zig 0.15.

Error:

const stdout = std.io.getStdOut().writer();
// ERROR: root source file struct 'Io' has no member named 'getStdOut'

Solution: Use std.posix.write() with std.posix.STDOUT_FILENO:

// Old way (doesn't work):
const stdout = std.io.getStdOut().writer();
try stdout.writeAll(output);

// New way (Zig 0.15):
_ = try std.posix.write(std.posix.STDOUT_FILENO, output);

Files affected:

  • parser.zig:105: Changed to use std.posix.write()

Type Inference Issues

Comptime Type Inference with Runtime Values

Issue: When using if-else chains with runtime string comparisons, the compiler may try to infer types as comptime when they should be runtime.

Error:

const type_bits = if (std.mem.eql(u8, underlying_type, "u8"))
    8
else if (std.mem.eql(u8, underlying_type, "u16"))
    16
else
    32;
// ERROR: value with comptime-only type 'comptime_int' depends on runtime control flow

Solution: Explicitly annotate the type:

const type_bits: u32 = if (std.mem.eql(u8, underlying_type, "u8"))
    8
else if (std.mem.eql(u8, underlying_type, "u16"))
    16
else
    32;  // ✅ Explicit type annotation

Files affected:

  • codegen.zig:148: Added explicit u32 type annotation

Bit Shift Operand Type Mismatch

Issue: Bit shift operations require the right operand to be exactly the right type for the shift amount.

Error:

var bit: u6 = 0;
while (bit < 32) : (bit += 1) {
    if (val == (@as(u32, 1) << bit)) return bit;
    // ERROR: expected type 'u5', found 'u6'
    // NOTE: unsigned 5-bit int cannot represent all possible unsigned 6-bit values
}

Explanation: Shifting a u32 requires a shift amount of type u5 (since 2^5 = 32 bits). A u6 can represent values 0-63, but only 0-31 are valid shift amounts for u32.

Solution: Cast the shift amount to the correct type:

var bit: u6 = 0;
while (bit < 32) : (bit += 1) {
    if (val == (@as(u32, 1) << @as(u5, @intCast(bit)))) return bit;  // ✅ Cast to u5
}

Files affected:

  • codegen.zig:238: Added @as(u5, @intCast(bit)) cast

Complete List of Compilation Errors Encountered

During the SDL3 parser implementation, we encountered the following compilation errors in order:

  1. root_source_file field doesn't exist in build.zig

    • File: build.zig
    • Fix: Changed to root_module = b.createModule(...)
  2. init expects 2 arguments, found 1 (ArrayList API)

    • Files: naming.zig, patterns.zig
    • Fix: Changed to initCapacity(allocator, capacity) and passed allocator to all methods
  3. expected 'an identifier', found 'opaque' (reserved keyword)

    • Files: patterns.zig:92, codegen.zig:44, codegen.zig:53, codegen.zig:247
    • Fix: Renamed all opaque variables to opaque_decl or opaque_type
  4. Error union not handled for readLine()

    • File: patterns.zig
    • Fix: Added try keyword before all readLine() calls
  5. Memory leak in test (GPA reported leak)

    • File: patterns.zig test
    • Fix: Changed to use arena allocator
  6. expected type '[]Declaration', found '*const [1]Declaration'

    • File: codegen.zig tests
    • Fix: Changed arrays from const to var and used array[0..] syntax
  7. writer() member function expected 1 argument(s), found 0

    • File: codegen.zig (multiple locations)
    • Fix: Passed allocator to all writer() calls: writer(allocator)
  8. value with comptime-only type 'comptime_int' depends on runtime control flow

    • File: codegen.zig:148
    • Fix: Added explicit type annotation: const type_bits: u32 = ...
  9. expected type 'u5', found 'u6' (bit shift operand)

    • File: codegen.zig:238
    • Fix: Cast shift amount: @as(u5, @intCast(bit))
  10. getCastType expects 1 argument, found 2

    • File: codegen.zig:249
    • Fix: Removed allocator parameter, getCastType() returns enum not string
  11. std.io.getStdOut() - no member named 'getStdOut'

    • File: parser.zig:105
    • Fix: Changed to std.posix.write(std.posix.STDOUT_FILENO, output)

Best Practices

  1. Always handle error unions: Use try or explicit catch
  2. Avoid reserved keywords: Check keyword list before naming variables
  3. Use arena allocators in tests: Simplifies cleanup and prevents leak errors
  4. Pass allocators explicitly: Don't assume methods have access to allocator
  5. Prefer explicit slices: Use array[0..] instead of relying on coercion
  6. Check build API docs: Build system API changes between versions
  7. Annotate types when using runtime conditions: Avoid comptime inference issues
  8. Match bit shift operand types: Use correct size for shift amounts (u5 for u32, etc.)
  9. Use var for arrays that need slicing: Even if you don't modify the array itself

Common Error Messages

Error Message Likely Cause Solution
expected 'an identifier', found 'opaque' Using reserved keyword Rename variable
expected type '[]T', found '*const [N]T' Array vs slice mismatch Use array[0..]
error union not handled Missing try or catch Add error handling
unused local variable Variable declared but not used Use it or assign to _
expected 2 arguments, found 1 ArrayList API change Pass allocator explicitly
root_source_file field doesn't exist Old build API Use root_module
member function expected 1 argument(s), found 0 Missing allocator for ArrayList methods Pass allocator to method
no member named 'getStdOut' Moved/renamed std lib function Use std.posix.write()
comptime-only type depends on runtime control flow Missing explicit type annotation Add : TypeName annotation
expected type 'u5', found 'u6' Shift operand type mismatch Cast to correct shift type
cast discards const qualifier Trying to get mutable slice from const array Declare array with var

Resources