13 KiB
AGENTS.md - Solutions to Common Issues in Zig Development
This document captures the main issues encountered during the zargs implementation and their solutions. This is valuable for AI coding agents and developers working with Zig 0.15+.
Table of Contents
- Zig 0.15 API Changes
- Comptime vs Runtime Issues
- Memory Management
- Type System Challenges
- Build System Issues
- Testing Strategies
Zig 0.15 API Changes
Issue 1: Type Union Field Names Changed
Problem: Code using @typeInfo() breaks with errors about union field names.
// Zig 0.14 and earlier:
if (info == .Bool) { ... }
// Zig 0.15:
if (info == .bool) { ... } // lowercase!
Solution: All @typeInfo() union fields are now lowercase:
.Bool→.bool.Int→.int.Pointer→.pointer.Enum→.@"enum".Optional→.optional
How to Fix: Search your codebase for patterns like == .Bool or .Int and convert to lowercase.
Issue 2: Struct Field default_value → default_value_ptr
Problem: std.builtin.Type.StructField.default_value doesn't exist.
// Zig 0.14:
if (field.default_value) |val| { ... }
// Zig 0.15:
if (field.default_value_ptr) |ptr| { ... }
Solution: Use default_value_ptr which is ?*const anyopaque. Cast it to the field's type:
if (field.default_value_ptr) |default_ptr| {
const value_ptr: *const T = @ptrCast(@alignCast(default_ptr));
const value = value_ptr.*;
// Use value...
}
Issue 3: ArrayList API Changes
Problem: ArrayList(T).init() doesn't exist, deinit() signature changed.
Solution: Use ArrayListUnmanaged for better control:
// OLD (doesn't work in 0.15):
var list = std.ArrayList(T).init(allocator);
list.deinit();
// NEW (Zig 0.15):
var list = std.ArrayListUnmanaged(T){};
try list.append(allocator, item);
list.deinit(allocator);
Why: ArrayListUnmanaged doesn't store the allocator, so deinit() needs it passed in.
Issue 4: Module System Changes
Problem: Direct file imports cause "file exists in multiple modules" errors.
// DON'T DO THIS:
const utils = @import("utils.zig");
// DO THIS:
const utils = @import("utils");
Solution: In build.zig, set up proper module dependencies:
const utils_mod = b.addModule("utils", .{
.root_source_file = b.path("src/utils.zig"),
...
});
const other_mod = b.addModule("other", .{
.root_source_file = b.path("src/other.zig"),
.imports = &.{
.{ .name = "utils", .module = utils_mod },
},
});
Then import by module name, not file path.
Comptime vs Runtime Issues
Issue 5: Returning Pointers to Comptime Locals
Problem: Functions that return pointers to comptime local variables fail when called from runtime contexts.
// BROKEN:
pub fn toKebabCase(comptime name: []const u8) []const u8 {
comptime {
var result: [100]u8 = undefined;
// ... fill result ...
return result[0..len]; // ERROR: returning pointer to local!
}
}
Error: "function called at runtime cannot return value at comptime"
Root Cause: Even though the function is comptime, if it's called from a runtime function (even a comptime parameter in a runtime function), Zig can't guarantee the returned pointer's lifetime.
Solution Options:
- Inline the logic: Don't return pointers, inline the computation:
// In caller:
inline for (fields) |field| {
const field_name = field.name; // Already comptime
// Use field_name directly
}
- Return arrays, not slices: If size is comptime-known:
pub fn toKebabCase(comptime name: []const u8) [computeLen(name)]u8 {
// Return array by value, not pointer
}
- Use comptime string literals: Store in the struct directly:
const arg_name = if (user_meta.name) |custom|
custom // This is a string literal
else
field.name; // This is also a string literal
Workaround We Used: Temporarily disabled kebab-case conversion and used field.name directly (which is always a comptime string literal).
Issue 6: Comptime Arrays in Runtime Structures
Problem: Storing comptime array slices in runtime-instantiated structs.
pub fn extractAllFieldMetadata(comptime T: type) []const ArgumentMetadata {
comptime {
var metadata: [fields.len]ArgumentMetadata = undefined;
// Fill metadata...
return &metadata; // ERROR!
}
}
Solution: Don't return slices of comptime arrays. Instead:
- Loop inline at call site:
// Instead of:
const all_meta = extractAllFieldMetadata(T);
for (all_meta) |meta| { ... }
// Do this:
inline for (type_info.@"struct".fields) |field| {
const meta = extractFieldMetadata(T, field);
// Use meta immediately
}
- Copy into runtime storage: If you must store, allocate and copy:
const comptime_data = extractSomething(T);
const runtime_copy = try allocator.dupe(T, comptime_data);
Memory Management
Issue 7: StringHashMap Key Ownership
Problem: Using temporary strings as HashMap keys causes dangling pointers.
// BROKEN:
const short_key = &[_]u8{short_char}; // Temporary!
try self.arguments.put(short_key, metadata);
// short_key is now dangling!
Solution: Allocate persistent keys:
const short_key = try self.allocator.alloc(u8, 1);
short_key[0] = short_char;
try self.arguments.put(short_key, metadata);
// short_key is now owned by the HashMap
Don't Forget Cleanup:
pub fn deinit(self: *Self) void {
var key_iter = self.map.keyIterator();
while (key_iter.next()) |key| {
if (key.len == 1) { // Our allocated short keys
self.allocator.free(key.*);
}
}
self.map.deinit();
}
Issue 8: HashMap Value vs Pointer Storage
Problem: Storing pointers to comptime data in HashMaps.
// BROKEN:
arguments: std.StringHashMap(*const ArgumentMetadata),
const comptime_meta = extractMetadata(...);
try arguments.put(name, &comptime_meta); // Pointer to comptime data!
Solution: Store values, not pointers:
arguments: std.StringHashMap(ArgumentMetadata), // Value, not pointer
const comptime_meta = extractMetadata(...);
try arguments.put(name, comptime_meta); // Copy the value
Accessing: Use getPtr() to get a pointer to the stored value:
pub fn getArgument(self: *const Self, name: []const u8) ?*const ArgumentMetadata {
if (self.arguments.getPtr(name)) |ptr| {
return ptr;
}
return null;
}
Type System Challenges
Issue 9: Checking for Optional Types
Problem: Detecting if a type is optional at comptime.
Solution:
const is_optional = @typeInfo(T) == .optional;
// To get the child type:
const ActualType = if (@typeInfo(T) == .optional)
@typeInfo(T).optional.child
else
T;
Use Case: Determining if an argument is required:
.required = user_meta.required orelse !is_optional,
Issue 10: Enum Type Introspection
Problem: Getting enum field names at comptime.
Solution:
const enum_info = @typeInfo(EnumType).@"enum";
for (enum_info.fields) |field| {
const name: []const u8 = field.name;
// name is a comptime string literal
}
Converting from string to enum:
inline for (enum_info.fields) |field| {
if (std.mem.eql(u8, str, field.name)) {
return @field(EnumType, field.name);
}
}
Issue 11: Type Matching for Collision Detection
Problem: Checking if two fields have compatible types.
Solution: Use the ArgumentType enum for normalized comparison:
pub const ArgumentType = enum {
bool, u8, u16, u32, u64, i8, i16, i32, i64,
string, string_list, enum_type,
};
// Extract type:
const arg_type = ArgumentType.fromZigType(field.type);
// Compare:
if (existing.arg_type == new.arg_type) {
// Compatible!
}
This handles optionals automatically since fromZigType unwraps them.
Build System Issues
Issue 12: Module Dependency Cycles
Problem: "file exists in multiple modules" errors.
Solution: Create a clear dependency graph:
// Base modules (no dependencies):
const base_mod = b.addModule("base", .{
.root_source_file = b.path("src/base.zig"),
});
// Dependent modules:
const derived_mod = b.addModule("derived", .{
.root_source_file = b.path("src/derived.zig"),
.imports = &.{
.{ .name = "base", .module = base_mod },
},
});
Rule: Never create circular dependencies. If module A imports B, B cannot import A.
Issue 13: Test Module Configuration
Problem: Tests can't find imports.
Solution: Set up test modules with all dependencies:
const test_mod = b.createModule(.{
.root_source_file = b.path("tests/test_foo.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "foo", .module = foo_mod },
.{ .name = "bar", .module = bar_mod },
// Include ALL transitive dependencies
},
});
const tests = b.addTest(.{
.name = "foo-tests",
.root_module = test_mod,
});
Testing Strategies
Issue 14: Testing Comptime Functions
Problem: Comptime functions can't be tested with runtime tests directly.
Solution: Use comptime test blocks:
test "comptime function" {
const result = comptime myComptimeFunc("input");
try std.testing.expectEqualStrings("expected", result);
}
Or embed comptime assertions in the source:
// In source file:
comptime {
const result = toKebabCase("camelCase");
if (!std.mem.eql(u8, result, "camel-case")) {
@compileError("toKebabCase failed");
}
}
Issue 15: Memory Leak Detection in Tests
Problem: Ensuring tests don't leak memory.
Solution: Use std.testing.allocator and verify cleanup:
test "no leaks" {
var registry = Registry.init(std.testing.allocator);
defer registry.deinit();
// Do stuff...
// If deinit() doesn't free everything, test will fail
}
The testing allocator tracks all allocations and will fail if any aren't freed.
Best Practices Learned
1. Comptime String Management
Rule: Comptime strings are fine as long as they're string literals or stored by value in comptime structures.
DO:
const name = field.name; // String literal
const meta = ArgumentMetadata{
.arg_name = name, // Stores the pointer to literal
};
DON'T:
const name = generateName(...); // Returns pointer to local
const meta = ArgumentMetadata{
.arg_name = name, // Dangling pointer!
};
2. Inline For Loops
Rule: When iterating over comptime arrays from runtime contexts, use inline for:
inline for (comptime_array) |item| {
// This unrolls at compile time
// Each iteration can use comptime values
}
3. Error Handling Patterns
Strategy: Use error unions consistently:
pub const Error = error{ ... };
pub fn function() Error!void {
// Can return any error from Error set
}
// Caller:
function() catch |err| {
switch (err) {
error.Specific => { ... },
else => { ... },
}
};
4. Arena Allocator for Parsing
Pattern: Use an arena for temporary parsing data:
var arena = std.heap.ArenaAllocator.init(parent_allocator);
defer arena.deinit();
const allocator = arena.allocator();
// All allocations freed at once when arena is deinit'd
5. Type-Safe Unions
Pattern: Use tagged unions for type-safe value storage:
pub const Value = union(enum) {
bool: bool,
int: i64,
string: []const u8,
pub fn asBool(self: Value) bool {
return switch (self) {
.bool => |b| b,
else => unreachable,
};
}
};
Debugging Tips
1. Comptime Error Messages
When you get cryptic comptime errors:
- Look for "referenced by" chain
- Start at the deepest call in the chain
- Check if you're mixing comptime/runtime inappropriately
2. Type Info Inspection
Debug type problems:
const info = @typeInfo(T);
std.debug.print("Type info: {}\n", .{info});
3. Build Cache Issues
If build behavior is weird:
rm -rf .zig-cache zig-out
zig build
4. Test Isolation
Run single test:
zig test src/file.zig --test-filter "test name"
Summary Checklist
When implementing similar features:
- Check for Zig 0.15 API changes (lowercase type names, default_value_ptr, etc.)
- Avoid returning pointers to comptime locals
- Use
inline forwhen iterating comptime arrays from runtime contexts - Store values in HashMaps, not pointers to comptime data
- Allocate HashMap keys that need to persist
- Free allocated HashMap keys in deinit()
- Use
ArrayListUnmanagedand pass allocator to deinit() - Set up proper module dependencies in build.zig
- Test with
std.testing.allocatorto catch leaks - Use arena allocators for temporary allocations
Resources
Document Version: 1.0 Last Updated: 2026-01-22 Zig Version: 0.15.2