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 }