UP | HOME

Applying the Diagnostics pattern in practice

One of the most commonly cited weaknesses of Zig is that error unions don't have an error payload, unlike Rust's Result. So, human-readable error information is usually either passed around with a Diagnostics struct, shat to stdout, or not there at all.

Here's an example of a Diagnostics from std.json:

/// To enable diagnostics, declare `var diagnostics = Diagnostics{};` then call `source.enableDiagnostics(&diagnostics);`
/// where `source` is either a `std.json.Reader` or a `std.json.Scanner` that has just been initialized.
/// At any time, notably just after an error, call `getLine()`, `getColumn()`, and/or `getByteOffset()`
/// to get meaningful information from this.
pub const Diagnostics = struct {
    line_number: u64 = 1,
    line_start_cursor: usize = @as(usize, @bitCast(@as(isize, -1))), // Start just "before" the input buffer to get a 1-based column for line 1.
    total_bytes_before_current_input: u64 = 0,
    cursor_pointer: *const usize = undefined,

    /// Starts at 1.
    pub fn getLine(self: *const @This()) u64 {
        return self.line_number;
    }
    /// Starts at 1.
    pub fn getColumn(self: *const @This()) u64 {
        return self.cursor_pointer.* -% self.line_start_cursor;
    }
    /// Starts at 0. Measures the byte offset since the start of the input.
    pub fn getByteOffset(self: *const @This()) u64 {
        return self.total_bytes_before_current_input + self.cursor_pointer.*;
    }
};

I want to share a more general and convenient implementation of this pattern I am writing for a work-in-progress multimedia library:

/// A single diagnostic message.
pub const Diagnostic = struct {
    pub const Component = enum {
        component_a,
        component_b,
        // ....
    };

    /// The severity of the failure.
    level: std.log.Level,
    /// The component this failure occurred in.
    component: Component,
    /// A human-readable description of the failure.
    message: std.BoundedArray(u8, 512),
    /// The machine-readable Zig error for this failure.
    err: anyerror,

    pub inline fn diagFmt(d: *Diagnostic, level: std.log.Level, component: Component, err: anyerror, comptime fmt: []const u8, args: anytype) void {
        d.level = level;
        d.component = component;
        d.err = err;
        d.message.len = @intCast((std.fmt.bufPrint(&d.message.buffer, fmt, args) catch "").len);
    }

    pub fn format(d: Diagnostic, comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void {
        return writer.print("[{s}] {s}: {s} ({})", .{ @tagName(d.level), @tagName(d.component), d.message.constSlice(), d.err });
    }
};

pub const Diagnostics = struct {
    list: std.ArrayList(Diagnostic),

    pub fn init(ally: std.mem.Allocator) Diagnostics {
        return .{ .list = std.ArrayList(Diagnostic).init(ally) };
    }

    pub fn deinit(d: *Diagnostics) void {
        d.list.deinit();
    }

    fn DiagRet(comptime Err: type) type {
        return if (@typeInfo(Err) == .ErrorSet) Err!noreturn else void;
    }

    // This is a piece of hackery that allows for fatal and non-fatal errors to be logged easily. `err` can be either {} or an error.
    pub inline fn diag(
        d: *Diagnostics,
        level: std.log.Level,
        component: Diagnostic.Component,
        err: anytype,
        comptime fmt: []const u8,
        args: anytype,
    ) DiagRet(@TypeOf(err)) {
        const p = d.list.addOne() catch return err;
        p.diagFmt(level, component, if (@typeInfo(@TypeOf(err)) == .ErrorSet) err else error.Unexpected, fmt, args);
        return err;
    }
};

And here it is in use for errors and warnings:

try z.diag(
    .err,
    .decoder,
    e,
    "Ran out of memory when allocating a frame of size {d}x{d} (sample format {}).",
    .{ width.?, height.?, sample_fmt },
);
z.diag(.warn, .decoder, {}, "The {s} field was duplicated.", .{"width"});

Caveats: