twrite_core/batteries/markdown/
table.rs1use std::ops::Range;
2
3use crate::{EditorBuffer, display_width};
4
5pub const TABLE_HEADER_TAG: &str = "markdown.table.header";
11pub const TABLE_CELL_TAG: &str = "markdown.table.cell";
13pub const TABLE_DELIMITER_TAG: &str = "markdown.table.delimiter";
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum TableAlignment {
19 Left,
21 Center,
23 Right,
25 None,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum TableRowKind {
32 Header,
34 Delimiter,
36 Body,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct TableBlock {
43 pub header_row: usize,
45 pub delimiter_row: usize,
47 pub end_row: usize,
49 pub col_count: usize,
51 pub aligns: Vec<TableAlignment>,
53}
54
55impl TableBlock {
56 pub fn kind_at(&self, row: usize) -> Option<TableRowKind> {
58 if row == self.header_row {
59 Some(TableRowKind::Header)
60 } else if row == self.delimiter_row {
61 Some(TableRowKind::Delimiter)
62 } else if row > self.delimiter_row && row <= self.end_row {
63 Some(TableRowKind::Body)
64 } else {
65 None
66 }
67 }
68
69 pub fn contains(&self, row: usize) -> bool {
71 self.kind_at(row).is_some()
72 }
73}
74
75pub(crate) fn clean_table_line(raw: &str) -> &str {
77 raw.strip_suffix("\r\n")
78 .or_else(|| raw.strip_suffix('\n'))
79 .unwrap_or(raw)
80}
81
82fn is_fence_line(cleaned: &str) -> bool {
83 let t = cleaned.trim_start();
84 t.starts_with("```") || t.starts_with("~~~")
85}
86
87pub fn fence_rows(buffer: &EditorBuffer) -> Vec<usize> {
92 let total = buffer.len_lines();
93 let mut fences = Vec::new();
94 for r in 0..total {
95 if is_fence_line(clean_table_line(&buffer.line_to_string(r))) {
96 fences.push(r);
97 }
98 }
99 fences
100}
101
102pub fn is_fenced_row(fences: &[usize], row: usize) -> bool {
108 fences.partition_point(|&f_row| f_row < row) % 2 == 1
109}
110
111fn code_span_ranges(line: &str) -> Vec<Range<usize>> {
116 let bytes = line.as_bytes();
117 let mut ranges = Vec::new();
118 let mut i = 0;
119 while i < bytes.len() {
120 if bytes[i] == b'\\' {
122 i += if i + 1 < bytes.len() { 2 } else { 1 };
123 continue;
124 }
125 if bytes[i] != b'`' {
126 i += 1;
127 continue;
128 }
129 let mut run = 0;
130 while i + run < bytes.len() && bytes[i + run] == b'`' {
131 run += 1;
132 }
133 let mut j = i + run;
135 let mut closed = None;
136 while j < bytes.len() {
137 if bytes[j] == b'\\' {
138 j += if j + 1 < bytes.len() { 2 } else { 1 };
139 continue;
140 }
141 if bytes[j] == b'`' {
142 let mut k = 0;
143 while j + k < bytes.len() && bytes[j + k] == b'`' {
144 k += 1;
145 }
146 if k == run {
147 closed = Some(j + k);
148 break;
149 }
150 j += k;
151 continue;
152 }
153 j += 1;
154 }
155 if let Some(end) = closed {
156 ranges.push(i..end);
157 i = end;
158 } else {
159 i += run;
160 }
161 }
162 ranges
163}
164
165fn is_escaped_at(line: &str, pos: usize) -> bool {
166 let bytes = line.as_bytes();
167 let mut slashes = 0;
168 let mut i = pos;
169 while i > 0 && bytes[i - 1] == b'\\' {
170 slashes += 1;
171 i -= 1;
172 }
173 slashes % 2 == 1
174}
175
176pub fn find_unescaped_pipes(line: &str) -> Vec<usize> {
181 let cleaned = clean_table_line(line);
182 let code = code_span_ranges(cleaned);
183 let mut pipes = Vec::new();
184 for (idx, ch) in cleaned.char_indices() {
185 if ch != '|' {
186 continue;
187 }
188 if is_escaped_at(cleaned, idx) {
189 continue;
190 }
191 if code.iter().any(|r| r.contains(&idx)) {
192 continue;
193 }
194 pipes.push(idx);
195 }
196 pipes
197}
198
199pub fn split_table_cells(line: &str) -> (Vec<usize>, Vec<Range<usize>>) {
205 let cleaned = clean_table_line(line);
206 let pipes = find_unescaped_pipes(cleaned);
207 if pipes.is_empty() {
208 let whole = 0..cleaned.len();
209 return (Vec::new(), vec![whole]);
210 }
211 let mut bounds = Vec::with_capacity(pipes.len() + 2);
212 bounds.push(0);
213 bounds.extend(pipes.iter().copied());
214 bounds.push(cleaned.len());
215 let mut cells: Vec<Range<usize>> = Vec::with_capacity(bounds.len() - 1);
216 for i in 0..bounds.len() - 1 {
217 let s = if i > 0 { bounds[i] + 1 } else { bounds[i] };
220 let e = bounds[i + 1];
221 cells.push(s..e.min(cleaned.len()));
222 }
223 if cleaned.trim_start().starts_with('|') && !cells.is_empty() {
225 cells.remove(0);
226 }
227 if cleaned.trim_end().ends_with('|') && !cells.is_empty() {
228 cells.pop();
229 }
230 (pipes, cells)
231}
232
233pub fn parse_delimiter_row(line: &str) -> Option<Vec<TableAlignment>> {
237 let cleaned = clean_table_line(line);
238 if cleaned.trim().is_empty() {
239 return None;
240 }
241 let (_, cells) = split_table_cells(cleaned);
242 if cells.is_empty() {
243 return None;
244 }
245 let mut aligns = Vec::with_capacity(cells.len());
249 for cell in &cells {
250 let content = cleaned
251 .get(cell.clone())
252 .unwrap_or("")
253 .trim()
254 .trim_matches(['\r', '\n']);
255 if content.is_empty() || !content.contains('-') {
256 return None;
257 }
258 let inner = content.trim_matches(':');
259 if inner.is_empty() || !inner.chars().all(|c| c == '-') {
260 return None;
261 }
262 let stripped_leading = content.strip_prefix(':').unwrap_or(content);
264 let stripped_both = stripped_leading
265 .strip_suffix(':')
266 .unwrap_or(stripped_leading);
267 if stripped_both.contains(':') {
268 return None;
269 }
270 let left = content.starts_with(':');
271 let right = content.ends_with(':');
272 aligns.push(match (left, right) {
273 (true, true) => TableAlignment::Center,
274 (true, false) => TableAlignment::Left,
275 (false, true) => TableAlignment::Right,
276 (false, false) => TableAlignment::None,
277 });
278 }
279 Some(aligns)
280}
281
282fn table_line_has_pipe(cleaned: &str) -> bool {
283 !find_unescaped_pipes(cleaned).is_empty()
284}
285
286pub fn table_block_at(buffer: &EditorBuffer, row: usize) -> Option<TableBlock> {
297 let fences = fence_rows(buffer);
298 table_block_at_with_fences(buffer, row, &fences)
299}
300
301pub fn table_block_at_with_fences(
308 buffer: &EditorBuffer,
309 row: usize,
310 fences: &[usize],
311) -> Option<TableBlock> {
312 let total = buffer.len_lines();
313 if row >= total {
314 return None;
315 }
316 let cur = clean_table_line(&buffer.line_to_string(row)).to_string();
317 if cur.trim().is_empty() || is_fence_line(&cur) || cur.trim_start().starts_with('>') {
318 return None;
319 }
320 if is_fenced_row(fences, row) {
321 return None;
322 }
323
324 let mut delim: Option<usize> = None;
327 if parse_delimiter_row(&cur).is_some() {
328 delim = Some(row);
329 } else if row + 1 < total {
330 let next = clean_table_line(&buffer.line_to_string(row + 1)).to_string();
331 if !is_fence_line(&next) && parse_delimiter_row(&next).is_some() {
332 delim = Some(row + 1);
333 }
334 }
335 if delim.is_none() {
336 let mut r = row.checked_sub(1);
339 let mut budget = 512;
340 while let Some(j) = r {
341 if budget == 0 {
342 break;
343 }
344 budget -= 1;
345 let text = clean_table_line(&buffer.line_to_string(j)).to_string();
346 if text.trim().is_empty() || is_fence_line(&text) {
347 break;
348 }
349 if parse_delimiter_row(&text).is_some() {
350 delim = Some(j);
351 break;
352 }
353 if !table_line_has_pipe(&text) {
354 break;
355 }
356 r = j.checked_sub(1);
357 }
358 }
359 let d = delim?;
360 if d == 0 {
361 return None; }
363 let header = clean_table_line(&buffer.line_to_string(d - 1)).to_string();
364 if header.trim().is_empty() || is_fence_line(&header) {
365 return None;
366 }
367 let header_pipes = table_line_has_pipe(&header);
368 let delim_text = clean_table_line(&buffer.line_to_string(d)).to_string();
369 let delim_pipes = table_line_has_pipe(&delim_text);
370 if !header_pipes && !delim_pipes {
373 return None;
374 }
375 let aligns = parse_delimiter_row(&delim_text)?;
376 let (_, header_cells) = split_table_cells(&header);
377 if header_cells.len() != aligns.len() {
378 return None;
379 }
380 let header_row = d - 1;
381 let mut end = d;
383 let mut r = d + 1;
384 while r < total {
385 let text = clean_table_line(&buffer.line_to_string(r)).to_string();
386 if text.trim().is_empty() || is_fence_line(&text) || !table_line_has_pipe(&text) {
387 break;
388 }
389 end = r;
390 r += 1;
391 if r - d > 4096 {
392 break;
393 }
394 }
395 if row < header_row || row > end {
398 return None;
399 }
400 Some(TableBlock {
401 header_row,
402 delimiter_row: d,
403 end_row: end,
404 col_count: aligns.len(),
405 aligns,
406 })
407}
408
409#[derive(Debug, Clone, PartialEq, Eq)]
414pub struct TableLayout {
415 pub block: TableBlock,
417 pub col_widths: Vec<usize>,
420}
421
422impl TableLayout {
423 pub fn build(buffer: &EditorBuffer, block: &TableBlock) -> Self {
428 let mut col_widths = vec![3; block.col_count];
429 for row in block.header_row..=block.end_row {
430 let line = clean_table_line(&buffer.line_to_string(row)).to_string();
431 let (_, cells) = split_table_cells(&line);
432 for (i, cell) in cells.iter().enumerate().take(block.col_count) {
433 let content = line.get(cell.clone()).unwrap_or("").trim();
434 col_widths[i] = col_widths[i].max(display_width(content));
435 }
436 }
437 Self {
438 block: block.clone(),
439 col_widths,
440 }
441 }
442}
443
444pub fn table_layouts(buffer: &EditorBuffer) -> Vec<TableLayout> {
451 let fences = fence_rows(buffer);
452 table_layouts_with_fences(buffer, &fences)
453}
454
455pub fn table_layouts_with_fences(buffer: &EditorBuffer, fences: &[usize]) -> Vec<TableLayout> {
460 let mut layouts = Vec::new();
461 let mut row = 0;
462 let total = buffer.len_lines();
463 while row < total {
464 if let Some(block) = table_block_at_with_fences(buffer, row, fences) {
465 let end = block.end_row;
466 layouts.push(TableLayout::build(buffer, &block));
467 row = end + 1;
468 } else {
469 row += 1;
470 }
471 }
472 layouts
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 #[test]
480 fn test_table_pipes_ignore_escapes_and_code() {
481 assert_eq!(find_unescaped_pipes("| a | b |"), vec![0, 4, 8]);
482 assert_eq!(find_unescaped_pipes("| a \\| b |"), vec![0, 9]);
484 assert_eq!(find_unescaped_pipes("| `a|b` | c |"), vec![0, 8, 12]);
486 assert_eq!(find_unescaped_pipes("| a \\\\| b |"), vec![0, 6, 10]);
488 }
489
490 #[test]
491 fn test_table_split_cells_outer_pipes_optional() {
492 let (pipes, cells) = split_table_cells("| a | b |");
493 assert_eq!(pipes, vec![0, 4, 8]);
494 assert_eq!(cells.len(), 2);
495
496 let (pipes_bare, cells_bare) = split_table_cells("a | b");
497 assert_eq!(pipes_bare, vec![2]);
498 assert_eq!(cells_bare.len(), 2);
499
500 let (pipes_none, cells_none) = split_table_cells("plain");
501 assert!(pipes_none.is_empty());
502 assert_eq!(cells_none, vec![0..5]);
503 }
504
505 #[test]
506 fn test_table_parse_delimiter_alignments() {
507 assert_eq!(
508 parse_delimiter_row("| --- | :--- | ---: | :---: |"),
509 Some(vec![
510 TableAlignment::None,
511 TableAlignment::Left,
512 TableAlignment::Right,
513 TableAlignment::Center
514 ])
515 );
516 assert_eq!(
517 parse_delimiter_row("--- | :-:"),
518 Some(vec![TableAlignment::None, TableAlignment::Center])
519 );
520 assert!(parse_delimiter_row("| --- | nope |").is_none());
521 assert!(parse_delimiter_row("| --- | :: |").is_none());
522 assert!(parse_delimiter_row("").is_none());
523 }
524
525 #[test]
526 fn test_table_block_detection_and_kinds() {
527 let buffer = EditorBuffer::new("| a | b |\n| --- | --- |\n| c | d |\n| e | f |");
528 let block = table_block_at(&buffer, 0).expect("header must detect block");
529 assert_eq!(block.header_row, 0);
530 assert_eq!(block.delimiter_row, 1);
531 assert_eq!(block.end_row, 3);
532 assert_eq!(block.col_count, 2);
533 assert_eq!(block.kind_at(0), Some(TableRowKind::Header));
534 assert_eq!(block.kind_at(1), Some(TableRowKind::Delimiter));
535 assert_eq!(block.kind_at(2), Some(TableRowKind::Body));
536 assert!(block.contains(3));
537 assert!(!block.contains(4));
538
539 let bare = EditorBuffer::new("a | b\n--- | ---\n c | d ");
541 let bare_block = table_block_at(&bare, 2).expect("bare pipes must detect");
542 assert_eq!(bare_block.col_count, 2);
543
544 let trailed = EditorBuffer::new("| a |\n| --- |\n| b |\nplain");
546 assert!(table_block_at(&trailed, 3).is_none());
547 }
548
549 #[test]
550 fn test_table_rejects_setext_hr_fence_and_quote() {
551 let setext = EditorBuffer::new("foo\n---\n");
553 assert!(table_block_at(&setext, 0).is_none());
554 assert!(table_block_at(&setext, 1).is_none());
555
556 let hr = EditorBuffer::new("---\n");
558 assert!(table_block_at(&hr, 0).is_none());
559
560 let uneven = EditorBuffer::new("| a | b |\n| --- |\n| c | d |");
562 assert!(table_block_at(&uneven, 0).is_none());
563
564 let fence = EditorBuffer::new("```\n| a |\n| --- |\n```");
566 assert!(table_block_at(&fence, 1).is_none());
567 assert!(table_block_at(&fence, 2).is_none());
568
569 let quote = EditorBuffer::new("> | a |\n> | --- |");
571 assert!(table_block_at("e, 0).is_none());
572 }
573
574 #[test]
575 fn test_table_layout_measures_max_source_width() {
576 let buffer = EditorBuffer::new("| a | b |\n| --- | --- |\n| looong | c |\n| d | e |");
577 let layouts = table_layouts(&buffer);
578 assert_eq!(layouts.len(), 1);
579 assert_eq!(layouts[0].col_widths, vec![6, 3]);
581
582 let two = EditorBuffer::new("| a |\n| --- |\n| b |\n\n| x | yy |\n| --- | --- |");
584 assert_eq!(table_layouts(&two).len(), 2);
585 }
586
587 #[test]
588 fn test_fenced_index_queries_agree_with_single_shot() {
589 let mut s = String::from("```rust\nfn f() {}\n```\n");
592 for i in 0..200 {
593 s.push_str(&format!("plain line {i}\n"));
594 }
595 s.push_str("| a | b |\n| --- | --- |\n| c | d |\n");
596 s.push_str("```\n| x |\n| --- |\n```\n");
597 let buffer = EditorBuffer::new(&s);
598 let fences = fence_rows(&buffer);
599 assert!(fences.contains(&0) && fences.contains(&2));
601 assert_eq!(fences.len(), 4);
602
603 let total = buffer.len_lines();
604 for row in 0..total {
605 assert_eq!(
606 table_block_at_with_fences(&buffer, row, &fences),
607 table_block_at(&buffer, row),
608 "indexed query must agree with single-shot at row {row}"
609 );
610 }
611 assert_eq!(
612 table_layouts_with_fences(&buffer, &fences),
613 table_layouts(&buffer),
614 "indexed layouts must agree with single-shot layouts"
615 );
616
617 let table_row = 203;
619 assert!(table_block_at(&buffer, table_row).is_some());
620 assert!(!is_fenced_row(&fences, table_row));
621 let fenced_row = total - 3;
622 assert!(is_fenced_row(&fences, fenced_row));
623 assert!(table_block_at(&buffer, fenced_row).is_none());
624 }
625}