1#[derive(Clone, Copy)]
9pub enum TableAlign {
10 Left,
11 Center,
12 Right,
13}
14
15pub enum TableSegment {
17 Text(String),
18 Table(Vec<Vec<String>>, Vec<TableAlign>),
19}
20
21pub 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
64fn looks_like_row(line: &str) -> bool {
66 let t = line.trim();
67 !t.is_empty() && t.contains('|')
68}
69
70fn 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
98fn 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
107fn 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
129pub 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; }
151 if in_fence {
152 out.push(line.to_string()); 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
172fn 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
188fn 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
198fn 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
210fn 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 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 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 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 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 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
272fn 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
282fn 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
292fn 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 ");
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}