//! 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; /// 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 isAnsiAt(s: []const u8, i: usize) bool { return i + 1 < s.len and s[i] == '\x1b' and s[i + 1] == '['; } fn skipAnsi(s: []const u8, start_i: usize) usize { var i = start_i + 2; while (i < s.len) : (i += 1) { const ch = s[i]; if (ch >= '@' and ch <= '~') return i + 1; } return s.len; } fn visibleWidth(s: []const u8) usize { var w: usize = 0; var i: usize = 0; while (i < s.len) { if (isAnsiAt(s, i)) { i = skipAnsi(s, i); continue; } const n = std.unicode.utf8ByteSequenceLength(s[i]) catch 1; i += @min(n, s.len - i); w += 1; } return w; } fn takeVisible(s: []const u8, max: usize) usize { var w: usize = 0; var i: usize = 0; while (i < s.len) { if (isAnsiAt(s, i)) { i = skipAnsi(s, i); continue; } const n = std.unicode.utf8ByteSequenceLength(s[i]) catch 1; const adv = @min(n, s.len - i); if (w + 1 > max) break; i += adv; w += 1; } return i; } /// 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; } } 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_item_depth_stack: [64]usize = [_]usize{0} ** 64, list_item_first_stack: [64]bool = [_]bool{false} ** 64, list_item_stack_len: usize = 0, heading_level: usize = 0, err: ?anyerror = null, pub fn render(self: *Renderer, src: []const u8) !void { self.buf = .empty; defer self.buf.deinit(self.alloc); self.err = null; var parser: c.MD_PARSER = std.mem.zeroes(c.MD_PARSER); parser.abi_version = 0; parser.flags = c.MD_FLAG_TABLES | 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 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 first_prefix.appendNTimes(self.alloc, ' ', indent); try cont_prefix.appendNTimes(self.alloc, ' ', indent + 2); if (self.list_item_first_paragraph) { try first_prefix.appendSlice(self.alloc, "• "); } else { try first_prefix.appendNTimes(self.alloc, ' ', 2); } 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 wrapped: std.ArrayList(u8) = .empty; defer wrapped.deinit(self.alloc); try wrapStyled(text, self.width, &wrapped, self.alloc); var it = std.mem.splitScalar(u8, wrapped.items, '\n'); while (it.next()) |line| try self.appendLine(line); } 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 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_H => { 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 => { self.in_code_block = true; self.buf.clearRetainingCapacity(); }, c.MD_BLOCK_UL, c.MD_BLOCK_OL => 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_stack_len += 1; } self.list_item_depth = self.list_depth; self.list_item_first_paragraph = true; }, 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_H => { 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 => { 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]; } else { self.list_item_depth = 0; self.list_item_first_paragraph = false; } }, 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; }, else => {}, } return 0; } fn enterSpan(t: c.MD_SPANTYPE, detail: ?*anyopaque, userdata: ?*anyopaque) callconv(.c) c_int { _ = detail; 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_A => "\x1b[4m", c.MD_SPAN_DEL => "\x1b[9m", else => "", }; self.add(s) catch |e| return self.fail(e); return 0; } fn leaveSpan(t: c.MD_SPANTYPE, detail: ?*anyopaque, userdata: ?*anyopaque) callconv(.c) c_int { _ = t; _ = detail; const self: *Renderer = @ptrCast(@alignCast(userdata.?)); self.add(theme.reset) catch |e| return self.fail(e); 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 => self.add(decodeEntity(s)) catch |e| return self.fail(e), else => self.add(s) catch |e| return self.fail(e), } return 0; } }; fn decodeEntity(s: []const u8) []const u8 { if (std.mem.eql(u8, s, "&")) return "&"; if (std.mem.eql(u8, s, "<")) return "<"; if (std.mem.eql(u8, s, ">")) return ">"; if (std.mem.eql(u8, s, """)) return "\""; if (std.mem.eql(u8, s, "'")) return "'"; return s; } 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 "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 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]); }