Skip to main content

specfold_core/markdown/
tables.rs

1//! Markdown pipe tables: find a row by the text of its first cell and change it, so an agent can
2//! update one line of a status table without rewriting the section.
3
4use anyhow::{Result, bail};
5
6/// The cells of a table line, or None when the line is not a table row.
7pub 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    /// Line indexes of the data rows.
51    rows: Vec<usize>,
52    /// Line index after the table.
53    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 = false;
60    while i < hi {
61        let l = lines[i].trim_start();
62        if l.starts_with("```") || l.starts_with("~~~") {
63            fence = !fence;
64            i += 1;
65            continue;
66        }
67        if !fence
68            && let Some(header) = cells(lines[i])
69            && i + 1 < hi
70            && cells(lines[i + 1]).is_some_and(|s| is_separator(&s))
71        {
72            let mut j = i + 2;
73            let mut rows = vec![];
74            while j < hi && cells(lines[j]).is_some() {
75                rows.push(j);
76                j += 1;
77            }
78            out.push(Table { header, rows, end: j });
79            i = j;
80            continue;
81        }
82        i += 1;
83    }
84    out
85}
86
87/// How to change the row.
88#[derive(Debug, Clone, Default)]
89pub struct RowChange {
90    /// The first-cell text that names the row.
91    pub key: String,
92    /// Column header = new value; empty means `row` holds the whole new row.
93    pub set: Vec<(String, String)>,
94    pub row: Option<String>,
95    /// Add the row at the end of the table when no row has the key.
96    pub add: bool,
97}
98
99/// The edited lines plus the row before and after (empty before when added).
100pub fn edit_row(lines: &[&str], lo: usize, hi: usize, ch: &RowChange) -> Result<(Vec<String>, String, String)> {
101    let found = tables(lines, lo, hi);
102    if found.is_empty() {
103        bail!("no table here; --row edits a Markdown pipe table");
104    }
105    let key = norm(&ch.key);
106    let hits: Vec<(&Table, usize)> = found
107        .iter()
108        .flat_map(|t| t.rows.iter().map(move |&r| (t, r)))
109        .filter(|(_, r)| cells(lines[*r]).and_then(|c| c.first().map(|f| norm(f) == key)).unwrap_or(false))
110        .collect();
111    let build = |t: &Table, old: Option<Vec<String>>| -> Result<Vec<String>> {
112        if let Some(text) = &ch.row {
113            let t2 = text.trim();
114            let line = if t2.starts_with('|') { t2.to_string() } else { format!("| {t2} |") };
115            let new = cells(&line).unwrap_or_default();
116            if new.len() != t.header.len() {
117                bail!(
118                    "the row has {} cells; the table has {} columns ({})",
119                    new.len(),
120                    t.header.len(),
121                    t.header.join(" | ")
122                );
123            }
124            return Ok(new);
125        }
126        let mut row = old.unwrap_or_else(|| {
127            let mut r = vec![String::new(); t.header.len()];
128            r[0] = ch.key.trim().to_string();
129            r
130        });
131        row.resize(t.header.len().max(row.len()), String::new());
132        for (col, val) in &ch.set {
133            let Some(i) = t.header.iter().position(|h| norm(h) == norm(col)) else {
134                bail!("no column '{col}' (columns: {})", t.header.join(" | "));
135            };
136            row[i] = val.replace('|', "\\|");
137        }
138        Ok(row)
139    };
140    let mut out: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
141    match hits.as_slice() {
142        [(t, r)] => {
143            let new = render(&build(t, cells(lines[*r]))?);
144            let before = lines[*r].to_string();
145            out[*r] = new.clone();
146            Ok((out, before, new))
147        }
148        [] if ch.add => {
149            if found.len() > 1 {
150                bail!("{} tables here; name the section with --section", found.len());
151            }
152            let t = &found[0];
153            let new = render(&build(t, None)?);
154            out.insert(t.end, new.clone());
155            Ok((out, String::new(), new))
156        }
157        [] => {
158            let keys: Vec<String> =
159                found.iter().flat_map(|t| &t.rows).filter_map(|&r| cells(lines[r])?.first().cloned()).collect();
160            bail!("no row '{}' (rows: {}); --append adds it", ch.key, keys.join(" | "))
161        }
162        many => bail!("'{}' names {} rows; name the section with --section", ch.key, many.len()),
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    const T: &str =
171        "intro\n\n| Id | Status | Note |\n|---|:-:|---|\n| `A1` | next | a \\| b |\n| A2 | waiting | |\n\nafter\n";
172
173    fn run(ch: RowChange) -> Result<String> {
174        let lines: Vec<&str> = T.lines().collect();
175        edit_row(&lines, 0, lines.len(), &ch).map(|(l, _, _)| l.join("\n"))
176    }
177
178    #[test]
179    fn sets_a_cell_by_key_and_column() {
180        let out =
181            run(RowChange { key: "a1".into(), set: vec![("status".into(), "done".into())], ..Default::default() })
182                .unwrap();
183        assert!(out.contains("| `A1` | done | a \\| b |"), "{out}");
184    }
185
186    #[test]
187    fn replaces_or_adds_whole_rows() {
188        let out =
189            run(RowChange { key: "A2".into(), row: Some("A2 | done | ok".into()), ..Default::default() }).unwrap();
190        assert!(out.contains("| A2 | done | ok |\n\nafter"), "{out}");
191        let out = run(RowChange {
192            key: "A3".into(),
193            set: vec![("Status".into(), "next".into())],
194            add: true,
195            ..Default::default()
196        })
197        .unwrap();
198        assert!(out.contains("| A2 | waiting | |\n| A3 | next |  |\n\nafter"), "{out}");
199        assert!(run(RowChange { key: "A3".into(), row: Some("A3 | x".into()), ..Default::default() }).is_err());
200        assert!(
201            run(RowChange { key: "A1".into(), set: vec![("Owner".into(), "x".into())], ..Default::default() }).is_err()
202        );
203    }
204}