Skip to main content

nexus_core/
markdown.rs

1//! Plain-text markdown helpers for the core crate: `to_plain` (markers
2//! stripped, for clipboard copy) and the GFM pipe-table splitter shared
3//! with the TUI's styled renderer (`crates/tui/src/ui/markdown.rs`). This
4//! module is deliberately free of ratatui/tui-markdown — styled rendering
5//! lives in the TUI crate.
6
7/// One table cell's horizontal alignment, parsed from the GFM delimiter row.
8#[derive(Clone, Copy)]
9pub enum TableAlign {
10    Left,
11    Center,
12    Right,
13}
14
15/// A chunk of `content`: plain text, or a parsed GFM pipe table.
16pub enum TableSegment {
17    Text(String),
18    Table(Vec<Vec<String>>, Vec<TableAlign>),
19}
20
21/// Split `content` into text and table segments. A table starts at a header
22/// row immediately followed by a valid delimiter row (`| --- | :---: |`), and
23/// continues while subsequent lines still look like table rows. Shared by
24/// `to_plain` here and the TUI's styled `render` — both skip anything inside
25/// a fenced code block.
26pub fn split_tables(content: &str) -> Vec<TableSegment> {
27    let lines: Vec<&str> = content.lines().collect();
28    let mut segments = Vec::new();
29    let mut buf: Vec<&str> = Vec::new();
30    let mut in_fence = false;
31    let mut i = 0;
32    while i < lines.len() {
33        let line = lines[i];
34        if line.trim_start().starts_with("```") {
35            in_fence = !in_fence;
36            buf.push(line);
37            i += 1;
38            continue;
39        }
40        if !in_fence && is_delimiter_row(lines.get(i + 1)) && looks_like_row(line) {
41            if !buf.is_empty() {
42                segments.push(TableSegment::Text(std::mem::take(&mut buf).join("\n")));
43            }
44            let aligns = parse_aligns(lines[i + 1]);
45            let mut rows = vec![split_cells(line)];
46            let mut j = i + 2;
47            while j < lines.len() && looks_like_row(lines[j]) {
48                rows.push(split_cells(lines[j]));
49                j += 1;
50            }
51            segments.push(TableSegment::Table(rows, aligns));
52            i = j;
53            continue;
54        }
55        buf.push(line);
56        i += 1;
57    }
58    if !buf.is_empty() {
59        segments.push(TableSegment::Text(buf.join("\n")));
60    }
61    segments
62}
63
64/// A plausible table row: non-empty and contains at least one `|`.
65fn looks_like_row(line: &str) -> bool {
66    let t = line.trim();
67    !t.is_empty() && t.contains('|')
68}
69
70/// The GFM delimiter row: cells of only `-`/`:`, at least one `-` each.
71fn is_delimiter_row(line: Option<&&str>) -> bool {
72    let Some(line) = line else { return false };
73    if !looks_like_row(line) {
74        return false;
75    }
76    let cells = split_cells(line);
77    !cells.is_empty()
78        && cells.iter().all(|c| {
79            let c = c.trim_matches(':');
80            !c.is_empty() && c.chars().all(|ch| ch == '-')
81        })
82}
83
84fn parse_aligns(delim_line: &str) -> Vec<TableAlign> {
85    split_cells(delim_line)
86        .iter()
87        .map(|c| {
88            let c = c.trim();
89            match (c.starts_with(':'), c.ends_with(':')) {
90                (true, true) => TableAlign::Center,
91                (false, true) => TableAlign::Right,
92                _ => TableAlign::Left,
93            }
94        })
95        .collect()
96}
97
98/// `| a | b |` -> `["a", "b"]`. Doesn't handle escaped `\|` — out of scope for
99/// chat-message tables.
100fn split_cells(line: &str) -> Vec<String> {
101    let t = line.trim();
102    let t = t.strip_prefix('|').unwrap_or(t);
103    let t = t.strip_suffix('|').unwrap_or(t);
104    t.split('|').map(|c| c.trim().to_string()).collect()
105}
106
107/// Reconstruct a clean, standard GFM table from parsed cells — nicer to paste
108/// elsewhere than whatever mangled text `tui_markdown` would have produced.
109fn plain_table(rows: &[Vec<String>], aligns: &[TableAlign]) -> String {
110    let Some(header) = rows.first() else {
111        return String::new();
112    };
113    let mut out = vec![format!("| {} |", header.join(" | "))];
114    let delim: Vec<&str> = aligns
115        .iter()
116        .map(|a| match a {
117            TableAlign::Left => "---",
118            TableAlign::Right => "---:",
119            TableAlign::Center => ":---:",
120        })
121        .collect();
122    out.push(format!("| {} |", delim.join(" | ")));
123    for row in &rows[1..] {
124        out.push(format!("| {} |", row.join(" | ")));
125    }
126    out.join("\n")
127}
128
129/// Plain text of `content` with markdown markers stripped (for clipboard
130/// copy). Tables are reconstructed as clean GFM markdown rather than mangled.
131pub fn to_plain(content: &str) -> String {
132    split_tables(content)
133        .into_iter()
134        .map(|seg| match seg {
135            TableSegment::Table(rows, aligns) => plain_table(&rows, &aligns),
136            TableSegment::Text(text) => plain_text_segment(&text),
137        })
138        .collect::<Vec<_>>()
139        .join("\n")
140}
141
142fn plain_text_segment(content: &str) -> String {
143    let mut out: Vec<String> = Vec::new();
144    let mut in_fence = false;
145    for line in content.lines() {
146        let trimmed = line.trim_start();
147        if trimmed.starts_with("```") {
148            in_fence = !in_fence;
149            continue; // fence lines themselves are dropped
150        }
151        if in_fence {
152            out.push(line.to_string()); // code content verbatim
153            continue;
154        }
155        let plain = strip_inline(line);
156        match classify_line(&plain) {
157            Block::Drop => {}
158            Block::Header(rest) | Block::List(rest) => out.push(rest),
159            Block::Plain => out.push(plain),
160        }
161    }
162    out.join("\n")
163}
164
165enum Block {
166    Drop,
167    Header(String),
168    List(String),
169    Plain,
170}
171
172/// Decide how a plain markdown line should be treated for copying: block
173/// markers (`#`, `-`) are stripped, fence lines dropped, everything else kept.
174fn classify_line(plain: &str) -> Block {
175    let trimmed = plain.trim_start();
176    if trimmed.starts_with("```") {
177        return Block::Drop;
178    }
179    if let Some(rest) = header_rest(trimmed) {
180        return Block::Header(rest);
181    }
182    if let Some(rest) = list_rest(plain) {
183        return Block::List(rest);
184    }
185    Block::Plain
186}
187
188/// `## Heading` -> `Heading` (1–6 `#` then a space).
189fn header_rest(trimmed: &str) -> Option<String> {
190    let hashes = trimmed.chars().take_while(|&c| c == '#').count();
191    if (1..=6).contains(&hashes) && trimmed[hashes..].starts_with(' ') {
192        Some(trimmed[hashes..].trim_start().to_string())
193    } else {
194        None
195    }
196}
197
198/// `- item` / `* item` / `+ item` -> `• item`, preserving indentation.
199fn list_rest(plain: &str) -> Option<String> {
200    let indent_len = plain.len() - plain.trim_start().len();
201    let (indent, s) = plain.split_at(indent_len);
202    for marker in ["- ", "* ", "+ "] {
203        if let Some(rest) = s.strip_prefix(marker) {
204            return Some(format!("{indent}• {rest}"));
205        }
206    }
207    None
208}
209
210/// Strip inline markdown markers from one line, keeping the plain text:
211/// `**bold**`/`*italic*`/`_em_`/`__strong__`/`` `code` ``/`[text](url)`/
212/// `![alt](url)`/`~~strike~~`. Unmatched markers are left as literal text
213/// (same as `tui_markdown`'s conservative behavior).
214fn strip_inline(text: &str) -> String {
215    let chars: Vec<char> = text.chars().collect();
216    let mut out = String::with_capacity(text.len());
217    let mut i = 0;
218    while i < chars.len() {
219        let c = chars[i];
220        // `![alt](url)` → `alt`.
221        if c == '!'
222            && chars.get(i + 1) == Some(&'[')
223            && let Some((alt, rest)) = take_link(&chars, i + 1)
224        {
225            out.push_str(&alt);
226            i = rest;
227            continue;
228        }
229        // `[text](url)` → `text`.
230        if c == '['
231            && let Some((link_text, rest)) = take_link(&chars, i)
232        {
233            out.push_str(&link_text);
234            i = rest;
235            continue;
236        }
237        // `` `code` `` → `code` (a run of backticks closes a run of the same
238        // length, per CommonMark).
239        if c == '`' {
240            let run = chars[i..].iter().take_while(|&&x| x == '`').count();
241            if let Some(close) = find_run(&chars, i + run, run, '`') {
242                let code: String = chars[i + run..close].iter().collect();
243                out.push_str(code.trim());
244                i = close + run;
245                continue;
246            }
247        }
248        // `~~strike~~`.
249        if c == '~'
250            && chars.get(i + 1) == Some(&'~')
251            && let Some(close) = find_run(&chars, i + 2, 2, '~')
252        {
253            out.push_str(&chars[i + 2..close].iter().collect::<String>());
254            i = close + 2;
255            continue;
256        }
257        // `**bold**`, `*italic*`, `__strong__`, `_em_`.
258        if (c == '*' || c == '_')
259            && let Some(run) = emphasis_run(&chars, i)
260            && let Some(close) = find_run(&chars, i + run, run, c)
261        {
262            out.push_str(&chars[i + run..close].iter().collect::<String>());
263            i = close + run;
264            continue;
265        }
266        out.push(c);
267        i += 1;
268    }
269    out
270}
271
272/// `[text](url)` starting at `open` (the `[`): returns `(text, index after ')')`.
273fn take_link(chars: &[char], open: usize) -> Option<(String, usize)> {
274    let close = chars[open..].iter().position(|&c| c == ']')? + open;
275    if chars.get(close + 1) != Some(&'(') {
276        return None;
277    }
278    let end = chars[close + 2..].iter().position(|&c| c == ')')? + close + 2;
279    Some((chars[open + 1..close].iter().collect(), end + 1))
280}
281
282/// Index of a run of `run` copies of `marker` starting at or after `from`.
283fn find_run(chars: &[char], from: usize, run: usize, marker: char) -> Option<usize> {
284    if run == 0 {
285        return None;
286    }
287    let start = chars[from..].iter().position(|&x| x == marker)? + from;
288    let len = chars[start..].iter().take_while(|&&x| x == marker).count();
289    (len >= run).then_some(start)
290}
291
292/// Length of an emphasis marker run at `i` (`*`/`_`), capped at 2 — a run of
293/// 3+ is left alone ( treats a triple run as strong+em).
294fn emphasis_run(chars: &[char], i: usize) -> Option<usize> {
295    let c = chars[i];
296    let run = chars[i..].iter().take_while(|&&x| x == c).count();
297    (run == 1 || run == 2).then_some(run)
298}
299
300#[cfg(test)]
301mod to_plain_tests {
302    use super::*;
303
304    const TABLE: &str = "| Name | Age |\n| --- | ---: |\n| Alice | 30 |\n| Bob | 7 |";
305
306    #[test]
307    fn to_plain_reconstructs_clean_markdown_table() {
308        let plain = to_plain(TABLE);
309        assert_eq!(
310            plain,
311            "| Name | Age |\n| --- | ---: |\n| Alice | 30 |\n| Bob | 7 |"
312        );
313    }
314
315    #[test]
316    fn detects_table_and_leaves_surrounding_text_alone() {
317        let content = format!("before\n\n{TABLE}\n\nafter");
318        let segs = split_tables(&content);
319        assert_eq!(segs.len(), 3);
320        assert!(matches!(&segs[0], TableSegment::Text(t) if t.trim() == "before"));
321        match &segs[1] {
322            TableSegment::Table(rows, aligns) => {
323                assert_eq!(
324                    rows,
325                    &[
326                        vec!["Name".to_string(), "Age".to_string()],
327                        vec!["Alice".to_string(), "30".to_string()],
328                        vec!["Bob".to_string(), "7".to_string()],
329                    ]
330                );
331                assert!(matches!(aligns[0], TableAlign::Left));
332                assert!(matches!(aligns[1], TableAlign::Right));
333            }
334            TableSegment::Text(_) => panic!("expected a table segment"),
335        }
336        assert!(matches!(&segs[2], TableSegment::Text(t) if t.trim() == "after"));
337    }
338
339    #[test]
340    fn pipes_inside_a_fenced_code_block_are_not_a_table() {
341        let content = "```\n| not | a | table |\n| --- | --- |\n```";
342        let segs = split_tables(content);
343        assert_eq!(segs.len(), 1);
344        assert!(matches!(&segs[0], TableSegment::Text(_)));
345    }
346
347    #[test]
348    fn strips_headers_lists_and_inline_markers() {
349        let plain = to_plain("# Big\n\n- one\n- two\n\nrun `cargo test` and **see** *it* work");
350        assert_eq!(
351            plain,
352            "Big\n\n• one\n• two\n\nrun cargo test and see it work"
353        );
354    }
355
356    #[test]
357    fn links_become_their_text_and_images_their_alt() {
358        let plain = to_plain("see [the docs](https://example.com) or ![diagram](x.png)");
359        assert_eq!(plain, "see the docs or diagram");
360    }
361
362    #[test]
363    fn unmatched_markers_stay_literal() {
364        assert_eq!(to_plain("a * lone star"), "a * lone star");
365        assert_eq!(to_plain("2 * 3 = 6"), "2 * 3 = 6");
366    }
367
368    #[test]
369    fn fenced_code_keeps_content_verbatim_and_drops_fences() {
370        let md = "```rust\nfn main() { println!(\"hi\"); }\n```\nafter";
371        assert_eq!(to_plain(md), "fn main() { println!(\"hi\"); }\nafter");
372    }
373}