1use omp_core::{Str, StrMut, fmts};
4use smallvec::SmallVec;
5
6use crate::{
7 context::{Charset, Theme, UiContext},
8 frame::{Color, Style},
9 rich::{Pipeline, Prefix, RichSink, RichText, cell_width},
10};
11
12mod graphviz;
13mod highlight;
14mod inline;
15mod mermaid;
16mod table;
17
18use inline::code_span_len;
19pub(crate) use inline::{math_span, parse_inline};
20
21#[derive(Clone, Copy)]
23struct DiagramStyles {
24 text: Style,
26 line: Style,
28 accent: Style,
30}
31
32#[derive(Clone, Copy)]
34pub struct MdTheme {
35 pub base: Style,
37 pub heading: Style,
39 pub strong: Style,
41 pub emphasis: Style,
43 pub code: Style,
45 pub code_block: Style,
47 pub code_border: Style,
49 pub quote: Style,
51 pub bullet: Style,
53 pub link: Style,
55 pub rule: Style,
57 highlight: highlight::HighlightStyles,
58 charset: Charset,
59 semantic: Theme,
60}
61
62impl Default for MdTheme {
63 fn default() -> Self {
64 Self::from_theme(&crate::context::Theme::default())
65 }
66}
67
68impl MdTheme {
69 pub const fn from_theme(theme: &Theme) -> Self {
71 let code_block = Style::new().fg(theme.fg);
72 Self {
73 base: Style::new().fg(theme.fg),
74 heading: Style::new().fg(theme.accent).bold(),
75 strong: Style::new().fg(theme.fg).bold(),
76 emphasis: Style::new().fg(theme.fg).italic(),
77 code: Style::new().fg(theme.warn).bg(theme.hover),
78 code_block,
79 code_border: Style::new().fg(theme.border),
80 quote: Style::new().fg(theme.muted).dim().italic(),
81 bullet: Style::new().fg(theme.info),
82 link: Style::new().fg(theme.accent).underline(),
83 rule: Style::new().fg(theme.border),
84 highlight: highlight::HighlightStyles::from_theme(theme),
85 charset: Charset::Unicode,
86 semantic: *theme,
87 }
88 }
89
90 pub const fn from_context(context: &UiContext) -> Self {
92 let mut theme = Self::from_theme(&context.theme);
93 theme.charset = context.charset;
94 theme
95 }
96
97 fn semantic_color(&self, name: &str) -> Option<Color> {
98 self.semantic.token(name)
99 }
100
101 pub fn cascade(mut self, style: Style) -> Self {
108 if style == Style::default() {
109 return self;
110 }
111 let flags = style.fg(Color::Default);
112 self.base = style.inherit(self.base);
113 self.strong = style.inherit(self.strong);
114 self.emphasis = style.inherit(self.emphasis);
115 self.code_block = style.inherit(self.code_block);
116 self.heading = flags.inherit(self.heading);
117 self.code = flags.inherit(self.code);
118 self.code_border = flags.inherit(self.code_border);
119 self.quote = flags.inherit(self.quote);
120 self.bullet = flags.inherit(self.bullet);
121 self.link = flags.inherit(self.link);
122 self.rule = flags.inherit(self.rule);
123 self
124 }
125}
126
127#[derive(Clone, Copy)]
129pub(crate) enum Alignment {
130 Left,
131 Center,
132 Right,
133}
134
135#[derive(Clone, Copy, PartialEq, Eq)]
136enum BlockKind {
137 Paragraph,
138 List,
139 Other,
140}
141struct DocumentSink<'a> {
142 inner: &'a mut dyn RichSink,
143 emitted: bool,
144 row_has_content: bool,
145 pending_blank: bool,
146 final_blank: bool,
147}
148
149impl RichSink for DocumentSink<'_> {
150 fn run(&mut self, style: Style, text: &str) {
151 if text.is_empty() {
152 return;
153 }
154 if self.pending_blank {
155 self.inner.newline();
156 self.pending_blank = false;
157 }
158 self.emitted = true;
159 self.row_has_content = true;
160 self.final_blank = false;
161 self.inner.run(style, text);
162 }
163
164 fn newline(&mut self) {
165 self.emitted = true;
166 if self.row_has_content {
167 self.inner.newline();
168 self.row_has_content = false;
169 self.final_blank = false;
170 } else {
171 self.pending_blank = true;
174 self.final_blank = true;
175 }
176 }
177}
178
179pub fn render(src: &Str, width: u16, theme: &MdTheme, sink: &mut dyn RichSink) {
181 let normalized = normalize_source(src);
182 render_document(&normalized, width.max(1), theme, sink);
185}
186
187fn render_document(source: &Str, width: u16, theme: &MdTheme, sink: &mut dyn RichSink) {
188 let mut lines: SmallVec<&str, 64> = source.as_str().split('\n').collect();
189 while lines.last().is_some_and(|line| line.is_empty()) {
190 lines.pop();
191 }
192 let mut tracked = DocumentSink {
193 inner: sink,
194 emitted: false,
195 row_has_content: false,
196 pending_blank: false,
197 final_blank: false,
198 };
199 let mut index = 0;
200 let mut previous = None;
201 while index < lines.len() {
202 let blank_start = index;
203 while index < lines.len() && lines[index].trim().is_empty() {
204 index += 1;
205 }
206 if index == lines.len() {
207 break;
208 }
209 let had_blank = index > blank_start;
210 let kind = block_kind(&lines, index);
211 if tracked.emitted && should_separate(previous, kind, had_blank) && !tracked.final_blank {
212 if tracked.row_has_content {
213 tracked.newline();
214 }
215 tracked.newline();
216 }
217
218 if let Some((depth, text)) = atx_heading(lines[index]) {
219 render_heading(text, depth, width, theme, &mut tracked);
220 index += 1;
221 } else if index + 1 < lines.len() {
222 if let Some(depth) = setext_depth(lines[index + 1]) {
223 render_heading(lines[index].trim(), depth, width, theme, &mut tracked);
224 index += 2;
225 } else if let Some((alignments, header)) = table_header(&lines, index) {
226 let mut rows = vec![header];
227 index += 2;
228 while index < lines.len()
229 && lines[index].contains('|')
230 && !lines[index].trim().is_empty()
231 {
232 let cells = table_cells(lines[index]);
233 if cells.len() != alignments.len() {
234 break;
235 }
236 rows.push(cells);
237 index += 1;
238 }
239 table::render_table(&rows, &alignments, width, theme, &mut tracked);
240 } else {
241 render_non_heading_block(&lines, &mut index, width, theme, &mut tracked);
242 }
243 } else {
244 render_non_heading_block(&lines, &mut index, width, theme, &mut tracked);
245 }
246 previous = Some(kind);
247 }
248}
249
250const fn should_separate(previous: Option<BlockKind>, current: BlockKind, had_blank: bool) -> bool {
251 match (previous, current) {
252 (None, _) => false,
253 (Some(BlockKind::Paragraph), BlockKind::List) => false,
254 (Some(BlockKind::List), _) => had_blank,
255 _ => true,
256 }
257}
258
259fn render_non_heading_block(
260 lines: &[&str],
261 index: &mut usize,
262 width: u16,
263 theme: &MdTheme,
264 sink: &mut dyn RichSink,
265) {
266 if let Some(end) = bare_math_with_lhs_end(lines, *index) {
267 let body = join_lines(&lines[*index..end]);
268 render_math(body.as_str(), width, theme, sink);
269 *index = end;
270 return;
271 }
272 if let Some((fence, language)) = fence_start(lines[*index]) {
273 render_fenced_code(lines, index, width, theme, fence, language, sink);
274 return;
275 }
276 if is_indented_code(lines[*index]) {
277 render_indented_code(lines, index, width, theme, sink);
278 return;
279 }
280 if let Some(fill) = horizontal_rule(lines[*index]) {
281 render_rule(fill, width, theme, sink);
282 *index += 1;
283 return;
284 }
285 if let Some((end, body)) = display_math(lines, *index) {
286 render_math(body.as_str(), width, theme, sink);
287 *index = end;
288 return;
289 }
290 if let Some(end) = bare_math_end(lines, *index) {
291 let body = join_lines(&lines[*index..end]);
292 render_math(body.as_str(), width, theme, sink);
293 *index = end;
294 return;
295 }
296 if quote_line(lines[*index]).is_some() {
297 render_blockquote(lines, index, width, theme, sink);
298 return;
299 }
300 if let Some(marker) = list_marker(lines[*index]) {
301 render_list(lines, index, width, theme, marker.indent, 0, marker.ordered, sink);
302 return;
303 }
304 render_paragraph(lines, index, width, theme, sink);
305}
306
307fn block_kind(lines: &[&str], index: usize) -> BlockKind {
308 if list_marker(lines[index]).is_some() {
309 BlockKind::List
310 } else if atx_heading(lines[index]).is_some()
311 || (index + 1 < lines.len() && setext_depth(lines[index + 1]).is_some())
312 || bare_math_with_lhs_end(lines, index).is_some()
313 || horizontal_rule(lines[index]).is_some()
314 || fence_start(lines[index]).is_some()
315 || is_indented_code(lines[index])
316 || quote_line(lines[index]).is_some()
317 || display_math(lines, index).is_some()
318 || bare_math_end(lines, index).is_some()
319 || table_header(lines, index).is_some()
320 {
321 BlockKind::Other
322 } else {
323 BlockKind::Paragraph
324 }
325}
326
327fn render_heading(text: &str, depth: usize, width: u16, theme: &MdTheme, sink: &mut dyn RichSink) {
328 let style = match depth {
329 1 => theme.heading.bold().underline(),
330 _ => theme.heading.bold(),
331 };
332 let mut wrap = (&mut *sink).wrap(width);
333 if depth >= 3 {
334 let mut prefix = StrMut::with_capacity(depth + 1);
335 for _ in 0..depth {
336 prefix.push('#');
337 }
338 prefix.push(' ');
339 wrap.run(style, prefix.as_str());
340 }
341 parse_inline(text, theme, style, &mut wrap);
342 wrap.finish();
343}
344
345fn atx_heading(line: &str) -> Option<(usize, &str)> {
346 let trimmed = line.trim_start_matches(' ');
347 if line.len().saturating_sub(trimmed.len()) > 3 {
348 return None;
349 }
350 let depth = trimmed.bytes().take_while(|byte| *byte == b'#').count();
351 if !(1..=6).contains(&depth) {
352 return None;
353 }
354 let rest = &trimmed[depth..];
355 if !rest.is_empty() && !rest.starts_with(' ') && !rest.starts_with('\t') {
356 return None;
357 }
358 let mut text = rest.trim();
359 if text.ends_with('#') {
360 text = text.trim_end_matches('#').trim_end();
361 }
362 Some((depth, text))
363}
364
365fn setext_depth(line: &str) -> Option<usize> {
366 let trimmed = line.trim();
367 if trimmed.is_empty()
368 || line
369 .len()
370 .saturating_sub(line.trim_start_matches(' ').len())
371 > 3
372 {
373 return None;
374 }
375 if trimmed.bytes().all(|byte| byte == b'=') {
376 Some(1)
377 } else if trimmed.bytes().all(|byte| byte == b'-') {
378 Some(2)
379 } else {
380 None
381 }
382}
383
384fn horizontal_rule(line: &str) -> Option<char> {
385 let trimmed = line.trim_start_matches(' ');
386 if line.len().saturating_sub(trimmed.len()) > 3 {
387 return None;
388 }
389 let mut compact = trimmed
390 .chars()
391 .filter(|character| !matches!(character, ' ' | '\t'));
392 let first = compact.next()?;
393 if !matches!(first, '-' | '*' | '_' | '=' | '─' | '━' | '═' | '–' | '—') {
394 return None;
395 }
396 let mut count = 1;
397 for character in compact {
398 if character != first {
399 return None;
400 }
401 count += 1;
402 }
403 (count >= 3).then_some(match first {
404 '=' => '=',
405 '═' => '═',
406 '━' => '━',
407 '─' => '─',
408 '–' => '–',
409 '—' => '—',
410 _ => '─',
411 })
412}
413
414fn render_rule(fill: char, width: u16, theme: &MdTheme, sink: &mut dyn RichSink) {
415 let fill = theme.charset.rule_fill(fill);
416 let count = usize::from(width.min(80));
417 if count > 0 {
418 let text = repeated_char(fill, count);
419 sink.run(theme.rule, text.as_str());
420 }
421 sink.newline();
422}
423
424fn clipped_row(sink: &mut dyn RichSink, width: u16, style: Style, text: &str) {
425 let mut clipped = (&mut *sink).clip(width, None);
426 clipped.run(style, text);
427 clipped.newline();
428}
429
430fn fence_start(line: &str) -> Option<(char, &str)> {
431 let trimmed = line.trim_start_matches(' ');
432 if line.len().saturating_sub(trimmed.len()) > 3 {
433 return None;
434 }
435 let fence = trimmed.chars().next()?;
436 if !matches!(fence, '`' | '~') {
437 return None;
438 }
439 let count = trimmed
440 .chars()
441 .take_while(|character| *character == fence)
442 .count();
443 (count >= 3).then(|| (fence, trimmed[count..].trim()))
444}
445
446fn fence_language(info: &str) -> &str {
447 info.split_ascii_whitespace().next().unwrap_or("")
448}
449
450#[derive(Clone, Copy)]
451enum DiagramLanguage {
452 Graphviz,
453 Mermaid,
454}
455
456fn diagram_language(info: &str) -> Option<DiagramLanguage> {
457 let language = fence_language(info);
458 if language.eq_ignore_ascii_case("mermaid") {
459 Some(DiagramLanguage::Mermaid)
460 } else if ["dot", "graphviz", "gv"]
461 .iter()
462 .any(|candidate| language.eq_ignore_ascii_case(candidate))
463 {
464 Some(DiagramLanguage::Graphviz)
465 } else {
466 None
467 }
468}
469
470impl DiagramLanguage {
471 fn render(self, source: &str, width: u16, theme: &MdTheme, sink: &mut dyn RichSink) -> bool {
472 let styles = DiagramStyles {
473 text: theme.base,
474 line: Style::new().fg(theme.semantic.muted),
475 accent: theme.bullet,
476 };
477 match self {
478 Self::Graphviz => graphviz::render(source, width, theme.charset, styles, sink),
479 Self::Mermaid => mermaid::render(source, width, theme.charset, styles, sink),
480 }
481 }
482}
483
484fn is_closing_fence(line: &str, fence: char, opening_count: usize) -> bool {
485 let candidate = line.trim_start_matches(' ');
486 let close_count = candidate
487 .chars()
488 .take_while(|character| *character == fence)
489 .count();
490 close_count >= opening_count && candidate[close_count..].trim().is_empty()
491}
492
493fn render_fenced_code(
494 lines: &[&str],
495 index: &mut usize,
496 width: u16,
497 theme: &MdTheme,
498 fence: char,
499 language: &str,
500 sink: &mut dyn RichSink,
501) {
502 let opening_count = lines[*index]
503 .trim_start()
504 .chars()
505 .take_while(|character| *character == fence)
506 .count();
507 let syntax = fence_language(language);
508 let diagram = diagram_language(language);
509 let highlighted = if diagram.is_some() || highlight::supports_language(syntax) {
510 let body_start = *index + 1;
511 let body_end = (body_start..lines.len())
512 .find(|candidate| is_closing_fence(lines[*candidate], fence, opening_count))
513 .unwrap_or(lines.len());
514 let body = join_lines(&lines[body_start..body_end]);
515 let after = after_fence(body_end, lines.len());
516 if let Some(diagram) = diagram
517 && diagram.render(body.as_str(), width, theme, sink)
518 {
519 *index = after;
520 return;
521 }
522 let mut rows = RichText::default();
523 highlight::render(body.as_str(), syntax, body_end - body_start, &theme.highlight, &mut rows)
524 .then_some((rows, after))
525 } else {
526 None
527 };
528
529 let top = if language.is_empty() {
530 Str::new_static("```")
531 } else {
532 fmts!("```{language}")
533 };
534 clipped_row(sink, width, theme.code_border, top.as_str());
535
536 if let Some((rows, after)) = highlighted {
537 push_highlighted_code_rows(&rows, width, theme, Prefix::empty_ref(), sink);
538 clipped_row(sink, width, theme.code_border, "```");
539 *index = after;
540 return;
541 }
542
543 *index += 1;
544 while *index < lines.len() {
545 if is_closing_fence(lines[*index], fence, opening_count) {
546 *index += 1;
547 clipped_row(sink, width, theme.code_border, "```");
548 return;
549 }
550 push_code_line(lines[*index], width, theme, sink);
551 *index += 1;
552 }
553 clipped_row(sink, width, theme.code_border, "```");
554}
555
556fn is_indented_code(line: &str) -> bool {
557 line.starts_with(" ") && !line.trim().is_empty()
558}
559
560fn render_indented_code(
561 lines: &[&str],
562 index: &mut usize,
563 width: u16,
564 theme: &MdTheme,
565 sink: &mut dyn RichSink,
566) {
567 clipped_row(sink, width, theme.code_border, "```");
568 while *index < lines.len() {
569 if let Some(body) = lines[*index].strip_prefix(" ") {
570 push_code_line(body, width, theme, sink);
571 *index += 1;
572 } else if lines[*index].trim().is_empty()
573 && *index + 1 < lines.len()
574 && lines[*index + 1].starts_with(" ")
575 {
576 push_code_line("", width, theme, sink);
577 *index += 1;
578 } else {
579 break;
580 }
581 }
582 clipped_row(sink, width, theme.code_border, "```");
583}
584
585fn push_code_line(body: &str, width: u16, theme: &MdTheme, sink: &mut dyn RichSink) {
586 let mut clipped = (&mut *sink).clip(width, None);
587 clipped.run(theme.code_block, " ");
588 clipped.run(theme.code_block, body);
589 clipped.newline();
590}
591
592fn push_highlighted_code_rows(
593 rows: &RichText,
594 width: u16,
595 theme: &MdTheme,
596 prefix: &Prefix,
597 sink: &mut dyn RichSink,
598) {
599 let mut gutter = Prefix::default();
600 gutter.push(theme.code_block, " ");
601 let clipped = (&mut *sink).clip(width, None);
602 let listed = clipped.prefixed(prefix, prefix);
603 let mut guttered = listed.prefixed(&gutter, &gutter);
604 rows.replay(&mut guttered);
605}
606
607const fn after_fence(end: usize, line_count: usize) -> usize {
608 if end < line_count { end + 1 } else { end }
609}
610
611fn display_math(lines: &[&str], index: usize) -> Option<(usize, Str)> {
612 let opening = lines[index].trim();
613 let closing = match opening {
614 "$$" => "$$",
615 "\\[" => "\\]",
616 _ => return None,
617 };
618 let mut end = index + 1;
619 while end < lines.len() && lines[end].trim() != closing {
620 end += 1;
621 }
622 if end >= lines.len() || end == index + 1 {
623 return None;
624 }
625 let body = join_lines(&lines[index + 1..end]);
626 (!body.trim().is_empty()).then_some((end + 1, body))
627}
628
629fn bare_math_end(lines: &[&str], index: usize) -> Option<usize> {
630 let line = lines.get(index)?;
631 let line = line.trim_start_matches([' ', '\t']);
632 if lines[index].len().saturating_sub(line.len()) > 3 {
633 return None;
634 }
635 let rest = line.strip_prefix("\\begin{")?;
636 let close = rest.find('}')?;
637 let environment = &rest[..close];
638 if !crate::latex::is_bare_math_environment(environment) {
639 return None;
640 }
641 let end_token = fmts!("\\end{{{environment}}}");
642 for (offset, candidate) in lines[index..].iter().enumerate() {
643 if offset > 0 && candidate.trim().is_empty() {
644 return None;
645 }
646 if candidate.contains(end_token.as_str()) {
647 return Some(index + offset + 1);
648 }
649 }
650 None
651}
652
653fn bare_math_with_lhs_end(lines: &[&str], index: usize) -> Option<usize> {
654 let lhs = lines.get(index)?.trim_end();
655 let last = lhs.chars().last()?;
656 if !matches!(last, '=' | '(' | '[' | '{') {
657 return None;
658 }
659 if index + 1 >= lines.len() {
660 return None;
661 }
662 bare_math_end(lines, index + 1)
663}
664
665fn render_math(body: &str, width: u16, theme: &MdTheme, sink: &mut dyn RichSink) {
666 {
667 let mut clipped = (&mut *sink).clip(width, None);
668 if crate::latex::latex_block(body, theme.base, &mut clipped) {
669 return;
670 }
671 }
672 let mut wrap = (&mut *sink).wrap(width);
673 crate::latex::latex_inline(body, theme.base, &mut wrap);
674 wrap.finish();
675}
676
677fn sole_display_math(text: &str) -> Option<&str> {
678 let trimmed = text.trim();
679 if let Some(body) = trimmed
680 .strip_prefix("$$")
681 .and_then(|body| body.strip_suffix("$$"))
682 {
683 return (!body.trim().is_empty()).then_some(body);
684 }
685 trimmed
686 .strip_prefix("\\[")
687 .and_then(|body| body.strip_suffix("\\]"))
688}
689
690fn quote_line(line: &str) -> Option<&str> {
691 let trimmed = line.trim_start_matches(' ');
692 if line.len().saturating_sub(trimmed.len()) > 3 {
693 return None;
694 }
695 trimmed
696 .strip_prefix('>')
697 .map(|text| text.strip_prefix(' ').unwrap_or(text))
698}
699
700fn render_blockquote(
701 lines: &[&str],
702 index: &mut usize,
703 width: u16,
704 theme: &MdTheme,
705 sink: &mut dyn RichSink,
706) {
707 let mut inner = StrMut::new("");
708 let mut saw_quote = false;
709 while *index < lines.len() {
710 if let Some(text) = quote_line(lines[*index]) {
711 if saw_quote {
712 inner.push('\n');
713 }
714 inner.push_str(text);
715 saw_quote = true;
716 *index += 1;
717 continue;
718 }
719 if saw_quote
720 && !lines[*index].trim().is_empty()
721 && block_kind(lines, *index) == BlockKind::Paragraph
722 {
723 inner.push('\n');
724 inner.push_str(lines[*index]);
725 *index += 1;
726 continue;
727 }
728 break;
729 }
730 let mut quote_theme = *theme;
731 quote_theme.base = theme.quote.italic();
732 quote_theme.strong = theme.strong.italic();
733 quote_theme.emphasis = theme.emphasis.italic();
734 quote_theme.code = theme.code.italic();
735 quote_theme.link = theme.link.italic();
736 let inner = inner.freeze();
737 let mut rendered = RichText::default();
738 render_document(&inner, width.saturating_sub(2).max(1), "e_theme, &mut rendered);
739 let mut rail = Prefix::default();
740 rail.push(theme.quote, theme.charset.quote_rail());
741 let clipped = (&mut *sink).clip(width, None);
742 let mut bordered = clipped.prefixed(&rail, &rail);
743 rendered.replay(&mut bordered);
744}
745
746#[derive(Clone, Copy)]
747struct ListMarker<'a> {
748 indent: usize,
749 ordered: bool,
750 start: usize,
751 text: &'a str,
752}
753
754fn list_marker(line: &str) -> Option<ListMarker<'_>> {
755 let spaces = line.bytes().take_while(|byte| *byte == b' ').count();
756 let rest = &line[spaces..];
757 if let Some(text) = rest
758 .strip_prefix("- ")
759 .or_else(|| rest.strip_prefix("* "))
760 .or_else(|| rest.strip_prefix("+ "))
761 {
762 return Some(ListMarker { indent: spaces, ordered: false, start: 1, text });
763 }
764 if matches!(rest, "-" | "*" | "+") {
765 return Some(ListMarker { indent: spaces, ordered: false, start: 1, text: "" });
766 }
767 let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
768 if digits == 0 || digits > 9 {
769 return None;
770 }
771 let delimiter = rest.as_bytes().get(digits).copied()?;
772 if !matches!(delimiter, b'.' | b')') {
773 return None;
774 }
775 let after = &rest[digits + 1..];
776 let text = if after.is_empty() {
777 ""
778 } else {
779 after
780 .strip_prefix(' ')
781 .or_else(|| after.strip_prefix('\t'))?
782 };
783 Some(ListMarker { indent: spaces, ordered: true, start: rest[..digits].parse().ok()?, text })
784}
785
786fn push_prefix_spaces(prefix: &mut Prefix, style: Style, mut count: usize) {
787 while count > 0 {
788 let take = count.min(crate::rich::SPACES.len());
789 prefix.push(style, &crate::rich::SPACES[..take]);
790 count -= take;
791 }
792}
793
794fn render_list(
795 lines: &[&str],
796 index: &mut usize,
797 width: u16,
798 theme: &MdTheme,
799 root_indent: usize,
800 depth: usize,
801 ordered: bool,
802 sink: &mut dyn RichSink,
803) {
804 let start = list_marker(lines[*index]).map_or(1, |marker| marker.start);
805 let mut ordinal = start;
806 while *index < lines.len() {
807 let Some(marker) = list_marker(lines[*index]) else {
808 break;
809 };
810 if marker.indent != root_indent || marker.ordered != ordered {
811 break;
812 }
813 let marker_text = if ordered {
814 let value = ordinal;
815 ordinal = ordinal.saturating_add(1);
816 fmts!("{value}. ")
817 } else {
818 Str::new_static("- ")
819 };
820 let indent = depth.saturating_mul(2);
821 let mut first_prefix = Prefix::default();
822 push_prefix_spaces(&mut first_prefix, theme.base, indent);
823 first_prefix.push(theme.bullet, marker_text.as_str());
824 let continuation_width = usize::from(cell_width(marker_text.as_str()));
825 let mut continuation = Prefix::default();
826 push_prefix_spaces(&mut continuation, theme.base, indent.saturating_add(continuation_width));
827 if let Some((fence, language)) = fence_start(marker.text) {
828 render_list_fenced_code(
829 lines,
830 index,
831 width,
832 theme,
833 fence,
834 language,
835 &first_prefix,
836 &continuation,
837 root_indent,
838 sink,
839 );
840 } else {
841 render_list_text(marker.text, width, theme, &first_prefix, &continuation, sink);
842 *index += 1;
843 }
844 loop {
845 if *index >= lines.len() {
846 break;
847 }
848 if lines[*index].trim().is_empty() {
849 let blank_at = *index;
850 while *index < lines.len() && lines[*index].trim().is_empty() {
851 *index += 1;
852 }
853 if *index < lines.len() {
854 let next_indent = lines[*index]
855 .bytes()
856 .take_while(|byte| *byte == b' ')
857 .count();
858 if list_marker(lines[*index]).is_some_and(|next| next.indent >= root_indent)
859 || next_indent > root_indent
860 {
861 continue;
864 }
865 }
866 *index = blank_at;
867 break;
868 }
869 let Some(next_marker) = list_marker(lines[*index]) else {
870 let leading = lines[*index]
871 .bytes()
872 .take_while(|byte| *byte == b' ')
873 .count();
874 if leading <= root_indent && starts_block(lines, *index) {
875 break;
876 }
877 let text = trim_list_continuation(lines[*index], root_indent);
878 if let Some((fence, language)) = fence_start(text) {
879 render_list_fenced_code(
880 lines,
881 index,
882 width,
883 theme,
884 fence,
885 language,
886 &continuation,
887 &continuation,
888 root_indent,
889 sink,
890 );
891 } else {
892 render_list_text(text, width, theme, &continuation, &continuation, sink);
893 *index += 1;
894 }
895 continue;
896 };
897 if next_marker.indent == root_indent && next_marker.ordered == ordered {
898 break;
899 }
900 if next_marker.indent <= root_indent {
901 break;
902 }
903 render_list(
904 lines,
905 index,
906 width,
907 theme,
908 next_marker.indent,
909 depth + 1,
910 next_marker.ordered,
911 sink,
912 );
913 }
914 }
915}
916
917fn render_list_fenced_code(
918 lines: &[&str],
919 index: &mut usize,
920 width: u16,
921 theme: &MdTheme,
922 fence: char,
923 language: &str,
924 first_prefix: &Prefix,
925 continuation: &Prefix,
926 root_indent: usize,
927 sink: &mut dyn RichSink,
928) {
929 let opening_count = 3;
930 let syntax = fence_language(language);
931 let diagram = diagram_language(language);
932 let highlighted = if diagram.is_some() || highlight::supports_language(syntax) {
933 let body_start = *index + 1;
934 let body_end = (body_start..lines.len())
935 .find(|candidate| {
936 is_closing_fence(
937 trim_list_continuation(lines[*candidate], root_indent),
938 fence,
939 opening_count,
940 )
941 })
942 .unwrap_or(lines.len());
943 let body = join_list_fence_lines(lines, body_start, body_end, root_indent);
944 let after = after_fence(body_end, lines.len());
945 if let Some(diagram) = diagram {
946 let content_width = width.saturating_sub(continuation.width());
947 let clipped = (&mut *sink).clip(width, None);
948 let mut prefixed = clipped.prefixed(first_prefix, continuation);
949 if diagram.render(body.as_str(), content_width, theme, &mut prefixed) {
950 *index = after;
951 return;
952 }
953 }
954 let mut rows = RichText::default();
955 highlight::render(body.as_str(), syntax, body_end - body_start, &theme.highlight, &mut rows)
956 .then_some((rows, after))
957 } else {
958 None
959 };
960
961 let top = if language.is_empty() {
962 Str::new_static("```")
963 } else {
964 fmts!("```{language}")
965 };
966 {
967 let clipped = (&mut *sink).clip(width, None);
968 let mut prefixed = clipped.prefixed(first_prefix, continuation);
969 prefixed.run(theme.code_border, top.as_str());
970 prefixed.newline();
971 }
972
973 if let Some((rows, after)) = highlighted {
974 push_highlighted_code_rows(&rows, width, theme, continuation, sink);
975 let clipped = (&mut *sink).clip(width, None);
976 let mut prefixed = clipped.prefixed(continuation, continuation);
977 prefixed.run(theme.code_border, "```");
978 prefixed.newline();
979 *index = after;
980 return;
981 }
982
983 *index += 1;
984 while *index < lines.len() {
985 let body = trim_list_continuation(lines[*index], root_indent);
986 if is_closing_fence(body, fence, opening_count) {
987 *index += 1;
988 let clipped = (&mut *sink).clip(width, None);
989 let mut prefixed = clipped.prefixed(continuation, continuation);
990 prefixed.run(theme.code_border, "```");
991 prefixed.newline();
992 return;
993 }
994 let clipped = (&mut *sink).clip(width, None);
995 let mut prefixed = clipped.prefixed(continuation, continuation);
996 prefixed.run(theme.code_block, " ");
997 prefixed.run(theme.code_block, body);
998 prefixed.newline();
999 *index += 1;
1000 }
1001 let clipped = (&mut *sink).clip(width, None);
1002 let mut prefixed = clipped.prefixed(continuation, continuation);
1003 prefixed.run(theme.code_border, "```");
1004 prefixed.newline();
1005}
1006
1007fn trim_list_continuation(line: &str, root_indent: usize) -> &str {
1008 let wanted = root_indent.saturating_add(2);
1009 let spaces = line.bytes().take_while(|byte| *byte == b' ').count();
1010 &line[spaces.min(wanted)..]
1011}
1012
1013fn join_list_fence_lines(lines: &[&str], start: usize, end: usize, root_indent: usize) -> Str {
1014 let slice = &lines[start..end];
1015 let capacity = slice
1016 .iter()
1017 .map(|line| trim_list_continuation(line, root_indent).len() + 1)
1018 .sum();
1019 let mut joined = StrMut::with_capacity(capacity);
1020 for (index, line) in slice.iter().enumerate() {
1021 if index > 0 {
1022 joined.push('\n');
1023 }
1024 joined.push_str(trim_list_continuation(line, root_indent));
1025 }
1026 joined.freeze()
1027}
1028
1029fn render_list_text(
1030 text: &str,
1031 width: u16,
1032 theme: &MdTheme,
1033 first: &Prefix,
1034 continuation: &Prefix,
1035 sink: &mut dyn RichSink,
1036) {
1037 if text.is_empty() {
1038 let clipped = (&mut *sink).clip(width, None);
1039 let mut prefixed = clipped.prefixed(first, continuation);
1040 prefixed.newline();
1041 return;
1042 }
1043 if let Some(body) = sole_display_math(text) {
1044 {
1045 let clipped = (&mut *sink).clip(width, None);
1046 let mut prefixed = clipped.prefixed(first, continuation);
1047 if crate::latex::latex_block(body, theme.base, &mut prefixed) {
1048 return;
1049 }
1050 }
1051 let mut wrapped = (&mut *sink).wrap_prefixed(width, first, continuation);
1052 crate::latex::latex_inline(body, theme.base, &mut wrapped);
1053 wrapped.finish();
1054 return;
1055 }
1056 let mut wrapped = (&mut *sink).wrap_prefixed(width, first, continuation);
1057 parse_inline(text.trim_end(), theme, theme.base, &mut wrapped);
1058 wrapped.finish();
1059}
1060
1061fn starts_block(lines: &[&str], index: usize) -> bool {
1062 atx_heading(lines[index]).is_some()
1063 || horizontal_rule(lines[index]).is_some()
1064 || fence_start(lines[index]).is_some()
1065 || is_indented_code(lines[index])
1066 || quote_line(lines[index]).is_some()
1067 || list_marker(lines[index]).is_some()
1068 || display_math(lines, index).is_some()
1069 || bare_math_end(lines, index).is_some()
1070 || bare_math_with_lhs_end(lines, index).is_some()
1071 || table_header(lines, index).is_some()
1072}
1073
1074fn render_paragraph(
1075 lines: &[&str],
1076 index: &mut usize,
1077 width: u16,
1078 theme: &MdTheme,
1079 sink: &mut dyn RichSink,
1080) {
1081 let start = *index;
1082 while *index < lines.len() && !lines[*index].trim().is_empty() {
1083 if *index > start && starts_block(lines, *index) {
1084 break;
1085 }
1086 if *index == start && *index + 1 < lines.len() && setext_depth(lines[*index + 1]).is_some() {
1087 break;
1088 }
1089 *index += 1;
1090 }
1091 if *index == start {
1092 *index += 1;
1093 }
1094 let paragraph = &lines[start..*index];
1095 if paragraph.len() == 1
1096 && let Some(body) = sole_display_math(paragraph[0])
1097 {
1098 render_math(body, width, theme, sink);
1099 return;
1100 }
1101 for text in paragraph {
1102 let mut wrapped = (&mut *sink).wrap(width);
1103 parse_inline(text.trim_end(), theme, theme.base, &mut wrapped);
1104 wrapped.finish();
1105 }
1106}
1107
1108fn table_header<'a>(lines: &[&'a str], index: usize) -> Option<(Vec<Alignment>, Vec<&'a str>)> {
1109 if index + 1 >= lines.len() || !lines[index].contains('|') {
1110 return None;
1111 }
1112 let alignments = table_separator(lines[index + 1])?;
1113 let header = table_cells(lines[index]);
1114 (header.len() == alignments.len() && !header.is_empty()).then_some((alignments, header))
1115}
1116
1117fn table_cells(line: &str) -> Vec<&str> {
1118 let trimmed = line.trim();
1119 let body = trimmed.strip_prefix('|').unwrap_or(trimmed);
1120 let body = body.strip_suffix('|').unwrap_or(body);
1121 body.split('|').map(str::trim).collect()
1122}
1123
1124fn table_separator(line: &str) -> Option<Vec<Alignment>> {
1125 if !line.contains('|') {
1126 return None;
1127 }
1128 let cells = table_cells(line);
1129 if cells.is_empty() {
1130 return None;
1131 }
1132 cells
1133 .into_iter()
1134 .map(|cell| {
1135 let left = cell.starts_with(':');
1136 let right = cell.ends_with(':');
1137 let dashes = cell.trim_matches(':');
1138 if dashes.len() < 3 || !dashes.bytes().all(|byte| byte == b'-') {
1139 return None;
1140 }
1141 Some(match (left, right) {
1142 (true, true) => Alignment::Center,
1143 (false, true) => Alignment::Right,
1144 _ => Alignment::Left,
1145 })
1146 })
1147 .collect()
1148}
1149
1150fn repeated_char(character: char, count: usize) -> Str {
1151 let mut output = StrMut::with_capacity(count.saturating_mul(character.len_utf8()));
1152 for _ in 0..count {
1153 output.push(character);
1154 }
1155 output.freeze()
1156}
1157
1158fn join_lines(lines: &[&str]) -> Str {
1159 let capacity = lines.iter().map(|line| line.len() + 1).sum();
1160 let mut joined = StrMut::with_capacity(capacity);
1161 for (index, line) in lines.iter().enumerate() {
1162 if index > 0 {
1163 joined.push('\n');
1164 }
1165 joined.push_str(line);
1166 }
1167 joined.freeze()
1168}
1169
1170fn normalize_source(source: &Str) -> Str {
1171 if !source.as_str().contains('\t')
1172 && !source.as_str().contains('<')
1173 && !source.as_str().contains('&')
1174 {
1175 return source.clone();
1176 }
1177 let tabs = replace_tabs(source);
1178 if !tabs.as_str().contains('<') && !tabs.as_str().contains('&') {
1179 return tabs;
1180 }
1181 let text = tabs.as_str();
1182 let mut output = StrMut::with_capacity(text.len());
1183 let mut outside_start = 0;
1184 let mut in_fence: Option<(char, usize)> = None;
1185 for line in text.split_inclusive('\n') {
1186 let offset = line.as_ptr() as usize - text.as_ptr() as usize;
1187 let fence = fence_start(line.trim_end_matches('\n'));
1188 if let Some((active, opening_count)) = in_fence {
1189 output.push_str(line);
1190 let closes = fence.is_some_and(|(candidate, language)| {
1191 candidate == active
1192 && language.is_empty()
1193 && line
1194 .trim_start()
1195 .chars()
1196 .take_while(|character| *character == candidate)
1197 .count() >= opening_count
1198 });
1199 if closes {
1200 in_fence = None;
1201 outside_start = offset + line.len();
1202 }
1203 continue;
1204 }
1205 if let Some((opening, _)) = fence {
1206 if outside_start < offset {
1207 normalize_html_chunk(&text[outside_start..offset], &mut output);
1208 }
1209 output.push_str(line);
1210 let opening_count = line
1211 .trim_start()
1212 .chars()
1213 .take_while(|character| *character == opening)
1214 .count();
1215 in_fence = Some((opening, opening_count));
1216 }
1217 }
1218 if in_fence.is_none() && outside_start < text.len() {
1219 normalize_html_chunk(&text[outside_start..], &mut output);
1220 }
1221 output.freeze()
1222}
1223
1224fn replace_tabs(source: &Str) -> Str {
1225 if !source.as_str().contains('\t') {
1226 return source.clone();
1227 }
1228 let tab_count = source
1229 .as_str()
1230 .bytes()
1231 .filter(|byte| *byte == b'\t')
1232 .count();
1233 let mut output = StrMut::with_capacity(source.len().saturating_add(tab_count.saturating_mul(2)));
1234 let mut start = 0;
1235 for (offset, character) in source.as_str().char_indices() {
1236 if character == '\t' {
1237 output.push_str(&source.as_str()[start..offset]);
1238 output.push_str(" ");
1239 start = offset + 1;
1240 }
1241 }
1242 output.push_str(&source.as_str()[start..]);
1243 output.freeze()
1244}
1245
1246#[derive(Clone, Copy)]
1247struct HtmlList {
1248 ordered: bool,
1249 next: usize,
1250}
1251
1252fn normalize_html_chunk(raw: &str, output: &mut StrMut) {
1253 let mut lists: SmallVec<HtmlList, 4> = SmallVec::new();
1254 let mut quote_depth = 0_usize;
1255 let mut cursor = 0;
1256 while cursor < raw.len() {
1257 if raw.as_bytes()[cursor] == b'`' {
1260 let run = raw[cursor..]
1261 .bytes()
1262 .take_while(|byte| *byte == b'`')
1263 .count();
1264 let len = code_span_len(&raw[cursor..]).unwrap_or(run);
1265 decode_html_text(&raw[cursor..cursor + len], output, quote_depth);
1266 cursor += len;
1267 continue;
1268 }
1269 if raw[cursor..].starts_with("<!--") {
1270 if let Some(end) = raw[cursor + 4..].find("-->") {
1271 cursor += end + 7;
1272 continue;
1273 }
1274 decode_html_text(&raw[cursor..], output, quote_depth);
1275 break;
1276 }
1277 let escaped = raw[..cursor]
1279 .bytes()
1280 .rev()
1281 .take_while(|byte| *byte == b'\\')
1282 .count()
1283 % 2 == 1;
1284 if raw.as_bytes()[cursor] == b'<'
1285 && !escaped
1286 && let Some(close) = raw[cursor..].find('>')
1287 {
1288 let tag = &raw[cursor..=(cursor + close)];
1289 if let Some((name, closing, self_closing)) = html_tag(tag) {
1290 let line_start = raw[..cursor].rfind('\n').map_or(0, |at| at + 1);
1291 let line_end = raw[cursor..].find('\n').map_or(raw.len(), |at| cursor + at);
1292 let in_table = raw[line_start..line_end].contains('|');
1293 let indented = raw[line_start..]
1296 .bytes()
1297 .take_while(|byte| *byte == b' ')
1298 .count() >= 4;
1299 if indented {
1300 output.push_str(tag);
1301 cursor += tag.len();
1302 continue;
1303 }
1304 if in_table && matches!(name, "br" | "hr" | "p" | "ol" | "ul" | "li" | "code") {
1305 output.push_str(tag);
1306 cursor += tag.len();
1307 continue;
1308 }
1309 match name {
1310 "span" => output.push_str(tag),
1313 "text" => {},
1314 "code" => output.push_str(tag),
1315 "br" => append_html_break(output, true, quote_depth),
1316 "hr" => {
1317 let before = raw[..cursor].trim_end_matches([' ', '\t']);
1320 let after = raw[cursor + tag.len()..].trim_start_matches([' ', '\t']);
1321 let standalone = (before.is_empty() || before.ends_with('\n'))
1322 && (after.is_empty() || after.starts_with('\n'));
1323 if standalone {
1324 append_html_break(output, false, quote_depth);
1325 output.push_str("---");
1326 }
1327 append_html_break(output, true, quote_depth);
1328 },
1329 "p" => append_html_break(output, closing, quote_depth),
1330 "blockquote" => {
1331 if closing {
1332 append_html_break(output, false, quote_depth);
1333 quote_depth = quote_depth.saturating_sub(1);
1334 } else if !self_closing {
1335 append_html_break(output, false, quote_depth);
1336 quote_depth += 1;
1337 append_quote_prefix(output, quote_depth);
1338 }
1339 },
1340 "ol" | "ul" => {
1341 if closing {
1342 lists.pop();
1343 } else if !self_closing {
1344 lists.push(HtmlList {
1345 ordered: name == "ol",
1346 next: if name == "ol" { html_ol_start(tag) } else { 1 },
1347 });
1348 }
1349 },
1350 "li" => {
1351 append_html_break(output, false, quote_depth);
1352 if !closing {
1353 let depth = lists.len().saturating_sub(1);
1354 for _ in 0..depth {
1355 output.push_str(" ");
1356 }
1357 if let Some(list) = lists.last_mut() {
1358 if list.ordered {
1359 output.push_str(fmts!("{}. ", list.next).as_str());
1360 list.next = list.next.saturating_add(1);
1361 } else {
1362 output.push_str("- ");
1363 }
1364 } else {
1365 output.push_str("- ");
1366 }
1367 }
1368 },
1369 _ => output.push_str(tag),
1370 }
1371 cursor += tag.len();
1372 continue;
1373 }
1374 }
1375 let next_tag = raw[cursor..].find('<').map_or(raw.len(), |at| cursor + at);
1376 let next_span = raw[cursor..].find('`').map_or(raw.len(), |at| cursor + at);
1377 let boundary = next_tag.min(next_span);
1378 let end = if boundary == cursor {
1379 raw[cursor..]
1380 .chars()
1381 .next()
1382 .map_or(raw.len(), |character| cursor + character.len_utf8())
1383 } else {
1384 boundary
1385 };
1386 let text = &raw[cursor..end];
1387 if !(text.trim().is_empty() && text.contains('\n') && !lists.is_empty()) {
1388 decode_html_text(text, output, quote_depth);
1389 }
1390 cursor = end;
1391 }
1392}
1393
1394fn decode_html_text(text: &str, output: &mut StrMut, quote_depth: usize) {
1395 if quote_depth == 0 || !text.contains('\n') {
1396 decode_entities(text, output);
1397 return;
1398 }
1399 let mut decoded = StrMut::with_capacity(text.len());
1400 decode_entities(text, &mut decoded);
1401 for chunk in decoded.as_str().split_inclusive('\n') {
1402 output.push_str(chunk);
1403 if chunk.ends_with('\n') {
1404 append_quote_prefix(output, quote_depth);
1405 }
1406 }
1407}
1408
1409const HTML_TAGS: &[&str] =
1411 &["br", "p", "ol", "ul", "li", "span", "text", "code", "hr", "blockquote"];
1412
1413fn html_tag(tag: &str) -> Option<(&str, bool, bool)> {
1414 let inside = tag.strip_prefix('<')?.strip_suffix('>')?.trim();
1415 let closing = inside.starts_with('/');
1416 let body = inside.strip_prefix('/').unwrap_or(inside).trim_start();
1417 let name_end = body
1418 .find(|character: char| character.is_whitespace() || character == '/')
1419 .unwrap_or(body.len());
1420 let name = &body[..name_end];
1421 let canonical = HTML_TAGS
1422 .iter()
1423 .find(|candidate| name.eq_ignore_ascii_case(candidate))?;
1424 Some((canonical, closing, body.trim_end().ends_with('/')))
1425}
1426
1427fn html_ol_start(tag: &str) -> usize {
1428 let lower = tag.to_ascii_lowercase();
1429 let Some(at) = lower.find("start") else {
1430 return 1;
1431 };
1432 let rest = lower[at + 5..].trim_start();
1433 let Some(rest) = rest.strip_prefix('=') else {
1434 return 1;
1435 };
1436 let rest = rest.trim_start();
1437 let rest = rest
1438 .strip_prefix('\'')
1439 .or_else(|| rest.strip_prefix('"'))
1440 .unwrap_or(rest);
1441 let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
1442 rest[..digits].parse().unwrap_or(1)
1443}
1444
1445fn append_html_break(output: &mut StrMut, force: bool, quote_depth: usize) {
1446 let trimmed = output.as_str().trim_end_matches([' ', '\t']).len();
1447 output.truncate(trimmed);
1448 if force || !output.as_str().ends_with('\n') {
1449 output.push('\n');
1450 }
1451 if quote_depth > 0 {
1452 append_quote_prefix(output, quote_depth);
1453 }
1454}
1455
1456fn append_quote_prefix(output: &mut StrMut, depth: usize) {
1457 for _ in 0..depth {
1458 output.push_str("> ");
1459 }
1460}
1461
1462fn decode_entities(text: &str, output: &mut StrMut) {
1463 let mut cursor = 0;
1464 while let Some(relative) = text[cursor..].find('&') {
1465 let at = cursor + relative;
1466 output.push_str(&text[cursor..at]);
1467 let Some(end_relative) = text[at..].find(';') else {
1468 output.push_str(&text[at..]);
1469 return;
1470 };
1471 let end = at + end_relative;
1472 let entity = &text[at + 1..end];
1473 let decoded = if entity.eq_ignore_ascii_case("amp") {
1474 Some('&')
1475 } else if entity.eq_ignore_ascii_case("lt") {
1476 Some('<')
1477 } else if entity.eq_ignore_ascii_case("gt") {
1478 Some('>')
1479 } else if entity.eq_ignore_ascii_case("quot") {
1480 Some('"')
1481 } else if entity.eq_ignore_ascii_case("apos") {
1482 Some('\'')
1483 } else if entity.eq_ignore_ascii_case("nbsp") {
1484 Some(' ')
1487 } else if let Some(hex) = entity
1488 .strip_prefix("#x")
1489 .or_else(|| entity.strip_prefix("#X"))
1490 {
1491 u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
1492 } else if let Some(decimal) = entity.strip_prefix('#') {
1493 decimal.parse().ok().and_then(char::from_u32)
1494 } else {
1495 None
1496 };
1497 if let Some(character) = decoded {
1498 output.push(character);
1499 } else {
1500 output.push_str(&text[at..=end]);
1501 }
1502 cursor = end + 1;
1503 }
1504 output.push_str(&text[cursor..]);
1505}
1506
1507#[cfg(test)]
1508mod tests {
1509 use super::*;
1510
1511 fn rendered(source: &str, width: u16, theme: &MdTheme) -> RichText {
1512 let source = Str::new(source);
1513 let mut rendered = RichText::default();
1514 render(&source, width, theme, &mut rendered);
1515 rendered
1516 }
1517
1518 fn plain(source: &str, width: u16) -> Vec<String> {
1519 let rendered = rendered(source, width, &MdTheme::default());
1520 (0..RichText::rows(&rendered))
1521 .map(|row| rendered.row_text(row).to_owned())
1522 .collect()
1523 }
1524
1525 #[test]
1526 fn ascii_tier_renders_quote_table_rule_and_bullets_in_pure_ascii() {
1527 let theme = MdTheme { charset: Charset::Ascii, ..MdTheme::default() };
1528 let source = "> quoted\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n\nitems:<li>one</li>";
1529 let rendered = rendered(source, 24, &theme);
1530 let rows: Vec<String> = (0..RichText::rows(&rendered))
1531 .map(|row| rendered.row_text(row).to_owned())
1532 .collect();
1533 for row in &rows {
1534 assert!(row.is_ascii(), "non-ASCII chrome leaked: {row:?}");
1535 }
1536 assert!(rows.iter().any(|row| row.starts_with("| quoted")), "quote rail: {rows:?}");
1537 assert!(rows.iter().any(|row| row.starts_with("+--")), "table border: {rows:?}");
1538 assert!(rows.iter().any(|row| row.contains("| 1")), "table cells: {rows:?}");
1539 assert!(rows.iter().any(|row| row.contains("----")), "rule fill: {rows:?}");
1540 assert!(rows.iter().any(|row| row.contains("- one")), "list marker: {rows:?}");
1542 }
1543
1544 fn style_containing(rendered: &RichText, needle: &str) -> Style {
1545 for row in 0..rendered.rows() {
1546 if let Some(style) = rendered
1547 .row_runs(row)
1548 .find_map(|(style, text)| text.contains(needle).then_some(style))
1549 {
1550 return style;
1551 }
1552 }
1553 panic!("missing highlighted segment {needle:?}");
1554 }
1555
1556 #[test]
1557 fn heading_levels_and_setext_are_preserved() {
1558 assert_eq!(plain("# One\n## Two\n### Three\n#### Four\n##### Five\n###### Six", 80), [
1559 "One",
1560 "",
1561 "Two",
1562 "",
1563 "### Three",
1564 "",
1565 "#### Four",
1566 "",
1567 "##### Five",
1568 "",
1569 "###### Six"
1570 ]);
1571 assert_eq!(plain("Title\n===\n\nSubtitle\n---", 80), ["Title", "", "Subtitle"]);
1572 }
1573
1574 #[test]
1575 fn custom_rule_characters_are_retained() {
1576 for (input, fill) in
1577 [("---", '─'), ("* * *", '─'), ("===", '='), ("━━━", '━'), ("═══", '═'), ("———", '—')]
1578 {
1579 assert_eq!(plain(input, 9), [fill.to_string().repeat(9)]);
1580 }
1581 }
1582
1583 #[test]
1584 fn source_soft_breaks_are_visible_lines() {
1585 assert_eq!(plain("first line\nsecond line", 80), ["first line", "second line"]);
1586 }
1587
1588 #[test]
1589 fn block_spacing_is_exactly_one_and_never_trailing() {
1590 let rendered = plain("# H\ntext\n\n---\n```rs\ncode\n```\n> quote\n\nafter", 80);
1591 for pair in rendered.windows(2) {
1592 assert!(!(pair[0].is_empty() && pair[1].is_empty()));
1593 }
1594 assert_ne!(rendered.last().map(String::as_str), Some(""));
1595 assert!(
1596 rendered
1597 .windows(3)
1598 .any(|rows| rows == ["```", "", "│ quote"])
1599 );
1600 }
1601
1602 #[test]
1603 fn every_block_pair_has_normalized_spacing() {
1604 let blocks = [
1605 ("paragraph", "paragraph"),
1606 ("heading", "# heading"),
1607 ("code", "```\ncode\n```"),
1608 ("quote", "> quote"),
1609 ("rule", "***"),
1610 ("list", "- item"),
1611 ("table", "| H |\n| --- |\n| C |"),
1612 ("math", "$$\nx\n$$"),
1613 ];
1614 for (left_name, left) in blocks {
1615 for (right_name, right) in blocks {
1616 let rendered = plain(&format!("{left}\n\n{right}"), 40);
1617 assert!(
1618 !rendered
1619 .windows(2)
1620 .any(|rows| rows[0].is_empty() && rows[1].is_empty()),
1621 "{left_name} then {right_name}",
1622 );
1623 let merges = (left_name == "paragraph" || left_name == "list") && right_name == "list";
1626 if !merges {
1627 assert!(rendered.iter().any(String::is_empty), "{left_name} then {right_name}");
1628 }
1629 assert_ne!(rendered.last().map(String::as_str), Some(""));
1630 }
1631 }
1632 }
1633
1634 #[test]
1635 fn nested_ordered_and_loose_lists() {
1636 assert_eq!(plain("10. alpha beta gamma\n - child\n - second\n11. next", 16), [
1637 "10. alpha beta",
1638 " gamma",
1639 " - child",
1640 " - second",
1641 "11. next"
1642 ]);
1643 assert_eq!(plain("- first\n\n- second", 80), ["- first", "- second"]);
1645 }
1646
1647 #[test]
1648 fn blockquote_uses_unicode_rail_on_every_row() {
1649 assert_eq!(plain("> A long quoted sentence that wraps", 14), [
1650 "│ A long",
1651 "│ quoted",
1652 "│ sentence",
1653 "│ that wraps"
1654 ]);
1655 assert_eq!(plain(">Foo\nbar", 80), ["│ Foo", "│ bar"]);
1656 }
1657
1658 #[test]
1659 fn fenced_and_indented_code_show_fences() {
1660 assert_eq!(plain("```rust\n a b\n```", 20), ["```rust", " a b", "```"]);
1661 assert_eq!(plain(" x", 20), ["```", " x", "```"]);
1662 }
1663
1664 #[test]
1665 fn fenced_code_uses_semantic_highlighting_across_lines_and_lists() {
1666 let theme = MdTheme::default();
1667 let palette = Theme::default();
1668 let source = Str::new(
1669 "```rust\npub fn main() {\n let value = \"hi\";\n /* first\n second */\n}\n```",
1670 );
1671 let fenced = rendered(source.as_str(), 80, &theme);
1672 assert_eq!(style_containing(&fenced, "pub").foreground_color(), palette.accent);
1673 assert_eq!(style_containing(&fenced, "hi").foreground_color(), palette.ok);
1674 assert_eq!(style_containing(&fenced, "second").foreground_color(), palette.muted);
1675
1676 let listed = Str::new("- ```rust\n let value = \"ok\";\n ```");
1677 let nested = rendered(listed.as_str(), 80, &theme);
1678 assert_eq!(style_containing(&nested, "let").foreground_color(), palette.accent);
1679 assert_eq!(style_containing(&nested, "ok").foreground_color(), palette.ok);
1680
1681 let unknown = Str::new("```not-a-language\nanswer = 42\n```");
1682 let fallback = rendered(unknown.as_str(), 80, &theme);
1683 assert_eq!(style_containing(&fallback, "answer"), theme.code_block);
1684 }
1685
1686 #[test]
1687 fn mermaid_fences_render_and_invalid_source_falls_back() {
1688 let rendered = plain("```mermaid\nflowchart LR\n A[Start] --> B[Stop]\n```", 80).join("\n");
1689 assert!(rendered.contains("Start"));
1690 assert!(rendered.contains("Stop"));
1691 assert!(!rendered.contains("flowchart"));
1692 assert!(!rendered.contains("```mermaid"));
1693
1694 let invalid = plain("```mermaid\nthis is not mermaid\n```", 80).join("\n");
1695 assert!(invalid.contains("```mermaid"));
1696 assert!(invalid.contains("this is not mermaid"));
1697 }
1698
1699 #[test]
1700 fn mermaid_diagrams_fit_lists_width_and_ascii_contexts() {
1701 let listed =
1702 plain("- ```mermaid\n flowchart TD\n A[One] --> B[Two]\n ```", 40).join("\n");
1703 assert!(listed.starts_with("- "));
1704 assert!(listed.contains("One"));
1705 assert!(listed.contains("Two"));
1706 assert!(!listed.contains("flowchart"));
1707
1708 let source =
1709 "```mermaid\nflowchart LR\n A[Start] --> B[Build] --> C[Test] --> D[Deploy]\n```";
1710 let narrow = plain(source, 16);
1711 assert!(
1712 narrow
1713 .iter()
1714 .all(|line| crate::rich::cell_width(line) <= 16)
1715 );
1716 assert!(
1717 ["Start", "Build", "Test", "Deploy"]
1718 .into_iter()
1719 .all(|label| narrow.iter().any(|line| line.contains(label)))
1720 );
1721
1722 let source = Str::new(source);
1723 let context = UiContext { charset: Charset::Ascii, ..UiContext::default() };
1724 let theme = MdTheme::from_context(&context);
1725 let ascii = rendered(source.as_str(), 40, &theme);
1726 let ascii = (0..RichText::rows(&ascii))
1727 .map(|row| ascii.row_text(row))
1728 .collect::<String>();
1729 assert!(ascii.is_ascii());
1730 }
1731
1732 #[test]
1733 fn graphviz_fences_render_dot_features_and_invalid_source_falls_back() {
1734 let source = concat!(
1735 "```dot\n",
1736 "strict digraph pipeline {\n",
1737 " rankdir=LR;\n",
1738 " node [shape=box];\n",
1739 " Start [shape=doublecircle];\n",
1740 " Parse [shape=record, label=\"{Read|Validate}\"];\n",
1741 " Start -> Parse [label=\"on success\"];\n",
1742 " Parse -> Done [style=dashed];\n",
1743 " Done -> Done [label=\"retry\"];\n",
1744 "}\n",
1745 "```",
1746 );
1747 let diagram = plain(source, 100).join("\n");
1748 for label in ["Start", "Read", "Validate", "on success", "Done", "retry"] {
1749 assert!(diagram.contains(label), "missing {label:?}: {diagram}");
1750 }
1751 assert!(!diagram.contains("digraph"), "{diagram}");
1752 assert!(!diagram.contains("```dot"), "{diagram}");
1753
1754 let invalid = plain("```dot\ndigraph { a -> }\n```", 80).join("\n");
1755 assert!(invalid.contains("```dot"), "{invalid}");
1756 assert!(invalid.contains("digraph { a -> }"), "{invalid}");
1757
1758 for invalid_source in [
1759 "subgraph { a }",
1760 "graph { a -> b }",
1761 "digraph { a -- b }",
1762 "digraph { a [shape=record, label=\"\"] }",
1763 "digraph { node [shape=record]; a [label=\"{\"] }",
1764 ] {
1765 let fenced = format!("```dot\n{invalid_source}\n```");
1766 let invalid = plain(&fenced, 80).join("\n");
1767 assert!(invalid.contains("```dot"), "{invalid_source}: {invalid}");
1768 assert!(invalid.contains(invalid_source), "{invalid_source}: {invalid}");
1769 }
1770 }
1771
1772 #[test]
1773 fn graphviz_aliases_fit_lists_width_and_ascii_contexts() {
1774 for language in ["dot", "graphviz", "gv"] {
1775 let source = format!("```{language}\ndigraph {{ One -> Two }}\n```");
1776 let diagram = plain(&source, 40).join("\n");
1777 assert!(diagram.contains("One"), "{language}: {diagram}");
1778 assert!(diagram.contains("Two"), "{language}: {diagram}");
1779 assert!(!diagram.contains("digraph"), "{language}: {diagram}");
1780 }
1781
1782 let listed = plain("- ```dot\n digraph {\n One -> Two\n }\n ```", 40).join("\n");
1783 assert!(listed.starts_with("- "), "{listed}");
1784 assert!(listed.contains("One"), "{listed}");
1785 assert!(listed.contains("Two"), "{listed}");
1786
1787 let source = "```dot\ndigraph { rankdir=LR; Start -> Build -> Test -> Deploy }\n```";
1788 let narrow = plain(source, 16);
1789 assert!(
1790 narrow
1791 .iter()
1792 .all(|line| crate::rich::cell_width(line) <= 16),
1793 "{narrow:?}"
1794 );
1795 assert!(
1796 ["Start", "Build", "Test", "Deploy"]
1797 .into_iter()
1798 .all(|label| narrow.iter().any(|line| line.contains(label))),
1799 "{narrow:?}",
1800 );
1801
1802 let source = Str::new(source);
1803 let context = UiContext { charset: Charset::Ascii, ..UiContext::default() };
1804 let theme = MdTheme::from_context(&context);
1805 let ascii = rendered(source.as_str(), 40, &theme);
1806 let ascii = (0..RichText::rows(&ascii))
1807 .map(|row| ascii.row_text(row))
1808 .collect::<String>();
1809 assert!(ascii.is_ascii(), "{ascii}");
1810 }
1811
1812 #[test]
1813 fn display_math_is_promoted() {
1814 let block = plain("$$\nx^2\n$$", 40);
1815 let paragraph = plain("$$x^2$$", 40);
1816 assert_eq!(block, paragraph);
1817 assert!(block.iter().any(|line| line.contains('²')));
1818 }
1819
1820 #[test]
1821 fn html_and_entities_are_normalized() {
1822 assert_eq!(plain("<span><x></span> & "q" 😀", 80), [
1823 "<x> & \"q\" 😀"
1824 ]);
1825 assert_eq!(plain("<ol start=3><li>Third</li><li>Fourth</li></ol>", 80), [
1826 "3. Third",
1827 "4. Fourth"
1828 ]);
1829 assert_eq!(plain("<blockquote>warning<br>now</blockquote>", 80), ["│ warning", "│ now"]);
1830 let rule = plain("before\n\n<hr>\n\nafter", 10);
1831 assert!(rule.contains(&"─".repeat(10)));
1832 assert!(!rule.iter().any(|line| line.contains("<hr>")));
1833 }
1834
1835 #[test]
1836 fn repeated_render_reuses_rich_text_capacity() {
1837 let source = Str::new("# Heading\n\nA paragraph with **styled** text.\n\n- one\n- two");
1838 let theme = MdTheme::default();
1839 let mut output = RichText::default();
1840 render(&source, 32, &theme, &mut output);
1841 let first = output.capacities();
1842 output.clear();
1843 render(&source, 32, &theme, &mut output);
1844 assert_eq!(output.capacities(), first);
1845 }
1846
1847 #[test]
1848 fn degenerate_widths_make_progress() {
1849 for width in [0, 1] {
1850 let rendered = plain("```\nunclosed\n|||\n- item", width);
1851 assert_ne!(rendered, [] as [std::string::String; 0]);
1852 assert!(rendered.len() < 20);
1853 }
1854 }
1855}