lib/zen/src/diagnostic.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const math = @import("math/root.zig");
  3 
  4 pub const max_page_bytes = 256;
  5 pub const max_equation_bytes = 256;
  6 pub const max_inline_bytes = 256;
  7 
  8 /// Fixed-capacity detail for a failed page, equation, heading, or inline feature.
  9 ///
 10 /// Supply its address through Markdown or site options. After an error, inspect
 11 /// `page()`, the feature source, `reason`, and `offset`, then call `reset` before
 12 /// reuse.
 13 pub const Diagnostic = struct {
 14     page_buffer: [max_page_bytes]u8 = undefined,
 15     page_len: usize = 0,
 16     equation_buffer: [max_equation_bytes]u8 = undefined,
 17     equation_len: usize = 0,
 18     inline_buffer: [max_inline_bytes]u8 = undefined,
 19     inline_len: usize = 0,
 20     /// Short cause suitable for a repair message; empty when no detail is captured.
 21     reason: []const u8 = "",
 22     /// Byte offset in the original inline source, heading feature, or equation.
 23     /// It can lie beyond a bounded or sanitized captured excerpt.
 24     offset: usize = 0,
 25 
 26     /// Returns the page path, truncated with an ellipsis if needed.
 27     pub fn page(self: *const Diagnostic) []const u8 {
 28         return self.page_buffer[0..self.page_len];
 29     }
 30 
 31     /// Returns the captured equation, truncated with an ellipsis if needed.
 32     pub fn equation(self: *const Diagnostic) []const u8 {
 33         return self.equation_buffer[0..self.equation_len];
 34     }
 35 
 36     /// Returns a bounded UTF-8-safe source prefix for context, not indexing.
 37     /// Invalid bytes become `?` and excess source ends in `...`.
 38     pub fn inlineSource(self: *const Diagnostic) []const u8 {
 39         return self.inline_buffer[0..self.inline_len];
 40     }
 41 
 42     /// Clears captured detail before a retry or unrelated operation.
 43     pub fn reset(self: *Diagnostic) void {
 44         self.* = .{};
 45     }
 46 
 47     pub fn setPage(self: *Diagnostic, path: []const u8) void {
 48         self.page_len = copyEllipsized(&self.page_buffer, path);
 49     }
 50 
 51     pub fn setEquation(self: *Diagnostic, source: []const u8, detail: math.Diagnostic) void {
 52         self.equation_len = copyEllipsized(&self.equation_buffer, source);
 53         self.inline_len = 0;
 54         self.reason = detail.reason;
 55         self.offset = detail.offset;
 56     }
 57 
 58     pub fn setHeading(self: *Diagnostic, reason: []const u8, offset: usize) void {
 59         self.equation_len = 0;
 60         self.inline_len = 0;
 61         self.reason = reason;
 62         self.offset = offset;
 63     }
 64 
 65     pub fn setInline(self: *Diagnostic, source: []const u8, reason: []const u8, offset: usize) void {
 66         self.equation_len = 0;
 67         self.inline_len = copyEllipsized(&self.inline_buffer, source);
 68         self.reason = reason;
 69         self.offset = offset;
 70     }
 71 };
 72 
 73 fn copyEllipsized(buffer: []u8, text: []const u8) usize {
 74     std.debug.assert(buffer.len >= 3);
 75     const payload_limit = if (text.len > buffer.len) buffer.len - 3 else buffer.len;
 76     var source_index: usize = 0;
 77     var output_index: usize = 0;
 78     while (source_index < text.len) {
 79         const sequence_length = std.unicode.utf8ByteSequenceLength(text[source_index]) catch 1;
 80         const sequence_end = @min(source_index + sequence_length, text.len);
 81         const sequence = text[source_index..sequence_end];
 82         const valid = sequence.len == sequence_length and std.unicode.utf8ValidateSlice(sequence);
 83         const replacement = if (valid) sequence else "?";
 84         if (output_index + replacement.len > payload_limit) break;
 85         @memcpy(buffer[output_index..][0..replacement.len], replacement);
 86         output_index += replacement.len;
 87         source_index += if (valid) sequence_length else 1;
 88     }
 89     if (source_index < text.len) {
 90         @memcpy(buffer[output_index..][0..3], "...");
 91         output_index += 3;
 92     }
 93     std.debug.assert(output_index <= buffer.len);
 94     return output_index;
 95 }
 96 
 97 test "diagnostic captures page and equation detail" {
 98     var diagnostic: Diagnostic = .{};
 99     try std.testing.expectEqualStrings("", diagnostic.page());
100     try std.testing.expectEqualStrings("", diagnostic.equation());
101     try std.testing.expectEqualStrings("", diagnostic.inlineSource());
102     diagnostic.setPage("writing/mathcheck.md");
103     diagnostic.setEquation("\\frac{1}", .{ .reason = "missing argument", .offset = 8 });
104     try std.testing.expectEqualStrings("writing/mathcheck.md", diagnostic.page());
105     try std.testing.expectEqualStrings("\\frac{1}", diagnostic.equation());
106     try std.testing.expectEqualStrings("missing argument", diagnostic.reason);
107     try std.testing.expectEqual(@as(usize, 8), diagnostic.offset);
108     diagnostic.setInline("[label](/missing", "link target is missing closing ')'", 7);
109     try std.testing.expectEqualStrings("", diagnostic.equation());
110     try std.testing.expectEqualStrings("[label](/missing", diagnostic.inlineSource());
111     try std.testing.expectEqualStrings("link target is missing closing ')'", diagnostic.reason);
112     try std.testing.expectEqual(@as(usize, 7), diagnostic.offset);
113     diagnostic.setHeading("duplicate heading anchor", 17);
114     try std.testing.expectEqualStrings("writing/mathcheck.md", diagnostic.page());
115     try std.testing.expectEqualStrings("", diagnostic.equation());
116     try std.testing.expectEqualStrings("", diagnostic.inlineSource());
117     try std.testing.expectEqualStrings("duplicate heading anchor", diagnostic.reason);
118     try std.testing.expectEqual(@as(usize, 17), diagnostic.offset);
119     diagnostic.reset();
120     try std.testing.expectEqualStrings("", diagnostic.page());
121     try std.testing.expectEqualStrings("", diagnostic.reason);
122 }
123 
124 test "diagnostic truncates long captures with an ellipsis" {
125     var diagnostic: Diagnostic = .{};
126     const long = @as([(300) * ("x").len]u8, @bitCast(@as([300][("x").len]u8, @splat(("x")[0..("x").len].*))));
127     diagnostic.setEquation(&long, .{ .reason = "unknown command", .offset = 280 });
128     try std.testing.expectEqual(@as(usize, max_equation_bytes), diagnostic.equation().len);
129     try std.testing.expect(std.mem.endsWith(u8, diagnostic.equation(), "..."));
130     try std.testing.expectEqual(@as(usize, 280), diagnostic.offset);
131     diagnostic.setInline(&long, "link target is missing closing ')'", 280);
132     try std.testing.expectEqual(@as(usize, max_inline_bytes), diagnostic.inlineSource().len);
133     try std.testing.expect(std.mem.endsWith(u8, diagnostic.inlineSource(), "..."));
134     try std.testing.expectEqual(@as(usize, 280), diagnostic.offset);
135 }
136 
137 test "diagnostic truncation preserves valid UTF-8 and replaces invalid bytes" {
138     var diagnostic: Diagnostic = .{};
139     var source: [max_inline_bytes + 8]u8 = @splat('x');
140     @memcpy(source[252..254], "ω");
141     diagnostic.setInline(&source, "invalid inline source", 252);
142     try std.testing.expect(std.unicode.utf8ValidateSlice(diagnostic.inlineSource()));
143     try std.testing.expectEqual(@as(usize, 255), diagnostic.inlineSource().len);
144     try std.testing.expect(std.mem.endsWith(u8, diagnostic.inlineSource(), "..."));
145     try std.testing.expectEqual(@as(u8, 'x'), diagnostic.inlineSource()[251]);
146 
147     const invalid = [_]u8{ 'a', 0xff, 'b' };
148     diagnostic.setInline(&invalid, "invalid inline source", 1);
149     try std.testing.expectEqualStrings("a?b", diagnostic.inlineSource());
150 }