1use super::blockquote::strip_blockquote_prefix;
6
7#[derive(Debug, Clone)]
9pub struct TableBlock {
10 pub start_line: usize,
11 pub end_line: usize,
12 pub header_line: usize,
13 pub delimiter_line: usize,
14 pub content_lines: Vec<usize>,
15 pub list_context: Option<ListTableContext>,
19}
20
21#[derive(Debug, Clone)]
23pub struct ListTableContext {
24 pub list_prefix: String,
26 pub content_indent: usize,
28}
29
30pub struct TableUtils;
32
33impl TableUtils {
34 fn has_unescaped_pipe_outside_spans(text: &str) -> bool {
46 let chars: Vec<char> = text.chars().collect();
47 let mut i = 0;
48 let mut in_code = false;
49 let mut code_delim_len = 0usize;
50 let mut in_math = false;
51 let mut math_delim_len = 0usize;
52
53 while i < chars.len() {
54 let ch = chars[i];
55
56 if ch == '\\' && !in_code && !in_math {
57 i += if i + 1 < chars.len() { 2 } else { 1 };
60 continue;
61 }
62
63 if ch == '`' && !in_math {
64 let mut run = 1usize;
65 while i + run < chars.len() && chars[i + run] == '`' {
66 run += 1;
67 }
68
69 if in_code {
70 if run == code_delim_len {
71 in_code = false;
72 code_delim_len = 0;
73 }
74 } else {
76 in_code = true;
77 code_delim_len = run;
78 }
79
80 i += run;
81 continue;
82 }
83
84 if ch == '$' && !in_code {
85 let mut run = 1usize;
86 while i + run < chars.len() && chars[i + run] == '$' {
87 run += 1;
88 }
89
90 if in_math {
91 if run == math_delim_len {
92 in_math = false;
93 math_delim_len = 0;
94 }
95 } else {
97 in_math = true;
98 math_delim_len = run;
99 }
100
101 i += run;
102 continue;
103 }
104
105 if ch == '|' && !in_code && !in_math {
106 return true;
107 }
108
109 i += 1;
110 }
111
112 false
113 }
114
115 pub fn is_potential_table_row_with_flavor(line: &str, flavor: crate::config::MarkdownFlavor) -> bool {
121 if flavor == crate::config::MarkdownFlavor::Obsidian && line.contains("[[") {
124 return Self::is_potential_table_row(&Self::mask_pipes_in_wikilinks(line));
125 }
126 Self::is_potential_table_row(line)
127 }
128
129 pub fn is_potential_table_row(line: &str) -> bool {
130 let trimmed = line.trim();
131 if trimmed.is_empty() || !trimmed.contains('|') {
132 return false;
133 }
134
135 if trimmed.starts_with("- ")
138 || trimmed.starts_with("* ")
139 || trimmed.starts_with("+ ")
140 || trimmed.starts_with("-\t")
141 || trimmed.starts_with("*\t")
142 || trimmed.starts_with("+\t")
143 {
144 return false;
145 }
146
147 if let Some(first_non_digit) = trimmed.find(|c: char| !c.is_ascii_digit())
149 && first_non_digit > 0
150 {
151 let after_digits = &trimmed[first_non_digit..];
152 if after_digits.starts_with(". ")
153 || after_digits.starts_with(".\t")
154 || after_digits.starts_with(") ")
155 || after_digits.starts_with(")\t")
156 {
157 return false;
158 }
159 }
160
161 if trimmed.starts_with('#') {
163 let hash_count = trimmed.bytes().take_while(|&b| b == b'#').count();
164 if hash_count <= 6 {
165 let after_hashes = &trimmed[hash_count..];
166 if after_hashes.is_empty() || after_hashes.starts_with(' ') || after_hashes.starts_with('\t') {
167 return false;
168 }
169 }
170 }
171
172 let has_outer_pipes = trimmed.starts_with('|') && trimmed.ends_with('|');
175 if !has_outer_pipes && !Self::has_unescaped_pipe_outside_spans(trimmed) {
176 return false;
177 }
178
179 let parts: Vec<&str> = trimmed.split('|').collect();
181 if parts.len() < 2 {
182 return false;
183 }
184
185 let mut valid_parts = 0;
187 let mut total_non_empty_parts = 0;
188
189 for part in &parts {
190 let part_trimmed = part.trim();
191 if part_trimmed.is_empty() {
193 continue;
194 }
195 total_non_empty_parts += 1;
196
197 if !part_trimmed.contains('\n') {
199 valid_parts += 1;
200 }
201 }
202
203 if total_non_empty_parts > 0 && valid_parts != total_non_empty_parts {
205 return false;
207 }
208
209 if total_non_empty_parts == 0 {
212 return trimmed.starts_with('|') && trimmed.ends_with('|') && parts.len() >= 3;
214 }
215
216 if trimmed.starts_with('|') && trimmed.ends_with('|') {
219 valid_parts >= 1
221 } else {
222 valid_parts >= 2
224 }
225 }
226
227 pub fn is_delimiter_row(line: &str) -> bool {
229 let trimmed = line.trim();
230 if !trimmed.contains('|') || !trimmed.contains('-') {
231 return false;
232 }
233
234 let parts: Vec<&str> = trimmed.split('|').collect();
236 let mut valid_delimiter_parts = 0;
237 let mut total_non_empty_parts = 0;
238
239 for part in &parts {
240 let part_trimmed = part.trim();
241 if part_trimmed.is_empty() {
242 continue; }
244
245 total_non_empty_parts += 1;
246
247 if part_trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace()) && part_trimmed.contains('-') {
249 valid_delimiter_parts += 1;
250 }
251 }
252
253 total_non_empty_parts > 0 && valid_delimiter_parts == total_non_empty_parts
255 }
256
257 pub fn find_table_blocks_with_code_info(
260 content: &str,
261 code_blocks: &[(usize, usize)],
262 code_spans: &[crate::lint_context::CodeSpan],
263 html_comment_ranges: &[crate::utils::skip_context::ByteRange],
264 flavor: crate::config::MarkdownFlavor,
265 ) -> Vec<TableBlock> {
266 let lines: Vec<&str> = content.lines().collect();
267 let mut tables = Vec::new();
268 let mut i = 0;
269
270 let mut line_positions = Vec::with_capacity(lines.len());
277 let content_bytes = content.as_bytes();
278 let mut pos = 0;
279 for line in &lines {
280 line_positions.push(pos);
281 pos += line.len();
282 if content_bytes.get(pos) == Some(&b'\r') {
283 pos += 1;
284 }
285 if content_bytes.get(pos) == Some(&b'\n') {
286 pos += 1;
287 }
288 }
289
290 let mut list_indent_stack: Vec<usize> = Vec::new();
294
295 while i < lines.len() {
296 let line_start = line_positions[i];
298 let in_code =
299 crate::utils::code_block_utils::CodeBlockUtils::is_in_code_block_or_span(code_blocks, line_start) || {
300 let idx = code_spans.partition_point(|span| span.byte_offset <= line_start);
302 idx > 0 && line_start < code_spans[idx - 1].byte_end
303 };
304 let in_html_comment = {
305 let idx = html_comment_ranges.partition_point(|range| range.start <= line_start);
307 idx > 0 && line_start < html_comment_ranges[idx - 1].end
308 };
309
310 if in_code || in_html_comment {
311 i += 1;
312 continue;
313 }
314
315 let line_content = strip_blockquote_prefix(lines[i]);
317
318 let (list_prefix, list_content, content_indent) = Self::extract_list_prefix(line_content);
320 if !list_prefix.is_empty() {
321 while list_indent_stack.last().is_some_and(|&top| top >= content_indent) {
323 list_indent_stack.pop();
324 }
325 list_indent_stack.push(content_indent);
326 } else if !line_content.trim().is_empty() {
327 let leading = line_content.len() - line_content.trim_start().len();
329 while list_indent_stack.last().is_some_and(|&top| leading < top) {
330 list_indent_stack.pop();
331 }
332 }
333 let (is_same_line_list_table, effective_content) =
338 if !list_prefix.is_empty() && Self::is_potential_table_row_content(list_content, flavor) {
339 (true, list_content)
340 } else {
341 (false, line_content)
342 };
343
344 let continuation_indent = if !is_same_line_list_table && list_prefix.is_empty() {
347 let leading = line_content.len() - line_content.trim_start().len();
348 list_indent_stack
350 .iter()
351 .rev()
352 .find(|&&indent| leading >= indent)
353 .copied()
354 } else {
355 None
356 };
357
358 let is_continuation_list_table = continuation_indent.is_some()
359 && {
360 let indent = continuation_indent.unwrap();
361 let leading = line_content.len() - line_content.trim_start().len();
362 leading < indent + 4
364 }
365 && Self::is_potential_table_row_with_flavor(effective_content, flavor);
366
367 let is_any_list_table = is_same_line_list_table || is_continuation_list_table;
368
369 let effective_content_indent = if is_same_line_list_table {
371 content_indent
372 } else if is_continuation_list_table {
373 continuation_indent.unwrap()
374 } else {
375 0
376 };
377
378 if is_any_list_table || Self::is_potential_table_row_with_flavor(effective_content, flavor) {
380 let (next_line_content, delimiter_has_valid_indent) = if i + 1 < lines.len() {
383 let next_raw = strip_blockquote_prefix(lines[i + 1]);
384 if is_any_list_table {
385 let leading_spaces = next_raw.len() - next_raw.trim_start().len();
387 if leading_spaces >= effective_content_indent {
388 (
390 Self::strip_list_continuation_indent(next_raw, effective_content_indent),
391 true,
392 )
393 } else {
394 (next_raw, false)
396 }
397 } else {
398 (next_raw, true)
399 }
400 } else {
401 ("", true)
402 };
403
404 let effective_is_list_table = is_any_list_table && delimiter_has_valid_indent;
406
407 if i + 1 < lines.len() && Self::is_delimiter_row(next_line_content) {
408 let table_start = i;
410 let header_line = i;
411 let delimiter_line = i + 1;
412 let mut table_end = i + 1; let mut content_lines = Vec::new();
414
415 let mut j = i + 2;
417 while j < lines.len() {
418 let line = lines[j];
419 let raw_content = strip_blockquote_prefix(line);
421
422 let line_content = if effective_is_list_table {
424 Self::strip_list_continuation_indent(raw_content, effective_content_indent)
425 } else {
426 raw_content
427 };
428
429 if line_content.trim().is_empty() {
430 break;
432 }
433
434 if effective_is_list_table {
436 let leading_spaces = raw_content.len() - raw_content.trim_start().len();
437 if leading_spaces < effective_content_indent {
438 break;
440 }
441 }
442
443 if Self::is_potential_table_row_with_flavor(line_content, flavor) {
444 content_lines.push(j);
445 table_end = j;
446 j += 1;
447 } else {
448 break;
450 }
451 }
452
453 let list_context = if effective_is_list_table {
454 if is_same_line_list_table {
455 Some(ListTableContext {
457 list_prefix: list_prefix.to_string(),
458 content_indent: effective_content_indent,
459 })
460 } else {
461 Some(ListTableContext {
463 list_prefix: " ".repeat(effective_content_indent),
464 content_indent: effective_content_indent,
465 })
466 }
467 } else {
468 None
469 };
470
471 tables.push(TableBlock {
472 start_line: table_start,
473 end_line: table_end,
474 header_line,
475 delimiter_line,
476 content_lines,
477 list_context,
478 });
479 i = table_end + 1;
480 } else {
481 i += 1;
482 }
483 } else {
484 i += 1;
485 }
486 }
487
488 tables
489 }
490
491 fn strip_list_continuation_indent(line: &str, expected_indent: usize) -> &str {
494 let bytes = line.as_bytes();
495 let mut spaces = 0;
496
497 for &b in bytes {
498 if b == b' ' {
499 spaces += 1;
500 } else if b == b'\t' {
501 spaces = (spaces / 4 + 1) * 4;
503 } else {
504 break;
505 }
506
507 if spaces >= expected_indent {
508 break;
509 }
510 }
511
512 let strip_count = spaces.min(expected_indent).min(line.len());
514 let mut byte_count = 0;
516 let mut counted_spaces = 0;
517 for &b in bytes {
518 if counted_spaces >= strip_count {
519 break;
520 }
521 if b == b' ' {
522 counted_spaces += 1;
523 byte_count += 1;
524 } else if b == b'\t' {
525 counted_spaces = (counted_spaces / 4 + 1) * 4;
526 byte_count += 1;
527 } else {
528 break;
529 }
530 }
531
532 &line[byte_count..]
533 }
534
535 pub fn find_table_blocks(content: &str, ctx: &crate::lint_context::LintContext) -> Vec<TableBlock> {
538 Self::find_table_blocks_with_code_info(
539 content,
540 &ctx.code_blocks,
541 &ctx.code_spans(),
542 ctx.html_comment_ranges(),
543 ctx.flavor,
544 )
545 }
546
547 pub fn count_cells(row: &str) -> usize {
549 Self::count_cells_with_flavor(row, crate::config::MarkdownFlavor::Standard)
550 }
551
552 pub fn count_cells_with_flavor(row: &str, flavor: crate::config::MarkdownFlavor) -> usize {
559 let (_, content) = Self::extract_blockquote_prefix(row);
561 Self::split_table_row_with_flavor(content, flavor).len()
562 }
563
564 fn count_preceding_backslashes(chars: &[char], pos: usize) -> usize {
566 let mut count = 0;
567 let mut k = pos;
568 while k > 0 {
569 k -= 1;
570 if chars[k] == '\\' {
571 count += 1;
572 } else {
573 break;
574 }
575 }
576 count
577 }
578
579 fn inline_code_spans(chars: &[char]) -> Vec<(usize, usize)> {
592 let mut spans = Vec::new();
593 let mut i = 0;
594
595 while i < chars.len() {
596 if chars[i] != '`' {
597 i += 1;
598 continue;
599 }
600
601 if Self::count_preceding_backslashes(chars, i) % 2 != 0 {
603 i += 1;
604 continue;
605 }
606
607 let start = i;
609 let mut backtick_count = 0;
610 while i < chars.len() && chars[i] == '`' {
611 backtick_count += 1;
612 i += 1;
613 }
614
615 let mut j = i;
619 while j < chars.len() {
620 if chars[j] == '`' {
621 let mut close_count = 0;
622 while j < chars.len() && chars[j] == '`' {
623 close_count += 1;
624 j += 1;
625 }
626
627 if close_count == backtick_count {
628 spans.push((start, j));
629 i = j;
630 break;
631 }
632 } else {
634 j += 1;
635 }
636 }
637 }
640
641 spans
642 }
643
644 pub fn mask_pipes_in_inline_code(text: &str) -> String {
649 if !text.contains('`') {
650 return text.to_string();
651 }
652
653 let chars: Vec<char> = text.chars().collect();
654 let spans = Self::inline_code_spans(&chars);
655 if spans.is_empty() {
656 return text.to_string();
657 }
658
659 let mut result = String::with_capacity(text.len());
660 let mut cursor = 0;
661 for (start, end) in spans {
662 result.extend(chars[cursor..start].iter());
663 for &ch in &chars[start..end] {
666 result.push(if ch == '|' { '_' } else { ch });
667 }
668 cursor = end;
669 }
670 result.extend(chars[cursor..].iter());
671
672 result
673 }
674
675 pub fn mask_pipes_in_wikilinks(text: &str) -> String {
693 if !text.contains("[[") || !text.contains('|') {
696 return text.to_string();
697 }
698
699 let chars: Vec<char> = text.chars().collect();
700 let code_spans = Self::inline_code_spans(&chars);
701 let code_span_at = |pos: usize| code_spans.iter().find(|&&(s, e)| pos >= s && pos < e).copied();
702
703 let mut result = String::with_capacity(text.len());
704 let mut i = 0;
705
706 while i < chars.len() {
707 if let Some((_, end)) = code_span_at(i) {
709 result.extend(chars[i..end].iter());
710 i = end;
711 continue;
712 }
713
714 if chars[i] == '['
715 && i + 1 < chars.len()
716 && chars[i + 1] == '['
717 && let Some(close) = Self::wikilink_close(&chars, &code_spans, i)
718 {
719 result.push_str("[[");
720 for &ch in &chars[i + 2..close] {
721 if ch == '|' {
722 result.push('_'); } else {
724 result.push(ch);
725 }
726 }
727 result.push_str("]]");
728 i = close + 2;
729 continue;
730 }
731
732 result.push(chars[i]);
733 i += 1;
734 }
735
736 result
737 }
738
739 fn wikilink_close(chars: &[char], code_spans: &[(usize, usize)], open: usize) -> Option<usize> {
745 let mut j = open + 2;
746 let mut first_pipe = None;
747
748 while j + 1 < chars.len() {
749 if let Some(&(_, end)) = code_spans.iter().find(|&&(s, _)| s == j) {
750 j = end;
751 continue;
752 }
753
754 if chars[j] == ']' && chars[j + 1] == ']' {
755 if let Some(pipe) = first_pipe
759 && chars[open + 2..pipe].iter().all(|c| c.is_whitespace())
760 {
761 return None;
762 }
763 return Some(j);
764 }
765
766 if chars[j] == '[' && chars[j + 1] == '[' {
768 return None;
769 }
770
771 if chars[j] == '|' && first_pipe.is_none() {
772 first_pipe = Some(j);
773 }
774
775 j += 1;
776 }
777
778 None
779 }
780
781 pub fn mask_pipes_for_table_parsing(text: &str) -> String {
790 let mut result = String::new();
791 let chars: Vec<char> = text.chars().collect();
792 let mut i = 0;
793
794 while i < chars.len() {
795 if chars[i] == '\\' {
796 if i + 1 < chars.len() && chars[i + 1] == '\\' {
797 result.push('\\');
800 result.push('\\');
801 i += 2;
802 } else if i + 1 < chars.len() && chars[i + 1] == '|' {
803 result.push('\\');
805 result.push('_'); i += 2;
807 } else {
808 result.push(chars[i]);
810 i += 1;
811 }
812 } else {
813 result.push(chars[i]);
814 i += 1;
815 }
816 }
817
818 result
819 }
820
821 pub fn split_table_row_with_flavor(row: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
828 let trimmed = row.trim();
829
830 if !trimmed.contains('|') {
831 return Vec::new();
832 }
833
834 let masked = Self::mask_pipes_for_table_parsing(trimmed);
836
837 let mut final_masked = Self::mask_pipes_in_inline_code(&masked);
839
840 if flavor == crate::config::MarkdownFlavor::Obsidian {
843 final_masked = Self::mask_pipes_in_wikilinks(&final_masked);
844 }
845
846 let has_leading = final_masked.starts_with('|');
847 let has_trailing = final_masked.ends_with('|');
848
849 let mut masked_content = final_masked.as_str();
850 let mut orig_content = trimmed;
851
852 if has_leading {
853 masked_content = &masked_content[1..];
854 orig_content = &orig_content[1..];
855 }
856
857 let stripped_trailing = has_trailing && !masked_content.is_empty();
859 if stripped_trailing {
860 masked_content = &masked_content[..masked_content.len() - 1];
861 orig_content = &orig_content[..orig_content.len() - 1];
862 }
863
864 if masked_content.is_empty() {
866 if stripped_trailing {
867 return vec![String::new()];
869 } else {
870 return Vec::new();
872 }
873 }
874
875 let masked_parts: Vec<&str> = masked_content.split('|').collect();
876 let mut cells = Vec::new();
877 let mut pos = 0;
878
879 for masked_cell in masked_parts {
880 let cell_len = masked_cell.len();
881 let orig_cell = if pos + cell_len <= orig_content.len() {
882 &orig_content[pos..pos + cell_len]
883 } else {
884 masked_cell
885 };
886 cells.push(orig_cell.to_string());
887 pos += cell_len + 1; }
889
890 cells
891 }
892
893 pub fn split_table_row(row: &str) -> Vec<String> {
895 Self::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Standard)
896 }
897
898 pub fn determine_pipe_style(line: &str) -> Option<&'static str> {
903 let content = strip_blockquote_prefix(line);
905 let trimmed = content.trim();
906 if !trimmed.contains('|') {
907 return None;
908 }
909
910 let has_leading = trimmed.starts_with('|');
911 let has_trailing = trimmed.ends_with('|');
912
913 match (has_leading, has_trailing) {
914 (true, true) => Some("leading_and_trailing"),
915 (true, false) => Some("leading_only"),
916 (false, true) => Some("trailing_only"),
917 (false, false) => Some("no_leading_or_trailing"),
918 }
919 }
920
921 pub fn extract_blockquote_prefix(line: &str) -> (&str, &str) {
926 let bytes = line.as_bytes();
928 let mut pos = 0;
929
930 while pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
932 pos += 1;
933 }
934
935 if pos >= bytes.len() || bytes[pos] != b'>' {
937 return ("", line);
938 }
939
940 while pos < bytes.len() {
942 if bytes[pos] == b'>' {
943 pos += 1;
944 if pos < bytes.len() && bytes[pos] == b' ' {
946 pos += 1;
947 }
948 } else if bytes[pos] == b' ' || bytes[pos] == b'\t' {
949 pos += 1;
950 } else {
951 break;
952 }
953 }
954
955 (&line[..pos], &line[pos..])
957 }
958
959 pub fn extract_list_prefix(line: &str) -> (&str, &str, usize) {
974 let bytes = line.as_bytes();
975
976 let leading_spaces = bytes.iter().take_while(|&&b| b == b' ' || b == b'\t').count();
978 let mut pos = leading_spaces;
979
980 if pos >= bytes.len() {
981 return ("", line, 0);
982 }
983
984 if matches!(bytes[pos], b'-' | b'*' | b'+') {
986 pos += 1;
987
988 if pos >= bytes.len() || bytes[pos] == b' ' || bytes[pos] == b'\t' {
990 if pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
992 pos += 1;
993 }
994 let content_indent = pos;
995 return (&line[..pos], &line[pos..], content_indent);
996 }
997 return ("", line, 0);
999 }
1000
1001 if bytes[pos].is_ascii_digit() {
1003 let digit_start = pos;
1004 while pos < bytes.len() && bytes[pos].is_ascii_digit() {
1005 pos += 1;
1006 }
1007
1008 if pos > digit_start && pos < bytes.len() {
1010 if bytes[pos] == b'.' || bytes[pos] == b')' {
1012 pos += 1;
1013 if pos >= bytes.len() || bytes[pos] == b' ' || bytes[pos] == b'\t' {
1014 if pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
1016 pos += 1;
1017 }
1018 let content_indent = pos;
1019 return (&line[..pos], &line[pos..], content_indent);
1020 }
1021 }
1022 }
1023 }
1024
1025 ("", line, 0)
1026 }
1027
1028 pub fn extract_table_row_content<'a>(line: &'a str, table_block: &TableBlock, line_index: usize) -> &'a str {
1033 let (_, after_blockquote) = Self::extract_blockquote_prefix(line);
1035
1036 if let Some(ref list_ctx) = table_block.list_context {
1038 if line_index == 0 {
1039 after_blockquote
1041 .strip_prefix(&list_ctx.list_prefix)
1042 .unwrap_or_else(|| Self::extract_list_prefix(after_blockquote).1)
1043 } else {
1044 Self::strip_list_continuation_indent(after_blockquote, list_ctx.content_indent)
1046 }
1047 } else {
1048 after_blockquote
1049 }
1050 }
1051
1052 pub fn is_list_item_with_table_row(line: &str, flavor: crate::config::MarkdownFlavor) -> bool {
1055 let (prefix, content, _) = Self::extract_list_prefix(line);
1056 if prefix.is_empty() {
1057 return false;
1058 }
1059
1060 let trimmed = content.trim();
1063 if !trimmed.starts_with('|') {
1064 return false;
1065 }
1066
1067 Self::is_potential_table_row_content(content, flavor)
1069 }
1070
1071 fn is_potential_table_row_content(content: &str, flavor: crate::config::MarkdownFlavor) -> bool {
1073 Self::is_potential_table_row_with_flavor(content, flavor)
1074 }
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079 use super::*;
1080 use crate::lint_context::LintContext;
1081
1082 #[test]
1083 fn test_is_potential_table_row() {
1084 assert!(TableUtils::is_potential_table_row("| Header 1 | Header 2 |"));
1086 assert!(TableUtils::is_potential_table_row("| Cell 1 | Cell 2 |"));
1087 assert!(TableUtils::is_potential_table_row("Cell 1 | Cell 2"));
1088 assert!(TableUtils::is_potential_table_row("| Cell |")); assert!(TableUtils::is_potential_table_row("| A | B | C | D | E |"));
1092
1093 assert!(TableUtils::is_potential_table_row(" | Indented | Table | "));
1095 assert!(TableUtils::is_potential_table_row("| Spaces | Around |"));
1096
1097 assert!(!TableUtils::is_potential_table_row("- List item"));
1099 assert!(!TableUtils::is_potential_table_row("* Another list"));
1100 assert!(!TableUtils::is_potential_table_row("+ Plus list"));
1101 assert!(!TableUtils::is_potential_table_row("Regular text"));
1102 assert!(!TableUtils::is_potential_table_row(""));
1103 assert!(!TableUtils::is_potential_table_row(" "));
1104
1105 assert!(!TableUtils::is_potential_table_row("`code with | pipe`"));
1107 assert!(!TableUtils::is_potential_table_row("``multiple | backticks``"));
1108 assert!(!TableUtils::is_potential_table_row("Use ``a|b`` in prose"));
1109 assert!(TableUtils::is_potential_table_row("| `fenced` | Uses ``` and ~~~ |"));
1110 assert!(TableUtils::is_potential_table_row("`!foo && bar` | `(!foo) && bar`"));
1111 assert!(!TableUtils::is_potential_table_row("`echo a | sed 's/a/b/'`"));
1112
1113 assert!(!TableUtils::is_potential_table_row(
1115 "Text with $|S|$ math notation here."
1116 ));
1117 assert!(!TableUtils::is_potential_table_row(
1118 "Size $|S|$ was even, check $|T|$ too."
1119 ));
1120 assert!(!TableUtils::is_potential_table_row("Display $$|A| + |B|$$ math here."));
1121 assert!(TableUtils::is_potential_table_row("| cell with $|S|$ math |"));
1123 assert!(TableUtils::is_potential_table_row("$a$ | $b$"));
1125 assert!(TableUtils::is_potential_table_row("$f(x)$ and $g(x)$ | result"));
1126 assert!(!TableUtils::is_potential_table_row("$5 | $10"));
1130
1131 assert!(!TableUtils::is_potential_table_row("Just one |"));
1133 assert!(!TableUtils::is_potential_table_row("| Just one"));
1134
1135 let long_cell = "a".repeat(150);
1137 assert!(TableUtils::is_potential_table_row(&format!("| {long_cell} | b |")));
1138
1139 assert!(!TableUtils::is_potential_table_row("| Cell with\nnewline | Other |"));
1141
1142 assert!(TableUtils::is_potential_table_row("|||")); assert!(TableUtils::is_potential_table_row("||||")); assert!(TableUtils::is_potential_table_row("| | |")); }
1147
1148 #[test]
1149 fn test_list_items_with_pipes_not_table_rows() {
1150 assert!(!TableUtils::is_potential_table_row("1. Item with | pipe"));
1152 assert!(!TableUtils::is_potential_table_row("10. Item with | pipe"));
1153 assert!(!TableUtils::is_potential_table_row("999. Item with | pipe"));
1154 assert!(!TableUtils::is_potential_table_row("1) Item with | pipe"));
1155 assert!(!TableUtils::is_potential_table_row("10) Item with | pipe"));
1156
1157 assert!(!TableUtils::is_potential_table_row("-\tItem with | pipe"));
1159 assert!(!TableUtils::is_potential_table_row("*\tItem with | pipe"));
1160 assert!(!TableUtils::is_potential_table_row("+\tItem with | pipe"));
1161
1162 assert!(!TableUtils::is_potential_table_row(" - Indented | pipe"));
1164 assert!(!TableUtils::is_potential_table_row(" * Deep indent | pipe"));
1165 assert!(!TableUtils::is_potential_table_row(" 1. Ordered indent | pipe"));
1166
1167 assert!(!TableUtils::is_potential_table_row("- [ ] task | pipe"));
1169 assert!(!TableUtils::is_potential_table_row("- [x] done | pipe"));
1170
1171 assert!(!TableUtils::is_potential_table_row("1. foo | bar | baz"));
1173 assert!(!TableUtils::is_potential_table_row("- alpha | beta | gamma"));
1174
1175 assert!(TableUtils::is_potential_table_row("| cell | cell |"));
1177 assert!(TableUtils::is_potential_table_row("cell | cell"));
1178 assert!(TableUtils::is_potential_table_row("| Header | Header |"));
1179 }
1180
1181 #[test]
1182 fn test_atx_headings_with_pipes_not_table_rows() {
1183 assert!(!TableUtils::is_potential_table_row("# Heading | with pipe"));
1185 assert!(!TableUtils::is_potential_table_row("## Heading | with pipe"));
1186 assert!(!TableUtils::is_potential_table_row("### Heading | with pipe"));
1187 assert!(!TableUtils::is_potential_table_row("#### Heading | with pipe"));
1188 assert!(!TableUtils::is_potential_table_row("##### Heading | with pipe"));
1189 assert!(!TableUtils::is_potential_table_row("###### Heading | with pipe"));
1190
1191 assert!(!TableUtils::is_potential_table_row("### col1 | col2 | col3"));
1193 assert!(!TableUtils::is_potential_table_row("## a|b|c"));
1194
1195 assert!(!TableUtils::is_potential_table_row("#\tHeading | pipe"));
1197 assert!(!TableUtils::is_potential_table_row("##\tHeading | pipe"));
1198
1199 assert!(!TableUtils::is_potential_table_row("# |"));
1201 assert!(!TableUtils::is_potential_table_row("## |"));
1202
1203 assert!(!TableUtils::is_potential_table_row(" ## Heading | pipe"));
1205 assert!(!TableUtils::is_potential_table_row(" ### Heading | pipe"));
1206
1207 assert!(!TableUtils::is_potential_table_row("#### ®aAA|ᯗ"));
1209
1210 assert!(TableUtils::is_potential_table_row("####### text | pipe"));
1214
1215 assert!(TableUtils::is_potential_table_row("#nospc|pipe"));
1217
1218 assert!(TableUtils::is_potential_table_row("| # Header | Value |"));
1220 assert!(TableUtils::is_potential_table_row("text | #tag"));
1221 }
1222
1223 #[test]
1224 fn test_is_delimiter_row() {
1225 assert!(TableUtils::is_delimiter_row("|---|---|"));
1227 assert!(TableUtils::is_delimiter_row("| --- | --- |"));
1228 assert!(TableUtils::is_delimiter_row("|:---|---:|"));
1229 assert!(TableUtils::is_delimiter_row("|:---:|:---:|"));
1230
1231 assert!(TableUtils::is_delimiter_row("|-|--|"));
1233 assert!(TableUtils::is_delimiter_row("|-------|----------|"));
1234
1235 assert!(TableUtils::is_delimiter_row("| --- | --- |"));
1237 assert!(TableUtils::is_delimiter_row("| :--- | ---: |"));
1238
1239 assert!(TableUtils::is_delimiter_row("|---|---|---|---|"));
1241
1242 assert!(TableUtils::is_delimiter_row("--- | ---"));
1244 assert!(TableUtils::is_delimiter_row(":--- | ---:"));
1245
1246 assert!(!TableUtils::is_delimiter_row("| Header | Header |"));
1248 assert!(!TableUtils::is_delimiter_row("Regular text"));
1249 assert!(!TableUtils::is_delimiter_row(""));
1250 assert!(!TableUtils::is_delimiter_row("|||"));
1251 assert!(!TableUtils::is_delimiter_row("| | |"));
1252
1253 assert!(!TableUtils::is_delimiter_row("| : | : |"));
1255 assert!(!TableUtils::is_delimiter_row("| | |"));
1256
1257 assert!(!TableUtils::is_delimiter_row("| --- | text |"));
1259 assert!(!TableUtils::is_delimiter_row("| abc | --- |"));
1260 }
1261
1262 #[test]
1263 fn test_count_cells() {
1264 assert_eq!(TableUtils::count_cells("| Cell 1 | Cell 2 | Cell 3 |"), 3);
1266 assert_eq!(TableUtils::count_cells("Cell 1 | Cell 2 | Cell 3"), 3);
1267 assert_eq!(TableUtils::count_cells("| Cell 1 | Cell 2"), 2);
1268 assert_eq!(TableUtils::count_cells("Cell 1 | Cell 2 |"), 2);
1269
1270 assert_eq!(TableUtils::count_cells("| Cell |"), 1);
1272 assert_eq!(TableUtils::count_cells("Cell"), 0); assert_eq!(TableUtils::count_cells("| | | |"), 3);
1276 assert_eq!(TableUtils::count_cells("| | | |"), 3);
1277
1278 assert_eq!(TableUtils::count_cells("| A | B | C | D | E | F |"), 6);
1280
1281 assert_eq!(TableUtils::count_cells("||"), 1); assert_eq!(TableUtils::count_cells("|||"), 2); assert_eq!(TableUtils::count_cells("Regular text"), 0);
1287 assert_eq!(TableUtils::count_cells(""), 0);
1288 assert_eq!(TableUtils::count_cells(" "), 0);
1289
1290 assert_eq!(TableUtils::count_cells(" | A | B | "), 2);
1292 assert_eq!(TableUtils::count_cells("| A | B |"), 2);
1293 }
1294
1295 #[test]
1296 fn test_count_cells_with_escaped_pipes() {
1297 assert_eq!(TableUtils::count_cells("| Challenge | Solution |"), 2);
1302 assert_eq!(TableUtils::count_cells("| A | B | C |"), 3);
1303 assert_eq!(TableUtils::count_cells("| One | Two |"), 2);
1304
1305 assert_eq!(TableUtils::count_cells(r"| Command | echo \| grep |"), 2);
1307 assert_eq!(TableUtils::count_cells(r"| A | B \| C |"), 2); assert_eq!(TableUtils::count_cells(r"| Command | `echo \| grep` |"), 2);
1311
1312 assert_eq!(TableUtils::count_cells(r"| A | B \\| C |"), 3); assert_eq!(TableUtils::count_cells(r"| A | `B \\| C` |"), 2);
1316
1317 assert_eq!(TableUtils::count_cells("| Command | `echo | grep` |"), 2);
1319 assert_eq!(TableUtils::count_cells("| `code | one` | `code | two` |"), 2);
1320 assert_eq!(TableUtils::count_cells("| `single|pipe` |"), 1);
1321
1322 assert_eq!(TableUtils::count_cells(r"| Hour formats | `^([0-1]?\d|2[0-3])` |"), 2);
1324 assert_eq!(TableUtils::count_cells(r"| Hour formats | `^([0-1]?\d\|2[0-3])` |"), 2);
1326 }
1327
1328 #[test]
1329 fn test_determine_pipe_style() {
1330 assert_eq!(
1332 TableUtils::determine_pipe_style("| Cell 1 | Cell 2 |"),
1333 Some("leading_and_trailing")
1334 );
1335 assert_eq!(
1336 TableUtils::determine_pipe_style("| Cell 1 | Cell 2"),
1337 Some("leading_only")
1338 );
1339 assert_eq!(
1340 TableUtils::determine_pipe_style("Cell 1 | Cell 2 |"),
1341 Some("trailing_only")
1342 );
1343 assert_eq!(
1344 TableUtils::determine_pipe_style("Cell 1 | Cell 2"),
1345 Some("no_leading_or_trailing")
1346 );
1347
1348 assert_eq!(
1350 TableUtils::determine_pipe_style(" | Cell 1 | Cell 2 | "),
1351 Some("leading_and_trailing")
1352 );
1353 assert_eq!(
1354 TableUtils::determine_pipe_style(" | Cell 1 | Cell 2 "),
1355 Some("leading_only")
1356 );
1357
1358 assert_eq!(TableUtils::determine_pipe_style("Regular text"), None);
1360 assert_eq!(TableUtils::determine_pipe_style(""), None);
1361 assert_eq!(TableUtils::determine_pipe_style(" "), None);
1362
1363 assert_eq!(TableUtils::determine_pipe_style("|"), Some("leading_and_trailing"));
1365 assert_eq!(TableUtils::determine_pipe_style("| Cell"), Some("leading_only"));
1366 assert_eq!(TableUtils::determine_pipe_style("Cell |"), Some("trailing_only"));
1367 }
1368
1369 #[test]
1370 fn test_find_table_blocks_simple() {
1371 let content = "| Header 1 | Header 2 |
1372|-----------|-----------|
1373| Cell 1 | Cell 2 |
1374| Cell 3 | Cell 4 |";
1375
1376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1377
1378 let tables = TableUtils::find_table_blocks(content, &ctx);
1379 assert_eq!(tables.len(), 1);
1380
1381 let table = &tables[0];
1382 assert_eq!(table.start_line, 0);
1383 assert_eq!(table.end_line, 3);
1384 assert_eq!(table.header_line, 0);
1385 assert_eq!(table.delimiter_line, 1);
1386 assert_eq!(table.content_lines, vec![2, 3]);
1387 }
1388
1389 #[test]
1390 fn test_find_table_blocks_multiple() {
1391 let content = "Some text
1392
1393| Table 1 | Col A |
1394|----------|-------|
1395| Data 1 | Val 1 |
1396
1397More text
1398
1399| Table 2 | Col 2 |
1400|----------|-------|
1401| Data 2 | Data |";
1402
1403 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1404
1405 let tables = TableUtils::find_table_blocks(content, &ctx);
1406 assert_eq!(tables.len(), 2);
1407
1408 assert_eq!(tables[0].start_line, 2);
1410 assert_eq!(tables[0].end_line, 4);
1411 assert_eq!(tables[0].header_line, 2);
1412 assert_eq!(tables[0].delimiter_line, 3);
1413 assert_eq!(tables[0].content_lines, vec![4]);
1414
1415 assert_eq!(tables[1].start_line, 8);
1417 assert_eq!(tables[1].end_line, 10);
1418 assert_eq!(tables[1].header_line, 8);
1419 assert_eq!(tables[1].delimiter_line, 9);
1420 assert_eq!(tables[1].content_lines, vec![10]);
1421 }
1422
1423 #[test]
1424 fn test_find_table_blocks_no_content_rows() {
1425 let content = "| Header 1 | Header 2 |
1426|-----------|-----------|
1427
1428Next paragraph";
1429
1430 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1431
1432 let tables = TableUtils::find_table_blocks(content, &ctx);
1433 assert_eq!(tables.len(), 1);
1434
1435 let table = &tables[0];
1436 assert_eq!(table.start_line, 0);
1437 assert_eq!(table.end_line, 1); assert_eq!(table.content_lines.len(), 0);
1439 }
1440
1441 #[test]
1442 fn test_find_table_blocks_in_code_block() {
1443 let content = "```
1444| Not | A | Table |
1445|-----|---|-------|
1446| In | Code | Block |
1447```
1448
1449| Real | Table |
1450|------|-------|
1451| Data | Here |";
1452
1453 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1454
1455 let tables = TableUtils::find_table_blocks(content, &ctx);
1456 assert_eq!(tables.len(), 1); let table = &tables[0];
1459 assert_eq!(table.header_line, 6);
1460 assert_eq!(table.delimiter_line, 7);
1461 }
1462
1463 #[test]
1464 fn test_find_table_blocks_no_tables() {
1465 let content = "Just regular text
1466No tables here
1467- List item with | pipe
1468* Another list item";
1469
1470 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1471
1472 let tables = TableUtils::find_table_blocks(content, &ctx);
1473 assert_eq!(tables.len(), 0);
1474 }
1475
1476 #[test]
1477 fn test_find_table_blocks_malformed() {
1478 let content = "| Header without delimiter |
1479| This looks like table |
1480But no delimiter row
1481
1482| Proper | Table |
1483|---------|-------|
1484| Data | Here |";
1485
1486 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1487
1488 let tables = TableUtils::find_table_blocks(content, &ctx);
1489 assert_eq!(tables.len(), 1); assert_eq!(tables[0].header_line, 4);
1491 }
1492
1493 #[test]
1494 fn test_find_table_blocks_keeps_obsidian_wikilink_prose_out_of_the_table() {
1495 let content = "| A | B |\n| - | - |\n| x | y |\n[[Foo|bar]] is a note.\n";
1496
1497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1498 let blocks = TableUtils::find_table_blocks(content, &ctx);
1499 assert_eq!(blocks.len(), 1, "Expected one table, got {blocks:?}");
1500 assert_eq!(
1501 blocks[0].end_line, 2,
1502 "Wikilink prose was absorbed into the table: {:?}",
1503 blocks[0]
1504 );
1505 assert_eq!(blocks[0].content_lines, vec![2]);
1506
1507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1510 let blocks = TableUtils::find_table_blocks(content, &ctx);
1511 assert_eq!(blocks.len(), 1, "Expected one table, got {blocks:?}");
1512 assert_eq!(
1513 blocks[0].end_line, 3,
1514 "GFM should still read the pipe as a delimiter: {:?}",
1515 blocks[0]
1516 );
1517 }
1518
1519 #[test]
1520 fn test_edge_cases() {
1521 assert!(!TableUtils::is_potential_table_row(""));
1523 assert!(!TableUtils::is_delimiter_row(""));
1524 assert_eq!(TableUtils::count_cells(""), 0);
1525 assert_eq!(TableUtils::determine_pipe_style(""), None);
1526
1527 assert!(!TableUtils::is_potential_table_row(" "));
1529 assert!(!TableUtils::is_delimiter_row(" "));
1530 assert_eq!(TableUtils::count_cells(" "), 0);
1531 assert_eq!(TableUtils::determine_pipe_style(" "), None);
1532
1533 assert!(!TableUtils::is_potential_table_row("|"));
1535 assert!(!TableUtils::is_delimiter_row("|"));
1536 assert_eq!(TableUtils::count_cells("|"), 0); let long_single = format!("| {} |", "a".repeat(200));
1541 assert!(TableUtils::is_potential_table_row(&long_single)); let long_multi = format!("| {} | {} |", "a".repeat(200), "b".repeat(200));
1544 assert!(TableUtils::is_potential_table_row(&long_multi)); assert!(TableUtils::is_potential_table_row("| 你好 | 世界 |"));
1548 assert!(TableUtils::is_potential_table_row("| émoji | 🎉 |"));
1549 assert_eq!(TableUtils::count_cells("| 你好 | 世界 |"), 2);
1550 }
1551
1552 #[test]
1553 fn test_table_block_struct() {
1554 let block = TableBlock {
1555 start_line: 0,
1556 end_line: 5,
1557 header_line: 0,
1558 delimiter_line: 1,
1559 content_lines: vec![2, 3, 4, 5],
1560 list_context: None,
1561 };
1562
1563 let debug_str = format!("{block:?}");
1565 assert!(debug_str.contains("TableBlock"));
1566 assert!(debug_str.contains("start_line: 0"));
1567
1568 let cloned = block.clone();
1570 assert_eq!(cloned.start_line, block.start_line);
1571 assert_eq!(cloned.end_line, block.end_line);
1572 assert_eq!(cloned.header_line, block.header_line);
1573 assert_eq!(cloned.delimiter_line, block.delimiter_line);
1574 assert_eq!(cloned.content_lines, block.content_lines);
1575 assert!(cloned.list_context.is_none());
1576 }
1577
1578 #[test]
1579 fn test_split_table_row() {
1580 let cells = TableUtils::split_table_row("| Cell 1 | Cell 2 | Cell 3 |");
1582 assert_eq!(cells.len(), 3);
1583 assert_eq!(cells[0].trim(), "Cell 1");
1584 assert_eq!(cells[1].trim(), "Cell 2");
1585 assert_eq!(cells[2].trim(), "Cell 3");
1586
1587 let cells = TableUtils::split_table_row("| Cell 1 | Cell 2");
1589 assert_eq!(cells.len(), 2);
1590
1591 let cells = TableUtils::split_table_row("| | | |");
1593 assert_eq!(cells.len(), 3);
1594
1595 let cells = TableUtils::split_table_row("| Cell |");
1597 assert_eq!(cells.len(), 1);
1598 assert_eq!(cells[0].trim(), "Cell");
1599
1600 let cells = TableUtils::split_table_row("No pipes here");
1602 assert_eq!(cells.len(), 0);
1603 }
1604
1605 #[test]
1606 fn test_split_table_row_with_escaped_pipes() {
1607 let cells = TableUtils::split_table_row(r"| A | B \| C |");
1609 assert_eq!(cells.len(), 2);
1610 assert!(cells[1].contains(r"\|"), "Escaped pipe should be in cell content");
1611
1612 let cells = TableUtils::split_table_row(r"| A | B \\| C |");
1614 assert_eq!(cells.len(), 3);
1615 }
1616
1617 #[test]
1618 fn test_split_table_row_with_flavor_mkdocs() {
1619 let cells =
1621 TableUtils::split_table_row_with_flavor("| Type | `x | y` |", crate::config::MarkdownFlavor::MkDocs);
1622 assert_eq!(cells.len(), 2);
1623 assert!(
1624 cells[1].contains("`x | y`"),
1625 "Inline code with pipe should be single cell in MkDocs flavor"
1626 );
1627
1628 let cells =
1630 TableUtils::split_table_row_with_flavor("| Type | `a | b | c` |", crate::config::MarkdownFlavor::MkDocs);
1631 assert_eq!(cells.len(), 2);
1632 assert!(cells[1].contains("`a | b | c`"));
1633 }
1634
1635 #[test]
1636 fn test_split_table_row_with_flavor_standard() {
1637 let cells =
1639 TableUtils::split_table_row_with_flavor("| Type | `x | y` |", crate::config::MarkdownFlavor::Standard);
1640 assert_eq!(
1641 cells.len(),
1642 2,
1643 "Pipes in code spans should not be cell delimiters, got {cells:?}"
1644 );
1645 assert!(
1646 cells[1].contains("`x | y`"),
1647 "Inline code with pipe should be single cell"
1648 );
1649 }
1650
1651 #[test]
1652 fn test_split_table_row_with_flavor_obsidian_wikilink() {
1653 let cells = TableUtils::split_table_row_with_flavor(
1656 "| Alice | [[White Rabbit|the Rabbit]] |",
1657 crate::config::MarkdownFlavor::Obsidian,
1658 );
1659 assert_eq!(cells.len(), 2, "Aliased wikilink should be one cell, got {cells:?}");
1660 assert!(cells[1].contains("[[White Rabbit|the Rabbit]]"));
1661
1662 let cells = TableUtils::split_table_row_with_flavor(
1664 "| Guests | [[Mad Hatter|the Hatter]] and [[March Hare|the Hare]] |",
1665 crate::config::MarkdownFlavor::Obsidian,
1666 );
1667 assert_eq!(
1668 cells.len(),
1669 2,
1670 "Two aliased wikilinks should be one cell, got {cells:?}"
1671 );
1672
1673 let cells = TableUtils::split_table_row_with_flavor(
1675 "| Alice | [[Cheshire Cat]] |",
1676 crate::config::MarkdownFlavor::Obsidian,
1677 );
1678 assert_eq!(cells.len(), 2);
1679
1680 let cells = TableUtils::split_table_row_with_flavor(
1682 "| Alice | [[White Rabbit | curious |",
1683 crate::config::MarkdownFlavor::Obsidian,
1684 );
1685 assert_eq!(
1686 cells.len(),
1687 3,
1688 "Unterminated wikilink should not mask pipes, got {cells:?}"
1689 );
1690 }
1691
1692 #[test]
1693 fn test_split_table_row_wikilink_only_for_obsidian() {
1694 for flavor in [
1696 crate::config::MarkdownFlavor::Standard,
1697 crate::config::MarkdownFlavor::MkDocs,
1698 ] {
1699 let cells = TableUtils::split_table_row_with_flavor("| Alice | [[White Rabbit|the Rabbit]] |", flavor);
1700 assert_eq!(
1701 cells.len(),
1702 3,
1703 "{flavor:?} should treat the wikilink pipe as a delimiter, got {cells:?}"
1704 );
1705 }
1706 }
1707
1708 #[test]
1709 fn test_mask_pipes_in_wikilinks_preserves_length() {
1710 for text in [
1712 "| Alice | [[White Rabbit|the Rabbit]] |",
1713 "| [[Mad Hatter|Hatter]] | [[March Hare|Hare]] |",
1714 "no wikilink here | just a pipe",
1715 "[[unterminated | still text",
1716 ] {
1717 assert_eq!(
1718 TableUtils::mask_pipes_in_wikilinks(text).len(),
1719 text.len(),
1720 "masking changed length of {text:?}"
1721 );
1722 }
1723 }
1724
1725 #[test]
1726 fn test_wikilink_brackets_in_a_code_span_stay_prose() {
1727 for row in [
1731 "| `[[` | mid | `]]` |",
1732 "| `[[Target` | mid | `Label]]` |",
1733 "| a | `[[` | b | `]]` |",
1734 "| `[[` and Target|Label]] |",
1737 ] {
1738 let obsidian = TableUtils::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Obsidian);
1739 let standard = TableUtils::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Standard);
1740 assert_eq!(
1741 obsidian, standard,
1742 "Obsidian disagreed with GFM about {row:?}: {obsidian:?} vs {standard:?}"
1743 );
1744 }
1745
1746 let cells = TableUtils::split_table_row_with_flavor(
1749 "| [[Target|Label with `a|b` inside]] |",
1750 crate::config::MarkdownFlavor::Obsidian,
1751 );
1752 assert_eq!(
1753 cells.len(),
1754 1,
1755 "Wikilink holding a code span should be one cell, got {cells:?}"
1756 );
1757
1758 let cells = TableUtils::split_table_row_with_flavor(
1761 "| [[Target | alias `]]` | tail |",
1762 crate::config::MarkdownFlavor::Obsidian,
1763 );
1764 assert_eq!(
1765 cells.len(),
1766 3,
1767 "A closer hidden in code should not close the link, got {cells:?}"
1768 );
1769 }
1770
1771 #[test]
1772 fn test_wikilink_with_a_blank_target_stays_prose() {
1773 for row in [
1776 "| [[ | ]] |",
1777 "| [[|Label]] |",
1778 "| starts [[ | ends ]] here |",
1779 "| [[\t|\tx]] |",
1780 ] {
1781 let obsidian = TableUtils::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Obsidian);
1782 let standard = TableUtils::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Standard);
1783 assert_eq!(
1784 obsidian, standard,
1785 "Obsidian disagreed with GFM about {row:?}: {obsidian:?} vs {standard:?}"
1786 );
1787 }
1788
1789 let cells = TableUtils::split_table_row_with_flavor("| [[x | y]] |", crate::config::MarkdownFlavor::Obsidian);
1791 assert_eq!(cells.len(), 1, "A named target should be one cell, got {cells:?}");
1792 }
1793
1794 #[test]
1795 fn test_inline_code_spans_agree_with_pipe_masking() {
1796 let text = "``x | y and `c|d`";
1799 let chars: Vec<char> = text.chars().collect();
1800 let spans = TableUtils::inline_code_spans(&chars);
1801 assert_eq!(spans.len(), 1, "Only the matched pair is a code span, got {spans:?}");
1802 let (start, end) = spans[0];
1803 assert_eq!(
1804 chars[start..end].iter().collect::<String>(),
1805 "`c|d`",
1806 "The span should start at the run that closes, not the unmatched opener"
1807 );
1808
1809 assert_eq!(TableUtils::mask_pipes_in_inline_code(text), "``x | y and `c_d`");
1811 }
1812
1813 #[test]
1816 fn test_extract_blockquote_prefix_no_blockquote() {
1817 let (prefix, content) = TableUtils::extract_blockquote_prefix("| H1 | H2 |");
1819 assert_eq!(prefix, "");
1820 assert_eq!(content, "| H1 | H2 |");
1821 }
1822
1823 #[test]
1824 fn test_extract_blockquote_prefix_single_level() {
1825 let (prefix, content) = TableUtils::extract_blockquote_prefix("> | H1 | H2 |");
1827 assert_eq!(prefix, "> ");
1828 assert_eq!(content, "| H1 | H2 |");
1829 }
1830
1831 #[test]
1832 fn test_extract_blockquote_prefix_double_level() {
1833 let (prefix, content) = TableUtils::extract_blockquote_prefix(">> | H1 | H2 |");
1835 assert_eq!(prefix, ">> ");
1836 assert_eq!(content, "| H1 | H2 |");
1837 }
1838
1839 #[test]
1840 fn test_extract_blockquote_prefix_triple_level() {
1841 let (prefix, content) = TableUtils::extract_blockquote_prefix(">>> | H1 | H2 |");
1843 assert_eq!(prefix, ">>> ");
1844 assert_eq!(content, "| H1 | H2 |");
1845 }
1846
1847 #[test]
1848 fn test_extract_blockquote_prefix_with_spaces() {
1849 let (prefix, content) = TableUtils::extract_blockquote_prefix("> > | H1 | H2 |");
1851 assert_eq!(prefix, "> > ");
1852 assert_eq!(content, "| H1 | H2 |");
1853 }
1854
1855 #[test]
1856 fn test_extract_blockquote_prefix_indented() {
1857 let (prefix, content) = TableUtils::extract_blockquote_prefix(" > | H1 | H2 |");
1859 assert_eq!(prefix, " > ");
1860 assert_eq!(content, "| H1 | H2 |");
1861 }
1862
1863 #[test]
1864 fn test_extract_blockquote_prefix_no_space_after() {
1865 let (prefix, content) = TableUtils::extract_blockquote_prefix(">| H1 | H2 |");
1867 assert_eq!(prefix, ">");
1868 assert_eq!(content, "| H1 | H2 |");
1869 }
1870
1871 #[test]
1872 fn test_determine_pipe_style_in_blockquote() {
1873 assert_eq!(
1875 TableUtils::determine_pipe_style("> | H1 | H2 |"),
1876 Some("leading_and_trailing")
1877 );
1878 assert_eq!(
1879 TableUtils::determine_pipe_style("> H1 | H2"),
1880 Some("no_leading_or_trailing")
1881 );
1882 assert_eq!(
1883 TableUtils::determine_pipe_style(">> | H1 | H2 |"),
1884 Some("leading_and_trailing")
1885 );
1886 assert_eq!(TableUtils::determine_pipe_style(">>> | H1 | H2"), Some("leading_only"));
1887 }
1888
1889 #[test]
1890 fn test_list_table_delimiter_requires_indentation() {
1891 let content = "- List item with | pipe\n|---|---|\n| Cell 1 | Cell 2 |";
1896 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1897 let tables = TableUtils::find_table_blocks(content, &ctx);
1898
1899 assert_eq!(tables.len(), 1, "Should find exactly one table");
1902 assert!(
1903 tables[0].list_context.is_none(),
1904 "Should NOT have list context since delimiter has no indentation"
1905 );
1906 }
1907
1908 #[test]
1909 fn test_list_table_with_properly_indented_delimiter() {
1910 let content = "- | Header 1 | Header 2 |\n |----------|----------|\n | Cell 1 | Cell 2 |";
1913 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1914 let tables = TableUtils::find_table_blocks(content, &ctx);
1915
1916 assert_eq!(tables.len(), 1, "Should find exactly one table");
1918 assert_eq!(tables[0].start_line, 0, "Table should start at list item line");
1919 assert!(
1920 tables[0].list_context.is_some(),
1921 "Should be a list table since delimiter is properly indented"
1922 );
1923 }
1924
1925 #[test]
1926 fn test_mask_pipes_in_inline_code_regular_backticks() {
1927 let result = TableUtils::mask_pipes_in_inline_code("| `code | here` |");
1929 assert_eq!(result, "| `code _ here` |");
1930 }
1931
1932 #[test]
1933 fn test_mask_pipes_in_inline_code_escaped_backtick_not_code_span() {
1934 let result = TableUtils::mask_pipes_in_inline_code(r"| \`not code | still pipe\` |");
1937 assert_eq!(result, r"| \`not code | still pipe\` |");
1938 }
1939
1940 #[test]
1941 fn test_mask_pipes_in_inline_code_escaped_backslash_then_backtick() {
1942 let result = TableUtils::mask_pipes_in_inline_code(r"| \\`real code | masked\\` |");
1945 assert_eq!(result, r"| \\`real code _ masked\\` |");
1948 }
1949
1950 #[test]
1951 fn test_mask_pipes_in_inline_code_triple_backslash_before_backtick() {
1952 let result = TableUtils::mask_pipes_in_inline_code(r"| \\\`not code | pipe\\\` |");
1954 assert_eq!(result, r"| \\\`not code | pipe\\\` |");
1955 }
1956
1957 #[test]
1958 fn test_mask_pipes_in_inline_code_four_backslashes_before_backtick() {
1959 let result = TableUtils::mask_pipes_in_inline_code(r"| \\\\`code | here\\\\` |");
1961 assert_eq!(result, r"| \\\\`code _ here\\\\` |");
1962 }
1963
1964 #[test]
1965 fn test_mask_pipes_in_inline_code_no_backslash() {
1966 let result = TableUtils::mask_pipes_in_inline_code("before `a | b` after");
1968 assert_eq!(result, "before `a _ b` after");
1969 }
1970
1971 #[test]
1972 fn test_mask_pipes_in_inline_code_no_code_span() {
1973 let result = TableUtils::mask_pipes_in_inline_code("| col1 | col2 |");
1975 assert_eq!(result, "| col1 | col2 |");
1976 }
1977
1978 #[test]
1979 fn test_mask_pipes_in_inline_code_backslash_before_closing_backtick() {
1980 let result = TableUtils::mask_pipes_in_inline_code(r"| `foo\` | bar |");
1989 assert_eq!(result, r"| `foo\` | bar |");
1992 }
1993
1994 #[test]
1995 fn test_mask_pipes_in_inline_code_backslash_literal_with_pipe_inside() {
1996 let result = TableUtils::mask_pipes_in_inline_code(r"| `a\|b` | col2 |");
2000 assert_eq!(result, r"| `a\_b` | col2 |");
2001 }
2002
2003 #[test]
2004 fn test_count_preceding_backslashes() {
2005 let chars: Vec<char> = r"abc\\\`def".chars().collect();
2006 assert_eq!(TableUtils::count_preceding_backslashes(&chars, 6), 3);
2008
2009 let chars2: Vec<char> = r"abc\\`def".chars().collect();
2010 assert_eq!(TableUtils::count_preceding_backslashes(&chars2, 5), 2);
2012
2013 let chars3: Vec<char> = "`def".chars().collect();
2014 assert_eq!(TableUtils::count_preceding_backslashes(&chars3, 0), 0);
2016 }
2017
2018 #[test]
2019 fn test_has_unescaped_pipe_backslash_literal_in_code_span() {
2020 assert!(TableUtils::has_unescaped_pipe_outside_spans(r"`foo\` | bar"));
2023
2024 assert!(TableUtils::has_unescaped_pipe_outside_spans(r"\`foo | bar\`"));
2026
2027 assert!(!TableUtils::has_unescaped_pipe_outside_spans(r"`foo | bar`"));
2029 }
2030
2031 #[test]
2032 fn test_table_after_code_span_detected() {
2033 use crate::config::MarkdownFlavor;
2034
2035 let content = "`code`\n\n| A | B |\n|---|---|\n| 1 | 2 |\n";
2036 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2037 assert!(!ctx.table_blocks.is_empty(), "Table after code span should be detected");
2038 }
2039
2040 #[test]
2041 fn test_table_inside_html_comment_not_detected() {
2042 use crate::config::MarkdownFlavor;
2043
2044 let content = "<!--\n| A | B |\n|---|---|\n| 1 | 2 |\n-->\n";
2045 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2046 assert!(
2047 ctx.table_blocks.is_empty(),
2048 "Table inside HTML comment should not be detected"
2049 );
2050 }
2051}