Skip to main content

omp_tui/markdown/
mod.rs

1//! Width-aware Markdown rendering into styled terminal lines.
2
3use 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/// Semantic styles shared by terminal diagram renderers.
22#[derive(Clone, Copy)]
23struct DiagramStyles {
24	/// Node labels and other prose.
25	text:   Style,
26	/// Borders, connectors, and corners.
27	line:   Style,
28	/// Arrowheads, markers, and fills.
29	accent: Style,
30}
31
32/// Styles used by the Markdown renderer.
33#[derive(Clone, Copy)]
34pub struct MdTheme {
35	/// Ordinary prose.
36	pub base:        Style,
37	/// Heading text.
38	pub heading:     Style,
39	/// Strong inline text.
40	pub strong:      Style,
41	/// Emphasized inline text.
42	pub emphasis:    Style,
43	/// Inline code spans.
44	pub code:        Style,
45	/// Code block text.
46	pub code_block:  Style,
47	/// Code block fences.
48	pub code_border: Style,
49	/// Blockquote rails and text.
50	pub quote:       Style,
51	/// Ordered and unordered list markers.
52	pub bullet:      Style,
53	/// Link labels.
54	pub link:        Style,
55	/// Horizontal rules.
56	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	/// Derives Markdown styles from the shared semantic theme.
70	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	/// Derives Markdown styles and diagram glyphs from the presentation context.
91	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	/// Folds a node's cascaded style into the palette: prose-family
102	/// entries (`base`, `strong`, `emphasis`, `code_block`) adopt its
103	/// foreground, every entry picks up its attribute flags, and semantic
104	/// hues (headings, links, inline code, bullets) stay their own.
105	/// Syntax-highlight token colors are a deliberate boundary and keep
106	/// their palette untouched.
107	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/// GFM table cell alignment.
128#[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			// Delay empty rows until later content proves they are not
172			// trailing; repeated requests normalize to one blank row.
173			self.pending_blank = true;
174			self.final_blank = true;
175		}
176	}
177}
178
179/// Renders Markdown into rows no wider than `width` terminal cells.
180pub fn render(src: &Str, width: u16, theme: &MdTheme, sink: &mut dyn RichSink) {
181	let normalized = normalize_source(src);
182	// degenerate viewports still make progress: every block renders at
183	// one cell and the paint layer clips
184	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), &quote_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						// pi renders loose lists tight: blank lines between
862						// items or item paragraphs never survive
863						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		// An inline code span keeps its contents verbatim: tags between
1258		// matching backtick runs are code, not HTML for this pass.
1259		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		// An odd backslash run escapes the `<`: the tag is markdown text.
1278		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				// A four-space-indented line is an indented code block:
1294				// its tags stay literal.
1295				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					// spans carry style attributes: kept verbatim for the
1311					// inline layer (`html_span`); bare `<text>` is a no-op
1312					"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						// spaces/tabs only around the tag on its line: a
1318						// standalone `<hr>` becomes a thematic break
1319						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
1409/// The HTML tag names Markdown normalizes itself.
1410const 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			// pi decodes to a plain space; the run survives because prose
1485			// whitespace is never collapsed
1486			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		// Html list items normalize to native `- ` markers before render.
1541		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				// blank-separated sibling lists merge tight (pi parity), and a
1624				// paragraph flows straight into a following list
1625				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		// pi renders loose lists tight: the separating blank never survives
1644		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>&lt;x&gt;</span> &amp; &quot;q&quot; &#128512;", 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}