1use crate::config::loader::SyntaxHighlightingConfig;
4use crate::ui::theme::{self, 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
331pub fn render_markdown(source: &str) -> Vec<MarkdownLine> {
335 let styles = theme::active_styles();
336 render_markdown_to_lines(source, Style::default(), &styles, None)
337}
338
339fn handle_start_tag(
340 tag: Tag,
341 style_stack: &mut Vec<Style>,
342 blockquote_depth: &mut usize,
343 list_stack: &mut Vec<ListState>,
344 pending_list_prefix: &mut Option<String>,
345 theme_styles: &ThemeStyles,
346 base_style: Style,
347 code_block: &mut Option<CodeBlockState>,
348) {
349 match tag {
350 Tag::Paragraph => {}
351 Tag::Heading(level, ..) => {
352 style_stack.push(heading_style(level, theme_styles, base_style));
353 }
354 Tag::BlockQuote => {
355 *blockquote_depth += 1;
356 }
357 Tag::List(start) => {
358 let depth = list_stack.len();
359 let kind = start
360 .map(|value| ListKind::Ordered {
361 next: max(1, value as usize),
362 })
363 .unwrap_or(ListKind::Unordered);
364 list_stack.push(ListState {
365 kind,
366 depth,
367 continuation: String::new(),
368 });
369 }
370 Tag::Item => {
371 if let Some(state) = list_stack.last_mut() {
372 let indent = " ".repeat(state.depth * LIST_INDENT_WIDTH);
373 match &mut state.kind {
374 ListKind::Unordered => {
375 let bullet = format!("{}- ", indent);
376 state.continuation = format!("{} ", indent);
377 *pending_list_prefix = Some(bullet);
378 }
379 ListKind::Ordered { next } => {
380 let bullet = format!("{}{}. ", indent, *next);
381 let width = bullet.len().saturating_sub(indent.len());
382 state.continuation = format!("{}{}", indent, " ".repeat(width));
383 *pending_list_prefix = Some(bullet);
384 *next += 1;
385 }
386 }
387 }
388 }
389 Tag::Emphasis => {
390 let style = style_stack.last().copied().unwrap_or(base_style).italic();
391 style_stack.push(style);
392 }
393 Tag::Strong => {
394 let style = style_stack.last().copied().unwrap_or(base_style).bold();
395 style_stack.push(style);
396 }
397 Tag::Strikethrough => {
398 let style = style_stack
399 .last()
400 .copied()
401 .unwrap_or(base_style)
402 .strikethrough();
403 style_stack.push(style);
404 }
405 Tag::Link { .. } | Tag::Image { .. } => {
406 let style = style_stack
407 .last()
408 .copied()
409 .unwrap_or(base_style)
410 .underline();
411 style_stack.push(style);
412 }
413 Tag::CodeBlock(kind) => {
414 let language = match kind {
415 CodeBlockKind::Fenced(info) => info
416 .split_whitespace()
417 .next()
418 .filter(|lang| !lang.is_empty())
419 .map(|lang| lang.to_string()),
420 CodeBlockKind::Indented => None,
421 };
422 *code_block = Some(CodeBlockState {
423 language,
424 buffer: String::new(),
425 });
426 }
427 _ => {}
428 }
429}
430
431fn handle_end_tag(
432 tag: Tag,
433 style_stack: &mut Vec<Style>,
434 blockquote_depth: &mut usize,
435 list_stack: &mut Vec<ListState>,
436 pending_list_prefix: &mut Option<String>,
437 lines: &mut Vec<MarkdownLine>,
438 current_line: &mut MarkdownLine,
439) {
440 match tag {
441 Tag::Paragraph => {
442 if !current_line.segments.is_empty() {
443 lines.push(std::mem::take(current_line));
444 }
445 push_blank_line(lines);
446 }
447 Tag::Heading(..) => {
448 if !current_line.segments.is_empty() {
449 lines.push(std::mem::take(current_line));
450 }
451 push_blank_line(lines);
452 style_stack.pop();
453 }
454 Tag::BlockQuote => {
455 if *blockquote_depth > 0 {
456 *blockquote_depth -= 1;
457 }
458 }
459 Tag::List(_) => {
460 list_stack.pop();
461 *pending_list_prefix = None;
462 if !current_line.segments.is_empty() {
463 lines.push(std::mem::take(current_line));
464 }
465 push_blank_line(lines);
466 }
467 Tag::Item => {
468 if !current_line.segments.is_empty() {
469 lines.push(std::mem::take(current_line));
470 }
471 *pending_list_prefix = None;
472 }
473 Tag::Emphasis | Tag::Strong | Tag::Strikethrough | Tag::Link { .. } | Tag::Image { .. } => {
474 style_stack.pop();
475 }
476 Tag::CodeBlock(_) => {}
477 Tag::Table(_)
478 | Tag::TableHead
479 | Tag::TableRow
480 | Tag::TableCell
481 | Tag::FootnoteDefinition(_) => {}
482 }
483}
484
485fn append_text(
486 text: &str,
487 current_line: &mut MarkdownLine,
488 lines: &mut Vec<MarkdownLine>,
489 style_stack: &[Style],
490 blockquote_depth: usize,
491 list_stack: &[ListState],
492 pending_list_prefix: &mut Option<String>,
493 theme_styles: &ThemeStyles,
494 base_style: Style,
495) {
496 let style = style_stack.last().copied().unwrap_or(base_style);
497
498 let mut start = 0usize;
499 let mut chars = text.char_indices().peekable();
500 while let Some((idx, ch)) = chars.next() {
501 if ch == '\n' {
502 let segment = &text[start..idx];
503 if !segment.is_empty() {
504 ensure_prefix(
505 current_line,
506 blockquote_depth,
507 list_stack,
508 pending_list_prefix,
509 theme_styles,
510 base_style,
511 );
512 current_line.push_segment(style, segment);
513 }
514 lines.push(std::mem::take(current_line));
515 start = idx + ch.len_utf8();
516 }
517 }
518
519 if start < text.len() {
520 let remaining = &text[start..];
521 ensure_prefix(
522 current_line,
523 blockquote_depth,
524 list_stack,
525 pending_list_prefix,
526 theme_styles,
527 base_style,
528 );
529 current_line.push_segment(style, remaining);
530 }
531}
532
533fn ensure_prefix(
534 current_line: &mut MarkdownLine,
535 blockquote_depth: usize,
536 list_stack: &[ListState],
537 pending_list_prefix: &mut Option<String>,
538 theme_styles: &ThemeStyles,
539 base_style: Style,
540) {
541 if !current_line.segments.is_empty() {
542 return;
543 }
544
545 for _ in 0..blockquote_depth {
546 current_line.push_segment(theme_styles.secondary.italic(), "│ ");
547 }
548
549 if let Some(prefix) = pending_list_prefix.take() {
550 current_line.push_segment(base_style, &prefix);
551 } else if !list_stack.is_empty() {
552 let mut continuation = String::new();
553 for state in list_stack {
554 continuation.push_str(&state.continuation);
555 }
556 if !continuation.is_empty() {
557 current_line.push_segment(base_style, &continuation);
558 }
559 }
560}
561
562fn flush_current_line(
563 lines: &mut Vec<MarkdownLine>,
564 current_line: &mut MarkdownLine,
565 blockquote_depth: usize,
566 list_stack: &[ListState],
567 pending_list_prefix: &mut Option<String>,
568 theme_styles: &ThemeStyles,
569 base_style: Style,
570) {
571 if current_line.segments.is_empty() {
572 if pending_list_prefix.is_some() {
573 ensure_prefix(
574 current_line,
575 blockquote_depth,
576 list_stack,
577 pending_list_prefix,
578 theme_styles,
579 base_style,
580 );
581 }
582 }
583
584 if !current_line.segments.is_empty() {
585 lines.push(std::mem::take(current_line));
586 }
587}
588
589fn push_blank_line(lines: &mut Vec<MarkdownLine>) {
590 if lines
591 .last()
592 .map(|line| line.segments.is_empty())
593 .unwrap_or(false)
594 {
595 return;
596 }
597 lines.push(MarkdownLine::default());
598}
599
600fn trim_trailing_blank_lines(lines: &mut Vec<MarkdownLine>) {
601 while lines
602 .last()
603 .map(|line| line.segments.is_empty())
604 .unwrap_or(false)
605 {
606 lines.pop();
607 }
608}
609
610fn inline_code_style(theme_styles: &ThemeStyles, base_style: Style) -> Style {
611 let fg = theme_styles
612 .secondary
613 .get_fg_color()
614 .or_else(|| base_style.get_fg_color());
615 let bg = Some(theme_styles.background.into());
616 let mut style = base_style;
617 if let Some(fg_color) = fg {
618 style = style.fg_color(Some(fg_color));
619 }
620 style.bg_color(bg).bold()
621}
622
623fn heading_style(level: HeadingLevel, theme_styles: &ThemeStyles, base_style: Style) -> Style {
624 match level {
625 HeadingLevel::H1 => theme_styles.primary.bold().underline(),
626 HeadingLevel::H2 => theme_styles.primary.bold(),
627 HeadingLevel::H3 => theme_styles.secondary.bold(),
628 _ => base_style.bold(),
629 }
630}
631
632fn build_prefix_segments(
633 blockquote_depth: usize,
634 list_stack: &[ListState],
635 theme_styles: &ThemeStyles,
636 base_style: Style,
637) -> Vec<PrefixSegment> {
638 let mut segments = Vec::new();
639 for _ in 0..blockquote_depth {
640 segments.push(PrefixSegment::new(theme_styles.secondary.italic(), "│ "));
641 }
642 if !list_stack.is_empty() {
643 let mut continuation = String::new();
644 for state in list_stack {
645 continuation.push_str(&state.continuation);
646 }
647 if !continuation.is_empty() {
648 segments.push(PrefixSegment::new(base_style, continuation));
649 }
650 }
651 segments
652}
653
654fn highlight_code_block(
655 code: &str,
656 language: Option<&str>,
657 highlight_config: Option<&SyntaxHighlightingConfig>,
658 theme_styles: &ThemeStyles,
659 base_style: Style,
660 prefix_segments: &[PrefixSegment],
661) -> Vec<MarkdownLine> {
662 let mut lines = Vec::new();
663 let mut augmented_prefix = prefix_segments.to_vec();
664 augmented_prefix.push(PrefixSegment::new(base_style, CODE_EXTRA_INDENT));
665
666 if let Some(config) = highlight_config.filter(|cfg| cfg.enabled) {
667 if let Some(highlighted) = try_highlight(code, language, config) {
668 for segments in highlighted {
669 let mut line = MarkdownLine::default();
670 line.prepend_segments(&augmented_prefix);
671 for (style, text) in segments {
672 line.push_segment(style, &text);
673 }
674 lines.push(line);
675 }
676 return lines;
677 }
678 }
679
680 for raw_line in LinesWithEndings::from(code) {
681 let trimmed = raw_line.trim_end_matches('\n');
682 let mut line = MarkdownLine::default();
683 line.prepend_segments(&augmented_prefix);
684 if !trimmed.is_empty() {
685 line.push_segment(code_block_style(theme_styles, base_style), trimmed);
686 }
687 lines.push(line);
688 }
689
690 if code.ends_with('\n') {
691 let mut line = MarkdownLine::default();
692 line.prepend_segments(&augmented_prefix);
693 lines.push(line);
694 }
695
696 lines
697}
698
699fn code_block_style(theme_styles: &ThemeStyles, base_style: Style) -> Style {
700 let fg = theme_styles
701 .output
702 .get_fg_color()
703 .or_else(|| base_style.get_fg_color());
704 let mut style = base_style;
705 if let Some(color) = fg {
706 style = style.fg_color(Some(color));
707 }
708 style
709}
710
711fn try_highlight(
712 code: &str,
713 language: Option<&str>,
714 config: &SyntaxHighlightingConfig,
715) -> Option<Vec<Vec<(Style, String)>>> {
716 let max_bytes = config.max_file_size_mb.saturating_mul(1024 * 1024);
717 if max_bytes > 0 && code.len() > max_bytes {
718 return None;
719 }
720
721 if let Some(lang) = language {
722 let enabled = config
723 .enabled_languages
724 .iter()
725 .any(|entry| entry.eq_ignore_ascii_case(lang));
726 if !enabled {
727 return None;
728 }
729 }
730
731 let syntax = select_syntax(language);
732 let theme = load_theme(&config.theme, config.cache_themes);
733 let mut highlighter = HighlightLines::new(syntax, &theme);
734 let mut rendered = Vec::new();
735
736 let mut ends_with_newline = false;
737 for line in LinesWithEndings::from(code) {
738 ends_with_newline = line.ends_with('\n');
739 let trimmed = line.trim_end_matches('\n');
740 let ranges = highlighter.highlight_line(trimmed, &SYNTAX_SET).ok()?;
741 let mut segments = Vec::new();
742 for (style, part) in ranges {
743 if part.is_empty() {
744 continue;
745 }
746 segments.push((to_anstyle(style), part.to_string()));
747 }
748 rendered.push(segments);
749 }
750
751 if ends_with_newline {
752 rendered.push(Vec::new());
753 }
754
755 Some(rendered)
756}
757
758fn select_syntax(language: Option<&str>) -> &'static SyntaxReference {
759 language
760 .and_then(|lang| SYNTAX_SET.find_syntax_by_token(lang))
761 .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text())
762}
763
764fn load_theme(theme_name: &str, cache: bool) -> Theme {
765 if let Some(theme) = THEME_CACHE.read().get(theme_name).cloned() {
766 return theme;
767 }
768
769 let defaults = ThemeSet::load_defaults();
770 if let Some(theme) = defaults.themes.get(theme_name).cloned() {
771 if cache {
772 let mut guard = THEME_CACHE.write();
773 if guard.len() >= MAX_THEME_CACHE_SIZE {
774 if let Some(first_key) = guard.keys().next().cloned() {
775 guard.remove(&first_key);
776 }
777 }
778 guard.insert(theme_name.to_string(), theme.clone());
779 }
780 theme
781 } else {
782 warn!(
783 "theme" = theme_name,
784 "Falling back to default syntax highlighting theme"
785 );
786 defaults
787 .themes
788 .into_iter()
789 .next()
790 .map(|(_, theme)| theme)
791 .unwrap_or_default()
792 }
793}