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 }