1#[derive(Debug, Clone)]
7pub struct TableBlock {
8 pub start_line: usize,
9 pub end_line: usize,
10 pub header_line: usize,
11 pub delimiter_line: usize,
12 pub content_lines: Vec<usize>,
13 pub list_context: Option<ListTableContext>,
17}
18
19#[derive(Debug, Clone)]
21pub struct ListTableContext {
22 pub list_prefix: String,
24 pub content_indent: usize,
26}
27
28pub struct TableUtils;
30
31impl TableUtils {
32 fn has_unescaped_pipe_outside_inline_code(text: &str) -> bool {
37 let chars: Vec<char> = text.chars().collect();
38 let mut i = 0;
39 let mut in_code = false;
40 let mut code_delim_len = 0usize;
41
42 while i < chars.len() {
43 let ch = chars[i];
44
45 if ch == '\\' {
46 i += if i + 1 < chars.len() { 2 } else { 1 };
48 continue;
49 }
50
51 if ch == '`' {
52 let mut run = 1usize;
53 while i + run < chars.len() && chars[i + run] == '`' {
54 run += 1;
55 }
56
57 if in_code {
58 if run == code_delim_len {
59 in_code = false;
60 code_delim_len = 0;
61 }
62 } else {
63 in_code = true;
64 code_delim_len = run;
65 }
66
67 i += run;
68 continue;
69 }
70
71 if ch == '|' && !in_code {
72 return true;
73 }
74
75 i += 1;
76 }
77
78 false
79 }
80
81 pub fn is_potential_table_row(line: &str) -> bool {
83 let trimmed = line.trim();
84 if trimmed.is_empty() || !trimmed.contains('|') {
85 return false;
86 }
87
88 if trimmed.starts_with("- ")
91 || trimmed.starts_with("* ")
92 || trimmed.starts_with("+ ")
93 || trimmed.starts_with("-\t")
94 || trimmed.starts_with("*\t")
95 || trimmed.starts_with("+\t")
96 {
97 return false;
98 }
99
100 if let Some(first_non_digit) = trimmed.find(|c: char| !c.is_ascii_digit())
102 && first_non_digit > 0
103 {
104 let after_digits = &trimmed[first_non_digit..];
105 if after_digits.starts_with(". ")
106 || after_digits.starts_with(".\t")
107 || after_digits.starts_with(") ")
108 || after_digits.starts_with(")\t")
109 {
110 return false;
111 }
112 }
113
114 if trimmed.starts_with('#') {
116 let hash_count = trimmed.bytes().take_while(|&b| b == b'#').count();
117 if hash_count <= 6 {
118 let after_hashes = &trimmed[hash_count..];
119 if after_hashes.is_empty() || after_hashes.starts_with(' ') || after_hashes.starts_with('\t') {
120 return false;
121 }
122 }
123 }
124
125 let has_outer_pipes = trimmed.starts_with('|') && trimmed.ends_with('|');
128 if !has_outer_pipes && !Self::has_unescaped_pipe_outside_inline_code(trimmed) {
129 return false;
130 }
131
132 let parts: Vec<&str> = trimmed.split('|').collect();
134 if parts.len() < 2 {
135 return false;
136 }
137
138 let mut valid_parts = 0;
140 let mut total_non_empty_parts = 0;
141
142 for part in &parts {
143 let part_trimmed = part.trim();
144 if part_trimmed.is_empty() {
146 continue;
147 }
148 total_non_empty_parts += 1;
149
150 if !part_trimmed.contains('\n') {
152 valid_parts += 1;
153 }
154 }
155
156 if total_non_empty_parts > 0 && valid_parts != total_non_empty_parts {
158 return false;
160 }
161
162 if total_non_empty_parts == 0 {
165 return trimmed.starts_with('|') && trimmed.ends_with('|') && parts.len() >= 3;
167 }
168
169 if trimmed.starts_with('|') && trimmed.ends_with('|') {
172 valid_parts >= 1
174 } else {
175 valid_parts >= 2
177 }
178 }
179
180 pub fn is_delimiter_row(line: &str) -> bool {
182 let trimmed = line.trim();
183 if !trimmed.contains('|') || !trimmed.contains('-') {
184 return false;
185 }
186
187 let parts: Vec<&str> = trimmed.split('|').collect();
189 let mut valid_delimiter_parts = 0;
190 let mut total_non_empty_parts = 0;
191
192 for part in &parts {
193 let part_trimmed = part.trim();
194 if part_trimmed.is_empty() {
195 continue; }
197
198 total_non_empty_parts += 1;
199
200 if part_trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace()) && part_trimmed.contains('-') {
202 valid_delimiter_parts += 1;
203 }
204 }
205
206 total_non_empty_parts > 0 && valid_delimiter_parts == total_non_empty_parts
208 }
209
210 fn strip_blockquote_prefix(line: &str) -> &str {
212 let trimmed = line.trim_start();
213 if trimmed.starts_with('>') {
214 let mut rest = trimmed;
216 while rest.starts_with('>') {
217 rest = rest.strip_prefix('>').unwrap_or(rest);
218 rest = rest.trim_start_matches(' ');
219 }
220 rest
221 } else {
222 line
223 }
224 }
225
226 pub fn find_table_blocks_with_code_info(
229 content: &str,
230 code_blocks: &[(usize, usize)],
231 code_spans: &[crate::lint_context::CodeSpan],
232 html_comment_ranges: &[crate::utils::skip_context::ByteRange],
233 ) -> Vec<TableBlock> {
234 let lines: Vec<&str> = content.lines().collect();
235 let mut tables = Vec::new();
236 let mut i = 0;
237
238 let mut line_positions = Vec::with_capacity(lines.len());
240 let mut pos = 0;
241 for line in &lines {
242 line_positions.push(pos);
243 pos += line.len() + 1; }
245
246 let mut list_indent_stack: Vec<usize> = Vec::new();
250
251 while i < lines.len() {
252 let line_start = line_positions[i];
254 let in_code =
255 crate::utils::code_block_utils::CodeBlockUtils::is_in_code_block_or_span(code_blocks, line_start)
256 || code_spans
257 .iter()
258 .any(|span| line_start >= span.byte_offset && line_start < span.byte_end);
259 let in_html_comment = html_comment_ranges
260 .iter()
261 .any(|range| line_start >= range.start && line_start < range.end);
262
263 if in_code || in_html_comment {
264 i += 1;
265 continue;
266 }
267
268 let line_content = Self::strip_blockquote_prefix(lines[i]);
270
271 let (list_prefix, list_content, content_indent) = Self::extract_list_prefix(line_content);
273 if !list_prefix.is_empty() {
274 while list_indent_stack.last().is_some_and(|&top| top >= content_indent) {
276 list_indent_stack.pop();
277 }
278 list_indent_stack.push(content_indent);
279 } else if !line_content.trim().is_empty() {
280 let leading = line_content.len() - line_content.trim_start().len();
282 while list_indent_stack.last().is_some_and(|&top| leading < top) {
283 list_indent_stack.pop();
284 }
285 }
286 let (is_same_line_list_table, effective_content) =
291 if !list_prefix.is_empty() && Self::is_potential_table_row_content(list_content) {
292 (true, list_content)
293 } else {
294 (false, line_content)
295 };
296
297 let continuation_indent = if !is_same_line_list_table && list_prefix.is_empty() {
300 let leading = line_content.len() - line_content.trim_start().len();
301 list_indent_stack
303 .iter()
304 .rev()
305 .find(|&&indent| leading >= indent)
306 .copied()
307 } else {
308 None
309 };
310
311 let is_continuation_list_table = continuation_indent.is_some()
312 && {
313 let indent = continuation_indent.unwrap();
314 let leading = line_content.len() - line_content.trim_start().len();
315 leading < indent + 4
317 }
318 && Self::is_potential_table_row(effective_content);
319
320 let is_any_list_table = is_same_line_list_table || is_continuation_list_table;
321
322 let effective_content_indent = if is_same_line_list_table {
324 content_indent
325 } else if is_continuation_list_table {
326 continuation_indent.unwrap()
327 } else {
328 0
329 };
330
331 if is_any_list_table || Self::is_potential_table_row(effective_content) {
333 let (next_line_content, delimiter_has_valid_indent) = if i + 1 < lines.len() {
336 let next_raw = Self::strip_blockquote_prefix(lines[i + 1]);
337 if is_any_list_table {
338 let leading_spaces = next_raw.len() - next_raw.trim_start().len();
340 if leading_spaces >= effective_content_indent {
341 (
343 Self::strip_list_continuation_indent(next_raw, effective_content_indent),
344 true,
345 )
346 } else {
347 (next_raw, false)
349 }
350 } else {
351 (next_raw, true)
352 }
353 } else {
354 ("", true)
355 };
356
357 let effective_is_list_table = is_any_list_table && delimiter_has_valid_indent;
359
360 if i + 1 < lines.len() && Self::is_delimiter_row(next_line_content) {
361 let table_start = i;
363 let header_line = i;
364 let delimiter_line = i + 1;
365 let mut table_end = i + 1; let mut content_lines = Vec::new();
367
368 let mut j = i + 2;
370 while j < lines.len() {
371 let line = lines[j];
372 let raw_content = Self::strip_blockquote_prefix(line);
374
375 let line_content = if effective_is_list_table {
377 Self::strip_list_continuation_indent(raw_content, effective_content_indent)
378 } else {
379 raw_content
380 };
381
382 if line_content.trim().is_empty() {
383 break;
385 }
386
387 if effective_is_list_table {
389 let leading_spaces = raw_content.len() - raw_content.trim_start().len();
390 if leading_spaces < effective_content_indent {
391 break;
393 }
394 }
395
396 if Self::is_potential_table_row(line_content) {
397 content_lines.push(j);
398 table_end = j;
399 j += 1;
400 } else {
401 break;
403 }
404 }
405
406 let list_context = if effective_is_list_table {
407 if is_same_line_list_table {
408 Some(ListTableContext {
410 list_prefix: list_prefix.to_string(),
411 content_indent: effective_content_indent,
412 })
413 } else {
414 Some(ListTableContext {
416 list_prefix: " ".repeat(effective_content_indent),
417 content_indent: effective_content_indent,
418 })
419 }
420 } else {
421 None
422 };
423
424 tables.push(TableBlock {
425 start_line: table_start,
426 end_line: table_end,
427 header_line,
428 delimiter_line,
429 content_lines,
430 list_context,
431 });
432 i = table_end + 1;
433 } else {
434 i += 1;
435 }
436 } else {
437 i += 1;
438 }
439 }
440
441 tables
442 }
443
444 fn strip_list_continuation_indent(line: &str, expected_indent: usize) -> &str {
447 let bytes = line.as_bytes();
448 let mut spaces = 0;
449
450 for &b in bytes {
451 if b == b' ' {
452 spaces += 1;
453 } else if b == b'\t' {
454 spaces = (spaces / 4 + 1) * 4;
456 } else {
457 break;
458 }
459
460 if spaces >= expected_indent {
461 break;
462 }
463 }
464
465 let strip_count = spaces.min(expected_indent).min(line.len());
467 let mut byte_count = 0;
469 let mut counted_spaces = 0;
470 for &b in bytes {
471 if counted_spaces >= strip_count {
472 break;
473 }
474 if b == b' ' {
475 counted_spaces += 1;
476 byte_count += 1;
477 } else if b == b'\t' {
478 counted_spaces = (counted_spaces / 4 + 1) * 4;
479 byte_count += 1;
480 } else {
481 break;
482 }
483 }
484
485 &line[byte_count..]
486 }
487
488 pub fn find_table_blocks(content: &str, ctx: &crate::lint_context::LintContext) -> Vec<TableBlock> {
491 Self::find_table_blocks_with_code_info(content, &ctx.code_blocks, &ctx.code_spans(), ctx.html_comment_ranges())
492 }
493
494 pub fn count_cells(row: &str) -> usize {
496 Self::count_cells_with_flavor(row, crate::config::MarkdownFlavor::Standard)
497 }
498
499 pub fn count_cells_with_flavor(row: &str, flavor: crate::config::MarkdownFlavor) -> usize {
507 let (_, content) = Self::extract_blockquote_prefix(row);
509 Self::split_table_row_with_flavor(content, flavor).len()
510 }
511
512 pub fn mask_pipes_in_inline_code(text: &str) -> String {
514 let mut result = String::new();
515 let chars: Vec<char> = text.chars().collect();
516 let mut i = 0;
517
518 while i < chars.len() {
519 if chars[i] == '`' {
520 let start = i;
522 let mut backtick_count = 0;
523 while i < chars.len() && chars[i] == '`' {
524 backtick_count += 1;
525 i += 1;
526 }
527
528 let mut found_closing = false;
530 let mut j = i;
531
532 while j < chars.len() {
533 if chars[j] == '`' {
534 let close_start = j;
536 let mut close_count = 0;
537 while j < chars.len() && chars[j] == '`' {
538 close_count += 1;
539 j += 1;
540 }
541
542 if close_count == backtick_count {
543 found_closing = true;
545
546 result.extend(chars[start..i].iter());
548
549 for &ch in chars.iter().take(close_start).skip(i) {
550 if ch == '|' {
551 result.push('_'); } else {
553 result.push(ch);
554 }
555 }
556
557 result.extend(chars[close_start..j].iter());
558 i = j;
559 break;
560 }
561 } else {
563 j += 1;
564 }
565 }
566
567 if !found_closing {
568 result.extend(chars[start..i].iter());
570 }
571 } else {
572 result.push(chars[i]);
573 i += 1;
574 }
575 }
576
577 result
578 }
579
580 pub fn escape_pipes_in_inline_code(text: &str) -> String {
584 let mut result = String::new();
585 let chars: Vec<char> = text.chars().collect();
586 let mut i = 0;
587
588 while i < chars.len() {
589 if chars[i] == '`' {
590 let start = i;
591 let mut backtick_count = 0;
592 while i < chars.len() && chars[i] == '`' {
593 backtick_count += 1;
594 i += 1;
595 }
596
597 let mut found_closing = false;
598 let mut j = i;
599
600 while j < chars.len() {
601 if chars[j] == '`' {
602 let close_start = j;
603 let mut close_count = 0;
604 while j < chars.len() && chars[j] == '`' {
605 close_count += 1;
606 j += 1;
607 }
608
609 if close_count == backtick_count {
610 found_closing = true;
611 result.extend(chars[start..i].iter());
612
613 for &ch in chars.iter().take(close_start).skip(i) {
614 if ch == '|' {
615 result.push('\\');
616 result.push('|');
617 } else {
618 result.push(ch);
619 }
620 }
621
622 result.extend(chars[close_start..j].iter());
623 i = j;
624 break;
625 }
626 } else {
627 j += 1;
628 }
629 }
630
631 if !found_closing {
632 result.extend(chars[start..i].iter());
633 }
634 } else {
635 result.push(chars[i]);
636 i += 1;
637 }
638 }
639
640 result
641 }
642
643 pub fn mask_pipes_for_table_parsing(text: &str) -> String {
656 let mut result = String::new();
657 let chars: Vec<char> = text.chars().collect();
658 let mut i = 0;
659
660 while i < chars.len() {
661 if chars[i] == '\\' {
662 if i + 1 < chars.len() && chars[i + 1] == '\\' {
663 result.push('\\');
666 result.push('\\');
667 i += 2;
668 } else if i + 1 < chars.len() && chars[i + 1] == '|' {
669 result.push('\\');
671 result.push('_'); i += 2;
673 } else {
674 result.push(chars[i]);
676 i += 1;
677 }
678 } else {
679 result.push(chars[i]);
680 i += 1;
681 }
682 }
683
684 result
685 }
686
687 pub fn split_table_row_with_flavor(row: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
695 let trimmed = row.trim();
696
697 if !trimmed.contains('|') {
698 return Vec::new();
699 }
700
701 let masked = Self::mask_pipes_for_table_parsing(trimmed);
703
704 let final_masked = if flavor == crate::config::MarkdownFlavor::MkDocs {
706 Self::mask_pipes_in_inline_code(&masked)
707 } else {
708 masked
709 };
710
711 let has_leading = final_masked.starts_with('|');
712 let has_trailing = final_masked.ends_with('|');
713
714 let mut masked_content = final_masked.as_str();
715 let mut orig_content = trimmed;
716
717 if has_leading {
718 masked_content = &masked_content[1..];
719 orig_content = &orig_content[1..];
720 }
721
722 let stripped_trailing = has_trailing && !masked_content.is_empty();
724 if stripped_trailing {
725 masked_content = &masked_content[..masked_content.len() - 1];
726 orig_content = &orig_content[..orig_content.len() - 1];
727 }
728
729 if masked_content.is_empty() {
731 if stripped_trailing {
732 return vec![String::new()];
734 } else {
735 return Vec::new();
737 }
738 }
739
740 let masked_parts: Vec<&str> = masked_content.split('|').collect();
741 let mut cells = Vec::new();
742 let mut pos = 0;
743
744 for masked_cell in masked_parts {
745 let cell_len = masked_cell.len();
746 let orig_cell = if pos + cell_len <= orig_content.len() {
747 &orig_content[pos..pos + cell_len]
748 } else {
749 masked_cell
750 };
751 cells.push(orig_cell.to_string());
752 pos += cell_len + 1; }
754
755 cells
756 }
757
758 pub fn split_table_row(row: &str) -> Vec<String> {
760 Self::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Standard)
761 }
762
763 pub fn determine_pipe_style(line: &str) -> Option<&'static str> {
768 let content = Self::strip_blockquote_prefix(line);
770 let trimmed = content.trim();
771 if !trimmed.contains('|') {
772 return None;
773 }
774
775 let has_leading = trimmed.starts_with('|');
776 let has_trailing = trimmed.ends_with('|');
777
778 match (has_leading, has_trailing) {
779 (true, true) => Some("leading_and_trailing"),
780 (true, false) => Some("leading_only"),
781 (false, true) => Some("trailing_only"),
782 (false, false) => Some("no_leading_or_trailing"),
783 }
784 }
785
786 pub fn extract_blockquote_prefix(line: &str) -> (&str, &str) {
791 let bytes = line.as_bytes();
793 let mut pos = 0;
794
795 while pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
797 pos += 1;
798 }
799
800 if pos >= bytes.len() || bytes[pos] != b'>' {
802 return ("", line);
803 }
804
805 while pos < bytes.len() {
807 if bytes[pos] == b'>' {
808 pos += 1;
809 if pos < bytes.len() && bytes[pos] == b' ' {
811 pos += 1;
812 }
813 } else if bytes[pos] == b' ' || bytes[pos] == b'\t' {
814 pos += 1;
815 } else {
816 break;
817 }
818 }
819
820 (&line[..pos], &line[pos..])
822 }
823
824 pub fn extract_list_prefix(line: &str) -> (&str, &str, usize) {
839 let bytes = line.as_bytes();
840
841 let leading_spaces = bytes.iter().take_while(|&&b| b == b' ' || b == b'\t').count();
843 let mut pos = leading_spaces;
844
845 if pos >= bytes.len() {
846 return ("", line, 0);
847 }
848
849 if matches!(bytes[pos], b'-' | b'*' | b'+') {
851 pos += 1;
852
853 if pos >= bytes.len() || bytes[pos] == b' ' || bytes[pos] == b'\t' {
855 if pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
857 pos += 1;
858 }
859 let content_indent = pos;
860 return (&line[..pos], &line[pos..], content_indent);
861 }
862 return ("", line, 0);
864 }
865
866 if bytes[pos].is_ascii_digit() {
868 let digit_start = pos;
869 while pos < bytes.len() && bytes[pos].is_ascii_digit() {
870 pos += 1;
871 }
872
873 if pos > digit_start && pos < bytes.len() {
875 if bytes[pos] == b'.' || bytes[pos] == b')' {
877 pos += 1;
878 if pos >= bytes.len() || bytes[pos] == b' ' || bytes[pos] == b'\t' {
879 if pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
881 pos += 1;
882 }
883 let content_indent = pos;
884 return (&line[..pos], &line[pos..], content_indent);
885 }
886 }
887 }
888 }
889
890 ("", line, 0)
891 }
892
893 pub fn extract_table_row_content<'a>(line: &'a str, table_block: &TableBlock, line_index: usize) -> &'a str {
898 let (_, after_blockquote) = Self::extract_blockquote_prefix(line);
900
901 if let Some(ref list_ctx) = table_block.list_context {
903 if line_index == 0 {
904 after_blockquote
906 .strip_prefix(&list_ctx.list_prefix)
907 .unwrap_or_else(|| Self::extract_list_prefix(after_blockquote).1)
908 } else {
909 Self::strip_list_continuation_indent(after_blockquote, list_ctx.content_indent)
911 }
912 } else {
913 after_blockquote
914 }
915 }
916
917 pub fn is_list_item_with_table_row(line: &str) -> bool {
920 let (prefix, content, _) = Self::extract_list_prefix(line);
921 if prefix.is_empty() {
922 return false;
923 }
924
925 let trimmed = content.trim();
928 if !trimmed.starts_with('|') {
929 return false;
930 }
931
932 Self::is_potential_table_row_content(content)
934 }
935
936 fn is_potential_table_row_content(content: &str) -> bool {
938 Self::is_potential_table_row(content)
939 }
940}
941
942#[cfg(test)]
943mod tests {
944 use super::*;
945 use crate::lint_context::LintContext;
946
947 #[test]
948 fn test_is_potential_table_row() {
949 assert!(TableUtils::is_potential_table_row("| Header 1 | Header 2 |"));
951 assert!(TableUtils::is_potential_table_row("| Cell 1 | Cell 2 |"));
952 assert!(TableUtils::is_potential_table_row("Cell 1 | Cell 2"));
953 assert!(TableUtils::is_potential_table_row("| Cell |")); assert!(TableUtils::is_potential_table_row("| A | B | C | D | E |"));
957
958 assert!(TableUtils::is_potential_table_row(" | Indented | Table | "));
960 assert!(TableUtils::is_potential_table_row("| Spaces | Around |"));
961
962 assert!(!TableUtils::is_potential_table_row("- List item"));
964 assert!(!TableUtils::is_potential_table_row("* Another list"));
965 assert!(!TableUtils::is_potential_table_row("+ Plus list"));
966 assert!(!TableUtils::is_potential_table_row("Regular text"));
967 assert!(!TableUtils::is_potential_table_row(""));
968 assert!(!TableUtils::is_potential_table_row(" "));
969
970 assert!(!TableUtils::is_potential_table_row("`code with | pipe`"));
972 assert!(!TableUtils::is_potential_table_row("``multiple | backticks``"));
973 assert!(!TableUtils::is_potential_table_row("Use ``a|b`` in prose"));
974 assert!(TableUtils::is_potential_table_row("| `fenced` | Uses ``` and ~~~ |"));
975 assert!(TableUtils::is_potential_table_row("`!foo && bar` | `(!foo) && bar`"));
976 assert!(!TableUtils::is_potential_table_row("`echo a | sed 's/a/b/'`"));
977
978 assert!(!TableUtils::is_potential_table_row("Just one |"));
980 assert!(!TableUtils::is_potential_table_row("| Just one"));
981
982 let long_cell = "a".repeat(150);
984 assert!(TableUtils::is_potential_table_row(&format!("| {long_cell} | b |")));
985
986 assert!(!TableUtils::is_potential_table_row("| Cell with\nnewline | Other |"));
988
989 assert!(TableUtils::is_potential_table_row("|||")); assert!(TableUtils::is_potential_table_row("||||")); assert!(TableUtils::is_potential_table_row("| | |")); }
994
995 #[test]
996 fn test_list_items_with_pipes_not_table_rows() {
997 assert!(!TableUtils::is_potential_table_row("1. Item with | pipe"));
999 assert!(!TableUtils::is_potential_table_row("10. Item with | pipe"));
1000 assert!(!TableUtils::is_potential_table_row("999. Item with | pipe"));
1001 assert!(!TableUtils::is_potential_table_row("1) Item with | pipe"));
1002 assert!(!TableUtils::is_potential_table_row("10) Item with | pipe"));
1003
1004 assert!(!TableUtils::is_potential_table_row("-\tItem with | pipe"));
1006 assert!(!TableUtils::is_potential_table_row("*\tItem with | pipe"));
1007 assert!(!TableUtils::is_potential_table_row("+\tItem with | pipe"));
1008
1009 assert!(!TableUtils::is_potential_table_row(" - Indented | pipe"));
1011 assert!(!TableUtils::is_potential_table_row(" * Deep indent | pipe"));
1012 assert!(!TableUtils::is_potential_table_row(" 1. Ordered indent | pipe"));
1013
1014 assert!(!TableUtils::is_potential_table_row("- [ ] task | pipe"));
1016 assert!(!TableUtils::is_potential_table_row("- [x] done | pipe"));
1017
1018 assert!(!TableUtils::is_potential_table_row("1. foo | bar | baz"));
1020 assert!(!TableUtils::is_potential_table_row("- alpha | beta | gamma"));
1021
1022 assert!(TableUtils::is_potential_table_row("| cell | cell |"));
1024 assert!(TableUtils::is_potential_table_row("cell | cell"));
1025 assert!(TableUtils::is_potential_table_row("| Header | Header |"));
1026 }
1027
1028 #[test]
1029 fn test_atx_headings_with_pipes_not_table_rows() {
1030 assert!(!TableUtils::is_potential_table_row("# Heading | with pipe"));
1032 assert!(!TableUtils::is_potential_table_row("## Heading | with pipe"));
1033 assert!(!TableUtils::is_potential_table_row("### Heading | with pipe"));
1034 assert!(!TableUtils::is_potential_table_row("#### Heading | with pipe"));
1035 assert!(!TableUtils::is_potential_table_row("##### Heading | with pipe"));
1036 assert!(!TableUtils::is_potential_table_row("###### Heading | with pipe"));
1037
1038 assert!(!TableUtils::is_potential_table_row("### col1 | col2 | col3"));
1040 assert!(!TableUtils::is_potential_table_row("## a|b|c"));
1041
1042 assert!(!TableUtils::is_potential_table_row("#\tHeading | pipe"));
1044 assert!(!TableUtils::is_potential_table_row("##\tHeading | pipe"));
1045
1046 assert!(!TableUtils::is_potential_table_row("# |"));
1048 assert!(!TableUtils::is_potential_table_row("## |"));
1049
1050 assert!(!TableUtils::is_potential_table_row(" ## Heading | pipe"));
1052 assert!(!TableUtils::is_potential_table_row(" ### Heading | pipe"));
1053
1054 assert!(!TableUtils::is_potential_table_row("#### ®aAA|ᯗ"));
1056
1057 assert!(TableUtils::is_potential_table_row("####### text | pipe"));
1061
1062 assert!(TableUtils::is_potential_table_row("#nospc|pipe"));
1064
1065 assert!(TableUtils::is_potential_table_row("| # Header | Value |"));
1067 assert!(TableUtils::is_potential_table_row("text | #tag"));
1068 }
1069
1070 #[test]
1071 fn test_is_delimiter_row() {
1072 assert!(TableUtils::is_delimiter_row("|---|---|"));
1074 assert!(TableUtils::is_delimiter_row("| --- | --- |"));
1075 assert!(TableUtils::is_delimiter_row("|:---|---:|"));
1076 assert!(TableUtils::is_delimiter_row("|:---:|:---:|"));
1077
1078 assert!(TableUtils::is_delimiter_row("|-|--|"));
1080 assert!(TableUtils::is_delimiter_row("|-------|----------|"));
1081
1082 assert!(TableUtils::is_delimiter_row("| --- | --- |"));
1084 assert!(TableUtils::is_delimiter_row("| :--- | ---: |"));
1085
1086 assert!(TableUtils::is_delimiter_row("|---|---|---|---|"));
1088
1089 assert!(TableUtils::is_delimiter_row("--- | ---"));
1091 assert!(TableUtils::is_delimiter_row(":--- | ---:"));
1092
1093 assert!(!TableUtils::is_delimiter_row("| Header | Header |"));
1095 assert!(!TableUtils::is_delimiter_row("Regular text"));
1096 assert!(!TableUtils::is_delimiter_row(""));
1097 assert!(!TableUtils::is_delimiter_row("|||"));
1098 assert!(!TableUtils::is_delimiter_row("| | |"));
1099
1100 assert!(!TableUtils::is_delimiter_row("| : | : |"));
1102 assert!(!TableUtils::is_delimiter_row("| | |"));
1103
1104 assert!(!TableUtils::is_delimiter_row("| --- | text |"));
1106 assert!(!TableUtils::is_delimiter_row("| abc | --- |"));
1107 }
1108
1109 #[test]
1110 fn test_count_cells() {
1111 assert_eq!(TableUtils::count_cells("| Cell 1 | Cell 2 | Cell 3 |"), 3);
1113 assert_eq!(TableUtils::count_cells("Cell 1 | Cell 2 | Cell 3"), 3);
1114 assert_eq!(TableUtils::count_cells("| Cell 1 | Cell 2"), 2);
1115 assert_eq!(TableUtils::count_cells("Cell 1 | Cell 2 |"), 2);
1116
1117 assert_eq!(TableUtils::count_cells("| Cell |"), 1);
1119 assert_eq!(TableUtils::count_cells("Cell"), 0); assert_eq!(TableUtils::count_cells("| | | |"), 3);
1123 assert_eq!(TableUtils::count_cells("| | | |"), 3);
1124
1125 assert_eq!(TableUtils::count_cells("| A | B | C | D | E | F |"), 6);
1127
1128 assert_eq!(TableUtils::count_cells("||"), 1); assert_eq!(TableUtils::count_cells("|||"), 2); assert_eq!(TableUtils::count_cells("Regular text"), 0);
1134 assert_eq!(TableUtils::count_cells(""), 0);
1135 assert_eq!(TableUtils::count_cells(" "), 0);
1136
1137 assert_eq!(TableUtils::count_cells(" | A | B | "), 2);
1139 assert_eq!(TableUtils::count_cells("| A | B |"), 2);
1140 }
1141
1142 #[test]
1143 fn test_count_cells_with_escaped_pipes() {
1144 assert_eq!(TableUtils::count_cells("| Challenge | Solution |"), 2);
1150 assert_eq!(TableUtils::count_cells("| A | B | C |"), 3);
1151 assert_eq!(TableUtils::count_cells("| One | Two |"), 2);
1152
1153 assert_eq!(TableUtils::count_cells(r"| Command | echo \| grep |"), 2);
1155 assert_eq!(TableUtils::count_cells(r"| A | B \| C |"), 2); assert_eq!(TableUtils::count_cells(r"| Command | `echo \| grep` |"), 2);
1159
1160 assert_eq!(TableUtils::count_cells(r"| A | B \\| C |"), 3); assert_eq!(TableUtils::count_cells(r"| A | `B \\| C` |"), 3); assert_eq!(TableUtils::count_cells("| Command | `echo | grep` |"), 3);
1167 assert_eq!(TableUtils::count_cells("| `code | one` | `code | two` |"), 4);
1168 assert_eq!(TableUtils::count_cells("| `single|pipe` |"), 2);
1169
1170 assert_eq!(TableUtils::count_cells(r"| Hour formats | `^([0-1]?\d|2[0-3])` |"), 3);
1173 assert_eq!(TableUtils::count_cells(r"| Hour formats | `^([0-1]?\d\|2[0-3])` |"), 2);
1175 }
1176
1177 #[test]
1178 fn test_determine_pipe_style() {
1179 assert_eq!(
1181 TableUtils::determine_pipe_style("| Cell 1 | Cell 2 |"),
1182 Some("leading_and_trailing")
1183 );
1184 assert_eq!(
1185 TableUtils::determine_pipe_style("| Cell 1 | Cell 2"),
1186 Some("leading_only")
1187 );
1188 assert_eq!(
1189 TableUtils::determine_pipe_style("Cell 1 | Cell 2 |"),
1190 Some("trailing_only")
1191 );
1192 assert_eq!(
1193 TableUtils::determine_pipe_style("Cell 1 | Cell 2"),
1194 Some("no_leading_or_trailing")
1195 );
1196
1197 assert_eq!(
1199 TableUtils::determine_pipe_style(" | Cell 1 | Cell 2 | "),
1200 Some("leading_and_trailing")
1201 );
1202 assert_eq!(
1203 TableUtils::determine_pipe_style(" | Cell 1 | Cell 2 "),
1204 Some("leading_only")
1205 );
1206
1207 assert_eq!(TableUtils::determine_pipe_style("Regular text"), None);
1209 assert_eq!(TableUtils::determine_pipe_style(""), None);
1210 assert_eq!(TableUtils::determine_pipe_style(" "), None);
1211
1212 assert_eq!(TableUtils::determine_pipe_style("|"), Some("leading_and_trailing"));
1214 assert_eq!(TableUtils::determine_pipe_style("| Cell"), Some("leading_only"));
1215 assert_eq!(TableUtils::determine_pipe_style("Cell |"), Some("trailing_only"));
1216 }
1217
1218 #[test]
1219 fn test_find_table_blocks_simple() {
1220 let content = "| Header 1 | Header 2 |
1221|-----------|-----------|
1222| Cell 1 | Cell 2 |
1223| Cell 3 | Cell 4 |";
1224
1225 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1226
1227 let tables = TableUtils::find_table_blocks(content, &ctx);
1228 assert_eq!(tables.len(), 1);
1229
1230 let table = &tables[0];
1231 assert_eq!(table.start_line, 0);
1232 assert_eq!(table.end_line, 3);
1233 assert_eq!(table.header_line, 0);
1234 assert_eq!(table.delimiter_line, 1);
1235 assert_eq!(table.content_lines, vec![2, 3]);
1236 }
1237
1238 #[test]
1239 fn test_find_table_blocks_multiple() {
1240 let content = "Some text
1241
1242| Table 1 | Col A |
1243|----------|-------|
1244| Data 1 | Val 1 |
1245
1246More text
1247
1248| Table 2 | Col 2 |
1249|----------|-------|
1250| Data 2 | Data |";
1251
1252 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1253
1254 let tables = TableUtils::find_table_blocks(content, &ctx);
1255 assert_eq!(tables.len(), 2);
1256
1257 assert_eq!(tables[0].start_line, 2);
1259 assert_eq!(tables[0].end_line, 4);
1260 assert_eq!(tables[0].header_line, 2);
1261 assert_eq!(tables[0].delimiter_line, 3);
1262 assert_eq!(tables[0].content_lines, vec![4]);
1263
1264 assert_eq!(tables[1].start_line, 8);
1266 assert_eq!(tables[1].end_line, 10);
1267 assert_eq!(tables[1].header_line, 8);
1268 assert_eq!(tables[1].delimiter_line, 9);
1269 assert_eq!(tables[1].content_lines, vec![10]);
1270 }
1271
1272 #[test]
1273 fn test_find_table_blocks_no_content_rows() {
1274 let content = "| Header 1 | Header 2 |
1275|-----------|-----------|
1276
1277Next paragraph";
1278
1279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1280
1281 let tables = TableUtils::find_table_blocks(content, &ctx);
1282 assert_eq!(tables.len(), 1);
1283
1284 let table = &tables[0];
1285 assert_eq!(table.start_line, 0);
1286 assert_eq!(table.end_line, 1); assert_eq!(table.content_lines.len(), 0);
1288 }
1289
1290 #[test]
1291 fn test_find_table_blocks_in_code_block() {
1292 let content = "```
1293| Not | A | Table |
1294|-----|---|-------|
1295| In | Code | Block |
1296```
1297
1298| Real | Table |
1299|------|-------|
1300| Data | Here |";
1301
1302 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1303
1304 let tables = TableUtils::find_table_blocks(content, &ctx);
1305 assert_eq!(tables.len(), 1); let table = &tables[0];
1308 assert_eq!(table.header_line, 6);
1309 assert_eq!(table.delimiter_line, 7);
1310 }
1311
1312 #[test]
1313 fn test_find_table_blocks_no_tables() {
1314 let content = "Just regular text
1315No tables here
1316- List item with | pipe
1317* Another list item";
1318
1319 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1320
1321 let tables = TableUtils::find_table_blocks(content, &ctx);
1322 assert_eq!(tables.len(), 0);
1323 }
1324
1325 #[test]
1326 fn test_find_table_blocks_malformed() {
1327 let content = "| Header without delimiter |
1328| This looks like table |
1329But no delimiter row
1330
1331| Proper | Table |
1332|---------|-------|
1333| Data | Here |";
1334
1335 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1336
1337 let tables = TableUtils::find_table_blocks(content, &ctx);
1338 assert_eq!(tables.len(), 1); assert_eq!(tables[0].header_line, 4);
1340 }
1341
1342 #[test]
1343 fn test_edge_cases() {
1344 assert!(!TableUtils::is_potential_table_row(""));
1346 assert!(!TableUtils::is_delimiter_row(""));
1347 assert_eq!(TableUtils::count_cells(""), 0);
1348 assert_eq!(TableUtils::determine_pipe_style(""), None);
1349
1350 assert!(!TableUtils::is_potential_table_row(" "));
1352 assert!(!TableUtils::is_delimiter_row(" "));
1353 assert_eq!(TableUtils::count_cells(" "), 0);
1354 assert_eq!(TableUtils::determine_pipe_style(" "), None);
1355
1356 assert!(!TableUtils::is_potential_table_row("|"));
1358 assert!(!TableUtils::is_delimiter_row("|"));
1359 assert_eq!(TableUtils::count_cells("|"), 0); let long_single = format!("| {} |", "a".repeat(200));
1364 assert!(TableUtils::is_potential_table_row(&long_single)); let long_multi = format!("| {} | {} |", "a".repeat(200), "b".repeat(200));
1367 assert!(TableUtils::is_potential_table_row(&long_multi)); assert!(TableUtils::is_potential_table_row("| 你好 | 世界 |"));
1371 assert!(TableUtils::is_potential_table_row("| émoji | 🎉 |"));
1372 assert_eq!(TableUtils::count_cells("| 你好 | 世界 |"), 2);
1373 }
1374
1375 #[test]
1376 fn test_table_block_struct() {
1377 let block = TableBlock {
1378 start_line: 0,
1379 end_line: 5,
1380 header_line: 0,
1381 delimiter_line: 1,
1382 content_lines: vec![2, 3, 4, 5],
1383 list_context: None,
1384 };
1385
1386 let debug_str = format!("{block:?}");
1388 assert!(debug_str.contains("TableBlock"));
1389 assert!(debug_str.contains("start_line: 0"));
1390
1391 let cloned = block.clone();
1393 assert_eq!(cloned.start_line, block.start_line);
1394 assert_eq!(cloned.end_line, block.end_line);
1395 assert_eq!(cloned.header_line, block.header_line);
1396 assert_eq!(cloned.delimiter_line, block.delimiter_line);
1397 assert_eq!(cloned.content_lines, block.content_lines);
1398 assert!(cloned.list_context.is_none());
1399 }
1400
1401 #[test]
1402 fn test_split_table_row() {
1403 let cells = TableUtils::split_table_row("| Cell 1 | Cell 2 | Cell 3 |");
1405 assert_eq!(cells.len(), 3);
1406 assert_eq!(cells[0].trim(), "Cell 1");
1407 assert_eq!(cells[1].trim(), "Cell 2");
1408 assert_eq!(cells[2].trim(), "Cell 3");
1409
1410 let cells = TableUtils::split_table_row("| Cell 1 | Cell 2");
1412 assert_eq!(cells.len(), 2);
1413
1414 let cells = TableUtils::split_table_row("| | | |");
1416 assert_eq!(cells.len(), 3);
1417
1418 let cells = TableUtils::split_table_row("| Cell |");
1420 assert_eq!(cells.len(), 1);
1421 assert_eq!(cells[0].trim(), "Cell");
1422
1423 let cells = TableUtils::split_table_row("No pipes here");
1425 assert_eq!(cells.len(), 0);
1426 }
1427
1428 #[test]
1429 fn test_split_table_row_with_escaped_pipes() {
1430 let cells = TableUtils::split_table_row(r"| A | B \| C |");
1432 assert_eq!(cells.len(), 2);
1433 assert!(cells[1].contains(r"\|"), "Escaped pipe should be in cell content");
1434
1435 let cells = TableUtils::split_table_row(r"| A | B \\| C |");
1437 assert_eq!(cells.len(), 3);
1438 }
1439
1440 #[test]
1441 fn test_split_table_row_with_flavor_mkdocs() {
1442 let cells =
1444 TableUtils::split_table_row_with_flavor("| Type | `x | y` |", crate::config::MarkdownFlavor::MkDocs);
1445 assert_eq!(cells.len(), 2);
1446 assert!(
1447 cells[1].contains("`x | y`"),
1448 "Inline code with pipe should be single cell in MkDocs flavor"
1449 );
1450
1451 let cells =
1453 TableUtils::split_table_row_with_flavor("| Type | `a | b | c` |", crate::config::MarkdownFlavor::MkDocs);
1454 assert_eq!(cells.len(), 2);
1455 assert!(cells[1].contains("`a | b | c`"));
1456 }
1457
1458 #[test]
1459 fn test_split_table_row_with_flavor_standard() {
1460 let cells =
1462 TableUtils::split_table_row_with_flavor("| Type | `x | y` |", crate::config::MarkdownFlavor::Standard);
1463 assert_eq!(cells.len(), 3);
1465 }
1466
1467 #[test]
1470 fn test_extract_blockquote_prefix_no_blockquote() {
1471 let (prefix, content) = TableUtils::extract_blockquote_prefix("| H1 | H2 |");
1473 assert_eq!(prefix, "");
1474 assert_eq!(content, "| H1 | H2 |");
1475 }
1476
1477 #[test]
1478 fn test_extract_blockquote_prefix_single_level() {
1479 let (prefix, content) = TableUtils::extract_blockquote_prefix("> | H1 | H2 |");
1481 assert_eq!(prefix, "> ");
1482 assert_eq!(content, "| H1 | H2 |");
1483 }
1484
1485 #[test]
1486 fn test_extract_blockquote_prefix_double_level() {
1487 let (prefix, content) = TableUtils::extract_blockquote_prefix(">> | H1 | H2 |");
1489 assert_eq!(prefix, ">> ");
1490 assert_eq!(content, "| H1 | H2 |");
1491 }
1492
1493 #[test]
1494 fn test_extract_blockquote_prefix_triple_level() {
1495 let (prefix, content) = TableUtils::extract_blockquote_prefix(">>> | H1 | H2 |");
1497 assert_eq!(prefix, ">>> ");
1498 assert_eq!(content, "| H1 | H2 |");
1499 }
1500
1501 #[test]
1502 fn test_extract_blockquote_prefix_with_spaces() {
1503 let (prefix, content) = TableUtils::extract_blockquote_prefix("> > | H1 | H2 |");
1505 assert_eq!(prefix, "> > ");
1506 assert_eq!(content, "| H1 | H2 |");
1507 }
1508
1509 #[test]
1510 fn test_extract_blockquote_prefix_indented() {
1511 let (prefix, content) = TableUtils::extract_blockquote_prefix(" > | H1 | H2 |");
1513 assert_eq!(prefix, " > ");
1514 assert_eq!(content, "| H1 | H2 |");
1515 }
1516
1517 #[test]
1518 fn test_extract_blockquote_prefix_no_space_after() {
1519 let (prefix, content) = TableUtils::extract_blockquote_prefix(">| H1 | H2 |");
1521 assert_eq!(prefix, ">");
1522 assert_eq!(content, "| H1 | H2 |");
1523 }
1524
1525 #[test]
1526 fn test_determine_pipe_style_in_blockquote() {
1527 assert_eq!(
1529 TableUtils::determine_pipe_style("> | H1 | H2 |"),
1530 Some("leading_and_trailing")
1531 );
1532 assert_eq!(
1533 TableUtils::determine_pipe_style("> H1 | H2"),
1534 Some("no_leading_or_trailing")
1535 );
1536 assert_eq!(
1537 TableUtils::determine_pipe_style(">> | H1 | H2 |"),
1538 Some("leading_and_trailing")
1539 );
1540 assert_eq!(TableUtils::determine_pipe_style(">>> | H1 | H2"), Some("leading_only"));
1541 }
1542
1543 #[test]
1544 fn test_list_table_delimiter_requires_indentation() {
1545 let content = "- List item with | pipe\n|---|---|\n| Cell 1 | Cell 2 |";
1550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1551 let tables = TableUtils::find_table_blocks(content, &ctx);
1552
1553 assert_eq!(tables.len(), 1, "Should find exactly one table");
1556 assert!(
1557 tables[0].list_context.is_none(),
1558 "Should NOT have list context since delimiter has no indentation"
1559 );
1560 }
1561
1562 #[test]
1563 fn test_list_table_with_properly_indented_delimiter() {
1564 let content = "- | Header 1 | Header 2 |\n |----------|----------|\n | Cell 1 | Cell 2 |";
1567 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1568 let tables = TableUtils::find_table_blocks(content, &ctx);
1569
1570 assert_eq!(tables.len(), 1, "Should find exactly one table");
1572 assert_eq!(tables[0].start_line, 0, "Table should start at list item line");
1573 assert!(
1574 tables[0].list_context.is_some(),
1575 "Should be a list table since delimiter is properly indented"
1576 );
1577 }
1578}