specfold_core/markdown/
tables.rs1use anyhow::{Result, bail};
5
6pub fn cells(line: &str) -> Option<Vec<String>> {
8 let t = line.trim();
9 if !t.starts_with('|') {
10 return None;
11 }
12 let mut out = vec![];
13 let mut cur = String::new();
14 let mut chars = t[1..].chars().peekable();
15 while let Some(c) = chars.next() {
16 match c {
17 '\\' if chars.peek() == Some(&'|') => {
18 cur.push_str("\\|");
19 chars.next();
20 }
21 '|' => out.push(std::mem::take(&mut cur).trim().to_string()),
22 _ => cur.push(c),
23 }
24 }
25 if !cur.trim().is_empty() {
26 out.push(cur.trim().to_string());
27 }
28 Some(out)
29}
30
31fn is_separator(cells: &[String]) -> bool {
32 !cells.is_empty()
33 && cells.iter().all(|c| {
34 let c = c.trim_matches(':');
35 !c.is_empty() && c.chars().all(|ch| ch == '-')
36 })
37}
38
39fn render(cells: &[String]) -> String {
40 format!("| {} |", cells.join(" | "))
41}
42
43fn norm(s: &str) -> String {
44 s.trim().trim_matches('`').trim_matches('*').trim().to_lowercase()
45}
46
47#[derive(Debug)]
48struct Table {
49 header: Vec<String>,
50 rows: Vec<usize>,
52 end: usize,
54}
55
56fn tables(lines: &[&str], lo: usize, hi: usize) -> Vec<Table> {
57 let mut out = vec![];
58 let mut i = lo;
59 let mut fence = crate::markdown::Fence::default();
60 while i < hi {
61 let l = lines[i].trim_start();
62 if fence.step(l) {
63 i += 1;
64 continue;
65 }
66 if !fence.inside()
67 && let Some(header) = cells(lines[i])
68 && i + 1 < hi
69 && cells(lines[i + 1]).is_some_and(|s| is_separator(&s))
70 {
71 let mut j = i + 2;
72 let mut rows = vec![];
73 while j < hi && cells(lines[j]).is_some() {
74 rows.push(j);
75 j += 1;
76 }
77 out.push(Table { header, rows, end: j });
78 i = j;
79 continue;
80 }
81 i += 1;
82 }
83 out
84}
85
86#[derive(Debug, Clone, Default)]
88pub struct RowChange {
89 pub key: String,
91 pub set: Vec<(String, String)>,
93 pub row: Option<String>,
94 pub add: bool,
96}
97
98pub fn edit_row(lines: &[&str], lo: usize, hi: usize, ch: &RowChange) -> Result<(Vec<String>, String, String)> {
100 let found = tables(lines, lo, hi);
101 if found.is_empty() {
102 bail!("no table here; --row edits a Markdown pipe table");
103 }
104 let key = norm(&ch.key);
105 let hits: Vec<(&Table, usize)> = found
106 .iter()
107 .flat_map(|t| t.rows.iter().map(move |&r| (t, r)))
108 .filter(|(_, r)| cells(lines[*r]).and_then(|c| c.first().map(|f| norm(f) == key)).unwrap_or(false))
109 .collect();
110 let build = |t: &Table, old: Option<Vec<String>>| -> Result<Vec<String>> {
111 if let Some(text) = &ch.row {
112 let t2 = text.trim();
113 let line = if t2.starts_with('|') { t2.to_string() } else { format!("| {t2} |") };
114 let new = cells(&line).unwrap_or_default();
115 if new.len() != t.header.len() {
116 bail!(
117 "the row has {} cells; the table has {} columns ({})",
118 new.len(),
119 t.header.len(),
120 t.header.join(" | ")
121 );
122 }
123 return Ok(new);
124 }
125 let mut row = old.unwrap_or_else(|| {
126 let mut r = vec![String::new(); t.header.len()];
127 r[0] = ch.key.trim().to_string();
128 r
129 });
130 row.resize(t.header.len().max(row.len()), String::new());
131 for (col, val) in &ch.set {
132 let Some(i) = t.header.iter().position(|h| norm(h) == norm(col)) else {
133 bail!("no column '{col}' (columns: {})", t.header.join(" | "));
134 };
135 row[i] = val.replace('|', "\\|");
136 }
137 Ok(row)
138 };
139 let mut out: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
140 match hits.as_slice() {
141 [(t, r)] => {
142 let new = render(&build(t, cells(lines[*r]))?);
143 let before = lines[*r].to_string();
144 out[*r] = new.clone();
145 Ok((out, before, new))
146 }
147 [] if ch.add => {
148 if found.len() > 1 {
149 bail!("{} tables here; name the section with --section", found.len());
150 }
151 let t = &found[0];
152 let new = render(&build(t, None)?);
153 out.insert(t.end, new.clone());
154 Ok((out, String::new(), new))
155 }
156 [] => {
157 let keys: Vec<String> =
158 found.iter().flat_map(|t| &t.rows).filter_map(|&r| cells(lines[r])?.first().cloned()).collect();
159 bail!("no row '{}' (rows: {}); --append adds it", ch.key, keys.join(" | "))
160 }
161 many => {
162 let mut tables: Vec<usize> = many.iter().map(|(t, _)| t.end).collect();
163 tables.dedup();
164 Err(DuplicateRows {
165 key: ch.key.clone(),
166 rows: many.iter().map(|(_, r)| *r).collect(),
167 tables: tables.len(),
168 }
169 .into())
170 }
171 }
172}
173
174#[derive(Debug)]
176pub struct DuplicateRows {
177 pub key: String,
178 pub rows: Vec<usize>,
179 pub tables: usize,
180}
181
182impl std::fmt::Display for DuplicateRows {
183 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184 write!(f, "'{}' names {} rows in {} table(s)", self.key, self.rows.len(), self.tables)
185 }
186}
187
188impl std::error::Error for DuplicateRows {}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 const T: &str =
195 "intro\n\n| Id | Status | Note |\n|---|:-:|---|\n| `A1` | next | a \\| b |\n| A2 | waiting | |\n\nafter\n";
196
197 fn run(ch: RowChange) -> Result<String> {
198 let lines: Vec<&str> = T.lines().collect();
199 edit_row(&lines, 0, lines.len(), &ch).map(|(l, _, _)| l.join("\n"))
200 }
201
202 #[test]
203 fn sets_a_cell_by_key_and_column() {
204 let out =
205 run(RowChange { key: "a1".into(), set: vec![("status".into(), "done".into())], ..Default::default() })
206 .unwrap();
207 assert!(out.contains("| `A1` | done | a \\| b |"), "{out}");
208 }
209
210 #[test]
211 fn replaces_or_adds_whole_rows() {
212 let out =
213 run(RowChange { key: "A2".into(), row: Some("A2 | done | ok".into()), ..Default::default() }).unwrap();
214 assert!(out.contains("| A2 | done | ok |\n\nafter"), "{out}");
215 let out = run(RowChange {
216 key: "A3".into(),
217 set: vec![("Status".into(), "next".into())],
218 add: true,
219 ..Default::default()
220 })
221 .unwrap();
222 assert!(out.contains("| A2 | waiting | |\n| A3 | next | |\n\nafter"), "{out}");
223 assert!(run(RowChange { key: "A3".into(), row: Some("A3 | x".into()), ..Default::default() }).is_err());
224 assert!(
225 run(RowChange { key: "A1".into(), set: vec![("Owner".into(), "x".into())], ..Default::default() }).is_err()
226 );
227 }
228}