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:
- Memory leaks from readLine() allocations - fixed with defer
- Function name conversion bug (gPUSupportsShaderFormats) - fixed acronym lowercasing logic
- Enum/struct parsing broken - fixed matchPrefix + readLine interaction
- 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 operationspatterns.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 toopaque_declcodegen.zig:44: Capture variable renamed toopaque_declcodegen.zig:53: Parameter renamed toopaque_typecodegen.zig:247: Test variable renamed toopaque_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:
- Use the variable
- Assign to
_if intentionally unused:_ = getSomething(); - 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: AllreadLine()calls neededtry
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 valuespatterns.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 begpuSupportsShaderFormats)
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:
GPUSupportsShaderFormats→gpuSupportsShaderFormats✓CreateGPUDevice→createGPUDevice✓GPUTextureFormat→gpuTextureFormat✓
Files affected:
naming.zig:functionNameToZig()- Implemented acronym lowercasing logic
Best Practices
- Always handle error unions: Use
tryor explicitcatch - Avoid reserved keywords: Check keyword list before naming variables
- Use arena allocators in tests: Simplifies cleanup and prevents leak errors
- Pass allocators explicitly: Don't assume methods have access to allocator
- Prefer explicit slices: Use
array[0..]instead of relying on coercion - Check build API docs: Build system API changes between versions
- Use defer for cleanup: Especially in loops where early breaks can skip cleanup
- Don't mix matchPrefix with readLine: Scanner position management requires care
- 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 fromconsttovar
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: Allwriter()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 usestd.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 explicitu32type 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:
-
root_source_filefield doesn't exist in build.zig- File:
build.zig - Fix: Changed to
root_module = b.createModule(...)
- File:
-
initexpects 2 arguments, found 1 (ArrayList API)- Files:
naming.zig,patterns.zig - Fix: Changed to
initCapacity(allocator, capacity)and passed allocator to all methods
- Files:
-
expected 'an identifier', found 'opaque'(reserved keyword)- Files:
patterns.zig:92,codegen.zig:44,codegen.zig:53,codegen.zig:247 - Fix: Renamed all
opaquevariables toopaque_decloropaque_type
- Files:
-
Error union not handled for
readLine()- File:
patterns.zig - Fix: Added
trykeyword before allreadLine()calls
- File:
-
Memory leak in test (GPA reported leak)
- File:
patterns.zigtest - Fix: Changed to use arena allocator
- File:
-
expected type '[]Declaration', found '*const [1]Declaration'- File:
codegen.zigtests - Fix: Changed arrays from
consttovarand usedarray[0..]syntax
- File:
-
writer()member function expected 1 argument(s), found 0- File:
codegen.zig(multiple locations) - Fix: Passed allocator to all
writer()calls:writer(allocator)
- File:
-
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 = ...
- File:
-
expected type 'u5', found 'u6'(bit shift operand)- File:
codegen.zig:238 - Fix: Cast shift amount:
@as(u5, @intCast(bit))
- File:
-
getCastTypeexpects 1 argument, found 2- File:
codegen.zig:249 - Fix: Removed allocator parameter,
getCastType()returns enum not string
- File:
-
std.io.getStdOut()- no member named 'getStdOut'- File:
parser.zig:105 - Fix: Changed to
std.posix.write(std.posix.STDOUT_FILENO, output)
- File:
Best Practices
- Always handle error unions: Use
tryor explicitcatch - Avoid reserved keywords: Check keyword list before naming variables
- Use arena allocators in tests: Simplifies cleanup and prevents leak errors
- Pass allocators explicitly: Don't assume methods have access to allocator
- Prefer explicit slices: Use
array[0..]instead of relying on coercion - Check build API docs: Build system API changes between versions
- Annotate types when using runtime conditions: Avoid comptime inference issues
- Match bit shift operand types: Use correct size for shift amounts (u5 for u32, etc.)
- Use
varfor 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
- Zig 0.15 Release Notes: https://ziglang.org/download/0.15.0/release-notes.html
- Build System API: https://ziglang.org/documentation/master/std/#std.Build
- ArrayList API: https://ziglang.org/documentation/master/std/#std.ArrayList
- Reserved Keywords: https://ziglang.org/documentation/master/#Keywords