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}