lattice_mode/modes/table/
layout.rs1use unicode_width::UnicodeWidthStr;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Align {
46 Left,
48 LeftMarked,
56 Right,
58 Center,
60}
61
62pub 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
96fn 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
118fn 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
127pub(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
161pub(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 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 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
282pub(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 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 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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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}