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  and ");
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 }