Skip to main content

lattice_mode/modes/table/
model.rs

1//! A pipe table as `table-mode` sees it: where it starts and ends,
2//! which cell the caret is in, and how to render it back (TB.1).
3//!
4//! [`layout`](super::layout) is the unattended half of this — it reformats
5//! every table in a document at content-build time, for `lattice-help`. This
6//! is the *interactive* half, and the two differ in three ways that all come
7//! from the same fact: **the user pointed at this table**.
8//!
9//! 1. **A separator row is not required.** `layout`'s recogniser demands one,
10//!    and is right to: it walks whole documents unattended, and prose like
11//!    ``use `a | b` `` must not become a one-column table. Nothing here runs
12//!    unattended — you put the caret on the line and pressed a key — so
13//!    demanding a separator would refuse to align exactly the org tables that
14//!    do not have one, which is most of them.
15//!
16//! 2. **The separator style is preserved, not chosen.** Org writes
17//!    `|---+---|` and markdown writes `|---|---|`; both are tables, and an
18//!    align that rewrote one into the other would edit a file's dialect
19//!    because you asked it to line up some columns.
20//!
21//!    That is why there is no `table.dialect` option and no seam for a major
22//!    to declare one: **the table says which dialect it is.** An option would
23//!    be a second source for a fact already in the buffer, and the two can
24//!    disagree — a `+`-joined table in a markdown file would be rewritten by
25//!    a correct-looking option. Reading the file cannot be wrong about the
26//!    file.
27//!
28//! 3. **The caret has to land somewhere.** Alignment rewrites every row, so
29//!    the byte offset the caret sat at is meaningless afterwards; the mode
30//!    tracks the *cell* and re-derives an offset in the rendered line.
31//!
32//! Width is measured by `unicode-width`, through [`layout`]'s own helpers —
33//! shared rather than re-derived, because a second measurement that disagreed
34//! would align tables one way in help pages and another way under the caret.
35
36use super::layout;
37
38/// One line of a table, parsed.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum Row {
41    /// `| a | b |` — the trimmed cell texts.
42    Cells(Vec<String>),
43    /// A rule between sections, and **how it was written**: the character
44    /// that joins its columns (`|` in markdown, `+` in org) and its
45    /// per-column alignment markers. Both are kept so re-rendering reproduces
46    /// the dialect it found rather than imposing one.
47    Separator {
48        /// The column-joining character as written (`|` or `+`).
49        join: char,
50        /// One alignment marker per column, as written.
51        aligns: Vec<layout::Align>,
52    },
53}
54
55/// A table lifted out of a buffer: its line span, its rows, and the
56/// indentation its first line carried.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Table {
59    /// First line of the table, inclusive.
60    pub first: u32,
61    /// Last line of the table, inclusive.
62    pub last: u32,
63    /// Every row, in buffer order — cells and separators alike.
64    pub rows: Vec<Row>,
65    /// Leading whitespace of the first row, reproduced on every rendered
66    /// line. An indented table stays where the author put it — org tables
67    /// under a headline routinely are.
68    pub indent: String,
69}
70
71/// True when `line` is part of a table: a `|` after optional indent.
72pub fn is_table_line(line: &str) -> bool {
73    line.trim_start().starts_with('|')
74}
75
76/// True when `line` is a rule row, and which character joins its columns.
77///
78/// Pipes, dashes, colons, plus signs and spaces — nothing else, and at least
79/// one dash. `+` is org's join; `:` are markdown's alignment markers. A row
80/// containing both is nonsense nobody writes, and is read as org's.
81fn separator_join(line: &str) -> Option<char> {
82    let t = line.trim();
83    if !t.starts_with('|') || t.len() < 2 || !t.contains('-') {
84        return None;
85    }
86    if !t.chars().all(|c| matches!(c, '|' | '-' | ':' | '+' | ' ')) {
87        return None;
88    }
89    Some(if t.contains('+') { '+' } else { '|' })
90}
91
92/// Is `at` inside a fenced code block?
93///
94/// A `| a | b |` line inside ```` ``` ```` or `#+BEGIN_SRC` is block *content*,
95/// and [`is_table_line`] cannot tell — it sees a pipe. Aligning it would
96/// reformat someone's ASCII art or, worse, their code.
97///
98/// The org plugin found this at OT.7 and answered it with the tree: an org
99/// `table` node does not span a src block. That answer is not available here,
100/// and deliberately so — `lattice-mode` has no tree-sitter dependency, and
101/// acquiring one to ask a question about `#+BEGIN_SRC` would be a large edge
102/// for a small fact. Counting delimiters from the top of the file is the same
103/// answer in `O(lines)` of `starts_with`, which is microseconds on a file far
104/// larger than anyone's notes and happens once per chord, not per frame.
105///
106/// Both spellings, because this mode serves both majors: markdown fences with
107/// backticks or tildes, org with `#+BEGIN_…`/`#+END_…` (any block type — a
108/// table inside `#+BEGIN_EXAMPLE` is as much not-a-table as one inside
109/// `#+BEGIN_SRC`).
110fn inside_a_code_block(line: &impl Fn(u32) -> Option<String>, at: u32) -> bool {
111    let mut open = false;
112    for n in 0..at {
113        let Some(text) = line(n) else { continue };
114        let t = text.trim_start();
115        if t.starts_with("```") || t.starts_with("~~~") {
116            open = !open;
117        } else {
118            let lower = t.to_ascii_lowercase();
119            if lower.starts_with("#+begin_") {
120                open = true;
121            } else if lower.starts_with("#+end_") {
122                open = false;
123            }
124        }
125    }
126    open
127}
128
129/// Parse one line into a row.
130pub fn parse_row(line: &str) -> Option<Row> {
131    if !is_table_line(line) {
132        return None;
133    }
134    if let Some(join) = separator_join(line) {
135        return Some(Row::Separator {
136            join,
137            aligns: layout::alignments(line),
138        });
139    }
140    Some(Row::Cells(layout::cells(line)))
141}
142
143impl Table {
144    /// The table containing `at`, or `None` when that line is not one.
145    ///
146    /// `line` answers for any row index; `line_count` bounds the walk. Taking
147    /// a closure rather than a `&Buffer` keeps this crate off `lattice-core`'s
148    /// rope and makes the whole model testable on a `Vec<&str>`.
149    pub fn at(line: impl Fn(u32) -> Option<String>, at: u32, line_count: u32) -> Option<Table> {
150        let here = line(at)?;
151        if !is_table_line(&here) {
152            return None;
153        }
154        if inside_a_code_block(&line, at) {
155            return None;
156        }
157        let mut first = at;
158        while first > 0 && line(first - 1).is_some_and(|t| is_table_line(&t)) {
159            first -= 1;
160        }
161        let mut last = at;
162        while last + 1 < line_count && line(last + 1).is_some_and(|t| is_table_line(&t)) {
163            last += 1;
164        }
165        let head = line(first)?;
166        let indent = head[..head.len() - head.trim_start().len()].to_string();
167        let rows: Vec<Row> = (first..=last)
168            .filter_map(|n| line(n).as_deref().and_then(parse_row))
169            .collect();
170        Some(Table {
171            first,
172            last,
173            rows,
174            indent,
175        })
176    }
177
178    /// How many columns the widest row has. A ragged table is normal
179    /// mid-edit, and the widest row is what every operation sizes against.
180    pub fn columns(&self) -> usize {
181        self.rows
182            .iter()
183            .filter_map(|r| match r {
184                Row::Cells(c) => Some(c.len()),
185                Row::Separator { .. } => None,
186            })
187            .max()
188            .unwrap_or(0)
189    }
190
191    /// The row index (into [`Self::rows`]) for buffer line `line`.
192    pub fn row_index(&self, line: u32) -> Option<usize> {
193        (line >= self.first && line <= self.last).then(|| (line - self.first) as usize)
194    }
195
196    /// Render every row, preserving the separator style the table was found
197    /// with and the indentation of its first line.
198    ///
199    /// Column widths come from the widest visible cell, floored at 1: a
200    /// column of empty cells still has to be wide enough to put the caret in,
201    /// and a zero-width column renders `||` with nowhere to type.
202    pub fn render(&self) -> Vec<String> {
203        let columns = self.columns();
204        if columns == 0 {
205            return self
206                .rows
207                .iter()
208                .map(|_| format!("{}|", self.indent))
209                .collect();
210        }
211        let mut widths = vec![1usize; columns];
212        for row in &self.rows {
213            if let Row::Cells(cells) = row {
214                for (c, cell) in cells.iter().enumerate() {
215                    widths[c] = widths[c].max(layout::visible_width(cell));
216                }
217            }
218        }
219        // Alignment markers come from the separator the table already has.
220        // A table with no separator has no markers to honour, and inventing
221        // one would add a row the user did not ask for.
222        let aligns = self.alignments();
223        self.rows
224            .iter()
225            .map(|row| {
226                let body = match row {
227                    Row::Separator { join, .. } => layout::render_separator(
228                        &widths,
229                        |c| aligns.get(c).copied().unwrap_or(layout::Align::Left),
230                        *join,
231                    ),
232                    Row::Cells(cells) => layout::render_row(cells, &widths, |c| {
233                        aligns.get(c).copied().unwrap_or(layout::Align::Left)
234                    }),
235                };
236                format!("{}{body}", self.indent)
237            })
238            .collect()
239    }
240
241    /// Per-column alignment, read off the first separator row. All-left when
242    /// there is none — a table with no rule has no markers to honour, and
243    /// inventing a rule to carry some would add a row nobody asked for.
244    fn alignments(&self) -> Vec<layout::Align> {
245        self.rows
246            .iter()
247            .find_map(|r| match r {
248                Row::Separator { aligns, .. } => Some(aligns.clone()),
249                Row::Cells(_) => None,
250            })
251            .unwrap_or_default()
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    #![allow(clippy::unwrap_used, clippy::panic)]
258    use super::*;
259
260    /// A buffer as a slice of lines, which is all the model needs.
261    fn table_at(src: &str, at: u32) -> Option<Table> {
262        let lines: Vec<String> = src.lines().map(str::to_string).collect();
263        let n = lines.len() as u32;
264        Table::at(|i| lines.get(i as usize).cloned(), at, n)
265    }
266
267    fn rendered(src: &str, at: u32) -> String {
268        table_at(src, at)
269            .expect("a table at that line")
270            .render()
271            .join("\n")
272    }
273
274    #[test]
275    fn a_line_outside_a_table_is_not_one() {
276        assert!(table_at("prose\n| a |\n", 0).is_none());
277    }
278
279    /// The interactive recogniser does NOT require a separator row. Org
280    /// tables routinely have none, and refusing to align them would refuse
281    /// the common case.
282    #[test]
283    fn a_table_with_no_separator_is_still_a_table() {
284        let t = table_at("| a | b |\n| c | d |\n", 1).expect("both lines are one table");
285        assert_eq!((t.first, t.last), (0, 1));
286        assert_eq!(t.columns(), 2);
287    }
288
289    /// …which is exactly where this differs from `layout::format_tables`,
290    /// whose stricter rule protects prose it walks unattended.
291    #[test]
292    fn the_unattended_pass_still_requires_one() {
293        let src = vec!["| a | b |".to_string(), "| c | d |".to_string()];
294        assert_eq!(
295            layout::format_tables(src.clone()),
296            src,
297            "format_tables must leave a separator-less block alone — it runs \
298             over whole documents with nobody pointing at anything"
299        );
300    }
301
302    /// The bounds are the contiguous run, and stop at the first non-table
303    /// line in each direction.
304    #[test]
305    fn bounds_cover_the_contiguous_run_only() {
306        let t = table_at("intro\n| a |\n| b |\nafter\n| c |\n", 2).unwrap();
307        assert_eq!((t.first, t.last), (1, 2));
308    }
309
310    /// Org's `+`-joined rule survives a render. Rewriting it to `|` would
311    /// change the file's dialect because the user asked to line up columns.
312    #[test]
313    fn an_org_rule_stays_org() {
314        let out = rendered("| Name | Qty |\n|--+--|\n| bread | 1 |\n", 0);
315        let rule = out.lines().nth(1).unwrap();
316        assert!(rule.contains('+'), "org's join is preserved: {out}");
317        assert!(
318            rule.starts_with('|') && rule.ends_with('|'),
319            "…and the edges are still pipes: {rule}"
320        );
321    }
322
323    /// And markdown's stays markdown — the same assertion in the other
324    /// direction, because a dialect-preserving renderer that only ever
325    /// preserved one of them would pass the test above.
326    #[test]
327    fn a_markdown_rule_stays_markdown() {
328        let out = rendered("| Name | Qty |\n|---|---|\n| bread | 1 |\n", 0);
329        let rule = out.lines().nth(1).unwrap();
330        assert!(!rule.contains('+'), "markdown joins with pipes: {out}");
331    }
332
333    /// Alignment markers are markdown's, and they have to survive a
334    /// realign — losing them silently re-lefts every right-aligned column.
335    #[test]
336    fn alignment_markers_survive_and_are_honoured() {
337        let out = rendered("| a | b |\n|:--|--:|\n| x | y |\n", 0);
338        let rule = out.lines().nth(1).unwrap();
339        assert!(rule.contains(":-") && rule.contains("-:"), "{out}");
340        let body = out.lines().nth(2).unwrap();
341        assert!(
342            body.trim_end().ends_with("y |"),
343            "the right-aligned column is padded on its left: {body:?}"
344        );
345    }
346
347    /// Columns line up by DISPLAY width. `é` is one column and `世` is two;
348    /// counting `char`s calls them both one and the table renders ragged.
349    /// This is the measurement the org plugin's own copy admitted it got
350    /// wrong, and the reason the engine lives here.
351    #[test]
352    fn columns_are_measured_by_display_width() {
353        let out = rendered("| 世界 | b |\n| xy | c |\n", 0);
354        let widths: Vec<usize> = out
355            .lines()
356            .map(|l| unicode_width::UnicodeWidthStr::width(l))
357            .collect();
358        assert!(
359            widths.windows(2).all(|w| w[0] == w[1]),
360            "every row occupies the same columns:\n{out}"
361        );
362    }
363
364    /// A ragged row is the normal mid-edit state — the moment alignment is
365    /// most wanted — so it is padded out, never refused or truncated.
366    #[test]
367    fn a_short_row_is_padded_rather_than_truncating_the_table() {
368        let out = rendered("| a | b | c |\n| x |\n", 0);
369        assert_eq!(out.lines().count(), 2);
370        assert_eq!(
371            out.lines().next().unwrap().matches('|').count(),
372            out.lines().nth(1).unwrap().matches('|').count(),
373            "the short row gains the missing columns:\n{out}"
374        );
375    }
376
377    /// An indented table stays indented. Org tables under a headline
378    /// routinely are, and re-rendering them at column 0 would move the
379    /// table as a side effect of aligning it.
380    #[test]
381    fn indentation_is_preserved() {
382        let out = rendered("  | a | b |\n  | c | d |\n", 0);
383        assert!(
384            out.lines().all(|l| l.starts_with("  |")),
385            "every row keeps the first row's indent:\n{out}"
386        );
387    }
388
389    /// A column of empty cells still has to be wide enough to put the caret
390    /// in — `||` renders with nowhere to type.
391    #[test]
392    fn an_empty_column_is_still_visible() {
393        let out = rendered("| a |  | b |\n", 0);
394        assert!(!out.contains("||"), "no zero-width column: {out:?}");
395    }
396
397    /// OT.7's lesson, carried over: a pipe line inside a source block is
398    /// block CONTENT. Aligning it would reformat someone's code, and it is
399    /// also what leaves `<Tab>` free to fall through to org's headline cycle
400    /// in exactly the place a table command makes no sense.
401    #[test]
402    fn a_pipe_line_inside_an_org_src_block_is_not_a_table() {
403        let src = "#+BEGIN_SRC rust\n| not | a | table |\n#+END_SRC\n";
404        assert!(table_at(src, 1).is_none());
405    }
406
407    #[test]
408    fn a_pipe_line_inside_a_markdown_fence_is_not_a_table() {
409        let src = "```text\n| not | a | table |\n```\n";
410        assert!(table_at(src, 1).is_none());
411    }
412
413    /// …and a real table AFTER a closed block still is one. A one-way
414    /// "saw a fence, give up" check would kill every table below the first
415    /// code block in the file, which in a notes file is most of them.
416    #[test]
417    fn a_table_after_a_closed_block_is_still_a_table() {
418        let src = "#+BEGIN_SRC rust\nlet x = 1;\n#+END_SRC\n\n| a | b |\n";
419        assert!(table_at(src, 4).is_some());
420        let src = "```rust\nlet x = 1;\n```\n\n| a | b |\n";
421        assert!(table_at(src, 4).is_some());
422    }
423}