lib/pretty/core/src/render.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const position = @import("position.zig");
3 const types = @import("types.zig");
4
5 const ColorMode = types.ColorMode;
6 const Doc = types.Doc;
7 const LayoutOptions = types.LayoutOptions;
8 const Style = types.Style;
9 const WriteState = types.WriteState;
10
11 pub const Error = error{IndentationOverflow};
12
13 const WriteError = Error || std.Io.Writer.Error;
14
15 pub const FlatFitWriter = struct {
16 writer: std.Io.Writer,
17 limit: usize,
18 count: usize = 0,
19
20 pub fn init(limit: usize) FlatFitWriter {
21 return .{
22 .writer = .{
23 .vtable = &.{ .drain = drain },
24 .buffer = &.{},
25 },
26 .limit = limit,
27 };
28 }
29
30 fn drain(
31 writer: *std.Io.Writer,
32 data: []const []const u8,
33 splat: usize,
34 ) std.Io.Writer.Error!usize {
35 const self: *FlatFitWriter = @alignCast(@fieldParentPtr("writer", writer));
36 try self.accept(writer.buffered());
37 writer.end = 0;
38
39 var consumed: usize = 0;
40 for (data[0 .. data.len - 1]) |bytes| {
41 try self.accept(bytes);
42 consumed += bytes.len;
43 }
44
45 const pattern = data[data.len - 1];
46 for (0..splat) |_| {
47 try self.accept(pattern);
48 consumed += pattern.len;
49 }
50 return consumed;
51 }
52
53 fn accept(self: *FlatFitWriter, bytes: []const u8) std.Io.Writer.Error!void {
54 var budget = self.limit -| self.count;
55 if (!position.consumeFlatBytes(bytes, &budget)) return error.WriteFailed;
56 self.count = self.limit - budget;
57 }
58 };
59
60 pub fn renderAlloc(
61 allocator: std.mem.Allocator,
62 doc: Doc,
63 options: LayoutOptions,
64 ) ![]u8 {
65 var out = std.Io.Writer.Allocating.init(allocator);
66 defer out.deinit();
67 try write(&out.writer, doc, options);
68 return try out.toOwnedSlice();
69 }
70
71 pub fn write(
72 writer: *std.Io.Writer,
73 doc: Doc,
74 options: LayoutOptions,
75 ) WriteError!void {
76 try writeWithState(writer, doc, options, .{});
77 }
78
79 pub fn writeWithState(
80 writer: *std.Io.Writer,
81 doc: Doc,
82 options: LayoutOptions,
83 state: WriteState,
84 ) WriteError!void {
85 var renderer = Renderer{
86 .writer = writer,
87 .width = options.width,
88 .color = options.color,
89 .col = state.column,
90 };
91 try renderer.writeDoc(doc, state.indent, .broken, null);
92 }
93
94 const Mode = enum {
95 flat,
96 broken,
97 };
98
99 const Pending = struct {
100 docs: []const Doc,
101 mode: Mode,
102 next: ?*const Pending,
103 };
104
105 const LineFit = enum {
106 continued,
107 broke,
108 overflowed,
109 };
110
111 const Renderer = struct {
112 writer: *std.Io.Writer,
113 width: usize,
114 color: ColorMode,
115 col: usize = 0,
116 active_style: ?Style = null,
117
118 fn writeDoc(
119 self: *Renderer,
120 doc: Doc,
121 indent: usize,
122 mode: Mode,
123 pending: ?*const Pending,
124 ) WriteError!void {
125 switch (doc) {
126 .empty => {},
127 .text => |bytes| {
128 try self.writer.writeAll(bytes);
129 self.col = position.Summary.fromBytes(bytes)
130 .advance(.{ .column = self.col }).column;
131 },
132 .concat => |parts| {
133 for (parts, 0..) |part, index| {
134 const rest = Pending{
135 .docs = parts[index + 1 ..],
136 .mode = mode,
137 .next = pending,
138 };
139 try self.writeDoc(part, indent, mode, &rest);
140 }
141 },
142 .line => |flat_text| {
143 if (mode == .flat) {
144 try self.writer.writeAll(flat_text);
145 self.col = position.Summary.fromBytes(flat_text)
146 .advance(.{ .column = self.col }).column;
147 return;
148 }
149 try self.writer.writeByte('\n');
150 try self.writer.splatByteAll(' ', indent);
151 self.col = indent;
152 },
153 .hardline => {
154 try self.writer.writeByte('\n');
155 try self.writer.splatByteAll(' ', indent);
156 self.col = indent;
157 },
158 .nest => |nested| {
159 const nested_indent = std.math.add(usize, indent, nested.amount) catch {
160 return error.IndentationOverflow;
161 };
162 try self.writeDoc(nested.doc.*, nested_indent, mode, pending);
163 },
164 .group => |grouped| {
165 const child_mode: Mode = if (mode == .flat or self.fits(grouped.*, pending))
166 .flat
167 else
168 .broken;
169 try self.writeDoc(grouped.*, indent, child_mode, pending);
170 },
171 .styled => |styled| {
172 if (styled.style == .plain) {
173 try self.writeDoc(styled.doc.*, indent, mode, pending);
174 return;
175 }
176 const previous_style = self.active_style;
177 try self.writeStyleStart(styled.style);
178 self.active_style = styled.style;
179 errdefer self.active_style = previous_style;
180 try self.writeDoc(styled.doc.*, indent, mode, pending);
181 try self.writeStyleEnd(previous_style);
182 self.active_style = previous_style;
183 },
184 }
185 }
186
187 fn fits(self: Renderer, doc: Doc, pending: ?*const Pending) bool {
188 return fitsCandidate(doc, pending, self.width -| self.col);
189 }
190
191 fn writeStyleStart(self: *Renderer, style_kind: Style) std.Io.Writer.Error!void {
192 if (self.color != .ansi or style_kind == .plain) return;
193 try self.writer.writeAll(ansiCodeFor(style_kind));
194 }
195
196 fn writeStyleEnd(self: *Renderer, previous_style: ?Style) std.Io.Writer.Error!void {
197 if (self.color != .ansi) return;
198 try self.writer.writeAll(ansi_reset);
199 if (previous_style) |style_kind| {
200 if (style_kind != .plain) try self.writer.writeAll(ansiCodeFor(style_kind));
201 }
202 }
203 };
204
205 fn fitsCandidate(doc: Doc, pending: ?*const Pending, budget: usize) bool {
206 var remaining = budget;
207 if (!fitsWithin(doc, &remaining)) return false;
208 return fitsPending(pending, &remaining);
209 }
210
211 fn fitsPending(initial: ?*const Pending, budget: *usize) bool {
212 var pending = initial;
213 while (pending) |work| : (pending = work.next) {
214 for (work.docs) |doc| {
215 switch (fitsLine(doc, work.mode, budget)) {
216 .continued => {},
217 .broke => return true,
218 .overflowed => return false,
219 }
220 }
221 }
222 return true;
223 }
224
225 fn fitsLine(doc: Doc, mode: Mode, budget: *usize) LineFit {
226 return switch (doc) {
227 .empty => .continued,
228 .text => |bytes| consumeLineBytes(bytes, budget),
229 .concat => |parts| blk: {
230 for (parts) |part| {
231 const result = fitsLine(part, mode, budget);
232 if (result != .continued) break :blk result;
233 }
234 break :blk .continued;
235 },
236 .line => |flat_text| if (mode == .flat)
237 consumeLineBytes(flat_text, budget)
238 else
239 .broke,
240 .hardline => .broke,
241 .nest => |nested| fitsLine(nested.doc.*, mode, budget),
242 .group => |grouped| fitsLine(grouped.*, mode, budget),
243 .styled => |styled| fitsLine(styled.doc.*, mode, budget),
244 };
245 }
246
247 fn consumeLineBytes(bytes: []const u8, budget: *usize) LineFit {
248 const prefix = bytes[0..@min(bytes.len, budget.* +| 1)];
249 if (std.mem.findAny(u8, prefix, "\r\n")) |index| {
250 budget.* -= index;
251 return .broke;
252 }
253 if (bytes.len > budget.*) return .overflowed;
254 budget.* -= bytes.len;
255 return .continued;
256 }
257
258 test "line lookahead preserves reset boundaries and overflow budgets" {
259 var bytes: [129]u8 = undefined;
260 for (0..bytes.len + 1) |length| {
261 for (0..length + 1) |reset_index| {
262 for ([_]u8{ '\r', '\n' }) |reset| {
263 @memset(&bytes, 'x');
264 if (reset_index < length) bytes[reset_index] = reset;
265 const limits = [_]usize{
266 0, reset_index -| 1, reset_index, length, std.math.maxInt(usize),
267 };
268 for (limits) |limit| {
269 var budget = limit;
270 const cells = @min(length, reset_index);
271 const expected: LineFit = if (cells > limit)
272 .overflowed
273 else if (reset_index < length)
274 .broke
275 else
276 .continued;
277 try std.testing.expectEqual(
278 expected,
279 consumeLineBytes(bytes[0..length], &budget),
280 );
281 const remaining = if (expected == .overflowed) limit else limit - cells;
282 try std.testing.expectEqual(remaining, budget);
283 }
284 }
285 }
286 }
287 }
288
289 test "payload rendering preserves continuation resets at the margin without allocation" {
290 var payload: [256]u8 = undefined;
291 var output: [260]u8 = undefined;
292 const parts = [_]Doc{ .{ .text = "key" }, types.softline };
293 const grouped: Doc = .{ .concat = &parts };
294 const docs = [_]Doc{ .{ .group = &grouped }, .{ .text = &payload } };
295 for ([_]usize{ 0, 1, 75, 76, 77, 255, 256 }) |reset_index| {
296 for ([_]u8{ '\r', '\n' }) |reset| {
297 @memset(&payload, 'x');
298 if (reset_index < payload.len) payload[reset_index] = reset;
299 var writer: std.Io.Writer = .fixed(&output);
300 try write(&writer, .{ .concat = &docs }, .{ .width = 80 });
301 const prefix = if (reset_index <= 76) "key " else "key\n";
302 try std.testing.expectEqualStrings(prefix, output[0..4]);
303 try std.testing.expectEqualSlices(u8, &payload, output[4..]);
304 try std.testing.expectEqual(output.len, writer.end);
305 }
306 }
307 }
308
309 fn fitsWithin(doc: Doc, budget: *usize) bool {
310 return switch (doc) {
311 .empty => true,
312 .text => |bytes| position.consumeFlatBytes(bytes, budget),
313 .concat => |parts| blk: {
314 for (parts) |part| {
315 if (!fitsWithin(part, budget)) break :blk false;
316 }
317 break :blk true;
318 },
319 .line => |flat_text| position.consumeFlatBytes(flat_text, budget),
320 .hardline => false,
321 .nest => |nested| fitsWithin(nested.doc.*, budget),
322 .group => |grouped| fitsWithin(grouped.*, budget),
323 .styled => |styled| fitsWithin(styled.doc.*, budget),
324 };
325 }
326
327 const ansi_reset = "\x1b[0m";
328
329 fn ansiCodeFor(style_kind: Style) []const u8 {
330 return switch (style_kind) {
331 .plain => "",
332 .title => "\x1b[1;96m",
333 .keyword => "\x1b[1;94m",
334 .name => "\x1b[96m",
335 .value => "\x1b[32m",
336 .string => "\x1b[32m",
337 .number => "\x1b[35m",
338 .type_name => "\x1b[33m",
339 .attribute => "\x1b[36m",
340 .block_label => "\x1b[1;35m",
341 .source => "\x1b[37m",
342 .punctuation => "\x1b[2m",
343 .muted => "\x1b[2m",
344 .accent => "\x1b[1m",
345 .warning => "\x1b[1;33m",
346 .danger => "\x1b[1;31m",
347 };
348 }