╭ markdown.zig ─────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 3 // a GFM subset: html and tables are shown verbatim as raw blocks. 4 5 pub const Style = struct { 6 bold: bool = false, 7 italic: bool = false, 8 strike: bool = false, 9 code: bool = false, 10 }; 11 12 // a flat styled run of text; a hard break is a '\n' inside the text. 13 pub const Inline = struct { 14 text: []const u8, 15 style: Style = .{}, 16 // the destination as written, or null outside a link 17 link: ?[]const u8 = null, 18 }; 19 20 pub const Item = struct { 21 // null for a plain item, else whether its task box is checked 22 task: ?bool, 23 blocks: []const Block, 24 }; 25 26 pub const Block = union(enum) { 27 heading: struct { level: u3, inlines: []const Inline }, 28 paragraph: []const Inline, 29 // fenced or indented 30 code: []const []const u8, 31 quote: []const Block, 32 list: struct { ordered: bool, start: usize, items: []const Item }, 33 rule, 34 // table lines and html blocks, shown verbatim 35 raw: []const []const u8, 36 }; 37 38 pub const Document = struct { 39 blocks: []const Block, 40 }; 41 42 // parse `lines` (each without its newline) into blocks allocated in `arena`. 43 pub fn parse(arena: std.mem.Allocator, lines: []const []const u8) !Document { 44 const expanded = try arena.alloc([]const u8, lines.len); 45 for (lines, expanded) |line, *out| out.* = try expandTabs(arena, line); 46 var parser: Parser = .{ .arena = arena }; 47 return .{ .blocks = try parser.parseBlocks(expanded) }; 48 } 49 50 // how deeply quotes and lists nest 51 const max_depth = 16; 52 // how deeply parens nest in an inline link's destination 53 const max_paren_depth = 32; 54 55 const Parser = struct { 56 arena: std.mem.Allocator, 57 // how many quotes and lists enclose the blocks being parsed 58 depth: usize = 0, 59 60 fn parseBlocks(p: *Parser, lines: []const []const u8) anyerror![]const Block { 61 var blocks: std.ArrayList(Block) = .empty; 62 var i: usize = 0; 63 while (i < lines.len) { 64 const line = lines[i]; 65 if (isBlank(line)) { 66 i += 1; 67 continue; 68 } 69 if (indentOf(line) >= 4) { 70 var end = i; 71 var j = i; 72 while (j < lines.len and (isBlank(lines[j]) or indentOf(lines[j]) >= 4)) : (j += 1) { 73 if (!isBlank(lines[j])) end = j + 1; 74 } 75 const code = try p.arena.alloc([]const u8, end - i); 76 for (lines[i..end], code) |l, *c| c.* = dropIndent(l, 4); 77 try blocks.append(p.arena, .{ .code = code }); 78 i = end; 79 continue; 80 } 81 if (fenceStart(line)) |fence| { 82 var code: std.ArrayList([]const u8) = .empty; 83 var j = i + 1; 84 while (j < lines.len and !isFenceClose(lines[j], fence)) : (j += 1) { 85 try code.append(p.arena, dropIndent(lines[j], fence.indent)); 86 } 87 try blocks.append(p.arena, .{ .code = code.items }); 88 i = @min(j + 1, lines.len); 89 continue; 90 } 91 if (atxHeading(line)) |heading| { 92 try blocks.append(p.arena, .{ .heading = .{ .level = heading.level, .inlines = try parseInlines(p.arena, heading.text) } }); 93 i += 1; 94 continue; 95 } 96 if (isThematicBreak(line)) { 97 try blocks.append(p.arena, .rule); 98 i += 1; 99 continue; 100 } 101 // deeper nesting reads as paragraph text, bounding the recursion 102 const can_nest = p.depth < max_depth; 103 if (can_nest and quoteContent(line) != null) { 104 i = try p.parseQuote(lines, i, &blocks); 105 continue; 106 } 107 if (can_nest and listMarker(line) != null) { 108 i = try p.parseList(lines, i, &blocks); 109 continue; 110 } 111 if (isTableStart(lines, i)) { 112 var j = i; 113 while (j < lines.len and !isBlank(lines[j]) and std.mem.indexOfScalar(u8, lines[j], '|') != null) : (j += 1) {} 114 try blocks.append(p.arena, .{ .raw = lines[i..j] }); 115 i = j; 116 continue; 117 } 118 if (isHtmlStart(line)) { 119 var j = i; 120 while (j < lines.len and !isBlank(lines[j])) : (j += 1) {} 121 try blocks.append(p.arena, .{ .raw = lines[i..j] }); 122 i = j; 123 continue; 124 } 125 i = try p.parseParagraph(lines, i, &blocks); 126 } 127 return blocks.items; 128 } 129 130 fn parseQuote(p: *Parser, lines: []const []const u8, start: usize, blocks: *std.ArrayList(Block)) !usize { 131 var content: std.ArrayList([]const u8) = .empty; 132 var fence: ?Fence = null; 133 var j = start; 134 while (j < lines.len) : (j += 1) { 135 const line = lines[j]; 136 const c = quoteContent(line) orelse lazy: { 137 // lazy continuation of a paragraph inside the quote 138 if (fence != null or isBlank(content.items[content.items.len - 1]) or interruptsParagraph(line)) break; 139 break :lazy line; 140 }; 141 fence = trackFence(fence, c); 142 try content.append(p.arena, c); 143 } 144 try blocks.append(p.arena, .{ .quote = try p.nested(content.items) }); 145 return j; 146 } 147 148 fn parseList(p: *Parser, lines: []const []const u8, start: usize, blocks: *std.ArrayList(Block)) !usize { 149 const first = listMarker(lines[start]) orelse unreachable; 150 var items: std.ArrayList(Item) = .empty; 151 var i = start; 152 while (i < lines.len) { 153 if (isThematicBreak(lines[i])) break; 154 const marker = listMarker(lines[i]) orelse break; 155 if (marker.ordered != first.ordered or marker.char != first.char) break; 156 157 var content: std.ArrayList([]const u8) = .empty; 158 try content.append(p.arena, lines[i][marker.content_start..]); 159 var fence = trackFence(null, content.items[0]); 160 var j = i + 1; 161 while (j < lines.len) : (j += 1) { 162 const line = lines[j]; 163 if (isBlank(line)) { 164 // an item may begin with at most one blank line 165 if (marker.empty and content.items.len == 1) break; 166 try content.append(p.arena, ""); 167 continue; 168 } 169 if (indentOf(line) >= marker.indent) { 170 const c = dropIndent(line, marker.indent); 171 fence = trackFence(fence, c); 172 try content.append(p.arena, c); 173 continue; 174 } 175 // a less indented line ends the item unless it lazily 176 // continues a paragraph 177 if (listMarker(line) != null or fence != null or isBlank(content.items[content.items.len - 1]) or interruptsParagraph(line)) break; 178 try content.append(p.arena, std.mem.trimStart(u8, line, " ")); 179 } 180 181 // trailing blank lines sit between items 182 var len = content.items.len; 183 while (len > 0 and isBlank(content.items[len - 1])) len -= 1; 184 const item_lines = content.items[0..len]; 185 var task: ?bool = null; 186 if (item_lines.len > 0) { 187 if (taskMarker(item_lines[0])) |t| { 188 task = t.checked; 189 item_lines[0] = t.rest; 190 } 191 } 192 try items.append(p.arena, .{ .task = task, .blocks = try p.nested(item_lines) }); 193 i = j; 194 } 195 try blocks.append(p.arena, .{ .list = .{ .ordered = first.ordered, .start = first.start, .items = items.items } }); 196 return i; 197 } 198 199 fn nested(p: *Parser, lines: []const []const u8) ![]const Block { 200 p.depth += 1; 201 defer p.depth -= 1; 202 return p.parseBlocks(lines); 203 } 204 205 fn parseParagraph(p: *Parser, lines: []const []const u8, start: usize, blocks: *std.ArrayList(Block)) !usize { 206 const i = start; 207 var j = i + 1; 208 var setext: ?u3 = null; 209 while (j < lines.len) : (j += 1) { 210 const line = lines[j]; 211 if (isBlank(line)) break; 212 if (setextLevel(line)) |level| { 213 setext = level; 214 break; 215 } 216 if (interruptsParagraph(line)) break; 217 } 218 const inlines_ = try parseInlines(p.arena, try joinLines(p.arena, lines[i..j])); 219 if (setext) |level| { 220 try blocks.append(p.arena, .{ .heading = .{ .level = level, .inlines = inlines_ } }); 221 return j + 1; 222 } 223 try blocks.append(p.arena, .{ .paragraph = inlines_ }); 224 return j; 225 } 226 }; 227 228 fn parseInlines(arena: std.mem.Allocator, src: []const u8) ![]const Inline { 229 var scanner: InlineScanner = .{ .arena = arena, .src = src }; 230 return scanner.run(); 231 } 232 233 // one piece of inline content; a delimiter run keeps its matching state 234 // until emphasis is resolved, after which any unmatched chars are text. 235 const Node = struct { 236 text: []const u8, 237 style: Style = .{}, 238 link: ?[]const u8 = null, 239 // '*', '_' or '~' for a delimiter run, else 0 240 delim: u8 = 0, 241 // the run's unmatched length, then its original length 242 count: usize = 0, 243 orig: usize = 0, 244 can_open: bool = false, 245 can_close: bool = false, 246 247 // drop the delimiter state, keeping its unmatched chars as text 248 fn literal(self: *Node) void { 249 if (self.delim == 0) return; 250 self.text = self.text[0..self.count]; 251 self.delim = 0; 252 } 253 }; 254 255 const Bracket = struct { 256 node: usize, 257 image: bool, 258 }; 259 260 const InlineScanner = struct { 261 arena: std.mem.Allocator, 262 src: []const u8, 263 nodes: std.ArrayList(Node) = .empty, 264 brackets: std.ArrayList(Bracket) = .empty, 265 // the start of the plain text not yet pushed as a node 266 text_start: usize = 0, 267 // brackets below this stack index can't open links, being inside one 268 no_links_below: usize = 0, 269 // backtick run lengths with no closer left in the source 270 unclosed_runs: std.AutoHashMapUnmanaged(usize, void) = .empty, 271 // the index of a `)` for link titles to end at, see inlineLink 272 next_close: usize = 0, 273 274 fn run(s: *InlineScanner) ![]const Inline { 275 const src = s.src; 276 var i: usize = 0; 277 while (i < src.len) { 278 const next = try s.special(i); 279 if (next) |n| { 280 s.text_start = n; 281 i = n; 282 } else i += 1; 283 } 284 try s.flush(src.len); 285 processEmphasis(s.nodes.items, 0); 286 return merge(s.arena, s.nodes.items); 287 } 288 289 fn flush(s: *InlineScanner, end: usize) !void { 290 if (end > s.text_start) try s.nodes.append(s.arena, .{ .text = s.src[s.text_start..end] }); 291 s.text_start = end; 292 } 293 294 fn push(s: *InlineScanner, at: usize, node: Node) !void { 295 try s.flush(at); 296 try s.nodes.append(s.arena, node); 297 } 298 299 // handle the construct starting at `i`, returning where scanning resumes, 300 // or null when the char is plain text. 301 fn special(s: *InlineScanner, i: usize) !?usize { 302 const src = s.src; 303 const arena = s.arena; 304 switch (src[i]) { 305 '\\' => if (i + 1 < src.len and isPunct(src[i + 1])) { 306 try s.push(i, .{ .text = src[i + 1 .. i + 2] }); 307 return i + 2; 308 }, 309 '`' => { 310 const n = runLength(src, i, '`'); 311 // a run with no closer is literal text 312 const close = (if (s.unclosed_runs.contains(n)) null else findBacktickRun(src, i + n, n)) orelse { 313 try s.unclosed_runs.put(arena, n, {}); 314 try s.push(i, .{ .text = src[i .. i + n] }); 315 return i + n; 316 }; 317 try s.push(i, .{ .text = try codeContent(arena, src[i + n .. close]), .style = .{ .code = true } }); 318 return close + n; 319 }, 320 '*', '_', '~' => { 321 const c = src[i]; 322 const n = runLength(src, i, c); 323 if (c == '~' and n > 2) { 324 try s.push(i, .{ .text = src[i .. i + n] }); 325 return i + n; 326 } 327 const before: u8 = if (i == 0) ' ' else src[i - 1]; 328 const after: u8 = if (i + n >= src.len) ' ' else src[i + n]; 329 const left = !isSpace(after) and (!isPunct(after) or isSpace(before) or isPunct(before)); 330 const right = !isSpace(before) and (!isPunct(before) or isSpace(after) or isPunct(after)); 331 var node: Node = .{ .text = src[i .. i + n], .delim = c, .count = n, .orig = n, .can_open = left, .can_close = right }; 332 if (c == '_') { 333 node.can_open = left and (!right or isPunct(before)); 334 node.can_close = right and (!left or isPunct(after)); 335 } 336 try s.push(i, node); 337 return i + n; 338 }, 339 '!' => if (i + 1 < src.len and src[i + 1] == '[') { 340 try s.push(i, .{ .text = src[i .. i + 2] }); 341 try s.pushBracket(true); 342 return i + 2; 343 }, 344 '[' => { 345 try s.push(i, .{ .text = src[i .. i + 1] }); 346 try s.pushBracket(false); 347 return i + 1; 348 }, 349 ']' => { 350 try s.flush(i); 351 if (try s.closeBracket(i)) |end| return end; 352 try s.push(i, .{ .text = src[i .. i + 1] }); 353 return i + 1; 354 }, 355 '<' => if (autolink(src[i..])) |url| { 356 try s.push(i, .{ .text = url, .link = url }); 357 return i + url.len + 2; 358 }, 359 'h', 'H' => if (i == 0 or isAutolinkBoundary(src[i - 1])) { 360 if (bareAutolink(src[i..])) |len| { 361 try s.push(i, .{ .text = src[i .. i + len], .link = src[i .. i + len] }); 362 return i + len; 363 } 364 }, 365 else => {}, 366 } 367 return null; 368 } 369 370 fn pushBracket(s: *InlineScanner, image: bool) !void { 371 s.no_links_below = @min(s.no_links_below, s.brackets.items.len); 372 try s.brackets.append(s.arena, .{ .node = s.nodes.items.len - 1, .image = image }); 373 } 374 375 // resolve the `]` at `i` against the innermost bracket, returning where 376 // scanning resumes when it closes a link. 377 fn closeBracket(s: *InlineScanner, i: usize) !?usize { 378 const src = s.src; 379 const bracket = s.brackets.pop() orelse return null; 380 if (!bracket.image and s.brackets.items.len < s.no_links_below) return null; 381 if (i + 1 >= src.len or src[i + 1] != '(') return null; 382 const link = (try inlineLink(s.arena, src, i + 1, &s.next_close)) orelse return null; 383 384 const nodes = s.nodes.items; 385 processEmphasis(nodes, bracket.node + 1); 386 for (nodes[bracket.node + 1 ..]) |*node| { 387 node.link = link.dest; 388 // an image shows its alt text as an italic link 389 if (bracket.image) node.style.italic = true; 390 } 391 nodes[bracket.node].text = ""; 392 // links can't contain links 393 if (!bracket.image) s.no_links_below = s.brackets.items.len; 394 return link.end; 395 } 396 }; 397 398 // resolve `*`, `_` and `~` runs from `bottom` on with the CommonMark 399 // delimiter algorithm, styling the nodes between each matched pair. 400 fn processEmphasis(nodes: []Node, bottom: usize) void { 401 // per closer kind, the lowest index an opener may still be found at 402 var openers_bottom: [3][2][3]usize = @splat(@splat(@splat(bottom))); 403 404 var ci = bottom; 405 while (ci < nodes.len) { 406 const closer = &nodes[ci]; 407 if (closer.delim == 0 or !closer.can_close or closer.count == 0) { 408 ci += 1; 409 continue; 410 } 411 const kind: usize = switch (closer.delim) { 412 '*' => 0, 413 '_' => 1, 414 else => 2, 415 }; 416 const lowest = &openers_bottom[kind][@intFromBool(closer.can_open)][closer.orig % 3]; 417 418 var found: ?usize = null; 419 var oi = ci; 420 while (oi > lowest.*) { 421 oi -= 1; 422 const opener = nodes[oi]; 423 if (opener.delim != closer.delim or !opener.can_open or opener.count == 0) continue; 424 if (closer.delim == '~') { 425 if (opener.count != closer.count) continue; 426 } else if ((opener.can_close or closer.can_open) and (opener.orig + closer.orig) % 3 == 0 and 427 !(opener.orig % 3 == 0 and closer.orig % 3 == 0)) 428 { 429 // the rule of 3 430 continue; 431 } 432 found = oi; 433 break; 434 } 435 436 const oi_found = found orelse { 437 lowest.* = ci; 438 if (!closer.can_open) closer.literal(); 439 ci += 1; 440 continue; 441 }; 442 const opener = &nodes[oi_found]; 443 const use: usize = if (closer.delim == '~') closer.count else if (opener.count >= 2 and closer.count >= 2) 2 else 1; 444 for (nodes[oi_found + 1 .. ci]) |*node| { 445 // delimiters between the pair can no longer match 446 node.literal(); 447 if (closer.delim == '~') { 448 node.style.strike = true; 449 } else if (use == 2) { 450 node.style.bold = true; 451 } else { 452 node.style.italic = true; 453 } 454 } 455 opener.count -= use; 456 closer.count -= use; 457 if (closer.count == 0) ci += 1; 458 } 459 460 for (nodes[bottom..]) |*node| node.literal(); 461 } 462 463 // join adjacent nodes that share a style and link into runs. 464 fn merge(arena: std.mem.Allocator, nodes: []const Node) ![]const Inline { 465 var runs: std.ArrayList(Inline) = .empty; 466 var text: std.ArrayList(u8) = .empty; 467 var style: Style = .{}; 468 var link: ?[]const u8 = null; 469 for (nodes) |node| { 470 if (node.text.len == 0) continue; 471 const same_link = if (link) |l| (if (node.link) |nl| std.mem.eql(u8, l, nl) else false) else node.link == null; 472 if (text.items.len > 0 and (!std.meta.eql(style, node.style) or !same_link)) { 473 try runs.append(arena, .{ .text = try text.toOwnedSlice(arena), .style = style, .link = link }); 474 } 475 style = node.style; 476 link = node.link; 477 try text.appendSlice(arena, node.text); 478 } 479 if (text.items.len > 0) try runs.append(arena, .{ .text = try text.toOwnedSlice(arena), .style = style, .link = link }); 480 return runs.items; 481 } 482 483 // join a paragraph's lines: a soft break becomes a space and a hard break 484 // (two trailing spaces or a trailing backslash) becomes '\n'. 485 fn joinLines(arena: std.mem.Allocator, lines: []const []const u8) ![]const u8 { 486 var out: std.ArrayList(u8) = .empty; 487 for (lines, 0..) |line, i| { 488 const trimmed = std.mem.trimStart(u8, line, " "); 489 const content = std.mem.trimEnd(u8, trimmed, " "); 490 const last = i + 1 == lines.len; 491 if (!last and content.len > 0 and content[content.len - 1] == '\\' and runLengthBack(content, content.len, '\\') % 2 == 1) { 492 try out.appendSlice(arena, content[0 .. content.len - 1]); 493 try out.append(arena, '\n'); 494 } else { 495 try out.appendSlice(arena, content); 496 if (!last) try out.append(arena, if (trimmed.len - content.len >= 2) '\n' else ' '); 497 } 498 } 499 return out.items; 500 } 501 502 fn expandTabs(arena: std.mem.Allocator, line: []const u8) ![]const u8 { 503 const trimmed = std.mem.trimEnd(u8, line, "\r"); 504 if (std.mem.indexOfScalar(u8, trimmed, '\t') == null) return trimmed; 505 var out: std.ArrayList(u8) = .empty; 506 for (trimmed) |c| { 507 if (c == '\t') { 508 try out.appendNTimes(arena, ' ', 4 - out.items.len % 4); 509 } else try out.append(arena, c); 510 } 511 return out.items; 512 } 513 514 fn isBlank(line: []const u8) bool { 515 return std.mem.trimStart(u8, line, " ").len == 0; 516 } 517 518 fn indentOf(line: []const u8) usize { 519 return line.len - std.mem.trimStart(u8, line, " ").len; 520 } 521 522 // `line` without up to `n` leading spaces 523 fn dropIndent(line: []const u8, n: usize) []const u8 { 524 return line[@min(indentOf(line), n)..]; 525 } 526 527 fn runLength(s: []const u8, start: usize, c: u8) usize { 528 var n: usize = 0; 529 while (start + n < s.len and s[start + n] == c) n += 1; 530 return n; 531 } 532 533 // how many `c` end at `end` 534 fn runLengthBack(s: []const u8, end: usize, c: u8) usize { 535 var n: usize = 0; 536 while (n < end and s[end - n - 1] == c) n += 1; 537 return n; 538 } 539 540 fn isSpace(c: u8) bool { 541 return c == ' ' or c == '\n'; 542 } 543 544 fn isPunct(c: u8) bool { 545 return std.ascii.isPrint(c) and !std.ascii.isAlphanumeric(c) and c != ' '; 546 } 547 548 fn interruptsParagraph(line: []const u8) bool { 549 if (isBlank(line)) return true; 550 if (indentOf(line) >= 4) return false; 551 if (atxHeading(line) != null or fenceStart(line) != null or quoteContent(line) != null or isThematicBreak(line)) return true; 552 const marker = listMarker(line) orelse return false; 553 return !marker.empty and (!marker.ordered or marker.start == 1); 554 } 555 556 const Fence = struct { 557 char: u8, 558 len: usize, 559 indent: usize, 560 }; 561 562 fn fenceStart(line: []const u8) ?Fence { 563 const indent = indentOf(line); 564 if (indent >= 4 or indent >= line.len) return null; 565 const c = line[indent]; 566 if (c != '`' and c != '~') return null; 567 const n = runLength(line, indent, c); 568 if (n < 3) return null; 569 if (c == '`' and std.mem.indexOfScalar(u8, line[indent + n ..], '`') != null) return null; 570 return .{ .char = c, .len = n, .indent = indent }; 571 } 572 573 fn isFenceClose(line: []const u8, fence: Fence) bool { 574 const indent = indentOf(line); 575 if (indent >= 4) return false; 576 const n = runLength(line, indent, fence.char); 577 return n >= fence.len and isBlank(line[indent + n ..]); 578 } 579 580 // the fence still open after `line`, given the one open before it 581 fn trackFence(open: ?Fence, line: []const u8) ?Fence { 582 if (open) |fence| return if (isFenceClose(line, fence)) null else fence; 583 return fenceStart(line); 584 } 585 586 fn atxHeading(line: []const u8) ?struct { level: u3, text: []const u8 } { 587 const indent = indentOf(line); 588 if (indent >= 4) return null; 589 const n = runLength(line, indent, '#'); 590 if (n == 0 or n > 6) return null; 591 const after = line[indent + n ..]; 592 if (after.len > 0 and after[0] != ' ') return null; 593 var text = std.mem.trim(u8, after, " "); 594 // an optional closing sequence of #s 595 const closing = runLengthBack(text, text.len, '#'); 596 if (closing == text.len) { 597 text = ""; 598 } else if (closing > 0 and text[text.len - closing - 1] == ' ') { 599 text = std.mem.trimEnd(u8, text[0 .. text.len - closing], " "); 600 } 601 return .{ .level = @intCast(n), .text = text }; 602 } 603 604 fn isThematicBreak(line: []const u8) bool { 605 const indent = indentOf(line); 606 if (indent >= 4 or indent >= line.len) return false; 607 const c = line[indent]; 608 if (c != '-' and c != '*' and c != '_') return false; 609 var count: usize = 0; 610 for (line[indent..]) |x| { 611 if (x == c) { 612 count += 1; 613 } else if (x != ' ') return false; 614 } 615 return count >= 3; 616 } 617 618 fn setextLevel(line: []const u8) ?u3 { 619 const indent = indentOf(line); 620 if (indent >= 4 or indent >= line.len) return null; 621 const c = line[indent]; 622 if (c != '=' and c != '-') return null; 623 const n = runLength(line, indent, c); 624 if (!isBlank(line[indent + n ..])) return null; 625 return if (c == '=') 1 else 2; 626 } 627 628 // the content after a block quote marker and its optional space 629 fn quoteContent(line: []const u8) ?[]const u8 { 630 const indent = indentOf(line); 631 if (indent >= 4 or indent >= line.len or line[indent] != '>') return null; 632 const rest = line[indent + 1 ..]; 633 return if (rest.len > 0 and rest[0] == ' ') rest[1..] else rest; 634 } 635 636 const Marker = struct { 637 ordered: bool, 638 // the bullet char or the ordered delimiter 639 char: u8, 640 start: usize, 641 // where the first line's content begins 642 content_start: usize, 643 // the column continuation lines must reach to belong to the item 644 indent: usize, 645 empty: bool, 646 }; 647 648 fn listMarker(line: []const u8) ?Marker { 649 const indent = indentOf(line); 650 if (indent >= 4 or indent >= line.len) return null; 651 var pos = indent; 652 var marker: Marker = .{ .ordered = false, .char = line[pos], .start = 0, .content_start = 0, .indent = 0, .empty = false }; 653 switch (line[pos]) { 654 '-', '+', '*' => pos += 1, 655 else => { 656 const digits = runDigits(line, pos); 657 if (digits == 0 or digits > 9) return null; 658 pos += digits; 659 if (pos >= line.len or (line[pos] != '.' and line[pos] != ')')) return null; 660 marker.ordered = true; 661 marker.char = line[pos]; 662 marker.start = std.fmt.parseInt(usize, line[indent..pos], 10) catch unreachable; 663 pos += 1; 664 }, 665 } 666 if (isBlank(line[pos..])) { 667 marker.empty = true; 668 marker.content_start = line.len; 669 marker.indent = pos + 1; 670 return marker; 671 } 672 if (line[pos] != ' ') return null; 673 var spaces = runLength(line, pos, ' '); 674 // five or more spaces start indented code after a single space 675 if (spaces > 4) spaces = 1; 676 marker.content_start = pos + spaces; 677 marker.indent = pos + spaces; 678 return marker; 679 } 680 681 fn runDigits(s: []const u8, start: usize) usize { 682 var n: usize = 0; 683 while (start + n < s.len and std.ascii.isDigit(s[start + n])) n += 1; 684 return n; 685 } 686 687 fn taskMarker(line: []const u8) ?struct { checked: bool, rest: []const u8 } { 688 if (line.len < 3 or line[0] != '[' or line[2] != ']') return null; 689 const checked = switch (line[1]) { 690 ' ' => false, 691 'x', 'X' => true, 692 else => return null, 693 }; 694 if (line.len > 3 and line[3] != ' ') return null; 695 return .{ .checked = checked, .rest = line[@min(4, line.len)..] }; 696 } 697 698 fn isTableStart(lines: []const []const u8, i: usize) bool { 699 if (i + 1 >= lines.len) return false; 700 if (!std.mem.startsWith(u8, std.mem.trimStart(u8, lines[i], " "), "|")) return false; 701 const row = std.mem.trim(u8, lines[i + 1], " "); 702 if (std.mem.indexOfScalar(u8, row, '-') == null or std.mem.indexOfScalar(u8, row, '|') == null) return false; 703 for (row) |c| switch (c) { 704 '|', ':', '-', ' ' => {}, 705 else => return false, 706 }; 707 return true; 708 } 709 710 fn isHtmlStart(line: []const u8) bool { 711 const indent = indentOf(line); 712 if (indent >= 4 or indent + 1 >= line.len or line[indent] != '<') return false; 713 const c = line[indent + 1]; 714 if (!std.ascii.isAlphabetic(c) and c != '/' and c != '!' and c != '?') return false; 715 return autolink(line[indent..]) == null; 716 } 717 718 // `s` with its backslash escapes resolved 719 fn unescape(arena: std.mem.Allocator, s: []const u8) ![]const u8 { 720 if (std.mem.indexOfScalar(u8, s, '\\') == null) return s; 721 var out: std.ArrayList(u8) = .empty; 722 var i: usize = 0; 723 while (i < s.len) : (i += 1) { 724 if (s[i] == '\\' and i + 1 < s.len and isPunct(s[i + 1])) i += 1; 725 try out.append(arena, s[i]); 726 } 727 return out.items; 728 } 729 730 // the index of the next run of exactly `n` backticks at or after `start` 731 fn findBacktickRun(s: []const u8, start: usize, n: usize) ?usize { 732 var i = start; 733 while (i < s.len) { 734 if (s[i] != '`') { 735 i += 1; 736 continue; 737 } 738 const len = runLength(s, i, '`'); 739 if (len == n) return i; 740 i += len; 741 } 742 return null; 743 } 744 745 fn codeContent(arena: std.mem.Allocator, raw: []const u8) ![]const u8 { 746 var content = raw; 747 if (std.mem.indexOfScalar(u8, content, '\n') != null) { 748 const copy = try arena.dupe(u8, content); 749 std.mem.replaceScalar(u8, copy, '\n', ' '); 750 content = copy; 751 } 752 // one space is stripped from each side of content that isn't all spaces 753 if (content.len >= 2 and content[0] == ' ' and content[content.len - 1] == ' ' and !isBlank(content)) { 754 content = content[1 .. content.len - 1]; 755 } 756 return content; 757 } 758 759 // parse the `(dest "title")` of an inline link starting at the `(` at `open`, 760 // ignoring the title. `next_close` caches the index of the next `)` (or 761 // src.len for none), so links scanned left to right never rescan for it. 762 fn inlineLink(arena: std.mem.Allocator, src: []const u8, open: usize, next_close: *usize) !?struct { dest: []const u8, end: usize } { 763 var j = open + 1; 764 while (j < src.len and isSpace(src[j])) j += 1; 765 const start = j; 766 var depth: usize = 0; 767 while (j < src.len) { 768 const c = src[j]; 769 if (c == ' ' or c < 0x20) break; 770 if (c == '\\' and j + 1 < src.len and isPunct(src[j + 1])) { 771 j += 2; 772 continue; 773 } 774 if (c == '(') { 775 // deeper nesting fails, which bounds the rescanning 776 if (depth == max_paren_depth) return null; 777 depth += 1; 778 } 779 if (c == ')') { 780 if (depth == 0) break; 781 depth -= 1; 782 } 783 j += 1; 784 } 785 if (depth != 0) return null; 786 const dest = src[start..j]; 787 const before_title = j; 788 while (j < src.len and isSpace(src[j])) j += 1; 789 // a title follows the destination after a space and runs to the `)` 790 if (j < src.len and src[j] != ')' and j > before_title) { 791 if (next_close.* < j) next_close.* = std.mem.indexOfScalarPos(u8, src, j, ')') orelse src.len; 792 j = next_close.*; 793 } 794 if (j >= src.len or src[j] != ')') return null; 795 return .{ .dest = try unescape(arena, dest), .end = j + 1 }; 796 } 797 798 // an autolink `<scheme:...>` or `<user@host>` at the start of `s` 799 fn autolink(s: []const u8) ?[]const u8 { 800 if (s.len < 3 or s[0] != '<') return null; 801 var close: usize = 1; 802 while (close < s.len and s[close] > ' ' and s[close] != '<' and s[close] != '>') close += 1; 803 if (close >= s.len or s[close] != '>') return null; 804 const body = s[1..close]; 805 806 const colon = std.mem.indexOfScalar(u8, body, ':') orelse return null; 807 const scheme = body[0..colon]; 808 if (scheme.len < 2 or scheme.len > 32 or !std.ascii.isAlphabetic(scheme[0])) return null; 809 for (scheme) |c| { 810 if (!std.ascii.isAlphanumeric(c) and c != '+' and c != '.' and c != '-') return null; 811 } 812 return body; 813 } 814 815 fn isAutolinkBoundary(c: u8) bool { 816 return switch (c) { 817 ' ', '\n', '*', '_', '~', '(' => true, 818 else => false, 819 }; 820 } 821 822 // the length of a bare `http://` or `https://` link at the start of `s` 823 fn bareAutolink(s: []const u8) ?usize { 824 const prefix_len: usize = if (std.ascii.startsWithIgnoreCase(s, "https://")) 8 else if (std.ascii.startsWithIgnoreCase(s, "http://")) 7 else return null; 825 var end = prefix_len; 826 while (end < s.len and !isSpace(s[end]) and s[end] != '<') end += 1; 827 var unmatched_closes = @as(isize, @intCast(std.mem.count(u8, s[0..end], ")"))) - @as(isize, @intCast(std.mem.count(u8, s[0..end], "("))); 828 // trailing punctuation is left out, as is an unbalanced closing paren 829 while (end > prefix_len) : (end -= 1) { 830 const c = s[end - 1]; 831 if (c == ')' and unmatched_closes > 0) { 832 unmatched_closes -= 1; 833 } else if (std.mem.indexOfScalar(u8, "?!.,:;*_~'\"", c) == null) break; 834 } 835 return if (end > prefix_len) end else null; 836 } 837