lib/markdown/src/inline.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const termtex = @import("termtex");
  3 
  4 pub const Role = enum {
  5     strong,
  6     emphasis,
  7     strong_emphasis,
  8     strikethrough,
  9     strong_strikethrough,
 10     emphasis_strikethrough,
 11     strong_emphasis_strikethrough,
 12     code,
 13     math,
 14     link,
 15     strong_link,
 16     emphasis_link,
 17     strong_emphasis_link,
 18     strikethrough_link,
 19     strong_strikethrough_link,
 20     emphasis_strikethrough_link,
 21     strong_emphasis_strikethrough_link,
 22     image,
 23 };
 24 
 25 pub const Span = struct {
 26     start: usize,
 27     end: usize,
 28     role: Role,
 29     link: ?usize = null,
 30     math: ?usize = null,
 31     image: ?usize = null,
 32 };
 33 
 34 pub const Link = struct {
 35     url: []u8,
 36 };
 37 
 38 pub const Math = struct {
 39     source: []u8,
 40 };
 41 
 42 pub const Image = struct {
 43     url: []u8,
 44 };
 45 
 46 pub const Line = struct {
 47     text: []u8,
 48     spans: []Span,
 49     links: []Link = &.{},
 50     math: []Math = &.{},
 51     images: []Image = &.{},
 52 
 53     pub fn deinit(self: *Line, allocator: std.mem.Allocator) void {
 54         for (self.links) |link| allocator.free(link.url);
 55         for (self.math) |math| allocator.free(math.source);
 56         for (self.images) |image| allocator.free(image.url);
 57         if (self.links.len != 0) allocator.free(self.links);
 58         if (self.math.len != 0) allocator.free(self.math);
 59         if (self.images.len != 0) allocator.free(self.images);
 60         allocator.free(self.text);
 61         allocator.free(self.spans);
 62         self.* = undefined;
 63     }
 64 };
 65 
 66 pub fn parse(allocator: std.mem.Allocator, text: []const u8) std.mem.Allocator.Error!Line {
 67     return parseWithMath(.terminal, allocator, text);
 68 }
 69 
 70 pub fn parseSource(allocator: std.mem.Allocator, text: []const u8) std.mem.Allocator.Error!Line {
 71     return parseWithMath(.source, allocator, text);
 72 }
 73 
 74 const MathPresentation = enum {
 75     source,
 76     terminal,
 77 };
 78 
 79 fn parseWithMath(comptime math_presentation: MathPresentation, allocator: std.mem.Allocator, text: []const u8) std.mem.Allocator.Error!Line {
 80     var rendered: std.ArrayListUnmanaged(u8) = .empty;
 81     errdefer rendered.deinit(allocator);
 82     var spans: std.ArrayListUnmanaged(Span) = .empty;
 83     errdefer spans.deinit(allocator);
 84     var links: std.ArrayListUnmanaged(Link) = .empty;
 85     errdefer {
 86         for (links.items) |link| allocator.free(link.url);
 87         links.deinit(allocator);
 88     }
 89     var math_sources: std.ArrayListUnmanaged(Math) = .empty;
 90     errdefer {
 91         for (math_sources.items) |item| allocator.free(item.source);
 92         math_sources.deinit(allocator);
 93     }
 94     var image_sources: std.ArrayListUnmanaged(Image) = .empty;
 95     errdefer {
 96         for (image_sources.items) |item| allocator.free(item.url);
 97         image_sources.deinit(allocator);
 98     }
 99 
100     var strong_delimiter: ?[]const u8 = null;
101     var emphasis_delimiter: ?[]const u8 = null;
102     var strikethrough_delimiter: ?[]const u8 = null;
103     var in_code = false;
104     var segment_start: usize = 0;
105     var index: usize = 0;
106 
107     while (index < text.len) {
108         if (!in_code) {
109             if (strong_delimiter) |delimiter| {
110                 if (std.mem.startsWith(u8, text[index..], delimiter) and canCloseDelimiter(text, index, delimiter)) {
111                     try appendSegment(allocator, &rendered, &spans, text[segment_start..index], inlineRole(strong_delimiter != null, emphasis_delimiter != null, strikethrough_delimiter != null, in_code));
112                     strong_delimiter = null;
113                     index += delimiter.len;
114                     segment_start = index;
115                     continue;
116                 }
117             } else if (strongDelimiterAt(text, index)) |delimiter| {
118                 if (!(canOpenDelimiter(text, index, delimiter) and closingDelimiter(text, index + delimiter.len, delimiter))) {
119                     index += delimiter.len;
120                     continue;
121                 }
122                 try appendSegment(allocator, &rendered, &spans, text[segment_start..index], inlineRole(strong_delimiter != null, emphasis_delimiter != null, strikethrough_delimiter != null, in_code));
123                 strong_delimiter = delimiter;
124                 index += delimiter.len;
125                 segment_start = index;
126                 continue;
127             }
128         }
129 
130         if (!in_code) {
131             if (emphasis_delimiter) |delimiter| {
132                 if (std.mem.startsWith(u8, text[index..], delimiter) and canCloseDelimiter(text, index, delimiter)) {
133                     try appendSegment(allocator, &rendered, &spans, text[segment_start..index], inlineRole(strong_delimiter != null, emphasis_delimiter != null, strikethrough_delimiter != null, in_code));
134                     emphasis_delimiter = null;
135                     index += delimiter.len;
136                     segment_start = index;
137                     continue;
138                 }
139             } else if (emphasisDelimiterAt(text, index)) |delimiter| {
140                 if (!(canOpenDelimiter(text, index, delimiter) and closingDelimiter(text, index + delimiter.len, delimiter))) {
141                     index += delimiter.len;
142                     continue;
143                 }
144                 try appendSegment(allocator, &rendered, &spans, text[segment_start..index], inlineRole(strong_delimiter != null, emphasis_delimiter != null, strikethrough_delimiter != null, in_code));
145                 emphasis_delimiter = delimiter;
146                 index += delimiter.len;
147                 segment_start = index;
148                 continue;
149             }
150         }
151 
152         if (!in_code) {
153             if (strikethrough_delimiter) |delimiter| {
154                 if (std.mem.startsWith(u8, text[index..], delimiter) and canCloseDelimiter(text, index, delimiter)) {
155                     try appendSegment(allocator, &rendered, &spans, text[segment_start..index], inlineRole(strong_delimiter != null, emphasis_delimiter != null, strikethrough_delimiter != null, in_code));
156                     strikethrough_delimiter = null;
157                     index += delimiter.len;
158                     segment_start = index;
159                     continue;
160                 }
161             } else if (strikethroughDelimiterAt(text, index)) |delimiter| {
162                 if (!(canOpenDelimiter(text, index, delimiter) and closingDelimiter(text, index + delimiter.len, delimiter))) {
163                     index += delimiter.len;
164                     continue;
165                 }
166                 try appendSegment(allocator, &rendered, &spans, text[segment_start..index], inlineRole(strong_delimiter != null, emphasis_delimiter != null, strikethrough_delimiter != null, in_code));
167                 strikethrough_delimiter = delimiter;
168                 index += delimiter.len;
169                 segment_start = index;
170                 continue;
171             }
172         }
173 
174         if (text[index] == '`' and (in_code or closingDelimiter(text, index + 1, "`"))) {
175             try appendSegment(allocator, &rendered, &spans, text[segment_start..index], inlineRole(strong_delimiter != null, emphasis_delimiter != null, strikethrough_delimiter != null, in_code));
176             in_code = !in_code;
177             index += 1;
178             segment_start = index;
179             continue;
180         }
181 
182         if (!in_code and (text[index] == '$' or std.mem.startsWith(u8, text[index..], "\\("))) {
183             if (try parseMath(math_presentation, allocator, text, index)) |parsed_math| {
184                 defer allocator.free(parsed_math.text);
185                 try appendSegment(allocator, &rendered, &spans, text[segment_start..index], inlineRole(strong_delimiter != null, emphasis_delimiter != null, strikethrough_delimiter != null, in_code));
186                 try appendMathSegment(allocator, &rendered, &spans, &math_sources, parsed_math.text, parsed_math.source);
187                 index = parsed_math.end;
188                 segment_start = index;
189                 continue;
190             }
191         }
192 
193         if (!in_code and text[index] == '!') {
194             if (parseImage(text, index)) |image| {
195                 try appendSegment(allocator, &rendered, &spans, text[segment_start..index], inlineRole(strong_delimiter != null, emphasis_delimiter != null, strikethrough_delimiter != null, in_code));
196                 try appendImageSegment(allocator, &rendered, &spans, &image_sources, image.text, image.url);
197                 index = image.end;
198                 segment_start = index;
199                 continue;
200             }
201         }
202 
203         if (!in_code and text[index] == '[') {
204             if (parseLink(text, index)) |link| {
205                 try appendSegment(allocator, &rendered, &spans, text[segment_start..index], inlineRole(strong_delimiter != null, emphasis_delimiter != null, strikethrough_delimiter != null, in_code));
206                 try appendLinkSegment(allocator, &rendered, &spans, &links, link.text, inlineLinkRole(strong_delimiter != null, emphasis_delimiter != null, strikethrough_delimiter != null), link.url);
207                 index = link.end;
208                 segment_start = index;
209                 continue;
210             }
211         }
212 
213         index += 1;
214     }
215 
216     try appendSegment(allocator, &rendered, &spans, text[segment_start..], inlineRole(strong_delimiter != null, emphasis_delimiter != null, strikethrough_delimiter != null, in_code));
217 
218     const owned_text = try rendered.toOwnedSlice(allocator);
219     errdefer allocator.free(owned_text);
220     const owned_spans = try spans.toOwnedSlice(allocator);
221     errdefer allocator.free(owned_spans);
222     const owned_links = try links.toOwnedSlice(allocator);
223     errdefer {
224         for (owned_links) |link| allocator.free(link.url);
225         allocator.free(owned_links);
226     }
227     const owned_math = try math_sources.toOwnedSlice(allocator);
228     errdefer {
229         for (owned_math) |item| allocator.free(item.source);
230         allocator.free(owned_math);
231     }
232     const owned_images = try image_sources.toOwnedSlice(allocator);
233     errdefer {
234         for (owned_images) |item| allocator.free(item.url);
235         allocator.free(owned_images);
236     }
237     return .{
238         .text = owned_text,
239         .spans = owned_spans,
240         .links = owned_links,
241         .math = owned_math,
242         .images = owned_images,
243     };
244 }
245 
246 fn appendSegment(
247     allocator: std.mem.Allocator,
248     rendered: *std.ArrayListUnmanaged(u8),
249     spans: *std.ArrayListUnmanaged(Span),
250     segment: []const u8,
251     role: ?Role,
252 ) std.mem.Allocator.Error!void {
253     if (segment.len == 0) return;
254     const start = rendered.items.len;
255     try rendered.appendSlice(allocator, segment);
256     const end = rendered.items.len;
257     if (role) |active| try spans.append(allocator, .{ .start = start, .end = end, .role = active });
258 }
259 
260 fn appendLinkSegment(
261     allocator: std.mem.Allocator,
262     rendered: *std.ArrayListUnmanaged(u8),
263     spans: *std.ArrayListUnmanaged(Span),
264     links: *std.ArrayListUnmanaged(Link),
265     segment: []const u8,
266     role: Role,
267     url: []const u8,
268 ) std.mem.Allocator.Error!void {
269     if (segment.len == 0) return;
270     var owned_url = try allocator.dupe(u8, url);
271     errdefer if (owned_url.len != 0) allocator.free(owned_url);
272     const link_index = links.items.len;
273     try links.append(allocator, .{ .url = owned_url });
274     owned_url = "";
275     const start = rendered.items.len;
276     try rendered.appendSlice(allocator, segment);
277     const end = rendered.items.len;
278     try spans.append(allocator, .{ .start = start, .end = end, .role = role, .link = link_index });
279 }
280 
281 fn appendMathSegment(
282     allocator: std.mem.Allocator,
283     rendered: *std.ArrayListUnmanaged(u8),
284     spans: *std.ArrayListUnmanaged(Span),
285     math: *std.ArrayListUnmanaged(Math),
286     segment: []const u8,
287     source: []const u8,
288 ) std.mem.Allocator.Error!void {
289     if (segment.len == 0) return;
290     var owned_source = try allocator.dupe(u8, source);
291     errdefer allocator.free(owned_source);
292     const math_index = math.items.len;
293     try math.append(allocator, .{ .source = owned_source });
294     owned_source = "";
295     const start = rendered.items.len;
296     try rendered.appendSlice(allocator, segment);
297     const end = rendered.items.len;
298     try spans.append(allocator, .{ .start = start, .end = end, .role = .math, .math = math_index });
299 }
300 
301 fn appendImageSegment(
302     allocator: std.mem.Allocator,
303     rendered: *std.ArrayListUnmanaged(u8),
304     spans: *std.ArrayListUnmanaged(Span),
305     images: *std.ArrayListUnmanaged(Image),
306     segment: []const u8,
307     url: []const u8,
308 ) std.mem.Allocator.Error!void {
309     const display = if (segment.len == 0) "image" else segment;
310     var owned_url = try allocator.dupe(u8, url);
311     errdefer if (owned_url.len != 0) allocator.free(owned_url);
312     const image_index = images.items.len;
313     try images.append(allocator, .{ .url = owned_url });
314     owned_url = "";
315     const start = rendered.items.len;
316     try rendered.appendSlice(allocator, display);
317     const end = rendered.items.len;
318     try spans.append(allocator, .{ .start = start, .end = end, .role = .image, .image = image_index });
319 }
320 
321 fn inlineRole(in_strong: bool, in_emphasis: bool, in_strikethrough: bool, in_code: bool) ?Role {
322     if (in_code) return .code;
323     if (in_strong and in_emphasis and in_strikethrough) return .strong_emphasis_strikethrough;
324     if (in_strong and in_strikethrough) return .strong_strikethrough;
325     if (in_emphasis and in_strikethrough) return .emphasis_strikethrough;
326     if (in_strong and in_emphasis) return .strong_emphasis;
327     if (in_strikethrough) return .strikethrough;
328     if (in_strong) return .strong;
329     if (in_emphasis) return .emphasis;
330     return null;
331 }
332 
333 fn inlineLinkRole(in_strong: bool, in_emphasis: bool, in_strikethrough: bool) Role {
334     if (in_strong and in_emphasis and in_strikethrough) return .strong_emphasis_strikethrough_link;
335     if (in_strong and in_strikethrough) return .strong_strikethrough_link;
336     if (in_emphasis and in_strikethrough) return .emphasis_strikethrough_link;
337     if (in_strong and in_emphasis) return .strong_emphasis_link;
338     if (in_strikethrough) return .strikethrough_link;
339     if (in_strong) return .strong_link;
340     if (in_emphasis) return .emphasis_link;
341     return .link;
342 }
343 
344 fn closingDelimiter(text: []const u8, start: usize, delimiter: []const u8) bool {
345     if (start >= text.len) return false;
346     var index = start;
347     while (std.mem.indexOf(u8, text[index..], delimiter)) |relative| {
348         const actual = index + relative;
349         if (canCloseDelimiter(text, actual, delimiter)) return true;
350         index = actual + delimiter.len;
351         if (index >= text.len) return false;
352     }
353     return false;
354 }
355 
356 fn strongDelimiterAt(text: []const u8, index: usize) ?[]const u8 {
357     if (std.mem.startsWith(u8, text[index..], "**")) return "**";
358     if (std.mem.startsWith(u8, text[index..], "__")) return "__";
359     return null;
360 }
361 
362 fn emphasisDelimiterAt(text: []const u8, index: usize) ?[]const u8 {
363     if (text[index] == '*') return "*";
364     if (text[index] == '_') return "_";
365     return null;
366 }
367 
368 fn strikethroughDelimiterAt(text: []const u8, index: usize) ?[]const u8 {
369     if (std.mem.startsWith(u8, text[index..], "~~")) return "~~";
370     return null;
371 }
372 
373 fn canOpenDelimiter(text: []const u8, index: usize, delimiter: []const u8) bool {
374     if (delimiter[0] != '_') return true;
375     if (index > 0 and std.ascii.isAlphanumeric(text[index - 1])) return false;
376     const after = index + delimiter.len;
377     return after < text.len and !isWhitespace(text[after]);
378 }
379 
380 fn canCloseDelimiter(text: []const u8, index: usize, delimiter: []const u8) bool {
381     if (delimiter[0] != '_') return true;
382     if (index == 0 or isWhitespace(text[index - 1])) return false;
383     const after = index + delimiter.len;
384     return after >= text.len or !std.ascii.isAlphanumeric(text[after]);
385 }
386 
387 fn isWhitespace(byte: u8) bool {
388     return byte == ' ' or byte == '\t' or byte == '\r' or byte == '\n';
389 }
390 
391 const ParsedLink = struct {
392     text: []const u8,
393     url: []const u8,
394     end: usize,
395 };
396 
397 const ParsedImage = struct {
398     text: []const u8,
399     url: []const u8,
400     end: usize,
401 };
402 
403 const ParsedMath = struct {
404     text: []u8,
405     source: []const u8,
406     end: usize,
407 };
408 
409 const MathDelimiter = struct {
410     open: []const u8,
411     close: []const u8,
412 };
413 
414 fn parseMath(comptime presentation: MathPresentation, allocator: std.mem.Allocator, text: []const u8, start: usize) std.mem.Allocator.Error!?ParsedMath {
415     const delimiter = mathDelimiterAt(text, start) orelse return null;
416     const source_start = start + delimiter.open.len;
417     var index = source_start;
418     while (index < text.len) {
419         if (std.mem.startsWith(u8, text[index..], delimiter.close)) {
420             if (index == source_start) return null;
421             if (std.mem.eql(u8, delimiter.close, "$") and index + 1 < text.len and text[index + 1] == '$') return null;
422             const source = text[source_start..index];
423             const rendered = switch (presentation) {
424                 .source => try allocator.dupe(u8, source),
425                 .terminal => termtex.renderInlineAlloc(allocator, source, .{}) catch |err| switch (err) {
426                     error.OutOfMemory => return error.OutOfMemory,
427                     else => return null,
428                 },
429             };
430             return .{ .text = rendered, .source = source, .end = index + delimiter.close.len };
431         }
432         if (text[index] == '\\') {
433             index += 1;
434             if (index < text.len) index += 1;
435             continue;
436         }
437         if (text[index] == '\n' or text[index] == '\r') return null;
438         const len = std.unicode.utf8ByteSequenceLength(text[index]) catch 1;
439         index += @min(len, text.len - index);
440     }
441     return null;
442 }
443 
444 fn mathDelimiterAt(text: []const u8, start: usize) ?MathDelimiter {
445     if (start >= text.len) return null;
446     if (text[start] == '$') {
447         if (start + 2 >= text.len) return null;
448         if (start + 1 < text.len and text[start + 1] == '$') return null;
449         return .{ .open = "$", .close = "$" };
450     }
451     if (std.mem.startsWith(u8, text[start..], "\\(")) return .{ .open = "\\(", .close = "\\)" };
452     return null;
453 }
454 
455 fn parseLink(text: []const u8, start: usize) ?ParsedLink {
456     if (start + 3 >= text.len or text[start] != '[') return null;
457 
458     const label_start = start + 1;
459     const label_end_rel = std.mem.indexOfScalar(u8, text[label_start..], ']') orelse return null;
460     const label_end = label_start + label_end_rel;
461     if (label_end == label_start) return null;
462     if (label_end + 1 >= text.len or text[label_end + 1] != '(') return null;
463 
464     const url_start = label_end + 2;
465     const url_end_rel = std.mem.indexOfScalar(u8, text[url_start..], ')') orelse return null;
466     const url_end = url_start + url_end_rel;
467     if (url_end == url_start) return null;
468 
469     return .{
470         .text = text[label_start..label_end],
471         .url = text[url_start..url_end],
472         .end = url_end + 1,
473     };
474 }
475 
476 fn parseImage(text: []const u8, start: usize) ?ParsedImage {
477     if (start + 4 >= text.len or text[start] != '!' or text[start + 1] != '[') return null;
478 
479     const label_start = start + 2;
480     const label_end_rel = std.mem.indexOfScalar(u8, text[label_start..], ']') orelse return null;
481     const label_end = label_start + label_end_rel;
482     if (label_end + 1 >= text.len or text[label_end + 1] != '(') return null;
483 
484     const url_start = label_end + 2;
485     const url_end_rel = std.mem.indexOfScalar(u8, text[url_start..], ')') orelse return null;
486     const url_end = url_start + url_end_rel;
487     if (url_end == url_start) return null;
488 
489     return .{
490         .text = text[label_start..label_end],
491         .url = text[url_start..url_end],
492         .end = url_end + 1,
493     };
494 }
495 
496 test "parse returns display text and styled byte ranges" {
497     const allocator = std.testing.allocator;
498     var line = try parse(allocator, "plain **bold** *em* ***both*** `code` [docs](https://example.com/docs) **[strong](https://example.com/strong)** *[soft](https://example.com/soft)* **open");
499     defer line.deinit(allocator);
500 
501     try std.testing.expectEqualStrings("plain bold em both code docs strong soft **open", line.text);
502     try std.testing.expectEqual(@as(usize, 7), line.spans.len);
503     try std.testing.expectEqual(Role.strong, line.spans[0].role);
504     try std.testing.expectEqualStrings("bold", line.text[line.spans[0].start..line.spans[0].end]);
505     try std.testing.expectEqual(Role.emphasis, line.spans[1].role);
506     try std.testing.expectEqualStrings("em", line.text[line.spans[1].start..line.spans[1].end]);
507     try std.testing.expectEqual(Role.strong_emphasis, line.spans[2].role);
508     try std.testing.expectEqualStrings("both", line.text[line.spans[2].start..line.spans[2].end]);
509     try std.testing.expectEqual(Role.code, line.spans[3].role);
510     try std.testing.expectEqualStrings("code", line.text[line.spans[3].start..line.spans[3].end]);
511     try std.testing.expectEqual(Role.link, line.spans[4].role);
512     try std.testing.expectEqualStrings("docs", line.text[line.spans[4].start..line.spans[4].end]);
513     try std.testing.expectEqualStrings("https://example.com/docs", line.links[line.spans[4].link.?].url);
514     try std.testing.expectEqual(Role.strong_link, line.spans[5].role);
515     try std.testing.expectEqualStrings("strong", line.text[line.spans[5].start..line.spans[5].end]);
516     try std.testing.expectEqualStrings("https://example.com/strong", line.links[line.spans[5].link.?].url);
517     try std.testing.expectEqual(Role.emphasis_link, line.spans[6].role);
518     try std.testing.expectEqualStrings("soft", line.text[line.spans[6].start..line.spans[6].end]);
519     try std.testing.expectEqualStrings("https://example.com/soft", line.links[line.spans[6].link.?].url);
520 }
521 
522 test "parse renders math through termtex" {
523     const allocator = std.testing.allocator;
524     var line = try parse(allocator, "area $\\pi r^2$ and bad $\\frac{x$ stays");
525     defer line.deinit(allocator);
526 
527     try std.testing.expectEqualStrings("area πr² and bad $\\frac{x$ stays", line.text);
528     try std.testing.expectEqual(@as(usize, 1), line.spans.len);
529     try std.testing.expectEqual(Role.math, line.spans[0].role);
530     try std.testing.expectEqual(@as(?usize, 0), line.spans[0].math);
531     try std.testing.expectEqualStrings("πr²", line.text[line.spans[0].start..line.spans[0].end]);
532     try std.testing.expectEqual(@as(usize, 1), line.math.len);
533     try std.testing.expectEqualStrings("\\pi r^2", line.math[0].source);
534 }
535 
536 test "parse renders paren math through termtex" {
537     const allocator = std.testing.allocator;
538     var line = try parse(allocator, "area \\(\\pi r^2\\) and bad \\(\\frac{x stays");
539     defer line.deinit(allocator);
540 
541     try std.testing.expectEqualStrings("area πr² and bad \\(\\frac{x stays", line.text);
542     try std.testing.expectEqual(@as(usize, 1), line.spans.len);
543     try std.testing.expectEqual(Role.math, line.spans[0].role);
544     try std.testing.expectEqual(@as(?usize, 0), line.spans[0].math);
545     try std.testing.expectEqualStrings("πr²", line.text[line.spans[0].start..line.spans[0].end]);
546     try std.testing.expectEqual(@as(usize, 1), line.math.len);
547     try std.testing.expectEqualStrings("\\pi r^2", line.math[0].source);
548 }
549 
550 test "parseSource preserves math for semantic renderers" {
551     const allocator = std.testing.allocator;
552     var line = try parseSource(allocator, "area $\\pi r^2$");
553     defer line.deinit(allocator);
554 
555     try std.testing.expectEqualStrings("area \\pi r^2", line.text);
556     try std.testing.expectEqual(@as(usize, 1), line.spans.len);
557     try std.testing.expectEqual(Role.math, line.spans[0].role);
558     try std.testing.expectEqualStrings("\\pi r^2", line.math[0].source);
559 }
560 
561 test "parse records image links" {
562     const allocator = std.testing.allocator;
563     var line = try parse(allocator, "open ![plot](plot.svg) and ![](empty.png)");
564     defer line.deinit(allocator);
565 
566     try std.testing.expectEqualStrings("open plot and image", line.text);
567     try std.testing.expectEqual(@as(usize, 2), line.spans.len);
568     try std.testing.expectEqual(Role.image, line.spans[0].role);
569     try std.testing.expectEqual(@as(?usize, 0), line.spans[0].image);
570     try std.testing.expectEqualStrings("plot", line.text[line.spans[0].start..line.spans[0].end]);
571     try std.testing.expectEqualStrings("plot.svg", line.images[0].url);
572     try std.testing.expectEqual(Role.image, line.spans[1].role);
573     try std.testing.expectEqual(@as(?usize, 1), line.spans[1].image);
574     try std.testing.expectEqualStrings("empty.png", line.images[1].url);
575 }
576 
577 test "parse supports underscore emphasis without snake case" {
578     const allocator = std.testing.allocator;
579     var line = try parse(allocator, "plain _em_ __strong__ ___both___ snake_case");
580     defer line.deinit(allocator);
581 
582     try std.testing.expectEqualStrings("plain em strong both snake_case", line.text);
583     try std.testing.expectEqual(@as(usize, 3), line.spans.len);
584     try std.testing.expectEqual(Role.emphasis, line.spans[0].role);
585     try std.testing.expectEqualStrings("em", line.text[line.spans[0].start..line.spans[0].end]);
586     try std.testing.expectEqual(Role.strong, line.spans[1].role);
587     try std.testing.expectEqualStrings("strong", line.text[line.spans[1].start..line.spans[1].end]);
588     try std.testing.expectEqual(Role.strong_emphasis, line.spans[2].role);
589     try std.testing.expectEqualStrings("both", line.text[line.spans[2].start..line.spans[2].end]);
590     try std.testing.expect(std.mem.indexOf(u8, line.text, "snake_case") != null);
591 }
592 
593 test "parse supports strikethrough" {
594     const allocator = std.testing.allocator;
595     var line = try parse(allocator, "plain ~~old~~ **~~bold old~~** ~~[docs](https://example.com/docs)~~ ~~open");
596     defer line.deinit(allocator);
597 
598     try std.testing.expectEqualStrings("plain old bold old docs ~~open", line.text);
599     try std.testing.expectEqual(@as(usize, 3), line.spans.len);
600     try std.testing.expectEqual(Role.strikethrough, line.spans[0].role);
601     try std.testing.expectEqualStrings("old", line.text[line.spans[0].start..line.spans[0].end]);
602     try std.testing.expectEqual(Role.strong_strikethrough, line.spans[1].role);
603     try std.testing.expectEqualStrings("bold old", line.text[line.spans[1].start..line.spans[1].end]);
604     try std.testing.expectEqual(Role.strikethrough_link, line.spans[2].role);
605     try std.testing.expectEqualStrings("docs", line.text[line.spans[2].start..line.spans[2].end]);
606     try std.testing.expectEqualStrings("https://example.com/docs", line.links[line.spans[2].link.?].url);
607 }
608 
609 test "parse leaves links literal inside code spans" {
610     const allocator = std.testing.allocator;
611     var line = try parse(allocator, "`[docs](https://example.com/docs)`");
612     defer line.deinit(allocator);
613 
614     try std.testing.expectEqualStrings("[docs](https://example.com/docs)", line.text);
615     try std.testing.expectEqual(@as(usize, 1), line.spans.len);
616     try std.testing.expectEqual(Role.code, line.spans[0].role);
617     try std.testing.expect(line.spans[0].link == null);
618     try std.testing.expectEqual(@as(usize, 0), line.links.len);
619 }