Skip to main content

lattice_mode/modes/table/
layout.rs

1//! Lay out markdown pipe tables so their columns line up (HP.1).
2//!
3//! Help pages are markdown, and nothing between the `.md` file and the
4//! help buffer used to touch tables — so a reader saw the raw source:
5//! `|---|---|` rows rendered literally, and cells whose widths had
6//! nothing to do with each other.
7//!
8//! **Why display width and not `char` count.** The docs that *were*
9//! hand-padded were padded by counting characters, which is a different
10//! number from the columns a terminal advances. `✓`, `─`, `↑`, `▸` and
11//! every CJK glyph break that assumption, so a table looked aligned in
12//! the source file and ragged on screen — the specific complaint this
13//! module answers. [`unicode_width`] measures what the terminal will
14//! actually do.
15//!
16//! **Why here and not in a renderer.** Two reasons, and the second is
17//! the load-bearing one:
18//!
19//! 1. A renderer-side pass is two implementations (TUI and GPUI) of one
20//!    piece of text layout.
21//! 2. The buffer's text would then differ from what is on screen, so
22//!    `/` search, `w` motions, visual selection and yank would all
23//!    operate on columns the reader cannot see. Formatting the text
24//!    keeps "what you see" and "what the buffer holds" the same string.
25//!
26//! **Ordering constraint.** This runs BEFORE
27//! `lattice_help::extract_links_and_clean`, never after. Link ranges are
28//! recorded against the cleaned text, so inserting padding afterwards
29//! would slide every link on a padded row and `<CR>` would follow the
30//! wrong one. Running first means extraction sees the final bytes and
31//! the ranges come out right with no offset bookkeeping — which is why
32//! `visible_width` strips link markup for *measurement only*: the
33//! column has to be as wide as the `label` the reader sees, not as wide
34//! as `[label](help:some-page)`.
35
36use unicode_width::UnicodeWidthStr;
37
38/// Column alignment, from the separator row's `:` markers.
39///
40/// Public from TB.1: [`super::model`] renders the same rows under the caret
41/// that this module renders at content-build time, and it carries them in a
42/// `pub` row type — a second copy of the alignment vocabulary is the
43/// duplication the whole `table/` directory exists to avoid.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Align {
46    /// No marker — the default, and what an unmarked `---` column means.
47    Left,
48    /// `:---` — left, said out loud.
49    ///
50    /// Distinct from [`Align::Left`] only so the marker ROUND-TRIPS. Both
51    /// render the same text; collapsing them would mean an interactive
52    /// realign silently deletes a `:` the author typed, which shows up in a
53    /// git diff of their notes as a change they did not make. TB.1 found
54    /// this; the unattended help pass gets the same fidelity for free.
55    LeftMarked,
56    /// `---:` — cells padded on the left.
57    Right,
58    /// `:---:` — padding split around the cell (extra space on the right).
59    Center,
60}
61
62/// Reformat every pipe table in `lines`, leaving everything else byte-
63/// identical.
64///
65/// Fenced code blocks are skipped wholesale. Help pages draw menu
66/// mock-ups inside ``` fences, and a fence containing `|` is ASCII art
67/// whose alignment the author already chose — re-laying it out would
68/// corrupt a picture to satisfy a rule about tables.
69pub fn format_tables(lines: Vec<String>) -> Vec<String> {
70    let mut out: Vec<String> = Vec::with_capacity(lines.len());
71    let mut i = 0;
72    let mut in_fence = false;
73    while i < lines.len() {
74        if is_fence_delimiter(&lines[i]) {
75            in_fence = !in_fence;
76            out.push(lines[i].clone());
77            i += 1;
78            continue;
79        }
80        if !in_fence && let Some(end) = table_extent(&lines, i) {
81            out.extend(layout(&lines[i..end]));
82            i = end;
83            continue;
84        }
85        out.push(lines[i].clone());
86        i += 1;
87    }
88    out
89}
90
91fn is_fence_delimiter(line: &str) -> bool {
92    let t = line.trim_start();
93    t.starts_with("```") || t.starts_with("~~~")
94}
95
96/// Is `lines[start..]` a table, and where does it end?
97///
98/// A table is a header row, a separator row, and zero or more body
99/// rows. The separator is what makes it a table rather than a
100/// paragraph that happens to contain `|` — requiring it is what keeps
101/// prose like "use `a | b`" from being mangled into a one-column
102/// table.
103fn table_extent(lines: &[String], start: usize) -> Option<usize> {
104    if !is_row(lines.get(start)?) || !is_separator(lines.get(start + 1)?) {
105        return None;
106    }
107    let mut end = start + 2;
108    while end < lines.len() && is_row(&lines[end]) && !is_fence_delimiter(&lines[end]) {
109        end += 1;
110    }
111    Some(end)
112}
113
114fn is_row(line: &str) -> bool {
115    line.trim_start().starts_with('|')
116}
117
118/// A separator row is pipes, dashes, colons and spaces — nothing else.
119fn is_separator(line: &str) -> bool {
120    let t = line.trim();
121    t.starts_with('|')
122        && t.len() > 1
123        && t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' '))
124        && t.contains('-')
125}
126
127/// Split a row into its cells.
128///
129/// `\|` is an escaped pipe and stays inside the cell it belongs to —
130/// several help tables document alternatives that way (`` `:reg\|:registers` ``),
131/// and splitting on it would invent a column.
132pub(super) fn cells(line: &str) -> Vec<String> {
133    let t = line.trim();
134    let inner = t
135        .strip_prefix('|')
136        .unwrap_or(t)
137        .strip_suffix('|')
138        .unwrap_or_else(|| t.strip_prefix('|').unwrap_or(t));
139    let mut out = Vec::new();
140    let mut cur = String::new();
141    let mut escaped = false;
142    for ch in inner.chars() {
143        if escaped {
144            cur.push(ch);
145            escaped = false;
146            continue;
147        }
148        match ch {
149            '\\' => {
150                cur.push(ch);
151                escaped = true;
152            }
153            '|' => out.push(std::mem::take(&mut cur)),
154            _ => cur.push(ch),
155        }
156    }
157    out.push(cur);
158    out.into_iter().map(|c| c.trim().to_string()).collect()
159}
160
161/// Columns the reader will see this cell occupy.
162///
163/// `[label](url)` measures as `label`, because that is all the link
164/// stripper leaves behind. Getting this wrong in the other direction —
165/// measuring the markup — would pad every column holding a cross-link
166/// out by the length of a URL nobody sees.
167pub(super) fn visible_width(cell: &str) -> usize {
168    UnicodeWidthStr::width(strip_link_markup(cell).as_str())
169}
170
171fn strip_link_markup(cell: &str) -> String {
172    let bytes = cell.as_bytes();
173    let mut out = String::with_capacity(cell.len());
174    let mut i = 0;
175    while i < bytes.len() {
176        if bytes[i] == b'['
177            && let Some(close) = bytes[i + 1..].iter().position(|&b| b == b']')
178        {
179            let label_end = i + 1 + close;
180            if bytes.get(label_end + 1) == Some(&b'(')
181                && let Some(paren) = bytes[label_end + 2..].iter().position(|&b| b == b')')
182            {
183                out.push_str(&cell[i + 1..label_end]);
184                i = label_end + 2 + paren + 1;
185                continue;
186            }
187        }
188        let ch_len = cell[i..].chars().next().map_or(1, char::len_utf8);
189        out.push_str(&cell[i..i + ch_len]);
190        i += ch_len;
191    }
192    out
193}
194
195pub(super) fn alignments(separator: &str) -> Vec<Align> {
196    cells(separator)
197        .into_iter()
198        .map(|c| {
199            let c = c.trim();
200            match (c.starts_with(':'), c.ends_with(':')) {
201                (true, true) => Align::Center,
202                (false, true) => Align::Right,
203                (true, false) => Align::LeftMarked,
204                (false, false) => Align::Left,
205            }
206        })
207        .collect()
208}
209
210fn layout(table: &[String]) -> Vec<String> {
211    let aligns = alignments(&table[1]);
212    let rows: Vec<Vec<String>> = table
213        .iter()
214        .enumerate()
215        .filter(|(i, _)| *i != 1)
216        .map(|(_, l)| cells(l))
217        .collect();
218
219    // Ragged rows are common in hand-written markdown. Take the widest
220    // row's column count so a row with a missing trailing cell is padded
221    // out rather than truncating the table.
222    let columns = rows.iter().map(Vec::len).max().unwrap_or(0);
223    if columns == 0 {
224        return table.to_vec();
225    }
226    let mut widths = vec![0usize; columns];
227    for row in &rows {
228        for (c, cell) in row.iter().enumerate() {
229            widths[c] = widths[c].max(visible_width(cell));
230        }
231    }
232    // A column of empty cells still needs to be visible as a column.
233    for w in &mut widths {
234        *w = (*w).max(1);
235    }
236
237    let align_of = |c: usize| aligns.get(c).copied().unwrap_or(Align::Left);
238    let mut out = Vec::with_capacity(table.len());
239    let mut rows = rows.into_iter();
240
241    if let Some(header) = rows.next() {
242        out.push(render_row(&header, &widths, align_of));
243    }
244    out.push(render_separator(&widths, align_of, '|'));
245    for row in rows {
246        out.push(render_row(&row, &widths, align_of));
247    }
248    out
249}
250
251pub(super) fn render_row(
252    row: &[String],
253    widths: &[usize],
254    align_of: impl Fn(usize) -> Align,
255) -> String {
256    let mut s = String::from("|");
257    for (c, width) in widths.iter().enumerate() {
258        let cell = row.get(c).map(String::as_str).unwrap_or("");
259        let pad = width.saturating_sub(visible_width(cell));
260        s.push(' ');
261        match align_of(c) {
262            Align::Left | Align::LeftMarked => {
263                s.push_str(cell);
264                s.extend(std::iter::repeat_n(' ', pad));
265            }
266            Align::Right => {
267                s.extend(std::iter::repeat_n(' ', pad));
268                s.push_str(cell);
269            }
270            Align::Center => {
271                let left = pad / 2;
272                s.extend(std::iter::repeat_n(' ', left));
273                s.push_str(cell);
274                s.extend(std::iter::repeat_n(' ', pad - left));
275            }
276        }
277        s.push_str(" |");
278    }
279    s
280}
281
282/// A rule row, its columns joined by `join`.
283///
284/// `join` is `|` for markdown and `+` for org (TB.1). The parameter exists
285/// so [`super::model`] can reproduce the dialect it found instead of carrying
286/// a second copy of this function that differs by one character — which is
287/// how the two halves of table support drift apart.
288pub(super) fn render_separator(
289    widths: &[usize],
290    align_of: impl Fn(usize) -> Align,
291    join: char,
292) -> String {
293    let mut s = String::from("|");
294    for (c, width) in widths.iter().enumerate() {
295        // `width + 2` covers the space either side of the cell, so the
296        // rule spans the whole column rather than stopping short of it.
297        let span = width + 2;
298        match align_of(c) {
299            Align::Left => s.extend(std::iter::repeat_n('-', span)),
300            Align::LeftMarked => {
301                s.push(':');
302                s.extend(std::iter::repeat_n('-', span - 1));
303            }
304            Align::Right => {
305                s.extend(std::iter::repeat_n('-', span - 1));
306                s.push(':');
307            }
308            Align::Center => {
309                s.push(':');
310                s.extend(std::iter::repeat_n('-', span - 2));
311                s.push(':');
312            }
313        }
314        s.push(join);
315    }
316    // The loop wrote a trailing join; a table's right edge is always a pipe,
317    // even in org where the interior joins are `+`.
318    s.pop();
319    s.push('|');
320    s
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    fn fmt(src: &str) -> String {
328        format_tables(src.lines().map(str::to_string).collect()).join("\n")
329    }
330
331    /// The base case: ragged source in, aligned columns out.
332    #[test]
333    fn columns_line_up() {
334        let out = fmt("| Chord | Action |\n|---|---|\n| `gr` | Refresh |\n| `]]` | Next section |");
335        let widths: Vec<usize> = out.lines().map(str::len).collect();
336        assert!(
337            widths.windows(2).all(|w| w[0] == w[1]),
338            "every row is the same width:\n{out}"
339        );
340        assert!(out.contains("| `gr`  | Refresh      |"), "{out}");
341    }
342
343    /// **The bug this module exists for.**
344    ///
345    /// `✓` is one `char` and one column; `─` is one `char` and one
346    /// column; but a CJK glyph is one `char` and TWO columns. Padding by
347    /// `char` count leaves the wide row one column short, which is
348    /// exactly how a hand-aligned table drifts. Asserted by display
349    /// width rather than `len()` because the rows genuinely differ in
350    /// bytes.
351    #[test]
352    fn a_wide_glyph_costs_two_columns_not_one() {
353        let out = fmt("| Key | Note |\n|---|---|\n| `a` | plain |\n| 日本 | wide |");
354        let cols: Vec<usize> = out.lines().map(UnicodeWidthStr::width).collect();
355        assert!(
356            cols.windows(2).all(|w| w[0] == w[1]),
357            "rows must be the same DISPLAY width, got {cols:?}:\n{out}"
358        );
359        // And the naive measure would have disagreed — proving the test
360        // is not passing by accident on a table where both happen to
361        // match.
362        let chars: Vec<usize> = out.lines().map(|l| l.chars().count()).collect();
363        assert!(
364            chars.windows(2).any(|w| w[0] != w[1]),
365            "this fixture must be one where char-count and display-width \
366             DISAGREE, else it cannot detect the bug: {chars:?}"
367        );
368    }
369
370    /// A column is padded to the width of the link's LABEL, not its
371    /// markup — the markup is gone by the time anyone sees the buffer.
372    #[test]
373    fn a_link_measures_as_its_label() {
374        let out = fmt("| A | B |\n|---|---|\n| [gr](help:magit-core-mode) | x |\n| ab | y |");
375        let body: Vec<&str> = out.lines().collect();
376        assert!(
377            body[2].starts_with("| [gr](help:magit-core-mode) |"),
378            "the link's markup is untouched: {:?}",
379            body[2]
380        );
381        assert!(
382            body[3].starts_with("| ab |"),
383            "`ab` is 2 columns like `gr`, so it needs NO padding — if the \
384             URL had been measured this row would be padded out to match \
385             it: {:?}",
386            body[3]
387        );
388    }
389
390    /// Alignment markers survive the round trip AND move the text.
391    ///
392    /// Every column is deliberately wider than its narrow cell, because
393    /// with equal widths there is no padding to place and left, right
394    /// and centre all render identically — a fixture that cannot tell
395    /// the three apart proves nothing about any of them.
396    #[test]
397    fn colons_still_mean_right_and_centre() {
398        let out = fmt("| Left | Centre | Right |\n|:--|:-:|--:|\n| a | b | c |");
399        let sep = out.lines().nth(1).unwrap();
400        // TB.1 changed this line's expectation, deliberately. It used to
401        // assert `|---` — the explicit left marker was DROPPED, since left is
402        // the default and the rendering is identical either way. That was
403        // fine for a pass over generated help pages and wrong the moment the
404        // same engine realigns the user's own file: a `:` they typed vanishing
405        // from a git diff is a change they did not make. The marker now
406        // round-trips; the alignment it means is unchanged.
407        assert!(
408            sep.starts_with("|:---"),
409            "an explicit left marker survives: {sep}"
410        );
411        assert!(sep.contains(":------:"), "centre keeps both colons: {sep}");
412        assert!(sep.ends_with(":|"), "right keeps its trailing colon: {sep}");
413
414        let row = out.lines().nth(2).unwrap();
415        assert!(
416            row.starts_with("| a    |"),
417            "left-aligned hugs the left: {row}"
418        );
419        assert!(
420            row.contains("|   b    |"),
421            "centred is padded both sides: {row}"
422        );
423        assert!(
424            row.ends_with("|     c |"),
425            "right-aligned hugs the right: {row}"
426        );
427    }
428
429    /// ASCII art inside a fence is a picture, not a table.
430    #[test]
431    fn a_fenced_block_is_left_alone() {
432        let src = "```text\n| not | a |\n|--|--|\n| table | here |\n```";
433        assert_eq!(fmt(src), src, "fenced content must be byte-identical");
434    }
435
436    /// Prose containing a pipe is not a one-column table. Without the
437    /// separator-row requirement, every such line would be reformatted.
438    #[test]
439    fn prose_with_a_pipe_is_not_a_table() {
440        let src = "Use `a | b` to alternate.\nAnd another line.";
441        assert_eq!(fmt(src), src);
442    }
443
444    /// An escaped pipe belongs to its cell.
445    #[test]
446    fn an_escaped_pipe_does_not_split_a_cell() {
447        let out = fmt("| Cmd | Does |\n|---|---|\n| `:reg\\|:registers` | List |");
448        let row = out.lines().nth(2).unwrap();
449        assert_eq!(
450            row.matches('|').count() - row.matches("\\|").count(),
451            3,
452            "three unescaped pipes = two columns: {row}"
453        );
454    }
455
456    /// A row missing its last cell is padded out, not truncated — the
457    /// table keeps its shape and the reader still sees the column.
458    #[test]
459    fn a_short_row_keeps_the_table_rectangular() {
460        let out = fmt("| A | B |\n|---|---|\n| only |");
461        let cols: Vec<usize> = out.lines().map(UnicodeWidthStr::width).collect();
462        assert!(
463            cols.windows(2).all(|w| w[0] == w[1]),
464            "short row is padded to full width, got {cols:?}:\n{out}"
465        );
466    }
467
468    /// Text around a table is untouched, and two tables in one document
469    /// are laid out independently rather than sharing column widths.
470    #[test]
471    fn tables_are_independent_and_prose_survives() {
472        let out = fmt("Intro.\n\n| A | B |\n|---|---|\n| x | y |\n\nMiddle.\n\n\
473             | Long header | Q |\n|---|---|\n| a | b |\n\nEnd.");
474        assert!(out.starts_with("Intro.\n"), "{out}");
475        assert!(out.ends_with("\nEnd."), "{out}");
476        assert!(
477            out.contains("| x | y |"),
478            "narrow table stays narrow:\n{out}"
479        );
480        assert!(
481            out.contains("| Long header | Q |"),
482            "wide table keeps its own width:\n{out}"
483        );
484    }
485}