summaryrefslogtreecommitdiff
path: root/src/markdown.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/markdown.zig')
-rw-r--r--src/markdown.zig332
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, "&amp;")) return "&";
+ if (std.mem.eql(u8, s, "&lt;")) return "<";
+ if (std.mem.eql(u8, s, "&gt;")) return ">";
+ if (std.mem.eql(u8, s, "&quot;")) return "\"";
+ if (std.mem.eql(u8, s, "&apos;")) 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);
+}