1use crate::config::loader::SyntaxHighlightingConfig;
4use crate::ui::theme::ThemeStyles;
5use anstyle::Style;
6use anstyle_syntect::to_anstyle;
7use once_cell::sync::Lazy;
8use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag};
9use std::cmp::max;
10use std::collections::HashMap;
11use syntect::easy::HighlightLines;
12use syntect::highlighting::{Theme, ThemeSet};
13use syntect::parsing::{SyntaxReference, SyntaxSet};
14use syntect::util::LinesWithEndings;
15use tracing::warn;
16
17const LIST_INDENT_WIDTH: usize = 2;
18const CODE_EXTRA_INDENT: &str = " ";
19const MAX_THEME_CACHE_SIZE: usize = 32;
20
21static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_newlines);
22static THEME_CACHE: Lazy<parking_lot::RwLock<HashMap<String, Theme>>> = Lazy::new(|| {
23 let defaults = ThemeSet::load_defaults();
24 let mut entries: Vec<(String, Theme)> = defaults.themes.into_iter().collect();
25 if entries.len() > MAX_THEME_CACHE_SIZE {
26 entries.truncate(MAX_THEME_CACHE_SIZE);
27 }
28 let themes: HashMap<_, _> = entries.into_iter().collect();
29 parking_lot::RwLock::new(themes)
30});
31
32#[derive(Clone, Debug)]
34pub struct MarkdownSegment {
35 pub style: Style,
36 pub text: String,
37}
38
39impl MarkdownSegment {
40 pub(crate) fn new(style: Style, text: impl Into<String>) -> Self {
41 Self {
42 style,
43 text: text.into(),
44 }
45 }
46}
47
48#[derive(Clone, Debug, Default)]
50pub struct MarkdownLine {
51 pub segments: Vec<MarkdownSegment>,
52}
53
54impl MarkdownLine {
55 fn push_segment(&mut self, style: Style, text: &str) {
56 if text.is_empty() {
57 return;
58 }
59 if let Some(last) = self.segments.last_mut() {
60 if last.style == style {
61 last.text.push_str(text);
62 return;
63 }
64 }
65 self.segments.push(MarkdownSegment::new(style, text));
66 }
67
68 fn prepend_segments(&mut self, segments: &[PrefixSegment]) {
69 if segments.is_empty() {
70 return;
71 }
72 let mut prefixed = Vec::with_capacity(segments.len() + self.segments.len());
73 for segment in segments {
74 prefixed.push(MarkdownSegment::new(segment.style, segment.text.clone()));
75 }
76 prefixed.append(&mut self.segments);
77 self.segments = prefixed;
78 }
79
80 pub(crate) fn is_empty(&self) -> bool {
81 self.segments
82 .iter()
83 .all(|segment| segment.text.trim().is_empty())
84 }
85}
86
87#[derive(Clone, Debug)]
88struct PrefixSegment {
89 style: Style,
90 text: String,
91}
92
93impl PrefixSegment {
94 fn new(style: Style, text: impl Into<String>) -> Self {
95 Self {
96 style,
97 text: text.into(),
98 }
99 }
100}
101
102#[derive(Clone, Debug)]
103struct CodeBlockState {
104 language: Option<String>,
105 buffer: String,
106}
107
108#[derive(Clone, Debug)]
109struct ListState {
110 kind: ListKind,
111 depth: usize,
112 continuation: String,
113}
114
115#[derive(Clone, Debug)]
116enum ListKind {
117 Unordered,
118 Ordered { next: usize },
119}
120
121pub fn render_markdown_to_lines(
123 source: &str,
124 base_style: Style,
125 theme_styles: &ThemeStyles,
126 highlight_config: Option<&SyntaxHighlightingConfig>,
127) -> Vec<MarkdownLine> {
128 let options = Options::ENABLE_STRIKETHROUGH
129 | Options::ENABLE_TABLES
130 | Options::ENABLE_TASKLISTS
131 | Options::ENABLE_FOOTNOTES;
132 let parser = Parser::new_ext(source, options);
133
134 let mut lines = Vec::new();
135 let mut current_line = MarkdownLine::default();
136 let mut style_stack = vec![base_style];
137 let mut blockquote_depth = 0usize;
138 let mut list_stack: Vec<ListState> = Vec::new();
139 let mut pending_list_prefix: Option<String> = None;
140 let mut code_block: Option<CodeBlockState> = None;
141
142 for event in parser {
143 if let Some(state) = code_block.as_mut() {
144 match event {
145 Event::Text(text) => {
146 state.buffer.push_str(&text);
147 continue;
148 }
149 Event::End(Tag::CodeBlock(_)) => {
150 flush_current_line(
151 &mut lines,
152 &mut current_line,
153 blockquote_depth,
154 &list_stack,
155 &mut pending_list_prefix,
156 theme_styles,
157 base_style,
158 );
159 let prefix = build_prefix_segments(
160 blockquote_depth,
161 &list_stack,
162 theme_styles,
163 base_style,
164 );
165 let highlighted = highlight_code_block(
166 &state.buffer,
167 state.language.as_deref(),
168 highlight_config,
169 theme_styles,
170 base_style,
171 &prefix,
172 );
173 lines.extend(highlighted);
174 push_blank_line(&mut lines);
175 code_block = None;
176 continue;
177 }
178 _ => {}
179 }
180 }
181
182 match event {
183 Event::Start(tag) => handle_start_tag(
184 tag,
185 &mut style_stack,
186 &mut blockquote_depth,
187 &mut list_stack,
188 &mut pending_list_prefix,
189 theme_styles,
190 base_style,
191 &mut code_block,
192 ),
193 Event::End(tag) => handle_end_tag(
194 tag,
195 &mut style_stack,
196 &mut blockquote_depth,
197 &mut list_stack,
198 &mut pending_list_prefix,
199 &mut lines,
200 &mut current_line,
201 ),
202 Event::Text(text) => append_text(
203 &text,
204 &mut current_line,
205 &mut lines,
206 &style_stack,
207 blockquote_depth,
208 &list_stack,
209 &mut pending_list_prefix,
210 theme_styles,
211 base_style,
212 ),
213 Event::Code(code_text) => {
214 ensure_prefix(
215 &mut current_line,
216 blockquote_depth,
217 &list_stack,
218 &mut pending_list_prefix,
219 theme_styles,
220 base_style,
221 );
222 current_line.push_segment(inline_code_style(theme_styles, base_style), &code_text);
223 }
224 Event::SoftBreak => {
225 append_text(
226 " ",
227 &mut current_line,
228 &mut lines,
229 &style_stack,
230 blockquote_depth,
231 &list_stack,
232 &mut pending_list_prefix,
233 theme_styles,
234 base_style,
235 );
236 }
237 Event::HardBreak => {
238 flush_current_line(
239 &mut lines,
240 &mut current_line,
241 blockquote_depth,
242 &list_stack,
243 &mut pending_list_prefix,
244 theme_styles,
245 base_style,
246 );
247 }
248 Event::Rule => {
249 flush_current_line(
250 &mut lines,
251 &mut current_line,
252 blockquote_depth,
253 &list_stack,
254 &mut pending_list_prefix,
255 theme_styles,
256 base_style,
257 );
258 let mut line = MarkdownLine::default();
259 let rule_style = theme_styles.secondary.bold();
260 line.push_segment(rule_style, "―".repeat(32).as_str());
261 lines.push(line);
262 push_blank_line(&mut lines);
263 }
264 Event::TaskListMarker(checked) => {
265 ensure_prefix(
266 &mut current_line,
267 blockquote_depth,
268 &list_stack,
269 &mut pending_list_prefix,
270 theme_styles,
271 base_style,
272 );
273 let marker = if checked { "[x] " } else { "[ ] " };
274 current_line.push_segment(base_style, marker);
275 }
276 Event::Html(html) => append_text(
277 &html,
278 &mut current_line,
279 &mut lines,
280 &style_stack,
281 blockquote_depth,
282 &list_stack,
283 &mut pending_list_prefix,
284 theme_styles,
285 base_style,
286 ),
287 Event::FootnoteReference(reference) => append_text(
288 &format!("[^{}]", reference),
289 &mut current_line,
290 &mut lines,
291 &style_stack,
292 blockquote_depth,
293 &list_stack,
294 &mut pending_list_prefix,
295 theme_styles,
296 base_style,
297 ),
298 }
299 }
300
301 if let Some(state) = code_block {
302 flush_current_line(
303 &mut lines,
304 &mut current_line,
305 blockquote_depth,
306 &list_stack,
307 &mut pending_list_prefix,
308 theme_styles,
309 base_style,
310 );
311 let prefix = build_prefix_segments(blockquote_depth, &list_stack, theme_styles, base_style);
312 let highlighted = highlight_code_block(
313 &state.buffer,
314 state.language.as_deref(),
315 highlight_config,
316 theme_styles,
317 base_style,
318 &prefix,
319 );
320 lines.extend(highlighted);
321 }
322
323 if !current_line.segments.is_empty() {
324 lines.push(current_line);
325 }
326
327 trim_trailing_blank_lines(&mut lines);
328 lines
329}
330
331fn handle_start_tag(
332 tag: Tag,
333 style_stack: &mut Vec<Style>,
334 blockquote_depth: &mut usize,
335 list_stack: &mut Vec<ListState>,
336 pending_list_prefix: &mut Option<String>,
337 theme_styles: &ThemeStyles,
338 base_style: Style,
339 code_block: &mut Option<CodeBlockState>,
340) {
341 match tag {
342 Tag::Paragraph => {}
343 Tag::Heading(level, ..) => {
344 style_stack.push(heading_style(level, theme_styles, base_style));
345 }
346 Tag::BlockQuote => {
347 *blockquote_depth += 1;
348 }
349 Tag::List(start) => {
350 let depth = list_stack.len();
351 let kind = start
352 .map(|value| ListKind::Ordered {
353 next: max(1, value as usize),
354 })
355 .unwrap_or(ListKind::Unordered);
356 list_stack.push(ListState {
357 kind,
358 depth,
359 continuation: String::new(),
360 });
361 }
362 Tag::Item => {
363 if let Some(state) = list_stack.last_mut() {
364 let indent = " ".repeat(state.depth * LIST_INDENT_WIDTH);
365 match &mut state.kind {
366 ListKind::Unordered => {
367 let bullet = format!("{}- ", indent);
368 state.continuation = format!("{} ", indent);
369 *pending_list_prefix = Some(bullet);
370 }
371 ListKind::Ordered { next } => {
372 let bullet = format!("{}{}. ", indent, *next);
373 let width = bullet.len().saturating_sub(indent.len());
374 state.continuation = format!("{}{}", indent, " ".repeat(width));
375 *pending_list_prefix = Some(bullet);
376 *next += 1;
377 }
378 }
379 }
380 }
381 Tag::Emphasis => {
382 let style = style_stack.last().copied().unwrap_or(base_style).italic();
383 style_stack.push(style);
384 }
385 Tag::Strong => {
386 let style = style_stack.last().copied().unwrap_or(base_style).bold();
387 style_stack.push(style);
388 }
389 Tag::Strikethrough => {
390 let style = style_stack
391 .last()
392 .copied()
393 .unwrap_or(base_style)
394 .strikethrough();
395 style_stack.push(style);
396 }
397 Tag::Link { .. } | Tag::Image { .. } => {
398 let style = style_stack
399 .last()
400 .copied()
401 .unwrap_or(base_style)
402 .underline();
403 style_stack.push(style);
404 }
405 Tag::CodeBlock(kind) => {
406 let language = match kind {
407 CodeBlockKind::Fenced(info) => info
408 .split_whitespace()
409 .next()
410 .filter(|lang| !lang.is_empty())
411 .map(|lang| lang.to_string()),
412 CodeBlockKind::Indented => None,
413 };
414 *code_block = Some(CodeBlockState {
415 language,
416 buffer: String::new(),
417 });
418 }
419 _ => {}
420 }
421}
422
423fn handle_end_tag(
424 tag: Tag,
425 style_stack: &mut Vec<Style>,
426 blockquote_depth: &mut usize,
427 list_stack: &mut Vec<ListState>,
428 pending_list_prefix: &mut Option<String>,
429 lines: &mut Vec<MarkdownLine>,
430 current_line: &mut MarkdownLine,
431) {
432 match tag {
433 Tag::Paragraph => {
434 if !current_line.segments.is_empty() {
435 lines.push(std::mem::take(current_line));
436 }
437 push_blank_line(lines);
438 }
439 Tag::Heading(..) => {
440 if !current_line.segments.is_empty() {
441 lines.push(std::mem::take(current_line));
442 }
443 push_blank_line(lines);
444 style_stack.pop();
445 }
446 Tag::BlockQuote => {
447 if *blockquote_depth > 0 {
448 *blockquote_depth -= 1;
449 }
450 }
451 Tag::List(_) => {
452 list_stack.pop();
453 *pending_list_prefix = None;
454 if !current_line.segments.is_empty() {
455 lines.push(std::mem::take(current_line));
456 }
457 push_blank_line(lines);
458 }
459 Tag::Item => {
460 if !current_line.segments.is_empty() {
461 lines.push(std::mem::take(current_line));
462 }
463 *pending_list_prefix = None;
464 }
465 Tag::Emphasis | Tag::Strong | Tag::Strikethrough | Tag::Link { .. } | Tag::Image { .. } => {
466 style_stack.pop();
467 }
468 Tag::CodeBlock(_) => {}
469 Tag::Table(_)
470 | Tag::TableHead
471 | Tag::TableRow
472 | Tag::TableCell
473 | Tag::FootnoteDefinition(_) => {}
474 }
475}
476
477fn append_text(
478 text: &str,
479 current_line: &mut MarkdownLine,
480 lines: &mut Vec<MarkdownLine>,
481 style_stack: &[Style],
482 blockquote_depth: usize,
483 list_stack: &[ListState],
484 pending_list_prefix: &mut Option<String>,
485 theme_styles: &ThemeStyles,
486 base_style: Style,
487) {
488 let style = style_stack.last().copied().unwrap_or(base_style);
489
490 let mut start = 0usize;
491 let mut chars = text.char_indices().peekable();
492 while let Some((idx, ch)) = chars.next() {
493 if ch == '\n' {
494 let segment = &text[start..idx];
495 if !segment.is_empty() {
496 ensure_prefix(
497 current_line,
498 blockquote_depth,
499 list_stack,
500 pending_list_prefix,
501 theme_styles,
502 base_style,
503 );
504 current_line.push_segment(style, segment);
505 }
506 lines.push(std::mem::take(current_line));
507 start = idx + ch.len_utf8();
508 }
509 }
510
511 if start < text.len() {
512 let remaining = &text[start..];
513 ensure_prefix(
514 current_line,
515 blockquote_depth,
516 list_stack,
517 pending_list_prefix,
518 theme_styles,
519 base_style,
520 );
521 current_line.push_segment(style, remaining);
522 }
523}
524
525fn ensure_prefix(
526 current_line: &mut MarkdownLine,
527 blockquote_depth: usize,
528 list_stack: &[ListState],
529 pending_list_prefix: &mut Option<String>,
530 theme_styles: &ThemeStyles,
531 base_style: Style,
532) {
533 if !current_line.segments.is_empty() {
534 return;
535 }
536
537 for _ in 0..blockquote_depth {
538 current_line.push_segment(theme_styles.secondary.italic(), "│ ");
539 }
540
541 if let Some(prefix) = pending_list_prefix.take() {
542 current_line.push_segment(base_style, &prefix);
543 } else if !list_stack.is_empty() {
544 let mut continuation = String::new();
545 for state in list_stack {
546 continuation.push_str(&state.continuation);
547 }
548 if !continuation.is_empty() {
549 current_line.push_segment(base_style, &continuation);
550 }
551 }
552}
553
554fn flush_current_line(
555 lines: &mut Vec<MarkdownLine>,
556 current_line: &mut MarkdownLine,
557 blockquote_depth: usize,
558 list_stack: &[ListState],
559 pending_list_prefix: &mut Option<String>,
560 theme_styles: &ThemeStyles,
561 base_style: Style,
562) {
563 if current_line.segments.is_empty() {
564 if pending_list_prefix.is_some() {
565 ensure_prefix(
566 current_line,
567 blockquote_depth,
568 list_stack,
569 pending_list_prefix,
570 theme_styles,
571 base_style,
572 );
573 }
574 }
575
576 if !current_line.segments.is_empty() {
577 lines.push(std::mem::take(current_line));
578 }
579}
580
581fn push_blank_line(lines: &mut Vec<MarkdownLine>) {
582 if lines
583 .last()
584 .map(|line| line.segments.is_empty())
585 .unwrap_or(false)
586 {
587 return;
588 }
589 lines.push(MarkdownLine::default());
590}
591
592fn trim_trailing_blank_lines(lines: &mut Vec<MarkdownLine>) {
593 while lines
594 .last()
595 .map(|line| line.segments.is_empty())
596 .unwrap_or(false)
597 {
598 lines.pop();
599 }
600}
601
602fn inline_code_style(theme_styles: &ThemeStyles, base_style: Style) -> Style {
603 let fg = theme_styles
604 .secondary
605 .get_fg_color()
606 .or_else(|| base_style.get_fg_color());
607 let bg = Some(theme_styles.background.into());
608 let mut style = base_style;
609 if let Some(fg_color) = fg {
610 style = style.fg_color(Some(fg_color));
611 }
612 style.bg_color(bg).bold()
613}
614
615fn heading_style(level: HeadingLevel, theme_styles: &ThemeStyles, base_style: Style) -> Style {
616 match level {
617 HeadingLevel::H1 => theme_styles.primary.bold().underline(),
618 HeadingLevel::H2 => theme_styles.primary.bold(),
619 HeadingLevel::H3 => theme_styles.secondary.bold(),
620 _ => base_style.bold(),
621 }
622}
623
624fn build_prefix_segments(
625 blockquote_depth: usize,
626 list_stack: &[ListState],
627 theme_styles: &ThemeStyles,
628 base_style: Style,
629) -> Vec<PrefixSegment> {
630 let mut segments = Vec::new();
631 for _ in 0..blockquote_depth {
632 segments.push(PrefixSegment::new(theme_styles.secondary.italic(), "│ "));
633 }
634 if !list_stack.is_empty() {
635 let mut continuation = String::new();
636 for state in list_stack {
637 continuation.push_str(&state.continuation);
638 }
639 if !continuation.is_empty() {
640 segments.push(PrefixSegment::new(base_style, continuation));
641 }
642 }
643 segments
644}
645
646fn highlight_code_block(
647 code: &str,
648 language: Option<&str>,
649 highlight_config: Option<&SyntaxHighlightingConfig>,
650 theme_styles: &ThemeStyles,
651 base_style: Style,
652 prefix_segments: &[PrefixSegment],
653) -> Vec<MarkdownLine> {
654 let mut lines = Vec::new();
655 let mut augmented_prefix = prefix_segments.to_vec();
656 augmented_prefix.push(PrefixSegment::new(base_style, CODE_EXTRA_INDENT));
657
658 if let Some(config) = highlight_config.filter(|cfg| cfg.enabled) {
659 if let Some(highlighted) = try_highlight(code, language, config) {
660 for segments in highlighted {
661 let mut line = MarkdownLine::default();
662 line.prepend_segments(&augmented_prefix);
663 for (style, text) in segments {
664 line.push_segment(style, &text);
665 }
666 lines.push(line);
667 }
668 return lines;
669 }
670 }
671
672 for raw_line in LinesWithEndings::from(code) {
673 let trimmed = raw_line.trim_end_matches('\n');
674 let mut line = MarkdownLine::default();
675 line.prepend_segments(&augmented_prefix);
676 if !trimmed.is_empty() {
677 line.push_segment(code_block_style(theme_styles, base_style), trimmed);
678 }
679 lines.push(line);
680 }
681
682 if code.ends_with('\n') {
683 let mut line = MarkdownLine::default();
684 line.prepend_segments(&augmented_prefix);
685 lines.push(line);
686 }
687
688 lines
689}
690
691fn code_block_style(theme_styles: &ThemeStyles, base_style: Style) -> Style {
692 let fg = theme_styles
693 .output
694 .get_fg_color()
695 .or_else(|| base_style.get_fg_color());
696 let mut style = base_style;
697 if let Some(color) = fg {
698 style = style.fg_color(Some(color));
699 }
700 style
701}
702
703fn try_highlight(
704 code: &str,
705 language: Option<&str>,
706 config: &SyntaxHighlightingConfig,
707) -> Option<Vec<Vec<(Style, String)>>> {
708 let max_bytes = config.max_file_size_mb.saturating_mul(1024 * 1024);
709 if max_bytes > 0 && code.len() > max_bytes {
710 return None;
711 }
712
713 if let Some(lang) = language {
714 let enabled = config
715 .enabled_languages
716 .iter()
717 .any(|entry| entry.eq_ignore_ascii_case(lang));
718 if !enabled {
719 return None;
720 }
721 }
722
723 let syntax = select_syntax(language);
724 let theme = load_theme(&config.theme, config.cache_themes);
725 let mut highlighter = HighlightLines::new(syntax, &theme);
726 let mut rendered = Vec::new();
727
728 let mut ends_with_newline = false;
729 for line in LinesWithEndings::from(code) {
730 ends_with_newline = line.ends_with('\n');
731 let trimmed = line.trim_end_matches('\n');
732 let ranges = highlighter.highlight_line(trimmed, &SYNTAX_SET).ok()?;
733 let mut segments = Vec::new();
734 for (style, part) in ranges {
735 if part.is_empty() {
736 continue;
737 }
738 segments.push((to_anstyle(style), part.to_string()));
739 }
740 rendered.push(segments);
741 }
742
743 if ends_with_newline {
744 rendered.push(Vec::new());
745 }
746
747 Some(rendered)
748}
749
750fn select_syntax(language: Option<&str>) -> &'static SyntaxReference {
751 language
752 .and_then(|lang| SYNTAX_SET.find_syntax_by_token(lang))
753 .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text())
754}
755
756fn load_theme(theme_name: &str, cache: bool) -> Theme {
757 if let Some(theme) = THEME_CACHE.read().get(theme_name).cloned() {
758 return theme;
759 }
760
761 let defaults = ThemeSet::load_defaults();
762 if let Some(theme) = defaults.themes.get(theme_name).cloned() {
763 if cache {
764 let mut guard = THEME_CACHE.write();
765 if guard.len() >= MAX_THEME_CACHE_SIZE {
766 if let Some(first_key) = guard.keys().next().cloned() {
767 guard.remove(&first_key);
768 }
769 }
770 guard.insert(theme_name.to_string(), theme.clone());
771 }
772 theme
773 } else {
774 warn!(
775 "theme" = theme_name,
776 "Falling back to default syntax highlighting theme"
777 );
778 defaults
779 .themes
780 .into_iter()
781 .next()
782 .map(|(_, theme)| theme)
783 .unwrap_or_default()
784 }
785}