Skip to main content

moss_core/ast/
cells.rs

1//! Cell-divider helper for the unified shortcode grammar.
2//!
3//! Shortcodes that take multiple cells (today: `grid`, `buttons`) split
4//! their body on lines containing only `+++`. This module is the single
5//! place that recognizes that divider.
6//!
7//! `+++` was chosen over `---` because `---` is a CommonMark thematic
8//! break inside a markdown body; reusing it would force a per-shortcode
9//! body-parsing rule and break the grammar's "body is always markdown"
10//! invariant. See `docs/archive/2026-05-02-shortcode-grammar-design.md`.
11//!
12//! Backward-compatible by design: a body without any `+++` returns a
13//! single-cell list, so existing buttons content (one link per line, no
14//! divider) keeps producing the same shortcode.
15
16/// Split `body` on lines containing only `+++` (after trim) into cells.
17///
18/// Returns at least one cell — a body with no divider produces a
19/// single-element vector containing the full body. Surrounding newlines
20/// inside each cell are preserved verbatim except for the divider line
21/// itself.
22///
23/// The match is strict: a line must trim to exactly `"+++"`. Variants
24/// like `++++` or `+ + +` or trailing comment text on the same line do
25/// NOT count, mirroring how CommonMark handles `---` thematic breaks
26/// vs. setext headings.
27pub fn split_cells(body: &str) -> Vec<String> {
28    if body.is_empty() {
29        return vec![String::new()];
30    }
31
32    let mut cells = Vec::new();
33    let mut current = String::new();
34    let mut first_line_in_cell = true;
35
36    for line in body.split_inclusive('\n') {
37        // Determine if this line is a divider. We strip the trailing
38        // newline (if any) before trimming to recognize the line content.
39        let content_no_eol = line.strip_suffix('\n').unwrap_or(line);
40        if content_no_eol.trim() == "+++" {
41            // Push current cell (without the divider line) and reset.
42            // Strip the trailing newline that the previous line added,
43            // since the divider's leading newline conceptually belongs
44            // between cells, not at the cell boundary.
45            if let Some(stripped) = current.strip_suffix('\n') {
46                current.truncate(stripped.len());
47            }
48            cells.push(std::mem::take(&mut current));
49            first_line_in_cell = true;
50            continue;
51        }
52
53        // Skip a single leading blank line at the start of a cell so
54        // `+++\n\nlink`-style content yields the same cell as `+++\nlink`.
55        if first_line_in_cell {
56            first_line_in_cell = false;
57            if content_no_eol.trim().is_empty() {
58                continue;
59            }
60        }
61
62        current.push_str(line);
63    }
64
65    // Drop a trailing newline so the final cell mirrors the others.
66    if let Some(stripped) = current.strip_suffix('\n') {
67        current.truncate(stripped.len());
68    }
69    cells.push(current);
70
71    cells
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn no_divider_returns_single_cell() {
80        let cells = split_cells("line one\nline two\n");
81        assert_eq!(cells, vec!["line one\nline two"]);
82    }
83
84    #[test]
85    fn empty_body_returns_one_empty_cell() {
86        let cells = split_cells("");
87        assert_eq!(cells, vec![""]);
88    }
89
90    #[test]
91    fn single_divider_splits_into_two_cells() {
92        let cells = split_cells("a\n+++\nb\n");
93        assert_eq!(cells, vec!["a", "b"]);
94    }
95
96    #[test]
97    fn two_dividers_three_cells() {
98        let cells = split_cells("a\n+++\nb\n+++\nc\n");
99        assert_eq!(cells, vec!["a", "b", "c"]);
100    }
101
102    #[test]
103    fn multi_line_cells_preserved_verbatim() {
104        let cells = split_cells("alpha\nbeta\n+++\ngamma\ndelta\n");
105        assert_eq!(cells, vec!["alpha\nbeta", "gamma\ndelta"]);
106    }
107
108    #[test]
109    fn divider_with_trailing_whitespace_recognized() {
110        let cells = split_cells("a\n+++   \nb\n");
111        assert_eq!(cells, vec!["a", "b"]);
112    }
113
114    #[test]
115    fn divider_with_leading_whitespace_recognized() {
116        let cells = split_cells("a\n  +++\nb\n");
117        assert_eq!(cells, vec!["a", "b"]);
118    }
119
120    #[test]
121    fn four_plus_signs_is_not_a_divider() {
122        let cells = split_cells("a\n++++\nb\n");
123        assert_eq!(cells, vec!["a\n++++\nb"]);
124    }
125
126    #[test]
127    fn plus_with_trailing_text_is_not_a_divider() {
128        let cells = split_cells("a\n+++ extra\nb\n");
129        assert_eq!(cells, vec!["a\n+++ extra\nb"]);
130    }
131
132    #[test]
133    fn empty_cells_preserved() {
134        // Two consecutive dividers leave the middle cell empty.
135        let cells = split_cells("a\n+++\n+++\nb\n");
136        assert_eq!(cells, vec!["a", "", "b"]);
137    }
138
139    #[test]
140    fn divider_at_start_creates_empty_first_cell() {
141        let cells = split_cells("+++\nb\n");
142        assert_eq!(cells, vec!["", "b"]);
143    }
144
145    #[test]
146    fn divider_at_end_creates_empty_last_cell() {
147        let cells = split_cells("a\n+++\n");
148        assert_eq!(cells, vec!["a", ""]);
149    }
150
151    #[test]
152    fn body_without_trailing_newline_works() {
153        let cells = split_cells("a\n+++\nb");
154        assert_eq!(cells, vec!["a", "b"]);
155    }
156
157    #[test]
158    fn leading_blank_line_after_divider_is_dropped() {
159        // Authors who write `+++\n\nnext cell` get the same content as
160        // `+++\nnext cell`. The blank line was a visual separator, not data.
161        let cells = split_cells("a\n+++\n\nb\n");
162        assert_eq!(cells, vec!["a", "b"]);
163    }
164
165    #[test]
166    fn buttons_style_one_link_per_line_no_divider() {
167        // Backward-compatibility shape: existing :::buttons content.
168        // Falls into a single cell — preserves identity for the legacy
169        // line-by-line button parser.
170        let body = "[Get started](/start)\n[Read the docs](/docs)\n";
171        let cells = split_cells(body);
172        assert_eq!(cells.len(), 1);
173        assert_eq!(cells[0], "[Get started](/start)\n[Read the docs](/docs)");
174    }
175
176    #[test]
177    fn spec_buttons_two_cells_with_divider() {
178        // Spec example: each button in its own cell.
179        let body = "[Get started](/start)\n+++\n[Read the docs](/docs)\n";
180        let cells = split_cells(body);
181        assert_eq!(cells.len(), 2);
182        assert_eq!(cells[0], "[Get started](/start)");
183        assert_eq!(cells[1], "[Read the docs](/docs)");
184    }
185
186    #[test]
187    fn spec_grid_three_ratio_cells() {
188        let body = "[Card one](/work/one)\n+++\n[Card two](/work/two)\n+++\n[Card three](/work/three)\n";
189        let cells = split_cells(body);
190        assert_eq!(cells.len(), 3);
191        assert_eq!(cells[0], "[Card one](/work/one)");
192        assert_eq!(cells[2], "[Card three](/work/three)");
193    }
194}