Skip to main content

tui/markdown/
source_lines.rs

1use super::headings::{MarkdownHeading, parse_markdown_headings};
2use super::renderer::render_markdown_result;
3use crate::line::Line;
4use crate::rendering::render_context::ViewContext;
5use crate::style::Style;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct SourceMarkdownLine {
9    pub source_line_no: usize,
10    pub line: Line,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct SourceMarkdownRenderResult {
15    pub lines: Vec<SourceMarkdownLine>,
16    pub headings: Vec<MarkdownHeading>,
17}
18
19/// Renders markdown while preserving a 1:1 mapping from rendered rows to source
20/// lines, which the block-oriented [`render_markdown_result`] cannot provide.
21pub fn render_markdown_source_lines(text: &str, ctx: &ViewContext) -> SourceMarkdownRenderResult {
22    let raw_lines = text.split('\n').collect::<Vec<_>>();
23    SourceMarkdownRenderResult { lines: render_source_lines(&raw_lines, ctx), headings: parse_markdown_headings(text) }
24}
25
26fn render_source_lines(raw_lines: &[&str], ctx: &ViewContext) -> Vec<SourceMarkdownLine> {
27    let mut rendered = Vec::with_capacity(raw_lines.len());
28    let mut index = 0usize;
29
30    while index < raw_lines.len() {
31        let raw = raw_lines[index];
32        if let Some(fence) = FenceDelimiter::parse(raw) {
33            rendered.push(SourceMarkdownLine {
34                source_line_no: index + 1,
35                line: render_raw_line(raw, Style::fg(ctx.theme.muted())),
36            });
37            index += 1;
38
39            let code_start = index;
40            while index < raw_lines.len() && FenceDelimiter::parse(raw_lines[index]).is_none() {
41                index += 1;
42            }
43            let code_end = index;
44            rendered.extend(render_fenced_code_block(code_start, &raw_lines[code_start..code_end], &fence.lang, ctx));
45
46            if index < raw_lines.len() {
47                rendered.push(SourceMarkdownLine {
48                    source_line_no: index + 1,
49                    line: render_raw_line(raw_lines[index], Style::fg(ctx.theme.muted())),
50                });
51                index += 1;
52            }
53        } else {
54            rendered
55                .push(SourceMarkdownLine { source_line_no: index + 1, line: render_single_markdown_line(raw, ctx) });
56            index += 1;
57        }
58    }
59
60    rendered
61}
62
63fn render_single_markdown_line(raw: &str, ctx: &ViewContext) -> Line {
64    if raw.trim().is_empty() {
65        return Line::default();
66    }
67
68    if is_table_source_line(raw) {
69        return Line::new(raw);
70    }
71
72    render_markdown_result(raw, ctx)
73        .lines
74        .into_iter()
75        .next()
76        .map(|line| line.line)
77        .filter(|line| !line.is_empty())
78        .unwrap_or_else(|| Line::new(raw))
79}
80
81fn render_raw_line(raw: &str, style: Style) -> Line {
82    Line::with_style(raw, style)
83}
84
85fn render_fenced_code_block(
86    source_start_index: usize,
87    raw_lines: &[&str],
88    lang: &str,
89    ctx: &ViewContext,
90) -> Vec<SourceMarkdownLine> {
91    if raw_lines.is_empty() {
92        return Vec::new();
93    }
94
95    let code_text = raw_lines.join("\n");
96    let highlighted = ctx.highlighter().highlight(&code_text, lang, &ctx.theme);
97    let lines = if highlighted.len() == raw_lines.len() {
98        highlighted
99    } else {
100        raw_lines.iter().map(|raw| render_raw_line(raw, Style::fg(ctx.theme.code_fg()))).collect()
101    };
102
103    lines
104        .into_iter()
105        .enumerate()
106        .map(|(offset, line)| SourceMarkdownLine { source_line_no: source_start_index + offset + 1, line })
107        .collect()
108}
109
110fn is_table_source_line(raw: &str) -> bool {
111    let trimmed = raw.trim();
112    trimmed.starts_with('|') && trimmed.ends_with('|') && trimmed.matches('|').count() >= 2
113}
114
115struct FenceDelimiter {
116    lang: String,
117}
118
119impl FenceDelimiter {
120    fn parse(raw: &str) -> Option<Self> {
121        let trimmed = raw.trim_start();
122        let marker = if trimmed.starts_with("```") {
123            "```"
124        } else if trimmed.starts_with("~~~") {
125            "~~~"
126        } else {
127            return None;
128        };
129
130        let rest = trimmed.trim_start_matches(marker).trim();
131        Some(Self { lang: rest.split_whitespace().next().unwrap_or("").to_string() })
132    }
133}