//! Markdown rendering for the TUI. //! //! Parsing is delegated to MD4C (a small C CommonMark parser). This module is //! only the terminal renderer: it turns MD4C's block/span/text callbacks into //! ANSI-styled, width-bounded lines for panto components. const std = @import("std"); const theme = @import("tui_theme.zig"); const c = @cImport({ @cInclude("md4c.h"); }); const Allocator = std.mem.Allocator; const max_style_depth = 64; const max_list_depth = 64; fn codepointWidth(cp: u21) usize { if (cp == 0x200D) return 0; if (cp >= 0x0300 and cp <= 0x036F) return 0; if (cp >= 0x0483 and cp <= 0x0489) return 0; if (cp >= 0x0591 and cp <= 0x05BD) return 0; if (cp >= 0x0610 and cp <= 0x061A) return 0; if (cp >= 0x064B and cp <= 0x065F) return 0; if (cp >= 0x1AB0 and cp <= 0x1AFF) return 0; if (cp >= 0x1DC0 and cp <= 0x1DFF) return 0; if (cp >= 0x20D0 and cp <= 0x20FF) return 0; if (cp >= 0xFE00 and cp <= 0xFE0F) return 0; if (cp >= 0xFE20 and cp <= 0xFE2F) return 0; if (cp >= 0xE0100 and cp <= 0xE01EF) return 0; if (cp >= 0x1100 and cp <= 0x115F) return 2; if (cp >= 0x2E80 and cp <= 0x303E) return 2; if (cp >= 0x3041 and cp <= 0x33BF) return 2; if (cp >= 0x3400 and cp <= 0x4DBF) return 2; if (cp >= 0x4E00 and cp <= 0x9FFF) return 2; if (cp >= 0xA000 and cp <= 0xA4CF) return 2; if (cp >= 0xA960 and cp <= 0xA97F) return 2; if (cp >= 0xAC00 and cp <= 0xD7AF) return 2; if (cp >= 0xD7B0 and cp <= 0xD7FF) return 2; if (cp >= 0xF900 and cp <= 0xFAFF) return 2; if (cp >= 0xFE10 and cp <= 0xFE1F) return 2; if (cp >= 0xFE30 and cp <= 0xFE4F) return 2; if (cp >= 0xFF01 and cp <= 0xFF60) return 2; if (cp >= 0xFFE0 and cp <= 0xFFE6) return 2; if (cp >= 0x1B000 and cp <= 0x1B0FF) return 2; if (cp >= 0x1F004 and cp <= 0x1F004) return 2; if (cp >= 0x1F0CF and cp <= 0x1F0CF) return 2; if (cp >= 0x1F200 and cp <= 0x1F2FF) return 2; if (cp >= 0x1F300 and cp <= 0x1F64F) return 2; if (cp >= 0x1F680 and cp <= 0x1F6FF) return 2; if (cp >= 0x1F700 and cp <= 0x1F77F) return 2; if (cp >= 0x1F780 and cp <= 0x1F7FF) return 2; if (cp >= 0x1F800 and cp <= 0x1F8FF) return 2; if (cp >= 0x1F900 and cp <= 0x1F9FF) return 2; if (cp >= 0x1FA00 and cp <= 0x1FAFF) return 2; if (cp >= 0x20000 and cp <= 0x2A6DF) return 2; if (cp >= 0x2A700 and cp <= 0x2CEAF) return 2; if (cp >= 0x2CEB0 and cp <= 0x2EBEF) return 2; if (cp >= 0x2F800 and cp <= 0x2FA1F) return 2; if (cp >= 0x30000 and cp <= 0x3134F) return 2; return 1; } fn tabWidth(col: usize) usize { const rem = col % 8; return if (rem == 0) 8 else 8 - rem; } fn codepointWidthAt(cp: u21, col: usize) usize { return if (cp == '\t') tabWidth(col) else codepointWidth(cp); } /// Return the largest prefix that is safe to render while markdown is still /// streaming. Avoid rendering an unterminated trailing line (which may still /// become a heading/list/code fence/etc.) and avoid entering an unclosed fenced /// code block. pub fn streamingSafeCut(buffer: []const u8) []const u8 { if (buffer.len == 0) return buffer[0..0]; const last_nl = std.mem.lastIndexOfScalar(u8, buffer, '\n') orelse return buffer[0..0]; var safe = buffer[0 .. last_nl + 1]; var in_fence = false; var fence_char: u8 = 0; var fence_len: usize = 0; var line_start: usize = 0; while (line_start < safe.len) { var line_end = line_start; while (line_end < safe.len and safe[line_end] != '\n') line_end += 1; const line = std.mem.trimStart(u8, safe[line_start..line_end], " \t"); if (line.len >= 3 and (line[0] == '`' or line[0] == '~')) { var n: usize = 0; while (n < line.len and line[n] == line[0]) n += 1; if (n >= 3) { if (!in_fence) { in_fence = true; fence_char = line[0]; fence_len = n; } else if (line[0] == fence_char and n >= fence_len) { in_fence = false; } } } line_start = if (line_end < safe.len) line_end + 1 else safe.len; } if (!in_fence) return safe; // If a fence is open, render only through the line before its opener. var opener: usize = 0; line_start = 0; while (line_start < safe.len) { var line_end = line_start; while (line_end < safe.len and safe[line_end] != '\n') line_end += 1; const line = std.mem.trimStart(u8, safe[line_start..line_end], " \t"); if (line.len >= 3 and line[0] == fence_char) { var n: usize = 0; while (n < line.len and line[n] == fence_char) n += 1; if (n >= fence_len) opener = line_start; } line_start = if (line_end < safe.len) line_end + 1 else safe.len; } return safe[0..opener]; } fn skipEscape(s: []const u8, start_i: usize) ?usize { if (start_i + 1 >= s.len or s[start_i] != '\x1b') return null; switch (s[start_i + 1]) { '[' => { var i = start_i + 2; while (i < s.len) : (i += 1) { if (s[i] >= '@' and s[i] <= '~') return i + 1; } return s.len; }, ']' => { var i = start_i + 2; while (i < s.len) : (i += 1) { if (s[i] == 0x07) return i + 1; if (s[i] == '\x1b' and i + 1 < s.len and s[i + 1] == '\\') return i + 2; } return s.len; }, else => return null, } } fn visibleWidth(s: []const u8) usize { var w: usize = 0; var i: usize = 0; while (i < s.len) { if (skipEscape(s, i)) |next| { i = next; continue; } const n = std.unicode.utf8ByteSequenceLength(s[i]) catch 1; const adv = @min(n, s.len - i); const cp = std.unicode.utf8Decode(s[i .. i + adv]) catch '?'; i += adv; w += codepointWidthAt(cp, w); } return w; } fn takeVisible(s: []const u8, max: usize) usize { var w: usize = 0; var i: usize = 0; while (i < s.len) { if (skipEscape(s, i)) |next| { i = next; continue; } const n = std.unicode.utf8ByteSequenceLength(s[i]) catch 1; const adv = @min(n, s.len - i); const cp = std.unicode.utf8Decode(s[i .. i + adv]) catch '?'; const cp_w = codepointWidthAt(cp, w); if (w + cp_w > max) break; i += adv; w += cp_w; } return i; } fn maxLineVisibleWidth(s: []const u8) usize { var max: usize = 0; var it = std.mem.splitScalar(u8, s, '\n'); while (it.next()) |line| max = @max(max, visibleWidth(line)); return max; } /// ANSI-aware greedy wrapping. The input may contain CSI styling escapes. pub fn wrapStyled(buf: []const u8, width: usize, out: *std.ArrayList(u8), alloc: Allocator) !void { const w = @max(width, 1); var line_start: usize = 0; while (line_start <= buf.len) { const nl = std.mem.indexOfScalarPos(u8, buf, line_start, '\n') orelse buf.len; var rest = buf[line_start..nl]; while (visibleWidth(rest) > w) { var cut = takeVisible(rest, w); if (cut < rest.len) { if (std.mem.lastIndexOfScalar(u8, rest[0..cut], ' ')) |sp| { if (sp > 0) cut = sp; } } try out.appendSlice(alloc, rest[0..cut]); try out.append(alloc, '\n'); rest = std.mem.trimStart(u8, rest[cut..], " "); } try out.appendSlice(alloc, rest); if (nl == buf.len) break; try out.append(alloc, '\n'); line_start = nl + 1; } } const TableCell = struct { const Align = enum { left, center, right }; text: []const u8, alignment: Align = .left, fn deinit(self: *TableCell, alloc: Allocator) void { alloc.free(self.text); } }; const TableRow = struct { cells: std.ArrayList(TableCell) = .empty, is_header: bool = false, fn deinit(self: *TableRow, alloc: Allocator) void { for (self.cells.items) |*cell| cell.deinit(alloc); self.cells.deinit(alloc); } }; const CellLines = struct { lines: std.ArrayList([]const u8) = .empty, fn deinit(self: *CellLines, alloc: Allocator) void { for (self.lines.items) |line| alloc.free(line); self.lines.deinit(alloc); } }; const ListState = struct { ordered: bool, next: usize = 1, }; const LinkState = struct { href: []const u8, fn deinit(self: *LinkState, alloc: Allocator) void { alloc.free(self.href); } }; pub const Renderer = struct { alloc: Allocator, width: usize, out_lines: *std.ArrayList([]const u8), buf: std.ArrayList(u8) = .empty, in_code_block: bool = false, list_depth: usize = 0, list_item_depth: usize = 0, list_item_first_paragraph: bool = false, list_stack: [max_list_depth]ListState = undefined, list_item_depth_stack: [max_list_depth]usize = [_]usize{0} ** max_list_depth, list_item_first_stack: [max_list_depth]bool = [_]bool{false} ** max_list_depth, list_item_marker_stack: [max_list_depth][32]u8 = undefined, list_item_marker_len_stack: [max_list_depth]usize = [_]usize{0} ** max_list_depth, list_item_stack_len: usize = 0, list_item_marker: [32]u8 = undefined, list_item_marker_len: usize = 0, heading_level: usize = 0, quote_depth: usize = 0, table_rows: std.ArrayList(TableRow) = .empty, current_table_row: ?TableRow = null, in_table: bool = false, in_table_cell: bool = false, current_cell_is_header: bool = false, current_cell_align: TableCell.Align = .left, style_stack: [max_style_depth][]const u8 = undefined, style_stack_len: usize = 0, current_link: ?LinkState = null, err: ?anyerror = null, pub fn render(self: *Renderer, src: []const u8) !void { self.buf = .empty; defer self.buf.deinit(self.alloc); defer self.clearLinks(); self.err = null; self.in_code_block = false; self.list_depth = 0; self.list_item_depth = 0; self.list_item_first_paragraph = false; self.list_item_stack_len = 0; self.list_item_marker_len = 0; self.heading_level = 0; self.quote_depth = 0; self.style_stack_len = 0; self.clearTable(); var parser: c.MD_PARSER = std.mem.zeroes(c.MD_PARSER); parser.abi_version = 0; parser.flags = c.MD_FLAG_TABLES | c.MD_FLAG_TASKLISTS | c.MD_FLAG_STRIKETHROUGH | c.MD_FLAG_PERMISSIVEURLAUTOLINKS; parser.enter_block = enterBlock; parser.leave_block = leaveBlock; parser.enter_span = enterSpan; parser.leave_span = leaveSpan; parser.text = textCb; const rc = c.md_parse(src.ptr, @intCast(src.len), &parser, self); if (self.err) |e| return e; if (rc != 0) return error.MarkdownParseFailed; try self.flushParagraph(); while (self.out_lines.items.len > 0 and self.out_lines.items[self.out_lines.items.len - 1].len == 0) { const last = self.out_lines.pop().?; self.alloc.free(last); } } fn add(self: *Renderer, s: []const u8) !void { try self.buf.appendSlice(self.alloc, s); } fn appendLine(self: *Renderer, s: []const u8) !void { const line = try self.alloc.dupe(u8, s); errdefer self.alloc.free(line); try self.out_lines.append(self.alloc, line); } fn appendBlank(self: *Renderer) !void { if (self.out_lines.items.len == 0) return; if (self.out_lines.items[self.out_lines.items.len - 1].len == 0) return; try self.appendLine(""); } fn quotePrefix(self: *Renderer, out: *std.ArrayList(u8)) !void { const quote = theme.default.fg(.dim); var i: usize = 0; while (i < self.quote_depth) : (i += 1) { try out.appendSlice(self.alloc, quote.open()); try out.appendSlice(self.alloc, "┃ "); try out.appendSlice(self.alloc, quote.close()); } } fn flushParagraph(self: *Renderer) !void { const text = std.mem.trim(u8, self.buf.items, " \t\n"); if (text.len == 0) { self.buf.clearRetainingCapacity(); return; } if (self.list_item_depth > 0) { var first_prefix: std.ArrayList(u8) = .empty; defer first_prefix.deinit(self.alloc); var cont_prefix: std.ArrayList(u8) = .empty; defer cont_prefix.deinit(self.alloc); const indent = (self.list_item_depth - 1) * 2; try self.quotePrefix(&first_prefix); try self.quotePrefix(&cont_prefix); try first_prefix.appendNTimes(self.alloc, ' ', indent); try cont_prefix.appendNTimes(self.alloc, ' ', indent + self.list_item_marker_len); if (self.list_item_first_paragraph) { try first_prefix.appendSlice(self.alloc, self.list_item_marker[0..self.list_item_marker_len]); } else { try first_prefix.appendNTimes(self.alloc, ' ', self.list_item_marker_len); } var wrapped: std.ArrayList(u8) = .empty; defer wrapped.deinit(self.alloc); const wrap_width = if (self.width > visibleWidth(first_prefix.items)) self.width - visibleWidth(first_prefix.items) else 1; try wrapStyled(text, wrap_width, &wrapped, self.alloc); var it = std.mem.splitScalar(u8, wrapped.items, '\n'); var first = true; while (it.next()) |line| { var tmp: std.ArrayList(u8) = .empty; defer tmp.deinit(self.alloc); try tmp.appendSlice(self.alloc, if (first) first_prefix.items else cont_prefix.items); try tmp.appendSlice(self.alloc, line); try self.appendLine(tmp.items); first = false; } self.list_item_first_paragraph = false; } else { var prefix: std.ArrayList(u8) = .empty; defer prefix.deinit(self.alloc); try self.quotePrefix(&prefix); var wrapped: std.ArrayList(u8) = .empty; defer wrapped.deinit(self.alloc); const wrap_width = if (self.width > visibleWidth(prefix.items)) self.width - visibleWidth(prefix.items) else 1; try wrapStyled(text, wrap_width, &wrapped, self.alloc); var it = std.mem.splitScalar(u8, wrapped.items, '\n'); while (it.next()) |line| { var tmp: std.ArrayList(u8) = .empty; defer tmp.deinit(self.alloc); try tmp.appendSlice(self.alloc, prefix.items); try tmp.appendSlice(self.alloc, line); try self.appendLine(tmp.items); } } self.buf.clearRetainingCapacity(); } fn flushCodeBlock(self: *Renderer) !void { const code = theme.default.fg(.tool_header); var it = std.mem.splitScalar(u8, self.buf.items, '\n'); while (it.next()) |line| { if (line.len == 0) continue; var tmp: std.ArrayList(u8) = .empty; defer tmp.deinit(self.alloc); try tmp.appendSlice(self.alloc, code.open()); try tmp.appendSlice(self.alloc, " "); try tmp.appendSlice(self.alloc, line); try tmp.appendSlice(self.alloc, code.close()); try self.appendLine(tmp.items); } self.buf.clearRetainingCapacity(); } fn appendTableCell(self: *Renderer) !void { const text = std.mem.trim(u8, self.buf.items, " \t\n"); const row = if (self.current_table_row) |*row| row else return; const owned = try self.alloc.dupe(u8, text); errdefer self.alloc.free(owned); try row.cells.append(self.alloc, .{ .text = owned, .alignment = self.current_cell_align }); row.is_header = row.is_header or self.current_cell_is_header; self.buf.clearRetainingCapacity(); self.in_table_cell = false; self.current_cell_is_header = false; self.current_cell_align = .left; } fn appendTableBorder( self: *Renderer, widths: []const usize, left: []const u8, join: []const u8, right: []const u8, ) !void { var line: std.ArrayList(u8) = .empty; defer line.deinit(self.alloc); try line.appendSlice(self.alloc, left); for (widths, 0..) |w, i| { var n: usize = 0; while (n < w + 2) : (n += 1) try line.appendSlice(self.alloc, "─"); try line.appendSlice(self.alloc, if (i + 1 == widths.len) right else join); } try self.appendLine(line.items); } fn fitTableWidths(self: *Renderer, widths: []usize) void { if (widths.len == 0) return; const overhead = widths.len * 3 + 1; const budget = if (self.width > overhead) self.width - overhead else widths.len; for (widths) |*w| w.* = @max(w.*, 1); while (true) { var sum: usize = 0; var biggest_i: usize = 0; var biggest: usize = 0; for (widths, 0..) |w, i| { sum += w; if (w > biggest) { biggest = w; biggest_i = i; } } if (sum <= budget or biggest <= 1) break; widths[biggest_i] -= 1; } } fn appendPaddedCell(self: *Renderer, line: *std.ArrayList(u8), text: []const u8, width: usize, alignment: TableCell.Align) !void { const pad = width -| visibleWidth(text); const left_pad: usize, const right_pad: usize = switch (alignment) { .left => .{ 0, pad }, .right => .{ pad, 0 }, .center => .{ pad / 2, pad - pad / 2 }, }; try line.append(self.alloc, ' '); try line.appendNTimes(self.alloc, ' ', left_pad); try line.appendSlice(self.alloc, text); try line.appendNTimes(self.alloc, ' ', right_pad + 1); } fn renderTableRow(self: *Renderer, row: TableRow, widths: []const usize) !void { var cells: std.ArrayList(CellLines) = .empty; defer { for (cells.items) |*cell| cell.deinit(self.alloc); cells.deinit(self.alloc); } try cells.ensureTotalCapacity(self.alloc, widths.len); var row_height: usize = 1; for (widths, 0..) |w, i| { var cell_lines: CellLines = .{}; errdefer cell_lines.deinit(self.alloc); const text = if (i < row.cells.items.len) row.cells.items[i].text else ""; var wrapped: std.ArrayList(u8) = .empty; defer wrapped.deinit(self.alloc); try wrapStyled(text, w, &wrapped, self.alloc); var it = std.mem.splitScalar(u8, wrapped.items, '\n'); while (it.next()) |part| try cell_lines.lines.append(self.alloc, try self.alloc.dupe(u8, part)); row_height = @max(row_height, cell_lines.lines.items.len); cells.appendAssumeCapacity(cell_lines); } var line_i: usize = 0; while (line_i < row_height) : (line_i += 1) { var line: std.ArrayList(u8) = .empty; defer line.deinit(self.alloc); try line.appendSlice(self.alloc, "│"); for (widths, 0..) |w, i| { const text = if (line_i < cells.items[i].lines.items.len) cells.items[i].lines.items[line_i] else ""; const alignment: TableCell.Align = if (i < row.cells.items.len) row.cells.items[i].alignment else .left; try self.appendPaddedCell(&line, text, w, alignment); try line.appendSlice(self.alloc, "│"); } try self.appendLine(line.items); } } fn renderTable(self: *Renderer) !void { if (self.table_rows.items.len == 0) return; var cols: usize = 0; for (self.table_rows.items) |row| cols = @max(cols, row.cells.items.len); if (cols == 0) return; var widths = try self.alloc.alloc(usize, cols); defer self.alloc.free(widths); @memset(widths, 1); for (self.table_rows.items) |row| { for (row.cells.items, 0..) |cell, i| { widths[i] = @max(widths[i], maxLineVisibleWidth(cell.text)); } } self.fitTableWidths(widths); try self.appendTableBorder(widths, "┌", "┬", "┐"); for (self.table_rows.items, 0..) |row, i| { try self.renderTableRow(row, widths); if (row.is_header and i + 1 < self.table_rows.items.len) { try self.appendTableBorder(widths, "├", "┼", "┤"); } } try self.appendTableBorder(widths, "└", "┴", "┘"); } fn clearTable(self: *Renderer) void { for (self.table_rows.items) |*row| row.deinit(self.alloc); self.table_rows.deinit(self.alloc); self.table_rows = .empty; if (self.current_table_row) |*row| row.deinit(self.alloc); self.current_table_row = null; self.in_table = false; self.in_table_cell = false; self.current_cell_is_header = false; self.current_cell_align = .left; self.buf.clearRetainingCapacity(); } fn clearLinks(self: *Renderer) void { if (self.current_link) |*link| link.deinit(self.alloc); self.current_link = null; } fn pushStyle(self: *Renderer, open_seq: []const u8) !void { if (open_seq.len == 0) return; if (self.style_stack_len < self.style_stack.len) { self.style_stack[self.style_stack_len] = open_seq; self.style_stack_len += 1; } try self.add(open_seq); } fn popStyle(self: *Renderer) !void { if (self.style_stack_len > 0) self.style_stack_len -= 1; try self.add(theme.reset); for (self.style_stack[0..self.style_stack_len]) |open_seq| try self.add(open_seq); } fn appendAttribute(self: *Renderer, attr: c.MD_ATTRIBUTE) ![]const u8 { var out: std.ArrayList(u8) = .empty; errdefer out.deinit(self.alloc); try appendDecodedEntities(&out, self.alloc, attr.text[0..@intCast(attr.size)]); return try out.toOwnedSlice(self.alloc); } fn pushLink(self: *Renderer, href: []const u8) !void { self.clearLinks(); const owned = try self.alloc.dupe(u8, href); errdefer self.alloc.free(owned); self.current_link = .{ .href = owned }; try self.add("\x1b]8;;"); try self.add(href); try self.add("\x1b\\"); try self.pushStyle("\x1b[4m"); } fn popLink(self: *Renderer) !void { try self.popStyle(); var link = self.current_link orelse return; self.current_link = null; defer link.deinit(self.alloc); try self.add("\x1b]8;;\x1b\\"); try self.add(" ("); try self.add(link.href); try self.add(")"); } fn fail(self: *Renderer, e: anyerror) c_int { self.err = e; return 1; } fn enterBlock(t: c.MD_BLOCKTYPE, detail: ?*anyopaque, userdata: ?*anyopaque) callconv(.c) c_int { const self: *Renderer = @ptrCast(@alignCast(userdata.?)); switch (t) { c.MD_BLOCK_QUOTE => { self.flushParagraph() catch |e| return self.fail(e); self.quote_depth += 1; }, c.MD_BLOCK_TABLE => { self.flushParagraph() catch |e| return self.fail(e); self.clearTable(); self.in_table = true; }, c.MD_BLOCK_TR => { if (self.in_table) self.current_table_row = .{}; }, c.MD_BLOCK_TH, c.MD_BLOCK_TD => { if (self.in_table) { self.buf.clearRetainingCapacity(); self.in_table_cell = true; self.current_cell_is_header = t == c.MD_BLOCK_TH; const d: *c.MD_BLOCK_TD_DETAIL = @ptrCast(@alignCast(detail.?)); self.current_cell_align = switch (d.@"align") { c.MD_ALIGN_RIGHT => .right, c.MD_ALIGN_CENTER => .center, else => .left, }; } }, c.MD_BLOCK_H => { if (self.in_table) return 0; self.flushParagraph() catch |e| return self.fail(e); const d: *c.MD_BLOCK_H_DETAIL = @ptrCast(@alignCast(detail.?)); self.heading_level = @intCast(d.level); self.add(theme.default.fg(.welcome).open()) catch |e| return self.fail(e); var i: usize = 0; while (i < self.heading_level) : (i += 1) self.add("#") catch |e| return self.fail(e); self.add(" ") catch |e| return self.fail(e); }, c.MD_BLOCK_P => {}, c.MD_BLOCK_CODE => if (!self.in_table) { self.in_code_block = true; self.buf.clearRetainingCapacity(); }, c.MD_BLOCK_UL => { if (self.list_depth < self.list_stack.len) { self.list_stack[self.list_depth] = .{ .ordered = false }; } self.list_depth += 1; }, c.MD_BLOCK_OL => { const d: *c.MD_BLOCK_OL_DETAIL = @ptrCast(@alignCast(detail.?)); if (self.list_depth < self.list_stack.len) { self.list_stack[self.list_depth] = .{ .ordered = true, .next = @intCast(d.start) }; } self.list_depth += 1; }, c.MD_BLOCK_LI => { self.flushParagraph() catch |e| return self.fail(e); if (self.list_item_stack_len < self.list_item_depth_stack.len) { self.list_item_depth_stack[self.list_item_stack_len] = self.list_item_depth; self.list_item_first_stack[self.list_item_stack_len] = self.list_item_first_paragraph; self.list_item_marker_stack[self.list_item_stack_len] = self.list_item_marker; self.list_item_marker_len_stack[self.list_item_stack_len] = self.list_item_marker_len; self.list_item_stack_len += 1; } self.list_item_depth = self.list_depth; self.list_item_first_paragraph = true; const li: *c.MD_BLOCK_LI_DETAIL = @ptrCast(@alignCast(detail.?)); const list_i = if (self.list_depth == 0) 0 else self.list_depth - 1; if (list_i < self.list_stack.len and self.list_stack[list_i].ordered) { const n = self.list_stack[list_i].next; self.list_stack[list_i].next += 1; const marker = std.fmt.bufPrint(&self.list_item_marker, "{d}. ", .{n}) catch "?. "; self.list_item_marker_len = marker.len; } else if (li.is_task != 0) { const mark = if (li.task_mark == 'x' or li.task_mark == 'X') "☑ " else "☐ "; @memcpy(self.list_item_marker[0..mark.len], mark); self.list_item_marker_len = mark.len; } else { @memcpy(self.list_item_marker[0.."• ".len], "• "); self.list_item_marker_len = "• ".len; } }, c.MD_BLOCK_HR => self.appendLine("────────") catch |e| return self.fail(e), else => {}, } return 0; } fn leaveBlock(t: c.MD_BLOCKTYPE, detail: ?*anyopaque, userdata: ?*anyopaque) callconv(.c) c_int { _ = detail; const self: *Renderer = @ptrCast(@alignCast(userdata.?)); switch (t) { c.MD_BLOCK_QUOTE => { self.flushParagraph() catch |e| return self.fail(e); if (self.quote_depth > 0) self.quote_depth -= 1; self.appendBlank() catch |e| return self.fail(e); }, c.MD_BLOCK_TABLE => { self.renderTable() catch |e| return self.fail(e); self.clearTable(); self.appendBlank() catch |e| return self.fail(e); }, c.MD_BLOCK_TR => { if (self.in_table) { const row = self.current_table_row orelse return 0; self.current_table_row = null; self.table_rows.append(self.alloc, row) catch |e| return self.fail(e); } }, c.MD_BLOCK_TH, c.MD_BLOCK_TD => { if (self.in_table_cell) self.appendTableCell() catch |e| return self.fail(e); }, c.MD_BLOCK_H => { if (self.in_table) return 0; self.add(theme.default.fg(.welcome).close()) catch |e| return self.fail(e); self.flushParagraph() catch |e| return self.fail(e); self.appendBlank() catch |e| return self.fail(e); self.heading_level = 0; }, c.MD_BLOCK_P => { if (self.in_table) return 0; self.flushParagraph() catch |e| return self.fail(e); if (self.list_item_depth == 0) self.appendBlank() catch |e| return self.fail(e); }, c.MD_BLOCK_LI => { self.flushParagraph() catch |e| return self.fail(e); if (self.list_item_stack_len > 0) { self.list_item_stack_len -= 1; self.list_item_depth = self.list_item_depth_stack[self.list_item_stack_len]; self.list_item_first_paragraph = self.list_item_first_stack[self.list_item_stack_len]; self.list_item_marker = self.list_item_marker_stack[self.list_item_stack_len]; self.list_item_marker_len = self.list_item_marker_len_stack[self.list_item_stack_len]; } else { self.list_item_depth = 0; self.list_item_first_paragraph = false; self.list_item_marker_len = 0; } }, c.MD_BLOCK_CODE => { self.flushCodeBlock() catch |e| return self.fail(e); self.appendBlank() catch |e| return self.fail(e); self.in_code_block = false; }, c.MD_BLOCK_UL, c.MD_BLOCK_OL => { if (self.list_depth > 0) self.list_depth -= 1; if (self.list_depth == 0 and self.list_item_depth == 0) self.appendBlank() catch |e| return self.fail(e); }, else => {}, } return 0; } fn enterSpan(t: c.MD_SPANTYPE, detail: ?*anyopaque, userdata: ?*anyopaque) callconv(.c) c_int { const self: *Renderer = @ptrCast(@alignCast(userdata.?)); const s = switch (t) { c.MD_SPAN_STRONG => "\x1b[1m", c.MD_SPAN_EM => "\x1b[3m", c.MD_SPAN_CODE => theme.default.fg(.tool_header).open(), c.MD_SPAN_DEL => "\x1b[9m", else => "", }; if (t == c.MD_SPAN_A) { const d: *c.MD_SPAN_A_DETAIL = @ptrCast(@alignCast(detail.?)); const href = self.appendAttribute(d.href) catch |e| return self.fail(e); defer self.alloc.free(href); self.pushLink(href) catch |e| return self.fail(e); } else { self.pushStyle(s) catch |e| return self.fail(e); } return 0; } fn leaveSpan(t: c.MD_SPANTYPE, detail: ?*anyopaque, userdata: ?*anyopaque) callconv(.c) c_int { _ = detail; const self: *Renderer = @ptrCast(@alignCast(userdata.?)); if (t == c.MD_SPAN_A) { self.popLink() catch |e| return self.fail(e); } else switch (t) { c.MD_SPAN_STRONG, c.MD_SPAN_EM, c.MD_SPAN_CODE, c.MD_SPAN_DEL => { self.popStyle() catch |e| return self.fail(e); }, else => {}, } return 0; } fn textCb(t: c.MD_TEXTTYPE, p: [*c]const u8, size: c.MD_SIZE, userdata: ?*anyopaque) callconv(.c) c_int { const self: *Renderer = @ptrCast(@alignCast(userdata.?)); const s = p[0..@intCast(size)]; switch (t) { c.MD_TEXT_BR, c.MD_TEXT_SOFTBR => self.add("\n") catch |e| return self.fail(e), c.MD_TEXT_NULLCHAR => self.add("�") catch |e| return self.fail(e), c.MD_TEXT_ENTITY => appendDecodedEntities(&self.buf, self.alloc, s) catch |e| return self.fail(e), else => self.add(s) catch |e| return self.fail(e), } return 0; } }; fn appendCodepoint(out: *std.ArrayList(u8), alloc: Allocator, cp: u21) !void { var buf: [4]u8 = undefined; const n = std.unicode.utf8Encode(cp, &buf) catch { try out.appendSlice(alloc, "�"); return; }; try out.appendSlice(alloc, buf[0..n]); } fn appendDecodedEntity(out: *std.ArrayList(u8), alloc: Allocator, entity: []const u8) !bool { if (std.mem.eql(u8, entity, "&")) { try out.append(alloc, '&'); } else if (std.mem.eql(u8, entity, "<")) { try out.append(alloc, '<'); } else if (std.mem.eql(u8, entity, ">")) { try out.append(alloc, '>'); } else if (std.mem.eql(u8, entity, """)) { try out.append(alloc, '"'); } else if (std.mem.eql(u8, entity, "'")) { try out.append(alloc, '\''); } else if (std.mem.eql(u8, entity, " ")) { try out.appendSlice(alloc, "\u{00a0}"); } else if (entity.len >= 4 and entity[0] == '&' and entity[1] == '#' and entity[entity.len - 1] == ';') { const body = entity[2 .. entity.len - 1]; const cp64 = if (body.len >= 2 and (body[0] == 'x' or body[0] == 'X')) std.fmt.parseInt(u21, body[1..], 16) catch return false else std.fmt.parseInt(u21, body, 10) catch return false; try appendCodepoint(out, alloc, cp64); } else { return false; } return true; } fn appendDecodedEntities(out: *std.ArrayList(u8), alloc: Allocator, text: []const u8) !void { var i: usize = 0; while (i < text.len) { if (text[i] == '&') { if (std.mem.indexOfScalarPos(u8, text, i, ';')) |semi| { const entity = text[i .. semi + 1]; if (try appendDecodedEntity(out, alloc, entity)) { i = semi + 1; continue; } } } try out.append(alloc, text[i]); i += 1; } } const testing = std.testing; test "streamingSafeCut waits for newline" { try testing.expectEqualStrings("", streamingSafeCut("hello")); try testing.expectEqualStrings("foo\n", streamingSafeCut("foo\nbar")); } test "wrapStyled wraps plain text" { var out: std.ArrayList(u8) = .empty; defer out.deinit(testing.allocator); try wrapStyled("the quick brown fox", 10, &out, testing.allocator); try testing.expectEqualStrings("the quick\nbrown fox", out.items); } test "wrapStyled preserves hard tabs while counting tab stops" { var out: std.ArrayList(u8) = .empty; defer out.deinit(testing.allocator); try wrapStyled("\treturn err", 14, &out, testing.allocator); try testing.expectEqualStrings("\treturn\nerr", out.items); try testing.expectEqual(@as(usize, 11), visibleWidth("\tfoo")); } test "Renderer uses MD4C for common markdown" { var out: std.ArrayList([]const u8) = .empty; defer { for (out.items) |l| testing.allocator.free(l); out.deinit(testing.allocator); } var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out }; try r.render("# Heading\n\ncode `x` and **bold** and *italic* and [a](u).\n\n```zig\nfn main() {}\n```\n"); try testing.expect(out.items.len > 3); try testing.expect(std.mem.indexOf(u8, out.items[0], "Heading") != null); var found_code = false; for (out.items) |l| { if (std.mem.indexOf(u8, l, "fn main") != null) found_code = true; } try testing.expect(found_code); } test "Renderer preserves user-authored line breaks and blank paragraph lines" { var out: std.ArrayList([]const u8) = .empty; defer { for (out.items) |l| testing.allocator.free(l); out.deinit(testing.allocator); } var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out }; try r.render("first\nsecond\n\nthird"); try testing.expectEqual(@as(usize, 4), out.items.len); try testing.expectEqualStrings("first", out.items[0]); try testing.expectEqualStrings("second", out.items[1]); try testing.expectEqualStrings("", out.items[2]); try testing.expectEqualStrings("third", out.items[3]); } test "Renderer keeps soft breaks between inline-styled lines" { var out: std.ArrayList([]const u8) = .empty; defer { for (out.items) |l| testing.allocator.free(l); out.deinit(testing.allocator); } var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out }; try r.render("`code block`\n_underline_\n**bold**\n~strikethrough~"); try testing.expectEqual(@as(usize, 4), out.items.len); try testing.expect(std.mem.indexOf(u8, out.items[0], "code block") != null); try testing.expect(std.mem.indexOf(u8, out.items[1], "underline") != null); try testing.expect(std.mem.indexOf(u8, out.items[2], "bold") != null); try testing.expect(std.mem.indexOf(u8, out.items[3], "strikethrough") != null); } test "Renderer preserves blank line between list and following paragraph" { var out: std.ArrayList([]const u8) = .empty; defer { for (out.items) |l| testing.allocator.free(l); out.deinit(testing.allocator); } var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out }; try r.render("- one\n- two\n\na summary here"); try testing.expectEqual(@as(usize, 4), out.items.len); try testing.expectEqualStrings("• one", out.items[0]); try testing.expectEqualStrings("• two", out.items[1]); try testing.expectEqualStrings("", out.items[2]); try testing.expectEqualStrings("a summary here", out.items[3]); } test "Renderer preserves nested list indentation" { var out: std.ArrayList([]const u8) = .empty; defer { for (out.items) |l| testing.allocator.free(l); out.deinit(testing.allocator); } var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out }; try r.render("- top\n - child\n- next\n"); try testing.expectEqual(@as(usize, 3), out.items.len); try testing.expectEqualStrings("• top", out.items[0]); try testing.expectEqualStrings(" • child", out.items[1]); try testing.expectEqualStrings("• next", out.items[2]); } test "Renderer renders markdown tables with aligned columns" { var out: std.ArrayList([]const u8) = .empty; defer { for (out.items) |l| testing.allocator.free(l); out.deinit(testing.allocator); } var r: Renderer = .{ .alloc = testing.allocator, .width = 120, .out_lines = &out }; try r.render( \\| Option | Where logic lives | Touches sliderule? | Verdict | \\|--------|-------------------|--------------------|---------| \\| A | Rewrite AST in Go | No | Works today | ); try testing.expectEqual(@as(usize, 5), out.items.len); try testing.expect(std.mem.indexOf(u8, out.items[0], "┌") != null); try testing.expect(std.mem.indexOf(u8, out.items[2], "├") != null); try testing.expect(std.mem.indexOf(u8, out.items[4], "└") != null); const option_sep = std.mem.indexOf(u8, out.items[1], "│ Where").?; try testing.expectEqual(option_sep, std.mem.indexOf(u8, out.items[3], "│ Rewrite").?); const verdict_sep = std.mem.indexOf(u8, out.items[1], "│ Verdict").?; try testing.expectEqual(verdict_sep, std.mem.indexOf(u8, out.items[3], "│ Works").?); } test "Renderer honors markdown table alignment" { var out: std.ArrayList([]const u8) = .empty; defer { for (out.items) |l| testing.allocator.free(l); out.deinit(testing.allocator); } var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out }; try r.render( \\| Left | Center | Right | \\|:-----|:------:|------:| \\| x | y | z | ); try testing.expect(std.mem.indexOf(u8, out.items[3], "│ x │") != null); try testing.expect(std.mem.indexOf(u8, out.items[3], "│ y │") != null); try testing.expect(std.mem.indexOf(u8, out.items[3], "│ z │") != null); } test "Renderer wraps wide markdown table cells to fit width" { var out: std.ArrayList([]const u8) = .empty; defer { for (out.items) |l| testing.allocator.free(l); out.deinit(testing.allocator); } var r: Renderer = .{ .alloc = testing.allocator, .width = 24, .out_lines = &out }; try r.render( \\| Short | Long | \\|-------|------| \\| A | one two three four five | ); try testing.expect(out.items.len > 5); for (out.items) |line| try testing.expect(visibleWidth(line) <= 24); } test "Renderer renders blockquotes with quote prefix" { var out: std.ArrayList([]const u8) = .empty; defer { for (out.items) |l| testing.allocator.free(l); out.deinit(testing.allocator); } var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out }; try r.render("> quoted text"); try testing.expectEqual(@as(usize, 1), out.items.len); try testing.expect(std.mem.indexOf(u8, out.items[0], "┃ ") != null); try testing.expect(std.mem.indexOf(u8, out.items[0], "quoted text") != null); } test "Renderer numbers ordered lists from source start" { var out: std.ArrayList([]const u8) = .empty; defer { for (out.items) |l| testing.allocator.free(l); out.deinit(testing.allocator); } var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out }; try r.render("3. three\n1. four\n1. five"); try testing.expectEqual(@as(usize, 3), out.items.len); try testing.expect(std.mem.indexOf(u8, out.items[0], "3. three") != null); try testing.expect(std.mem.indexOf(u8, out.items[1], "4. four") != null); try testing.expect(std.mem.indexOf(u8, out.items[2], "5. five") != null); } test "Renderer renders task list markers" { var out: std.ArrayList([]const u8) = .empty; defer { for (out.items) |l| testing.allocator.free(l); out.deinit(testing.allocator); } var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out }; try r.render("- [ ] todo\n- [x] done"); try testing.expectEqual(@as(usize, 2), out.items.len); try testing.expect(std.mem.indexOf(u8, out.items[0], "☐ todo") != null); try testing.expect(std.mem.indexOf(u8, out.items[1], "☑ done") != null); } test "Renderer shows link targets and emits OSC 8 hyperlink" { var out: std.ArrayList([]const u8) = .empty; defer { for (out.items) |l| testing.allocator.free(l); out.deinit(testing.allocator); } var r: Renderer = .{ .alloc = testing.allocator, .width = 120, .out_lines = &out }; try r.render("[site](https://example.com)"); try testing.expect(std.mem.indexOf(u8, out.items[0], "\x1b]8;;https://example.com\x1b\\") != null); try testing.expect(std.mem.indexOf(u8, out.items[0], "site") != null); try testing.expect(std.mem.indexOf(u8, out.items[0], "(https://example.com)") != null); } test "Renderer restores parent inline style after nested link" { var out: std.ArrayList([]const u8) = .empty; defer { for (out.items) |l| testing.allocator.free(l); out.deinit(testing.allocator); } var r: Renderer = .{ .alloc = testing.allocator, .width = 120, .out_lines = &out }; try r.render("**before [site](https://example.com) after**"); try testing.expect(std.mem.indexOf(u8, out.items[0], "\x1b[0m\x1b[1m") != null); try testing.expect(std.mem.indexOf(u8, out.items[0], " after") != null); } test "Renderer decodes numeric entities and counts wide glyphs" { var out: std.ArrayList([]const u8) = .empty; defer { for (out.items) |l| testing.allocator.free(l); out.deinit(testing.allocator); } var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out }; try r.render("🤖 © &"); try testing.expect(std.mem.indexOf(u8, out.items[0], "🤖 © &") != null); try testing.expectEqual(@as(usize, 6), visibleWidth("🤖 © &")); }