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 pub fn is_potential_table_row(line: &str) -> bool {
34 let trimmed = line.trim();
35 if trimmed.is_empty() || !trimmed.contains('|') {
36 return false;
37 }
38
39 if trimmed.starts_with("- ")
42 || trimmed.starts_with("* ")
43 || trimmed.starts_with("+ ")
44 || trimmed.starts_with("-\t")
45 || trimmed.starts_with("*\t")
46 || trimmed.starts_with("+\t")
47 {
48 return false;
49 }
50
51 if let Some(first_non_digit) = trimmed.find(|c: char| !c.is_ascii_digit())
53 && first_non_digit > 0
54 {
55 let after_digits = &trimmed[first_non_digit..];
56 if after_digits.starts_with(". ")
57 || after_digits.starts_with(".\t")
58 || after_digits.starts_with(") ")
59 || after_digits.starts_with(")\t")
60 {
61 return false;
62 }
63 }
64
65 if trimmed.starts_with("`") || trimmed.contains("``") {
67 return false;
68 }
69
70 let parts: Vec<&str> = trimmed.split('|').collect();
72 if parts.len() < 2 {
73 return false;
74 }
75
76 let mut valid_parts = 0;
78 let mut total_non_empty_parts = 0;
79
80 for part in &parts {
81 let part_trimmed = part.trim();
82 if part_trimmed.is_empty() {
84 continue;
85 }
86 total_non_empty_parts += 1;
87
88 if !part_trimmed.contains('\n') {
90 valid_parts += 1;
91 }
92 }
93
94 if total_non_empty_parts > 0 && valid_parts != total_non_empty_parts {
96 return false;
98 }
99
100 if total_non_empty_parts == 0 {
103 return trimmed.starts_with('|') && trimmed.ends_with('|') && parts.len() >= 3;
105 }
106
107 if trimmed.starts_with('|') && trimmed.ends_with('|') {
110 valid_parts >= 1
112 } else {
113 valid_parts >= 2
115 }
116 }
117
118 pub fn is_delimiter_row(line: &str) -> bool {
120 let trimmed = line.trim();
121 if !trimmed.contains('|') || !trimmed.contains('-') {
122 return false;
123 }
124
125 let parts: Vec<&str> = trimmed.split('|').collect();
127 let mut valid_delimiter_parts = 0;
128 let mut total_non_empty_parts = 0;
129
130 for part in &parts {
131 let part_trimmed = part.trim();
132 if part_trimmed.is_empty() {
133 continue; }
135
136 total_non_empty_parts += 1;
137
138 if part_trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace()) && part_trimmed.contains('-') {
140 valid_delimiter_parts += 1;
141 }
142 }
143
144 total_non_empty_parts > 0 && valid_delimiter_parts == total_non_empty_parts
146 }
147
148 fn strip_blockquote_prefix(line: &str) -> &str {
150 let trimmed = line.trim_start();
151 if trimmed.starts_with('>') {
152 let mut rest = trimmed;
154 while rest.starts_with('>') {
155 rest = rest.strip_prefix('>').unwrap_or(rest);
156 rest = rest.trim_start_matches(' ');
157 }
158 rest
159 } else {
160 line
161 }
162 }
163
164 pub fn find_table_blocks_with_code_info(
167 content: &str,
168 code_blocks: &[(usize, usize)],
169 code_spans: &[crate::lint_context::CodeSpan],
170 html_comment_ranges: &[crate::utils::skip_context::ByteRange],
171 ) -> Vec<TableBlock> {
172 let lines: Vec<&str> = content.lines().collect();
173 let mut tables = Vec::new();
174 let mut i = 0;
175
176 let mut line_positions = Vec::with_capacity(lines.len());
178 let mut pos = 0;
179 for line in &lines {
180 line_positions.push(pos);
181 pos += line.len() + 1; }
183
184 while i < lines.len() {
185 let line_start = line_positions[i];
187 let in_code =
188 crate::utils::code_block_utils::CodeBlockUtils::is_in_code_block_or_span(code_blocks, line_start)
189 || code_spans
190 .iter()
191 .any(|span| line_start >= span.byte_offset && line_start < span.byte_end);
192 let in_html_comment = html_comment_ranges
193 .iter()
194 .any(|range| line_start >= range.start && line_start < range.end);
195
196 if in_code || in_html_comment {
197 i += 1;
198 continue;
199 }
200
201 let line_content = Self::strip_blockquote_prefix(lines[i]);
203
204 let (list_prefix, list_content, content_indent) = Self::extract_list_prefix(line_content);
206 let (is_list_table, effective_content) =
207 if !list_prefix.is_empty() && Self::is_potential_table_row_content(list_content) {
208 (true, list_content)
209 } else {
210 (false, line_content)
211 };
212
213 if is_list_table || Self::is_potential_table_row(effective_content) {
215 let (next_line_content, delimiter_has_valid_indent) = if i + 1 < lines.len() {
218 let next_raw = Self::strip_blockquote_prefix(lines[i + 1]);
219 if is_list_table {
220 let leading_spaces = next_raw.len() - next_raw.trim_start().len();
223 if leading_spaces >= content_indent {
224 (Self::strip_list_continuation_indent(next_raw, content_indent), true)
226 } else {
227 (next_raw, false)
229 }
230 } else {
231 (next_raw, true)
232 }
233 } else {
234 ("", true)
235 };
236
237 let effective_is_list_table = is_list_table && delimiter_has_valid_indent;
239
240 if i + 1 < lines.len() && Self::is_delimiter_row(next_line_content) {
241 let table_start = i;
243 let header_line = i;
244 let delimiter_line = i + 1;
245 let mut table_end = i + 1; let mut content_lines = Vec::new();
247
248 let mut j = i + 2;
250 while j < lines.len() {
251 let line = lines[j];
252 let raw_content = Self::strip_blockquote_prefix(line);
254
255 let line_content = if effective_is_list_table {
257 Self::strip_list_continuation_indent(raw_content, content_indent)
258 } else {
259 raw_content
260 };
261
262 if line_content.trim().is_empty() {
263 break;
265 }
266
267 if effective_is_list_table {
269 let leading_spaces = raw_content.len() - raw_content.trim_start().len();
270 if leading_spaces < content_indent {
271 break;
273 }
274 }
275
276 if Self::is_potential_table_row(line_content) {
277 content_lines.push(j);
278 table_end = j;
279 j += 1;
280 } else {
281 break;
283 }
284 }
285
286 let list_context = if effective_is_list_table {
287 Some(ListTableContext {
288 list_prefix: list_prefix.to_string(),
289 content_indent,
290 })
291 } else {
292 None
293 };
294
295 tables.push(TableBlock {
296 start_line: table_start,
297 end_line: table_end,
298 header_line,
299 delimiter_line,
300 content_lines,
301 list_context,
302 });
303 i = table_end + 1;
304 } else {
305 i += 1;
306 }
307 } else {
308 i += 1;
309 }
310 }
311
312 tables
313 }
314
315 fn strip_list_continuation_indent(line: &str, expected_indent: usize) -> &str {
318 let bytes = line.as_bytes();
319 let mut spaces = 0;
320
321 for &b in bytes {
322 if b == b' ' {
323 spaces += 1;
324 } else if b == b'\t' {
325 spaces = (spaces / 4 + 1) * 4;
327 } else {
328 break;
329 }
330
331 if spaces >= expected_indent {
332 break;
333 }
334 }
335
336 let strip_count = spaces.min(expected_indent).min(line.len());
338 let mut byte_count = 0;
340 let mut counted_spaces = 0;
341 for &b in bytes {
342 if counted_spaces >= strip_count {
343 break;
344 }
345 if b == b' ' {
346 counted_spaces += 1;
347 byte_count += 1;
348 } else if b == b'\t' {
349 counted_spaces = (counted_spaces / 4 + 1) * 4;
350 byte_count += 1;
351 } else {
352 break;
353 }
354 }
355
356 &line[byte_count..]
357 }
358
359 pub fn find_table_blocks(content: &str, ctx: &crate::lint_context::LintContext) -> Vec<TableBlock> {
362 Self::find_table_blocks_with_code_info(content, &ctx.code_blocks, &ctx.code_spans(), ctx.html_comment_ranges())
363 }
364
365 pub fn count_cells(row: &str) -> usize {
367 Self::count_cells_with_flavor(row, crate::config::MarkdownFlavor::Standard)
368 }
369
370 pub fn count_cells_with_flavor(row: &str, flavor: crate::config::MarkdownFlavor) -> usize {
378 let (_, content) = Self::extract_blockquote_prefix(row);
380 Self::split_table_row_with_flavor(content, flavor).len()
381 }
382
383 pub fn mask_pipes_in_inline_code(text: &str) -> String {
385 let mut result = String::new();
386 let chars: Vec<char> = text.chars().collect();
387 let mut i = 0;
388
389 while i < chars.len() {
390 if chars[i] == '`' {
391 let start = i;
393 let mut backtick_count = 0;
394 while i < chars.len() && chars[i] == '`' {
395 backtick_count += 1;
396 i += 1;
397 }
398
399 let mut found_closing = false;
401 let mut j = i;
402
403 while j < chars.len() {
404 if chars[j] == '`' {
405 let close_start = j;
407 let mut close_count = 0;
408 while j < chars.len() && chars[j] == '`' {
409 close_count += 1;
410 j += 1;
411 }
412
413 if close_count == backtick_count {
414 found_closing = true;
416
417 result.extend(chars[start..i].iter());
419
420 for &ch in chars.iter().take(close_start).skip(i) {
421 if ch == '|' {
422 result.push('_'); } else {
424 result.push(ch);
425 }
426 }
427
428 result.extend(chars[close_start..j].iter());
429 i = j;
430 break;
431 }
432 } else {
434 j += 1;
435 }
436 }
437
438 if !found_closing {
439 result.extend(chars[start..i].iter());
441 }
442 } else {
443 result.push(chars[i]);
444 i += 1;
445 }
446 }
447
448 result
449 }
450
451 pub fn escape_pipes_in_inline_code(text: &str) -> String {
455 let mut result = String::new();
456 let chars: Vec<char> = text.chars().collect();
457 let mut i = 0;
458
459 while i < chars.len() {
460 if chars[i] == '`' {
461 let start = i;
462 let mut backtick_count = 0;
463 while i < chars.len() && chars[i] == '`' {
464 backtick_count += 1;
465 i += 1;
466 }
467
468 let mut found_closing = false;
469 let mut j = i;
470
471 while j < chars.len() {
472 if chars[j] == '`' {
473 let close_start = j;
474 let mut close_count = 0;
475 while j < chars.len() && chars[j] == '`' {
476 close_count += 1;
477 j += 1;
478 }
479
480 if close_count == backtick_count {
481 found_closing = true;
482 result.extend(chars[start..i].iter());
483
484 for &ch in chars.iter().take(close_start).skip(i) {
485 if ch == '|' {
486 result.push('\\');
487 result.push('|');
488 } else {
489 result.push(ch);
490 }
491 }
492
493 result.extend(chars[close_start..j].iter());
494 i = j;
495 break;
496 }
497 } else {
498 j += 1;
499 }
500 }
501
502 if !found_closing {
503 result.extend(chars[start..i].iter());
504 }
505 } else {
506 result.push(chars[i]);
507 i += 1;
508 }
509 }
510
511 result
512 }
513
514 pub fn mask_pipes_for_table_parsing(text: &str) -> String {
527 let mut result = String::new();
528 let chars: Vec<char> = text.chars().collect();
529 let mut i = 0;
530
531 while i < chars.len() {
532 if chars[i] == '\\' {
533 if i + 1 < chars.len() && chars[i + 1] == '\\' {
534 result.push('\\');
537 result.push('\\');
538 i += 2;
539 } else if i + 1 < chars.len() && chars[i + 1] == '|' {
540 result.push('\\');
542 result.push('_'); i += 2;
544 } else {
545 result.push(chars[i]);
547 i += 1;
548 }
549 } else {
550 result.push(chars[i]);
551 i += 1;
552 }
553 }
554
555 result
556 }
557
558 pub fn split_table_row_with_flavor(row: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
566 let trimmed = row.trim();
567
568 if !trimmed.contains('|') {
569 return Vec::new();
570 }
571
572 let masked = Self::mask_pipes_for_table_parsing(trimmed);
574
575 let final_masked = if flavor == crate::config::MarkdownFlavor::MkDocs {
577 Self::mask_pipes_in_inline_code(&masked)
578 } else {
579 masked
580 };
581
582 let has_leading = final_masked.starts_with('|');
583 let has_trailing = final_masked.ends_with('|');
584
585 let mut masked_content = final_masked.as_str();
586 let mut orig_content = trimmed;
587
588 if has_leading {
589 masked_content = &masked_content[1..];
590 orig_content = &orig_content[1..];
591 }
592
593 let stripped_trailing = has_trailing && !masked_content.is_empty();
595 if stripped_trailing {
596 masked_content = &masked_content[..masked_content.len() - 1];
597 orig_content = &orig_content[..orig_content.len() - 1];
598 }
599
600 if masked_content.is_empty() {
602 if stripped_trailing {
603 return vec![String::new()];
605 } else {
606 return Vec::new();
608 }
609 }
610
611 let masked_parts: Vec<&str> = masked_content.split('|').collect();
612 let mut cells = Vec::new();
613 let mut pos = 0;
614
615 for masked_cell in masked_parts {
616 let cell_len = masked_cell.len();
617 let orig_cell = if pos + cell_len <= orig_content.len() {
618 &orig_content[pos..pos + cell_len]
619 } else {
620 masked_cell
621 };
622 cells.push(orig_cell.to_string());
623 pos += cell_len + 1; }
625
626 cells
627 }
628
629 pub fn split_table_row(row: &str) -> Vec<String> {
631 Self::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Standard)
632 }
633
634 pub fn determine_pipe_style(line: &str) -> Option<&'static str> {
639 let content = Self::strip_blockquote_prefix(line);
641 let trimmed = content.trim();
642 if !trimmed.contains('|') {
643 return None;
644 }
645
646 let has_leading = trimmed.starts_with('|');
647 let has_trailing = trimmed.ends_with('|');
648
649 match (has_leading, has_trailing) {
650 (true, true) => Some("leading_and_trailing"),
651 (true, false) => Some("leading_only"),
652 (false, true) => Some("trailing_only"),
653 (false, false) => Some("no_leading_or_trailing"),
654 }
655 }
656
657 pub fn extract_blockquote_prefix(line: &str) -> (&str, &str) {
662 let bytes = line.as_bytes();
664 let mut pos = 0;
665
666 while pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
668 pos += 1;
669 }
670
671 if pos >= bytes.len() || bytes[pos] != b'>' {
673 return ("", line);
674 }
675
676 while pos < bytes.len() {
678 if bytes[pos] == b'>' {
679 pos += 1;
680 if pos < bytes.len() && bytes[pos] == b' ' {
682 pos += 1;
683 }
684 } else if bytes[pos] == b' ' || bytes[pos] == b'\t' {
685 pos += 1;
686 } else {
687 break;
688 }
689 }
690
691 (&line[..pos], &line[pos..])
693 }
694
695 pub fn extract_list_prefix(line: &str) -> (&str, &str, usize) {
710 let bytes = line.as_bytes();
711
712 let leading_spaces = bytes.iter().take_while(|&&b| b == b' ' || b == b'\t').count();
714 let mut pos = leading_spaces;
715
716 if pos >= bytes.len() {
717 return ("", line, 0);
718 }
719
720 if matches!(bytes[pos], b'-' | b'*' | b'+') {
722 pos += 1;
723
724 if pos >= bytes.len() || bytes[pos] == b' ' || bytes[pos] == b'\t' {
726 if pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
728 pos += 1;
729 }
730 let content_indent = pos;
731 return (&line[..pos], &line[pos..], content_indent);
732 }
733 return ("", line, 0);
735 }
736
737 if bytes[pos].is_ascii_digit() {
739 let digit_start = pos;
740 while pos < bytes.len() && bytes[pos].is_ascii_digit() {
741 pos += 1;
742 }
743
744 if pos > digit_start && pos < bytes.len() {
746 if bytes[pos] == b'.' || bytes[pos] == b')' {
748 pos += 1;
749 if pos >= bytes.len() || bytes[pos] == b' ' || bytes[pos] == b'\t' {
750 if pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
752 pos += 1;
753 }
754 let content_indent = pos;
755 return (&line[..pos], &line[pos..], content_indent);
756 }
757 }
758 }
759 }
760
761 ("", line, 0)
762 }
763
764 pub fn extract_table_row_content<'a>(line: &'a str, table_block: &TableBlock, line_index: usize) -> &'a str {
769 let (_, after_blockquote) = Self::extract_blockquote_prefix(line);
771
772 if let Some(ref list_ctx) = table_block.list_context {
774 if line_index == 0 {
775 Self::extract_list_prefix(after_blockquote).1
777 } else {
778 Self::strip_list_continuation_indent(after_blockquote, list_ctx.content_indent)
780 }
781 } else {
782 after_blockquote
783 }
784 }
785
786 pub fn is_list_item_with_table_row(line: &str) -> bool {
789 let (prefix, content, _) = Self::extract_list_prefix(line);
790 if prefix.is_empty() {
791 return false;
792 }
793
794 let trimmed = content.trim();
797 if !trimmed.starts_with('|') {
798 return false;
799 }
800
801 Self::is_potential_table_row_content(content)
803 }
804
805 fn is_potential_table_row_content(content: &str) -> bool {
807 let trimmed = content.trim();
808 if trimmed.is_empty() || !trimmed.contains('|') {
809 return false;
810 }
811
812 if trimmed.starts_with('`') || trimmed.contains("``") {
814 return false;
815 }
816
817 let parts: Vec<&str> = trimmed.split('|').collect();
819 if parts.len() < 2 {
820 return false;
821 }
822
823 let mut valid_parts = 0;
825 let mut total_non_empty_parts = 0;
826
827 for part in &parts {
828 let part_trimmed = part.trim();
829 if part_trimmed.is_empty() {
830 continue;
831 }
832 total_non_empty_parts += 1;
833
834 if !part_trimmed.contains('\n') {
835 valid_parts += 1;
836 }
837 }
838
839 if total_non_empty_parts > 0 && valid_parts != total_non_empty_parts {
840 return false;
841 }
842
843 if total_non_empty_parts == 0 {
844 return trimmed.starts_with('|') && trimmed.ends_with('|') && parts.len() >= 3;
845 }
846
847 if trimmed.starts_with('|') && trimmed.ends_with('|') {
848 valid_parts >= 1
849 } else {
850 valid_parts >= 2
851 }
852 }
853}
854
855#[cfg(test)]
856mod tests {
857 use super::*;
858 use crate::lint_context::LintContext;
859
860 #[test]
861 fn test_is_potential_table_row() {
862 assert!(TableUtils::is_potential_table_row("| Header 1 | Header 2 |"));
864 assert!(TableUtils::is_potential_table_row("| Cell 1 | Cell 2 |"));
865 assert!(TableUtils::is_potential_table_row("Cell 1 | Cell 2"));
866 assert!(TableUtils::is_potential_table_row("| Cell |")); assert!(TableUtils::is_potential_table_row("| A | B | C | D | E |"));
870
871 assert!(TableUtils::is_potential_table_row(" | Indented | Table | "));
873 assert!(TableUtils::is_potential_table_row("| Spaces | Around |"));
874
875 assert!(!TableUtils::is_potential_table_row("- List item"));
877 assert!(!TableUtils::is_potential_table_row("* Another list"));
878 assert!(!TableUtils::is_potential_table_row("+ Plus list"));
879 assert!(!TableUtils::is_potential_table_row("Regular text"));
880 assert!(!TableUtils::is_potential_table_row(""));
881 assert!(!TableUtils::is_potential_table_row(" "));
882
883 assert!(!TableUtils::is_potential_table_row("`code with | pipe`"));
885 assert!(!TableUtils::is_potential_table_row("``multiple | backticks``"));
886
887 assert!(!TableUtils::is_potential_table_row("Just one |"));
889 assert!(!TableUtils::is_potential_table_row("| Just one"));
890
891 let long_cell = "a".repeat(150);
893 assert!(TableUtils::is_potential_table_row(&format!("| {long_cell} | b |")));
894
895 assert!(!TableUtils::is_potential_table_row("| Cell with\nnewline | Other |"));
897
898 assert!(TableUtils::is_potential_table_row("|||")); assert!(TableUtils::is_potential_table_row("||||")); assert!(TableUtils::is_potential_table_row("| | |")); }
903
904 #[test]
905 fn test_list_items_with_pipes_not_table_rows() {
906 assert!(!TableUtils::is_potential_table_row("1. Item with | pipe"));
908 assert!(!TableUtils::is_potential_table_row("10. Item with | pipe"));
909 assert!(!TableUtils::is_potential_table_row("999. Item with | pipe"));
910 assert!(!TableUtils::is_potential_table_row("1) Item with | pipe"));
911 assert!(!TableUtils::is_potential_table_row("10) Item with | pipe"));
912
913 assert!(!TableUtils::is_potential_table_row("-\tItem with | pipe"));
915 assert!(!TableUtils::is_potential_table_row("*\tItem with | pipe"));
916 assert!(!TableUtils::is_potential_table_row("+\tItem with | pipe"));
917
918 assert!(!TableUtils::is_potential_table_row(" - Indented | pipe"));
920 assert!(!TableUtils::is_potential_table_row(" * Deep indent | pipe"));
921 assert!(!TableUtils::is_potential_table_row(" 1. Ordered indent | pipe"));
922
923 assert!(!TableUtils::is_potential_table_row("- [ ] task | pipe"));
925 assert!(!TableUtils::is_potential_table_row("- [x] done | pipe"));
926
927 assert!(!TableUtils::is_potential_table_row("1. foo | bar | baz"));
929 assert!(!TableUtils::is_potential_table_row("- alpha | beta | gamma"));
930
931 assert!(TableUtils::is_potential_table_row("| cell | cell |"));
933 assert!(TableUtils::is_potential_table_row("cell | cell"));
934 assert!(TableUtils::is_potential_table_row("| Header | Header |"));
935 }
936
937 #[test]
938 fn test_is_delimiter_row() {
939 assert!(TableUtils::is_delimiter_row("|---|---|"));
941 assert!(TableUtils::is_delimiter_row("| --- | --- |"));
942 assert!(TableUtils::is_delimiter_row("|:---|---:|"));
943 assert!(TableUtils::is_delimiter_row("|:---:|:---:|"));
944
945 assert!(TableUtils::is_delimiter_row("|-|--|"));
947 assert!(TableUtils::is_delimiter_row("|-------|----------|"));
948
949 assert!(TableUtils::is_delimiter_row("| --- | --- |"));
951 assert!(TableUtils::is_delimiter_row("| :--- | ---: |"));
952
953 assert!(TableUtils::is_delimiter_row("|---|---|---|---|"));
955
956 assert!(TableUtils::is_delimiter_row("--- | ---"));
958 assert!(TableUtils::is_delimiter_row(":--- | ---:"));
959
960 assert!(!TableUtils::is_delimiter_row("| Header | Header |"));
962 assert!(!TableUtils::is_delimiter_row("Regular text"));
963 assert!(!TableUtils::is_delimiter_row(""));
964 assert!(!TableUtils::is_delimiter_row("|||"));
965 assert!(!TableUtils::is_delimiter_row("| | |"));
966
967 assert!(!TableUtils::is_delimiter_row("| : | : |"));
969 assert!(!TableUtils::is_delimiter_row("| | |"));
970
971 assert!(!TableUtils::is_delimiter_row("| --- | text |"));
973 assert!(!TableUtils::is_delimiter_row("| abc | --- |"));
974 }
975
976 #[test]
977 fn test_count_cells() {
978 assert_eq!(TableUtils::count_cells("| Cell 1 | Cell 2 | Cell 3 |"), 3);
980 assert_eq!(TableUtils::count_cells("Cell 1 | Cell 2 | Cell 3"), 3);
981 assert_eq!(TableUtils::count_cells("| Cell 1 | Cell 2"), 2);
982 assert_eq!(TableUtils::count_cells("Cell 1 | Cell 2 |"), 2);
983
984 assert_eq!(TableUtils::count_cells("| Cell |"), 1);
986 assert_eq!(TableUtils::count_cells("Cell"), 0); assert_eq!(TableUtils::count_cells("| | | |"), 3);
990 assert_eq!(TableUtils::count_cells("| | | |"), 3);
991
992 assert_eq!(TableUtils::count_cells("| A | B | C | D | E | F |"), 6);
994
995 assert_eq!(TableUtils::count_cells("||"), 1); assert_eq!(TableUtils::count_cells("|||"), 2); assert_eq!(TableUtils::count_cells("Regular text"), 0);
1001 assert_eq!(TableUtils::count_cells(""), 0);
1002 assert_eq!(TableUtils::count_cells(" "), 0);
1003
1004 assert_eq!(TableUtils::count_cells(" | A | B | "), 2);
1006 assert_eq!(TableUtils::count_cells("| A | B |"), 2);
1007 }
1008
1009 #[test]
1010 fn test_count_cells_with_escaped_pipes() {
1011 assert_eq!(TableUtils::count_cells("| Challenge | Solution |"), 2);
1017 assert_eq!(TableUtils::count_cells("| A | B | C |"), 3);
1018 assert_eq!(TableUtils::count_cells("| One | Two |"), 2);
1019
1020 assert_eq!(TableUtils::count_cells(r"| Command | echo \| grep |"), 2);
1022 assert_eq!(TableUtils::count_cells(r"| A | B \| C |"), 2); assert_eq!(TableUtils::count_cells(r"| Command | `echo \| grep` |"), 2);
1026
1027 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);
1034 assert_eq!(TableUtils::count_cells("| `code | one` | `code | two` |"), 4);
1035 assert_eq!(TableUtils::count_cells("| `single|pipe` |"), 2);
1036
1037 assert_eq!(TableUtils::count_cells(r"| Hour formats | `^([0-1]?\d|2[0-3])` |"), 3);
1040 assert_eq!(TableUtils::count_cells(r"| Hour formats | `^([0-1]?\d\|2[0-3])` |"), 2);
1042 }
1043
1044 #[test]
1045 fn test_determine_pipe_style() {
1046 assert_eq!(
1048 TableUtils::determine_pipe_style("| Cell 1 | Cell 2 |"),
1049 Some("leading_and_trailing")
1050 );
1051 assert_eq!(
1052 TableUtils::determine_pipe_style("| Cell 1 | Cell 2"),
1053 Some("leading_only")
1054 );
1055 assert_eq!(
1056 TableUtils::determine_pipe_style("Cell 1 | Cell 2 |"),
1057 Some("trailing_only")
1058 );
1059 assert_eq!(
1060 TableUtils::determine_pipe_style("Cell 1 | Cell 2"),
1061 Some("no_leading_or_trailing")
1062 );
1063
1064 assert_eq!(
1066 TableUtils::determine_pipe_style(" | Cell 1 | Cell 2 | "),
1067 Some("leading_and_trailing")
1068 );
1069 assert_eq!(
1070 TableUtils::determine_pipe_style(" | Cell 1 | Cell 2 "),
1071 Some("leading_only")
1072 );
1073
1074 assert_eq!(TableUtils::determine_pipe_style("Regular text"), None);
1076 assert_eq!(TableUtils::determine_pipe_style(""), None);
1077 assert_eq!(TableUtils::determine_pipe_style(" "), None);
1078
1079 assert_eq!(TableUtils::determine_pipe_style("|"), Some("leading_and_trailing"));
1081 assert_eq!(TableUtils::determine_pipe_style("| Cell"), Some("leading_only"));
1082 assert_eq!(TableUtils::determine_pipe_style("Cell |"), Some("trailing_only"));
1083 }
1084
1085 #[test]
1086 fn test_find_table_blocks_simple() {
1087 let content = "| Header 1 | Header 2 |
1088|-----------|-----------|
1089| Cell 1 | Cell 2 |
1090| Cell 3 | Cell 4 |";
1091
1092 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1093
1094 let tables = TableUtils::find_table_blocks(content, &ctx);
1095 assert_eq!(tables.len(), 1);
1096
1097 let table = &tables[0];
1098 assert_eq!(table.start_line, 0);
1099 assert_eq!(table.end_line, 3);
1100 assert_eq!(table.header_line, 0);
1101 assert_eq!(table.delimiter_line, 1);
1102 assert_eq!(table.content_lines, vec![2, 3]);
1103 }
1104
1105 #[test]
1106 fn test_find_table_blocks_multiple() {
1107 let content = "Some text
1108
1109| Table 1 | Col A |
1110|----------|-------|
1111| Data 1 | Val 1 |
1112
1113More text
1114
1115| Table 2 | Col 2 |
1116|----------|-------|
1117| Data 2 | Data |";
1118
1119 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1120
1121 let tables = TableUtils::find_table_blocks(content, &ctx);
1122 assert_eq!(tables.len(), 2);
1123
1124 assert_eq!(tables[0].start_line, 2);
1126 assert_eq!(tables[0].end_line, 4);
1127 assert_eq!(tables[0].header_line, 2);
1128 assert_eq!(tables[0].delimiter_line, 3);
1129 assert_eq!(tables[0].content_lines, vec![4]);
1130
1131 assert_eq!(tables[1].start_line, 8);
1133 assert_eq!(tables[1].end_line, 10);
1134 assert_eq!(tables[1].header_line, 8);
1135 assert_eq!(tables[1].delimiter_line, 9);
1136 assert_eq!(tables[1].content_lines, vec![10]);
1137 }
1138
1139 #[test]
1140 fn test_find_table_blocks_no_content_rows() {
1141 let content = "| Header 1 | Header 2 |
1142|-----------|-----------|
1143
1144Next paragraph";
1145
1146 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1147
1148 let tables = TableUtils::find_table_blocks(content, &ctx);
1149 assert_eq!(tables.len(), 1);
1150
1151 let table = &tables[0];
1152 assert_eq!(table.start_line, 0);
1153 assert_eq!(table.end_line, 1); assert_eq!(table.content_lines.len(), 0);
1155 }
1156
1157 #[test]
1158 fn test_find_table_blocks_in_code_block() {
1159 let content = "```
1160| Not | A | Table |
1161|-----|---|-------|
1162| In | Code | Block |
1163```
1164
1165| Real | Table |
1166|------|-------|
1167| Data | Here |";
1168
1169 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1170
1171 let tables = TableUtils::find_table_blocks(content, &ctx);
1172 assert_eq!(tables.len(), 1); let table = &tables[0];
1175 assert_eq!(table.header_line, 6);
1176 assert_eq!(table.delimiter_line, 7);
1177 }
1178
1179 #[test]
1180 fn test_find_table_blocks_no_tables() {
1181 let content = "Just regular text
1182No tables here
1183- List item with | pipe
1184* Another list item";
1185
1186 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1187
1188 let tables = TableUtils::find_table_blocks(content, &ctx);
1189 assert_eq!(tables.len(), 0);
1190 }
1191
1192 #[test]
1193 fn test_find_table_blocks_malformed() {
1194 let content = "| Header without delimiter |
1195| This looks like table |
1196But no delimiter row
1197
1198| Proper | Table |
1199|---------|-------|
1200| Data | Here |";
1201
1202 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1203
1204 let tables = TableUtils::find_table_blocks(content, &ctx);
1205 assert_eq!(tables.len(), 1); assert_eq!(tables[0].header_line, 4);
1207 }
1208
1209 #[test]
1210 fn test_edge_cases() {
1211 assert!(!TableUtils::is_potential_table_row(""));
1213 assert!(!TableUtils::is_delimiter_row(""));
1214 assert_eq!(TableUtils::count_cells(""), 0);
1215 assert_eq!(TableUtils::determine_pipe_style(""), None);
1216
1217 assert!(!TableUtils::is_potential_table_row(" "));
1219 assert!(!TableUtils::is_delimiter_row(" "));
1220 assert_eq!(TableUtils::count_cells(" "), 0);
1221 assert_eq!(TableUtils::determine_pipe_style(" "), None);
1222
1223 assert!(!TableUtils::is_potential_table_row("|"));
1225 assert!(!TableUtils::is_delimiter_row("|"));
1226 assert_eq!(TableUtils::count_cells("|"), 0); let long_single = format!("| {} |", "a".repeat(200));
1231 assert!(TableUtils::is_potential_table_row(&long_single)); let long_multi = format!("| {} | {} |", "a".repeat(200), "b".repeat(200));
1234 assert!(TableUtils::is_potential_table_row(&long_multi)); assert!(TableUtils::is_potential_table_row("| 你好 | 世界 |"));
1238 assert!(TableUtils::is_potential_table_row("| émoji | 🎉 |"));
1239 assert_eq!(TableUtils::count_cells("| 你好 | 世界 |"), 2);
1240 }
1241
1242 #[test]
1243 fn test_table_block_struct() {
1244 let block = TableBlock {
1245 start_line: 0,
1246 end_line: 5,
1247 header_line: 0,
1248 delimiter_line: 1,
1249 content_lines: vec![2, 3, 4, 5],
1250 list_context: None,
1251 };
1252
1253 let debug_str = format!("{block:?}");
1255 assert!(debug_str.contains("TableBlock"));
1256 assert!(debug_str.contains("start_line: 0"));
1257
1258 let cloned = block.clone();
1260 assert_eq!(cloned.start_line, block.start_line);
1261 assert_eq!(cloned.end_line, block.end_line);
1262 assert_eq!(cloned.header_line, block.header_line);
1263 assert_eq!(cloned.delimiter_line, block.delimiter_line);
1264 assert_eq!(cloned.content_lines, block.content_lines);
1265 assert!(cloned.list_context.is_none());
1266 }
1267
1268 #[test]
1269 fn test_split_table_row() {
1270 let cells = TableUtils::split_table_row("| Cell 1 | Cell 2 | Cell 3 |");
1272 assert_eq!(cells.len(), 3);
1273 assert_eq!(cells[0].trim(), "Cell 1");
1274 assert_eq!(cells[1].trim(), "Cell 2");
1275 assert_eq!(cells[2].trim(), "Cell 3");
1276
1277 let cells = TableUtils::split_table_row("| Cell 1 | Cell 2");
1279 assert_eq!(cells.len(), 2);
1280
1281 let cells = TableUtils::split_table_row("| | | |");
1283 assert_eq!(cells.len(), 3);
1284
1285 let cells = TableUtils::split_table_row("| Cell |");
1287 assert_eq!(cells.len(), 1);
1288 assert_eq!(cells[0].trim(), "Cell");
1289
1290 let cells = TableUtils::split_table_row("No pipes here");
1292 assert_eq!(cells.len(), 0);
1293 }
1294
1295 #[test]
1296 fn test_split_table_row_with_escaped_pipes() {
1297 let cells = TableUtils::split_table_row(r"| A | B \| C |");
1299 assert_eq!(cells.len(), 2);
1300 assert!(cells[1].contains(r"\|"), "Escaped pipe should be in cell content");
1301
1302 let cells = TableUtils::split_table_row(r"| A | B \\| C |");
1304 assert_eq!(cells.len(), 3);
1305 }
1306
1307 #[test]
1308 fn test_split_table_row_with_flavor_mkdocs() {
1309 let cells =
1311 TableUtils::split_table_row_with_flavor("| Type | `x | y` |", crate::config::MarkdownFlavor::MkDocs);
1312 assert_eq!(cells.len(), 2);
1313 assert!(
1314 cells[1].contains("`x | y`"),
1315 "Inline code with pipe should be single cell in MkDocs flavor"
1316 );
1317
1318 let cells =
1320 TableUtils::split_table_row_with_flavor("| Type | `a | b | c` |", crate::config::MarkdownFlavor::MkDocs);
1321 assert_eq!(cells.len(), 2);
1322 assert!(cells[1].contains("`a | b | c`"));
1323 }
1324
1325 #[test]
1326 fn test_split_table_row_with_flavor_standard() {
1327 let cells =
1329 TableUtils::split_table_row_with_flavor("| Type | `x | y` |", crate::config::MarkdownFlavor::Standard);
1330 assert_eq!(cells.len(), 3);
1332 }
1333
1334 #[test]
1337 fn test_extract_blockquote_prefix_no_blockquote() {
1338 let (prefix, content) = TableUtils::extract_blockquote_prefix("| H1 | H2 |");
1340 assert_eq!(prefix, "");
1341 assert_eq!(content, "| H1 | H2 |");
1342 }
1343
1344 #[test]
1345 fn test_extract_blockquote_prefix_single_level() {
1346 let (prefix, content) = TableUtils::extract_blockquote_prefix("> | H1 | H2 |");
1348 assert_eq!(prefix, "> ");
1349 assert_eq!(content, "| H1 | H2 |");
1350 }
1351
1352 #[test]
1353 fn test_extract_blockquote_prefix_double_level() {
1354 let (prefix, content) = TableUtils::extract_blockquote_prefix(">> | H1 | H2 |");
1356 assert_eq!(prefix, ">> ");
1357 assert_eq!(content, "| H1 | H2 |");
1358 }
1359
1360 #[test]
1361 fn test_extract_blockquote_prefix_triple_level() {
1362 let (prefix, content) = TableUtils::extract_blockquote_prefix(">>> | H1 | H2 |");
1364 assert_eq!(prefix, ">>> ");
1365 assert_eq!(content, "| H1 | H2 |");
1366 }
1367
1368 #[test]
1369 fn test_extract_blockquote_prefix_with_spaces() {
1370 let (prefix, content) = TableUtils::extract_blockquote_prefix("> > | H1 | H2 |");
1372 assert_eq!(prefix, "> > ");
1373 assert_eq!(content, "| H1 | H2 |");
1374 }
1375
1376 #[test]
1377 fn test_extract_blockquote_prefix_indented() {
1378 let (prefix, content) = TableUtils::extract_blockquote_prefix(" > | H1 | H2 |");
1380 assert_eq!(prefix, " > ");
1381 assert_eq!(content, "| H1 | H2 |");
1382 }
1383
1384 #[test]
1385 fn test_extract_blockquote_prefix_no_space_after() {
1386 let (prefix, content) = TableUtils::extract_blockquote_prefix(">| H1 | H2 |");
1388 assert_eq!(prefix, ">");
1389 assert_eq!(content, "| H1 | H2 |");
1390 }
1391
1392 #[test]
1393 fn test_determine_pipe_style_in_blockquote() {
1394 assert_eq!(
1396 TableUtils::determine_pipe_style("> | H1 | H2 |"),
1397 Some("leading_and_trailing")
1398 );
1399 assert_eq!(
1400 TableUtils::determine_pipe_style("> H1 | H2"),
1401 Some("no_leading_or_trailing")
1402 );
1403 assert_eq!(
1404 TableUtils::determine_pipe_style(">> | H1 | H2 |"),
1405 Some("leading_and_trailing")
1406 );
1407 assert_eq!(TableUtils::determine_pipe_style(">>> | H1 | H2"), Some("leading_only"));
1408 }
1409
1410 #[test]
1411 fn test_list_table_delimiter_requires_indentation() {
1412 let content = "- List item with | pipe\n|---|---|\n| Cell 1 | Cell 2 |";
1417 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1418 let tables = TableUtils::find_table_blocks(content, &ctx);
1419
1420 assert_eq!(tables.len(), 1, "Should find exactly one table");
1423 assert!(
1424 tables[0].list_context.is_none(),
1425 "Should NOT have list context since delimiter has no indentation"
1426 );
1427 }
1428
1429 #[test]
1430 fn test_list_table_with_properly_indented_delimiter() {
1431 let content = "- | Header 1 | Header 2 |\n |----------|----------|\n | Cell 1 | Cell 2 |";
1434 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1435 let tables = TableUtils::find_table_blocks(content, &ctx);
1436
1437 assert_eq!(tables.len(), 1, "Should find exactly one table");
1439 assert_eq!(tables[0].start_line, 0, "Table should start at list item line");
1440 assert!(
1441 tables[0].list_context.is_some(),
1442 "Should be a list table since delimiter is properly indented"
1443 );
1444 }
1445}