diff options
Diffstat (limited to 'src/markdown.zig')
| -rw-r--r-- | src/markdown.zig | 332 |
1 files changed, 332 insertions, 0 deletions
diff --git a/src/markdown.zig b/src/markdown.zig new file mode 100644 index 0000000..a029756 --- /dev/null +++ b/src/markdown.zig @@ -0,0 +1,332 @@ +//! 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, + 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; } + 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); + self.add("• ") catch |e| return self.fail(e); + }, + 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, c.MD_BLOCK_LI => { + self.flushParagraph() catch |e| return self.fail(e); + }, + 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(" ") 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); +} |
