1use super::blockquote::{parse_blockquote_prefix, strip_blockquote_prefix};
6
7fn blockquote_depth(line: &str) -> usize {
9 parse_blockquote_prefix(line).map_or(0, |parsed| parsed.nesting_level)
10}
11
12#[derive(Debug, Clone)]
14pub struct TableBlock {
15 pub start_line: usize,
16 pub end_line: usize,
17 pub header_line: usize,
18 pub delimiter_line: usize,
19 pub content_lines: Vec<usize>,
20 pub list_context: Option<ListTableContext>,
24}
25
26#[derive(Debug, Clone)]
28pub struct ListTableContext {
29 pub list_prefix: String,
31 pub content_indent: usize,
33}
34
35pub struct TableUtils;
37
38impl TableUtils {
39 fn has_unescaped_pipe_outside_spans(text: &str) -> bool {
51 let chars: Vec<char> = text.chars().collect();
52 let mut i = 0;
53 let mut in_code = false;
54 let mut code_delim_len = 0usize;
55 let mut in_math = false;
56 let mut math_delim_len = 0usize;
57
58 while i < chars.len() {
59 let ch = chars[i];
60
61 if ch == '\\' && !in_code && !in_math {
62 i += if i + 1 < chars.len() { 2 } else { 1 };
65 continue;
66 }
67
68 if ch == '`' && !in_math {
69 let mut run = 1usize;
70 while i + run < chars.len() && chars[i + run] == '`' {
71 run += 1;
72 }
73
74 if in_code {
75 if run == code_delim_len {
76 in_code = false;
77 code_delim_len = 0;
78 }
79 } else {
81 in_code = true;
82 code_delim_len = run;
83 }
84
85 i += run;
86 continue;
87 }
88
89 if ch == '$' && !in_code {
90 let mut run = 1usize;
91 while i + run < chars.len() && chars[i + run] == '$' {
92 run += 1;
93 }
94
95 if in_math {
96 if run == math_delim_len {
97 in_math = false;
98 math_delim_len = 0;
99 }
100 } else {
102 in_math = true;
103 math_delim_len = run;
104 }
105
106 i += run;
107 continue;
108 }
109
110 if ch == '|' && !in_code && !in_math {
111 return true;
112 }
113
114 i += 1;
115 }
116
117 false
118 }
119
120 pub fn is_potential_table_row_with_flavor(line: &str, flavor: crate::config::MarkdownFlavor) -> bool {
126 if flavor == crate::config::MarkdownFlavor::Obsidian && line.contains("[[") {
129 return Self::is_potential_table_row(&Self::mask_pipes_in_wikilinks(line));
130 }
131 Self::is_potential_table_row(line)
132 }
133
134 pub fn is_potential_table_row(line: &str) -> bool {
135 let trimmed = line.trim();
136 if trimmed.is_empty() || !trimmed.contains('|') {
137 return false;
138 }
139
140 if trimmed.starts_with("- ")
143 || trimmed.starts_with("* ")
144 || trimmed.starts_with("+ ")
145 || trimmed.starts_with("-\t")
146 || trimmed.starts_with("*\t")
147 || trimmed.starts_with("+\t")
148 {
149 return false;
150 }
151
152 if let Some(first_non_digit) = trimmed.find(|c: char| !c.is_ascii_digit())
154 && first_non_digit > 0
155 {
156 let after_digits = &trimmed[first_non_digit..];
157 if after_digits.starts_with(". ")
158 || after_digits.starts_with(".\t")
159 || after_digits.starts_with(") ")
160 || after_digits.starts_with(")\t")
161 {
162 return false;
163 }
164 }
165
166 if trimmed.starts_with('#') {
168 let hash_count = trimmed.bytes().take_while(|&b| b == b'#').count();
169 if hash_count <= 6 {
170 let after_hashes = &trimmed[hash_count..];
171 if after_hashes.is_empty() || after_hashes.starts_with(' ') || after_hashes.starts_with('\t') {
172 return false;
173 }
174 }
175 }
176
177 let has_outer_pipes = trimmed.starts_with('|') && trimmed.ends_with('|');
180 if !has_outer_pipes && !Self::has_unescaped_pipe_outside_spans(trimmed) {
181 return false;
182 }
183
184 let parts: Vec<&str> = trimmed.split('|').collect();
186 if parts.len() < 2 {
187 return false;
188 }
189
190 let mut valid_parts = 0;
192 let mut total_non_empty_parts = 0;
193
194 for part in &parts {
195 let part_trimmed = part.trim();
196 if part_trimmed.is_empty() {
198 continue;
199 }
200 total_non_empty_parts += 1;
201
202 if !part_trimmed.contains('\n') {
204 valid_parts += 1;
205 }
206 }
207
208 if total_non_empty_parts > 0 && valid_parts != total_non_empty_parts {
210 return false;
212 }
213
214 if total_non_empty_parts == 0 {
217 return trimmed.starts_with('|') && trimmed.ends_with('|') && parts.len() >= 3;
219 }
220
221 if trimmed.starts_with('|') && trimmed.ends_with('|') {
224 valid_parts >= 1
226 } else {
227 valid_parts >= 2
229 }
230 }
231
232 pub fn is_delimiter_row(line: &str) -> bool {
234 let trimmed = line.trim();
235 if !trimmed.contains('|') || !trimmed.contains('-') {
236 return false;
237 }
238
239 let parts: Vec<&str> = trimmed.split('|').collect();
241 let mut valid_delimiter_parts = 0;
242 let mut total_non_empty_parts = 0;
243
244 for part in &parts {
245 let part_trimmed = part.trim();
246 if part_trimmed.is_empty() {
247 continue; }
249
250 total_non_empty_parts += 1;
251
252 if part_trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace()) && part_trimmed.contains('-') {
254 valid_delimiter_parts += 1;
255 }
256 }
257
258 total_non_empty_parts > 0 && valid_delimiter_parts == total_non_empty_parts
260 }
261
262 pub fn find_table_blocks_with_code_info(
265 content: &str,
266 code_blocks: &[(usize, usize)],
267 code_spans: &[crate::lint_context::CodeSpan],
268 html_comment_ranges: &[crate::utils::skip_context::ByteRange],
269 flavor: crate::config::MarkdownFlavor,
270 ) -> Vec<TableBlock> {
271 let lines: Vec<&str> = content.lines().collect();
272 let mut tables = Vec::new();
273 let mut i = 0;
274
275 let mut line_positions = Vec::with_capacity(lines.len());
282 let content_bytes = content.as_bytes();
283 let mut pos = 0;
284 for line in &lines {
285 line_positions.push(pos);
286 pos += line.len();
287 if content_bytes.get(pos) == Some(&b'\r') {
288 pos += 1;
289 }
290 if content_bytes.get(pos) == Some(&b'\n') {
291 pos += 1;
292 }
293 }
294
295 let mut list_indent_stack: Vec<usize> = Vec::new();
299
300 while i < lines.len() {
301 let line_start = line_positions[i];
303 let in_code =
304 crate::utils::code_block_utils::CodeBlockUtils::is_in_code_block_or_span(code_blocks, line_start) || {
305 let idx = code_spans.partition_point(|span| span.byte_offset <= line_start);
307 idx > 0 && line_start < code_spans[idx - 1].byte_end
308 };
309 let in_html_comment = {
310 let idx = html_comment_ranges.partition_point(|range| range.start <= line_start);
312 idx > 0 && line_start < html_comment_ranges[idx - 1].end
313 };
314
315 if in_code || in_html_comment {
316 i += 1;
317 continue;
318 }
319
320 let line_content = strip_blockquote_prefix(lines[i]);
322
323 let (list_prefix, list_content, content_indent) = Self::extract_list_prefix(line_content);
325 if !list_prefix.is_empty() {
326 while list_indent_stack.last().is_some_and(|&top| top >= content_indent) {
328 list_indent_stack.pop();
329 }
330 list_indent_stack.push(content_indent);
331 } else if !line_content.trim().is_empty() {
332 let leading = line_content.len() - line_content.trim_start().len();
334 while list_indent_stack.last().is_some_and(|&top| leading < top) {
335 list_indent_stack.pop();
336 }
337 }
338 let (is_same_line_list_table, effective_content) =
343 if !list_prefix.is_empty() && Self::is_potential_table_row_content(list_content, flavor) {
344 (true, list_content)
345 } else {
346 (false, line_content)
347 };
348
349 let continuation_indent = if !is_same_line_list_table && list_prefix.is_empty() {
352 let leading = line_content.len() - line_content.trim_start().len();
353 list_indent_stack
355 .iter()
356 .rev()
357 .find(|&&indent| leading >= indent)
358 .copied()
359 } else {
360 None
361 };
362
363 let is_continuation_list_table = continuation_indent.is_some()
364 && {
365 let indent = continuation_indent.unwrap();
366 let leading = line_content.len() - line_content.trim_start().len();
367 leading < indent + 4
369 }
370 && Self::is_potential_table_row_with_flavor(effective_content, flavor);
371
372 let is_any_list_table = is_same_line_list_table || is_continuation_list_table;
373
374 let effective_content_indent = if is_same_line_list_table {
376 content_indent
377 } else if is_continuation_list_table {
378 continuation_indent.unwrap()
379 } else {
380 0
381 };
382
383 if is_any_list_table || Self::is_potential_table_row_with_flavor(effective_content, flavor) {
385 let (next_line_content, delimiter_has_valid_indent) = if i + 1 < lines.len() {
388 let next_raw = strip_blockquote_prefix(lines[i + 1]);
389 if is_any_list_table {
390 let leading_spaces = next_raw.len() - next_raw.trim_start().len();
392 if leading_spaces >= effective_content_indent {
393 (
395 Self::strip_list_continuation_indent(next_raw, effective_content_indent),
396 true,
397 )
398 } else {
399 (next_raw, false)
401 }
402 } else {
403 (next_raw, true)
404 }
405 } else {
406 ("", true)
407 };
408
409 let effective_is_list_table = is_any_list_table && delimiter_has_valid_indent;
411
412 if i + 1 < lines.len() && Self::is_delimiter_row(next_line_content) {
413 let table_start = i;
415 let header_line = i;
416 let delimiter_line = i + 1;
417 let mut table_end = i + 1; let mut content_lines = Vec::new();
419
420 let table_depth = blockquote_depth(lines[i]);
422 let mut j = i + 2;
423 while j < lines.len() {
424 let line = lines[j];
425 let raw_content = strip_blockquote_prefix(line);
427
428 let line_content = if effective_is_list_table {
430 Self::strip_list_continuation_indent(raw_content, effective_content_indent)
431 } else {
432 raw_content
433 };
434
435 if line_content.trim().is_empty() {
436 break;
438 }
439
440 if effective_is_list_table {
442 let leading_spaces = raw_content.len() - raw_content.trim_start().len();
443 if leading_spaces < effective_content_indent {
444 break;
446 }
447 }
448
449 if blockquote_depth(line) != table_depth
455 || Self::starts_block_after_table(line_content)
456 || (flavor == crate::config::MarkdownFlavor::Obsidian
457 && !Self::is_potential_table_row_with_flavor(line_content, flavor))
458 {
459 break;
460 }
461 content_lines.push(j);
462 table_end = j;
463 j += 1;
464 }
465
466 let list_context = if effective_is_list_table {
467 if is_same_line_list_table {
468 Some(ListTableContext {
470 list_prefix: list_prefix.to_string(),
471 content_indent: effective_content_indent,
472 })
473 } else {
474 Some(ListTableContext {
476 list_prefix: " ".repeat(effective_content_indent),
477 content_indent: effective_content_indent,
478 })
479 }
480 } else {
481 None
482 };
483
484 tables.push(TableBlock {
485 start_line: table_start,
486 end_line: table_end,
487 header_line,
488 delimiter_line,
489 content_lines,
490 list_context,
491 });
492 i = table_end + 1;
493 } else {
494 i += 1;
495 }
496 } else {
497 i += 1;
498 }
499 }
500
501 tables
502 }
503
504 fn starts_block_after_table(line: &str) -> bool {
521 let indent = line.bytes().take_while(|&b| b == b' ').count();
522 if indent >= 4 || line[indent..].starts_with('\t') {
523 return false;
524 }
525 let text = line[indent..].trim_end();
526 let bytes = text.as_bytes();
527 let Some(&first) = bytes.first() else {
528 return false;
529 };
530 let space_or_end = |at: usize| bytes.get(at).is_none_or(|&b| b == b' ' || b == b'\t');
531 match first {
532 b'>' => true,
533 b'-' | b'*' | b'+' if space_or_end(1) => true,
534 b'-' | b'*' | b'_' => crate::utils::thematic_break::is_thematic_break(text),
535 b'0'..=b'9' => {
536 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
537 digits <= 9 && matches!(bytes.get(digits), Some(b'.' | b')')) && space_or_end(digits + 1)
538 }
539 b'#' => {
540 let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
541 hashes <= 6 && bytes.get(hashes).is_none_or(|&b| b == b' ')
542 }
543 b'`' | b'~' | b':' => bytes.iter().take_while(|&&b| b == first).count() >= 3,
544 b'<' => {
545 crate::utils::html_block::opens_untagged_html_block(text)
546 || crate::utils::html_block::parse_html_block_start(text).is_some_and(|(tag, _)| {
547 !crate::utils::html_block::NON_SPEC_BLOCK_ELEMENTS.contains(&tag.as_str())
548 })
549 }
550 b'[' => text
551 .strip_prefix("[^")
552 .and_then(|rest| rest.split_once("]:"))
553 .is_some_and(|(label, _)| {
554 !label.is_empty() && !label.contains(|c: char| c.is_whitespace() || c == ']')
555 }),
556 _ => false,
557 }
558 }
559
560 fn strip_list_continuation_indent(line: &str, expected_indent: usize) -> &str {
563 let bytes = line.as_bytes();
564 let mut spaces = 0;
565
566 for &b in bytes {
567 if b == b' ' {
568 spaces += 1;
569 } else if b == b'\t' {
570 spaces = (spaces / 4 + 1) * 4;
572 } else {
573 break;
574 }
575
576 if spaces >= expected_indent {
577 break;
578 }
579 }
580
581 let strip_count = spaces.min(expected_indent).min(line.len());
583 let mut byte_count = 0;
585 let mut counted_spaces = 0;
586 for &b in bytes {
587 if counted_spaces >= strip_count {
588 break;
589 }
590 if b == b' ' {
591 counted_spaces += 1;
592 byte_count += 1;
593 } else if b == b'\t' {
594 counted_spaces = (counted_spaces / 4 + 1) * 4;
595 byte_count += 1;
596 } else {
597 break;
598 }
599 }
600
601 &line[byte_count..]
602 }
603
604 pub fn find_table_blocks(content: &str, ctx: &crate::lint_context::LintContext) -> Vec<TableBlock> {
607 Self::find_table_blocks_with_code_info(
608 content,
609 &ctx.code_blocks,
610 &ctx.code_spans(),
611 ctx.html_comment_ranges(),
612 ctx.flavor,
613 )
614 }
615
616 pub fn count_cells(row: &str) -> usize {
618 Self::count_cells_with_flavor(row, crate::config::MarkdownFlavor::Standard)
619 }
620
621 pub fn count_cells_with_flavor(row: &str, flavor: crate::config::MarkdownFlavor) -> usize {
628 let (_, content) = Self::extract_blockquote_prefix(row);
630 Self::split_table_row_with_flavor(content, flavor).len()
631 }
632
633 fn count_preceding_backslashes(chars: &[char], pos: usize) -> usize {
635 let mut count = 0;
636 let mut k = pos;
637 while k > 0 {
638 k -= 1;
639 if chars[k] == '\\' {
640 count += 1;
641 } else {
642 break;
643 }
644 }
645 count
646 }
647
648 fn inline_code_spans(chars: &[char]) -> Vec<(usize, usize)> {
661 let mut spans = Vec::new();
662 let mut i = 0;
663
664 while i < chars.len() {
665 if chars[i] != '`' {
666 i += 1;
667 continue;
668 }
669
670 if Self::count_preceding_backslashes(chars, i) % 2 != 0 {
672 i += 1;
673 continue;
674 }
675
676 let start = i;
678 let mut backtick_count = 0;
679 while i < chars.len() && chars[i] == '`' {
680 backtick_count += 1;
681 i += 1;
682 }
683
684 let mut j = i;
688 while j < chars.len() {
689 if chars[j] == '`' {
690 let mut close_count = 0;
691 while j < chars.len() && chars[j] == '`' {
692 close_count += 1;
693 j += 1;
694 }
695
696 if close_count == backtick_count {
697 spans.push((start, j));
698 i = j;
699 break;
700 }
701 } else {
703 j += 1;
704 }
705 }
706 }
709
710 spans
711 }
712
713 pub fn mask_pipes_in_inline_code(text: &str) -> String {
718 if !text.contains('`') {
719 return text.to_string();
720 }
721
722 let chars: Vec<char> = text.chars().collect();
723 let spans = Self::inline_code_spans(&chars);
724 if spans.is_empty() {
725 return text.to_string();
726 }
727
728 let mut result = String::with_capacity(text.len());
729 let mut cursor = 0;
730 for (start, end) in spans {
731 result.extend(chars[cursor..start].iter());
732 for &ch in &chars[start..end] {
735 result.push(if ch == '|' { '_' } else { ch });
736 }
737 cursor = end;
738 }
739 result.extend(chars[cursor..].iter());
740
741 result
742 }
743
744 pub fn mask_pipes_in_wikilinks(text: &str) -> String {
762 if !text.contains("[[") || !text.contains('|') {
765 return text.to_string();
766 }
767
768 let chars: Vec<char> = text.chars().collect();
769 let code_spans = Self::inline_code_spans(&chars);
770 let code_span_at = |pos: usize| code_spans.iter().find(|&&(s, e)| pos >= s && pos < e).copied();
771
772 let mut result = String::with_capacity(text.len());
773 let mut i = 0;
774
775 while i < chars.len() {
776 if let Some((_, end)) = code_span_at(i) {
778 result.extend(chars[i..end].iter());
779 i = end;
780 continue;
781 }
782
783 if chars[i] == '['
784 && i + 1 < chars.len()
785 && chars[i + 1] == '['
786 && let Some(close) = Self::wikilink_close(&chars, &code_spans, i)
787 {
788 result.push_str("[[");
789 for &ch in &chars[i + 2..close] {
790 if ch == '|' {
791 result.push('_'); } else {
793 result.push(ch);
794 }
795 }
796 result.push_str("]]");
797 i = close + 2;
798 continue;
799 }
800
801 result.push(chars[i]);
802 i += 1;
803 }
804
805 result
806 }
807
808 fn wikilink_close(chars: &[char], code_spans: &[(usize, usize)], open: usize) -> Option<usize> {
814 let mut j = open + 2;
815 let mut first_pipe = None;
816
817 while j + 1 < chars.len() {
818 if let Some(&(_, end)) = code_spans.iter().find(|&&(s, _)| s == j) {
819 j = end;
820 continue;
821 }
822
823 if chars[j] == ']' && chars[j + 1] == ']' {
824 if let Some(pipe) = first_pipe
828 && chars[open + 2..pipe].iter().all(|c| c.is_whitespace())
829 {
830 return None;
831 }
832 return Some(j);
833 }
834
835 if chars[j] == '[' && chars[j + 1] == '[' {
837 return None;
838 }
839
840 if chars[j] == '|' && first_pipe.is_none() {
841 first_pipe = Some(j);
842 }
843
844 j += 1;
845 }
846
847 None
848 }
849
850 pub fn mask_pipes_for_table_parsing(text: &str) -> String {
859 let mut result = String::new();
860 let chars: Vec<char> = text.chars().collect();
861 let mut i = 0;
862
863 while i < chars.len() {
864 if chars[i] == '\\' {
865 if i + 1 < chars.len() && chars[i + 1] == '\\' {
866 result.push('\\');
869 result.push('\\');
870 i += 2;
871 } else if i + 1 < chars.len() && chars[i + 1] == '|' {
872 result.push('\\');
874 result.push('_'); i += 2;
876 } else {
877 result.push(chars[i]);
879 i += 1;
880 }
881 } else {
882 result.push(chars[i]);
883 i += 1;
884 }
885 }
886
887 result
888 }
889
890 pub fn split_table_row_with_flavor(row: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
897 let trimmed = row.trim();
898
899 if !trimmed.contains('|') {
900 return Vec::new();
901 }
902
903 let masked = Self::mask_pipes_for_table_parsing(trimmed);
905
906 let mut final_masked = Self::mask_pipes_in_inline_code(&masked);
908
909 if flavor == crate::config::MarkdownFlavor::Obsidian {
912 final_masked = Self::mask_pipes_in_wikilinks(&final_masked);
913 }
914
915 let has_leading = final_masked.starts_with('|');
916 let has_trailing = final_masked.ends_with('|');
917
918 let mut masked_content = final_masked.as_str();
919 let mut orig_content = trimmed;
920
921 if has_leading {
922 masked_content = &masked_content[1..];
923 orig_content = &orig_content[1..];
924 }
925
926 let stripped_trailing = has_trailing && !masked_content.is_empty();
928 if stripped_trailing {
929 masked_content = &masked_content[..masked_content.len() - 1];
930 orig_content = &orig_content[..orig_content.len() - 1];
931 }
932
933 if masked_content.is_empty() {
935 if stripped_trailing {
936 return vec![String::new()];
938 } else {
939 return Vec::new();
941 }
942 }
943
944 let masked_parts: Vec<&str> = masked_content.split('|').collect();
945 let mut cells = Vec::new();
946 let mut pos = 0;
947
948 for masked_cell in masked_parts {
949 let cell_len = masked_cell.len();
950 let orig_cell = if pos + cell_len <= orig_content.len() {
951 &orig_content[pos..pos + cell_len]
952 } else {
953 masked_cell
954 };
955 cells.push(orig_cell.to_string());
956 pos += cell_len + 1; }
958
959 cells
960 }
961
962 pub fn split_table_row(row: &str) -> Vec<String> {
964 Self::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Standard)
965 }
966
967 pub(crate) fn has_unescaped_trailing_pipe(line: &str) -> bool {
969 let bytes = line.trim_end().as_bytes();
970 if bytes.last() != Some(&b'|') {
971 return false;
972 }
973 bytes[..bytes.len() - 1]
974 .iter()
975 .rev()
976 .take_while(|&&byte| byte == b'\\')
977 .count()
978 % 2
979 == 0
980 }
981
982 pub fn determine_pipe_style(line: &str) -> Option<&'static str> {
987 let content = strip_blockquote_prefix(line);
989 let trimmed = content.trim();
990 if !trimmed.contains('|') {
991 return None;
992 }
993
994 let has_leading = trimmed.starts_with('|');
995 let has_trailing = Self::has_unescaped_trailing_pipe(trimmed);
996
997 match (has_leading, has_trailing) {
998 (true, true) => Some("leading_and_trailing"),
999 (true, false) => Some("leading_only"),
1000 (false, true) => Some("trailing_only"),
1001 (false, false) => Some("no_leading_or_trailing"),
1002 }
1003 }
1004
1005 pub fn extract_blockquote_prefix(line: &str) -> (&str, &str) {
1010 let bytes = line.as_bytes();
1012 let mut pos = 0;
1013
1014 while pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
1016 pos += 1;
1017 }
1018
1019 if pos >= bytes.len() || bytes[pos] != b'>' {
1021 return ("", line);
1022 }
1023
1024 while pos < bytes.len() {
1026 if bytes[pos] == b'>' {
1027 pos += 1;
1028 if pos < bytes.len() && bytes[pos] == b' ' {
1030 pos += 1;
1031 }
1032 } else if bytes[pos] == b' ' || bytes[pos] == b'\t' {
1033 pos += 1;
1034 } else {
1035 break;
1036 }
1037 }
1038
1039 (&line[..pos], &line[pos..])
1041 }
1042
1043 pub fn extract_list_prefix(line: &str) -> (&str, &str, usize) {
1058 let bytes = line.as_bytes();
1059
1060 let leading_spaces = bytes.iter().take_while(|&&b| b == b' ' || b == b'\t').count();
1062 let mut pos = leading_spaces;
1063
1064 if pos >= bytes.len() {
1065 return ("", line, 0);
1066 }
1067
1068 if matches!(bytes[pos], b'-' | b'*' | b'+') {
1070 pos += 1;
1071
1072 if pos >= bytes.len() || bytes[pos] == b' ' || bytes[pos] == b'\t' {
1074 if pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
1076 pos += 1;
1077 }
1078 let content_indent = pos;
1079 return (&line[..pos], &line[pos..], content_indent);
1080 }
1081 return ("", line, 0);
1083 }
1084
1085 if bytes[pos].is_ascii_digit() {
1087 let digit_start = pos;
1088 while pos < bytes.len() && bytes[pos].is_ascii_digit() {
1089 pos += 1;
1090 }
1091
1092 if pos > digit_start && pos < bytes.len() {
1094 if bytes[pos] == b'.' || bytes[pos] == b')' {
1096 pos += 1;
1097 if pos >= bytes.len() || bytes[pos] == b' ' || bytes[pos] == b'\t' {
1098 if pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
1100 pos += 1;
1101 }
1102 let content_indent = pos;
1103 return (&line[..pos], &line[pos..], content_indent);
1104 }
1105 }
1106 }
1107 }
1108
1109 ("", line, 0)
1110 }
1111
1112 pub fn extract_table_row_content<'a>(line: &'a str, table_block: &TableBlock, line_index: usize) -> &'a str {
1117 let (_, after_blockquote) = Self::extract_blockquote_prefix(line);
1119
1120 if let Some(ref list_ctx) = table_block.list_context {
1122 if line_index == 0 {
1123 after_blockquote
1125 .strip_prefix(&list_ctx.list_prefix)
1126 .unwrap_or_else(|| Self::extract_list_prefix(after_blockquote).1)
1127 } else {
1128 Self::strip_list_continuation_indent(after_blockquote, list_ctx.content_indent)
1130 }
1131 } else {
1132 after_blockquote
1133 }
1134 }
1135
1136 pub fn is_list_item_with_table_row(line: &str, flavor: crate::config::MarkdownFlavor) -> bool {
1139 let (prefix, content, _) = Self::extract_list_prefix(line);
1140 if prefix.is_empty() {
1141 return false;
1142 }
1143
1144 let trimmed = content.trim();
1147 if !trimmed.starts_with('|') {
1148 return false;
1149 }
1150
1151 Self::is_potential_table_row_content(content, flavor)
1153 }
1154
1155 fn is_potential_table_row_content(content: &str, flavor: crate::config::MarkdownFlavor) -> bool {
1157 Self::is_potential_table_row_with_flavor(content, flavor)
1158 }
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163 use super::*;
1164 use crate::lint_context::LintContext;
1165
1166 #[test]
1167 fn test_is_potential_table_row() {
1168 assert!(TableUtils::is_potential_table_row("| Header 1 | Header 2 |"));
1170 assert!(TableUtils::is_potential_table_row("| Cell 1 | Cell 2 |"));
1171 assert!(TableUtils::is_potential_table_row("Cell 1 | Cell 2"));
1172 assert!(TableUtils::is_potential_table_row("| Cell |")); assert!(TableUtils::is_potential_table_row("| A | B | C | D | E |"));
1176
1177 assert!(TableUtils::is_potential_table_row(" | Indented | Table | "));
1179 assert!(TableUtils::is_potential_table_row("| Spaces | Around |"));
1180
1181 assert!(!TableUtils::is_potential_table_row("- List item"));
1183 assert!(!TableUtils::is_potential_table_row("* Another list"));
1184 assert!(!TableUtils::is_potential_table_row("+ Plus list"));
1185 assert!(!TableUtils::is_potential_table_row("Regular text"));
1186 assert!(!TableUtils::is_potential_table_row(""));
1187 assert!(!TableUtils::is_potential_table_row(" "));
1188
1189 assert!(!TableUtils::is_potential_table_row("`code with | pipe`"));
1191 assert!(!TableUtils::is_potential_table_row("``multiple | backticks``"));
1192 assert!(!TableUtils::is_potential_table_row("Use ``a|b`` in prose"));
1193 assert!(TableUtils::is_potential_table_row("| `fenced` | Uses ``` and ~~~ |"));
1194 assert!(TableUtils::is_potential_table_row("`!foo && bar` | `(!foo) && bar`"));
1195 assert!(!TableUtils::is_potential_table_row("`echo a | sed 's/a/b/'`"));
1196
1197 assert!(!TableUtils::is_potential_table_row(
1199 "Text with $|S|$ math notation here."
1200 ));
1201 assert!(!TableUtils::is_potential_table_row(
1202 "Size $|S|$ was even, check $|T|$ too."
1203 ));
1204 assert!(!TableUtils::is_potential_table_row("Display $$|A| + |B|$$ math here."));
1205 assert!(TableUtils::is_potential_table_row("| cell with $|S|$ math |"));
1207 assert!(TableUtils::is_potential_table_row("$a$ | $b$"));
1209 assert!(TableUtils::is_potential_table_row("$f(x)$ and $g(x)$ | result"));
1210 assert!(!TableUtils::is_potential_table_row("$5 | $10"));
1214
1215 assert!(!TableUtils::is_potential_table_row("Just one |"));
1217 assert!(!TableUtils::is_potential_table_row("| Just one"));
1218
1219 let long_cell = "a".repeat(150);
1221 assert!(TableUtils::is_potential_table_row(&format!("| {long_cell} | b |")));
1222
1223 assert!(!TableUtils::is_potential_table_row("| Cell with\nnewline | Other |"));
1225
1226 assert!(TableUtils::is_potential_table_row("|||")); assert!(TableUtils::is_potential_table_row("||||")); assert!(TableUtils::is_potential_table_row("| | |")); }
1231
1232 #[test]
1233 fn test_list_items_with_pipes_not_table_rows() {
1234 assert!(!TableUtils::is_potential_table_row("1. Item with | pipe"));
1236 assert!(!TableUtils::is_potential_table_row("10. Item with | pipe"));
1237 assert!(!TableUtils::is_potential_table_row("999. Item with | pipe"));
1238 assert!(!TableUtils::is_potential_table_row("1) Item with | pipe"));
1239 assert!(!TableUtils::is_potential_table_row("10) Item with | pipe"));
1240
1241 assert!(!TableUtils::is_potential_table_row("-\tItem with | pipe"));
1243 assert!(!TableUtils::is_potential_table_row("*\tItem with | pipe"));
1244 assert!(!TableUtils::is_potential_table_row("+\tItem with | pipe"));
1245
1246 assert!(!TableUtils::is_potential_table_row(" - Indented | pipe"));
1248 assert!(!TableUtils::is_potential_table_row(" * Deep indent | pipe"));
1249 assert!(!TableUtils::is_potential_table_row(" 1. Ordered indent | pipe"));
1250
1251 assert!(!TableUtils::is_potential_table_row("- [ ] task | pipe"));
1253 assert!(!TableUtils::is_potential_table_row("- [x] done | pipe"));
1254
1255 assert!(!TableUtils::is_potential_table_row("1. foo | bar | baz"));
1257 assert!(!TableUtils::is_potential_table_row("- alpha | beta | gamma"));
1258
1259 assert!(TableUtils::is_potential_table_row("| cell | cell |"));
1261 assert!(TableUtils::is_potential_table_row("cell | cell"));
1262 assert!(TableUtils::is_potential_table_row("| Header | Header |"));
1263 }
1264
1265 #[test]
1266 fn test_atx_headings_with_pipes_not_table_rows() {
1267 assert!(!TableUtils::is_potential_table_row("# Heading | with pipe"));
1269 assert!(!TableUtils::is_potential_table_row("## Heading | with pipe"));
1270 assert!(!TableUtils::is_potential_table_row("### Heading | with pipe"));
1271 assert!(!TableUtils::is_potential_table_row("#### Heading | with pipe"));
1272 assert!(!TableUtils::is_potential_table_row("##### Heading | with pipe"));
1273 assert!(!TableUtils::is_potential_table_row("###### Heading | with pipe"));
1274
1275 assert!(!TableUtils::is_potential_table_row("### col1 | col2 | col3"));
1277 assert!(!TableUtils::is_potential_table_row("## a|b|c"));
1278
1279 assert!(!TableUtils::is_potential_table_row("#\tHeading | pipe"));
1281 assert!(!TableUtils::is_potential_table_row("##\tHeading | pipe"));
1282
1283 assert!(!TableUtils::is_potential_table_row("# |"));
1285 assert!(!TableUtils::is_potential_table_row("## |"));
1286
1287 assert!(!TableUtils::is_potential_table_row(" ## Heading | pipe"));
1289 assert!(!TableUtils::is_potential_table_row(" ### Heading | pipe"));
1290
1291 assert!(!TableUtils::is_potential_table_row("#### ®aAA|ᯗ"));
1293
1294 assert!(TableUtils::is_potential_table_row("####### text | pipe"));
1298
1299 assert!(TableUtils::is_potential_table_row("#nospc|pipe"));
1301
1302 assert!(TableUtils::is_potential_table_row("| # Header | Value |"));
1304 assert!(TableUtils::is_potential_table_row("text | #tag"));
1305 }
1306
1307 #[test]
1308 fn test_is_delimiter_row() {
1309 assert!(TableUtils::is_delimiter_row("|---|---|"));
1311 assert!(TableUtils::is_delimiter_row("| --- | --- |"));
1312 assert!(TableUtils::is_delimiter_row("|:---|---:|"));
1313 assert!(TableUtils::is_delimiter_row("|:---:|:---:|"));
1314
1315 assert!(TableUtils::is_delimiter_row("|-|--|"));
1317 assert!(TableUtils::is_delimiter_row("|-------|----------|"));
1318
1319 assert!(TableUtils::is_delimiter_row("| --- | --- |"));
1321 assert!(TableUtils::is_delimiter_row("| :--- | ---: |"));
1322
1323 assert!(TableUtils::is_delimiter_row("|---|---|---|---|"));
1325
1326 assert!(TableUtils::is_delimiter_row("--- | ---"));
1328 assert!(TableUtils::is_delimiter_row(":--- | ---:"));
1329
1330 assert!(!TableUtils::is_delimiter_row("| Header | Header |"));
1332 assert!(!TableUtils::is_delimiter_row("Regular text"));
1333 assert!(!TableUtils::is_delimiter_row(""));
1334 assert!(!TableUtils::is_delimiter_row("|||"));
1335 assert!(!TableUtils::is_delimiter_row("| | |"));
1336
1337 assert!(!TableUtils::is_delimiter_row("| : | : |"));
1339 assert!(!TableUtils::is_delimiter_row("| | |"));
1340
1341 assert!(!TableUtils::is_delimiter_row("| --- | text |"));
1343 assert!(!TableUtils::is_delimiter_row("| abc | --- |"));
1344 }
1345
1346 #[test]
1347 fn test_count_cells() {
1348 assert_eq!(TableUtils::count_cells("| Cell 1 | Cell 2 | Cell 3 |"), 3);
1350 assert_eq!(TableUtils::count_cells("Cell 1 | Cell 2 | Cell 3"), 3);
1351 assert_eq!(TableUtils::count_cells("| Cell 1 | Cell 2"), 2);
1352 assert_eq!(TableUtils::count_cells("Cell 1 | Cell 2 |"), 2);
1353
1354 assert_eq!(TableUtils::count_cells("| Cell |"), 1);
1356 assert_eq!(TableUtils::count_cells("Cell"), 0); assert_eq!(TableUtils::count_cells("| | | |"), 3);
1360 assert_eq!(TableUtils::count_cells("| | | |"), 3);
1361
1362 assert_eq!(TableUtils::count_cells("| A | B | C | D | E | F |"), 6);
1364
1365 assert_eq!(TableUtils::count_cells("||"), 1); assert_eq!(TableUtils::count_cells("|||"), 2); assert_eq!(TableUtils::count_cells("Regular text"), 0);
1371 assert_eq!(TableUtils::count_cells(""), 0);
1372 assert_eq!(TableUtils::count_cells(" "), 0);
1373
1374 assert_eq!(TableUtils::count_cells(" | A | B | "), 2);
1376 assert_eq!(TableUtils::count_cells("| A | B |"), 2);
1377 }
1378
1379 #[test]
1380 fn test_count_cells_with_escaped_pipes() {
1381 assert_eq!(TableUtils::count_cells("| Challenge | Solution |"), 2);
1386 assert_eq!(TableUtils::count_cells("| A | B | C |"), 3);
1387 assert_eq!(TableUtils::count_cells("| One | Two |"), 2);
1388
1389 assert_eq!(TableUtils::count_cells(r"| Command | echo \| grep |"), 2);
1391 assert_eq!(TableUtils::count_cells(r"| A | B \| C |"), 2); assert_eq!(TableUtils::count_cells(r"| Command | `echo \| grep` |"), 2);
1395
1396 assert_eq!(TableUtils::count_cells(r"| A | B \\| C |"), 3); assert_eq!(TableUtils::count_cells(r"| A | `B \\| C` |"), 2);
1400
1401 assert_eq!(TableUtils::count_cells("| Command | `echo | grep` |"), 2);
1403 assert_eq!(TableUtils::count_cells("| `code | one` | `code | two` |"), 2);
1404 assert_eq!(TableUtils::count_cells("| `single|pipe` |"), 1);
1405
1406 assert_eq!(TableUtils::count_cells(r"| Hour formats | `^([0-1]?\d|2[0-3])` |"), 2);
1408 assert_eq!(TableUtils::count_cells(r"| Hour formats | `^([0-1]?\d\|2[0-3])` |"), 2);
1410 }
1411
1412 #[test]
1413 fn test_determine_pipe_style() {
1414 assert_eq!(
1416 TableUtils::determine_pipe_style("| Cell 1 | Cell 2 |"),
1417 Some("leading_and_trailing")
1418 );
1419 assert_eq!(
1420 TableUtils::determine_pipe_style("| Cell 1 | Cell 2"),
1421 Some("leading_only")
1422 );
1423 assert_eq!(
1424 TableUtils::determine_pipe_style("Cell 1 | Cell 2 |"),
1425 Some("trailing_only")
1426 );
1427 assert_eq!(
1428 TableUtils::determine_pipe_style("Cell 1 | Cell 2"),
1429 Some("no_leading_or_trailing")
1430 );
1431
1432 assert_eq!(
1434 TableUtils::determine_pipe_style(" | Cell 1 | Cell 2 | "),
1435 Some("leading_and_trailing")
1436 );
1437 assert_eq!(
1438 TableUtils::determine_pipe_style(" | Cell 1 | Cell 2 "),
1439 Some("leading_only")
1440 );
1441
1442 assert_eq!(TableUtils::determine_pipe_style("Regular text"), None);
1444 assert_eq!(TableUtils::determine_pipe_style(""), None);
1445 assert_eq!(TableUtils::determine_pipe_style(" "), None);
1446
1447 assert_eq!(TableUtils::determine_pipe_style("|"), Some("leading_and_trailing"));
1449 assert_eq!(TableUtils::determine_pipe_style("| Cell"), Some("leading_only"));
1450 assert_eq!(TableUtils::determine_pipe_style("Cell |"), Some("trailing_only"));
1451 }
1452
1453 #[test]
1454 fn test_determine_pipe_style_distinguishes_escaped_cell_pipes() {
1455 for backslashes in 0..=6 {
1456 let row = format!("A | B {}|", "\\".repeat(backslashes));
1457 let expected = if backslashes % 2 == 0 {
1458 "trailing_only"
1459 } else {
1460 "no_leading_or_trailing"
1461 };
1462 for prefix in ["", "> ", ">> "] {
1463 assert_eq!(
1464 TableUtils::determine_pipe_style(&format!("{prefix}{row}")),
1465 Some(expected)
1466 );
1467 }
1468 }
1469 }
1470
1471 #[test]
1472 fn test_find_table_blocks_simple() {
1473 let content = "| Header 1 | Header 2 |
1474|-----------|-----------|
1475| Cell 1 | Cell 2 |
1476| Cell 3 | Cell 4 |";
1477
1478 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1479
1480 let tables = TableUtils::find_table_blocks(content, &ctx);
1481 assert_eq!(tables.len(), 1);
1482
1483 let table = &tables[0];
1484 assert_eq!(table.start_line, 0);
1485 assert_eq!(table.end_line, 3);
1486 assert_eq!(table.header_line, 0);
1487 assert_eq!(table.delimiter_line, 1);
1488 assert_eq!(table.content_lines, vec![2, 3]);
1489 }
1490
1491 #[test]
1492 fn test_find_table_blocks_multiple() {
1493 let content = "Some text
1494
1495| Table 1 | Col A |
1496|----------|-------|
1497| Data 1 | Val 1 |
1498
1499More text
1500
1501| Table 2 | Col 2 |
1502|----------|-------|
1503| Data 2 | Data |";
1504
1505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1506
1507 let tables = TableUtils::find_table_blocks(content, &ctx);
1508 assert_eq!(tables.len(), 2);
1509
1510 assert_eq!(tables[0].start_line, 2);
1512 assert_eq!(tables[0].end_line, 4);
1513 assert_eq!(tables[0].header_line, 2);
1514 assert_eq!(tables[0].delimiter_line, 3);
1515 assert_eq!(tables[0].content_lines, vec![4]);
1516
1517 assert_eq!(tables[1].start_line, 8);
1519 assert_eq!(tables[1].end_line, 10);
1520 assert_eq!(tables[1].header_line, 8);
1521 assert_eq!(tables[1].delimiter_line, 9);
1522 assert_eq!(tables[1].content_lines, vec![10]);
1523 }
1524
1525 #[test]
1526 fn test_find_table_blocks_no_content_rows() {
1527 let content = "| Header 1 | Header 2 |
1528|-----------|-----------|
1529
1530Next paragraph";
1531
1532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1533
1534 let tables = TableUtils::find_table_blocks(content, &ctx);
1535 assert_eq!(tables.len(), 1);
1536
1537 let table = &tables[0];
1538 assert_eq!(table.start_line, 0);
1539 assert_eq!(table.end_line, 1); assert_eq!(table.content_lines.len(), 0);
1541 }
1542
1543 #[test]
1544 fn test_find_table_blocks_in_code_block() {
1545 let content = "```
1546| Not | A | Table |
1547|-----|---|-------|
1548| In | Code | Block |
1549```
1550
1551| Real | Table |
1552|------|-------|
1553| Data | Here |";
1554
1555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1556
1557 let tables = TableUtils::find_table_blocks(content, &ctx);
1558 assert_eq!(tables.len(), 1); let table = &tables[0];
1561 assert_eq!(table.header_line, 6);
1562 assert_eq!(table.delimiter_line, 7);
1563 }
1564
1565 #[test]
1566 fn test_find_table_blocks_no_tables() {
1567 let content = "Just regular text
1568No tables here
1569- List item with | pipe
1570* Another list item";
1571
1572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1573
1574 let tables = TableUtils::find_table_blocks(content, &ctx);
1575 assert_eq!(tables.len(), 0);
1576 }
1577
1578 #[test]
1579 fn test_find_table_blocks_malformed() {
1580 let content = "| Header without delimiter |
1581| This looks like table |
1582But no delimiter row
1583
1584| Proper | Table |
1585|---------|-------|
1586| Data | Here |";
1587
1588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1589
1590 let tables = TableUtils::find_table_blocks(content, &ctx);
1591 assert_eq!(tables.len(), 1); assert_eq!(tables[0].header_line, 4);
1593 }
1594
1595 #[test]
1596 fn test_find_table_blocks_keeps_obsidian_wikilink_prose_out_of_the_table() {
1597 let content = "| A | B |\n| - | - |\n| x | y |\n[[Foo|bar]] is a note.\n";
1598
1599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1600 let blocks = TableUtils::find_table_blocks(content, &ctx);
1601 assert_eq!(blocks.len(), 1, "Expected one table, got {blocks:?}");
1602 assert_eq!(
1603 blocks[0].end_line, 2,
1604 "Wikilink prose was absorbed into the table: {:?}",
1605 blocks[0]
1606 );
1607 assert_eq!(blocks[0].content_lines, vec![2]);
1608
1609 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1612 let blocks = TableUtils::find_table_blocks(content, &ctx);
1613 assert_eq!(blocks.len(), 1, "Expected one table, got {blocks:?}");
1614 assert_eq!(
1615 blocks[0].end_line, 3,
1616 "GFM should still read the pipe as a delimiter: {:?}",
1617 blocks[0]
1618 );
1619 }
1620
1621 #[test]
1622 fn test_edge_cases() {
1623 assert!(!TableUtils::is_potential_table_row(""));
1625 assert!(!TableUtils::is_delimiter_row(""));
1626 assert_eq!(TableUtils::count_cells(""), 0);
1627 assert_eq!(TableUtils::determine_pipe_style(""), None);
1628
1629 assert!(!TableUtils::is_potential_table_row(" "));
1631 assert!(!TableUtils::is_delimiter_row(" "));
1632 assert_eq!(TableUtils::count_cells(" "), 0);
1633 assert_eq!(TableUtils::determine_pipe_style(" "), None);
1634
1635 assert!(!TableUtils::is_potential_table_row("|"));
1637 assert!(!TableUtils::is_delimiter_row("|"));
1638 assert_eq!(TableUtils::count_cells("|"), 0); let long_single = format!("| {} |", "a".repeat(200));
1643 assert!(TableUtils::is_potential_table_row(&long_single)); let long_multi = format!("| {} | {} |", "a".repeat(200), "b".repeat(200));
1646 assert!(TableUtils::is_potential_table_row(&long_multi)); assert!(TableUtils::is_potential_table_row("| 你好 | 世界 |"));
1650 assert!(TableUtils::is_potential_table_row("| émoji | 🎉 |"));
1651 assert_eq!(TableUtils::count_cells("| 你好 | 世界 |"), 2);
1652 }
1653
1654 #[test]
1655 fn test_table_block_struct() {
1656 let block = TableBlock {
1657 start_line: 0,
1658 end_line: 5,
1659 header_line: 0,
1660 delimiter_line: 1,
1661 content_lines: vec![2, 3, 4, 5],
1662 list_context: None,
1663 };
1664
1665 let debug_str = format!("{block:?}");
1667 assert!(debug_str.contains("TableBlock"));
1668 assert!(debug_str.contains("start_line: 0"));
1669
1670 let cloned = block.clone();
1672 assert_eq!(cloned.start_line, block.start_line);
1673 assert_eq!(cloned.end_line, block.end_line);
1674 assert_eq!(cloned.header_line, block.header_line);
1675 assert_eq!(cloned.delimiter_line, block.delimiter_line);
1676 assert_eq!(cloned.content_lines, block.content_lines);
1677 assert!(cloned.list_context.is_none());
1678 }
1679
1680 #[test]
1681 fn test_split_table_row() {
1682 let cells = TableUtils::split_table_row("| Cell 1 | Cell 2 | Cell 3 |");
1684 assert_eq!(cells.len(), 3);
1685 assert_eq!(cells[0].trim(), "Cell 1");
1686 assert_eq!(cells[1].trim(), "Cell 2");
1687 assert_eq!(cells[2].trim(), "Cell 3");
1688
1689 let cells = TableUtils::split_table_row("| Cell 1 | Cell 2");
1691 assert_eq!(cells.len(), 2);
1692
1693 let cells = TableUtils::split_table_row("| | | |");
1695 assert_eq!(cells.len(), 3);
1696
1697 let cells = TableUtils::split_table_row("| Cell |");
1699 assert_eq!(cells.len(), 1);
1700 assert_eq!(cells[0].trim(), "Cell");
1701
1702 let cells = TableUtils::split_table_row("No pipes here");
1704 assert_eq!(cells.len(), 0);
1705 }
1706
1707 #[test]
1708 fn test_split_table_row_with_escaped_pipes() {
1709 let cells = TableUtils::split_table_row(r"| A | B \| C |");
1711 assert_eq!(cells.len(), 2);
1712 assert!(cells[1].contains(r"\|"), "Escaped pipe should be in cell content");
1713
1714 let cells = TableUtils::split_table_row(r"| A | B \\| C |");
1716 assert_eq!(cells.len(), 3);
1717 }
1718
1719 #[test]
1720 fn test_split_table_row_with_flavor_mkdocs() {
1721 let cells =
1723 TableUtils::split_table_row_with_flavor("| Type | `x | y` |", crate::config::MarkdownFlavor::MkDocs);
1724 assert_eq!(cells.len(), 2);
1725 assert!(
1726 cells[1].contains("`x | y`"),
1727 "Inline code with pipe should be single cell in MkDocs flavor"
1728 );
1729
1730 let cells =
1732 TableUtils::split_table_row_with_flavor("| Type | `a | b | c` |", crate::config::MarkdownFlavor::MkDocs);
1733 assert_eq!(cells.len(), 2);
1734 assert!(cells[1].contains("`a | b | c`"));
1735 }
1736
1737 #[test]
1738 fn test_split_table_row_with_flavor_standard() {
1739 let cells =
1741 TableUtils::split_table_row_with_flavor("| Type | `x | y` |", crate::config::MarkdownFlavor::Standard);
1742 assert_eq!(
1743 cells.len(),
1744 2,
1745 "Pipes in code spans should not be cell delimiters, got {cells:?}"
1746 );
1747 assert!(
1748 cells[1].contains("`x | y`"),
1749 "Inline code with pipe should be single cell"
1750 );
1751 }
1752
1753 #[test]
1754 fn test_split_table_row_with_flavor_obsidian_wikilink() {
1755 let cells = TableUtils::split_table_row_with_flavor(
1758 "| Alice | [[White Rabbit|the Rabbit]] |",
1759 crate::config::MarkdownFlavor::Obsidian,
1760 );
1761 assert_eq!(cells.len(), 2, "Aliased wikilink should be one cell, got {cells:?}");
1762 assert!(cells[1].contains("[[White Rabbit|the Rabbit]]"));
1763
1764 let cells = TableUtils::split_table_row_with_flavor(
1766 "| Guests | [[Mad Hatter|the Hatter]] and [[March Hare|the Hare]] |",
1767 crate::config::MarkdownFlavor::Obsidian,
1768 );
1769 assert_eq!(
1770 cells.len(),
1771 2,
1772 "Two aliased wikilinks should be one cell, got {cells:?}"
1773 );
1774
1775 let cells = TableUtils::split_table_row_with_flavor(
1777 "| Alice | [[Cheshire Cat]] |",
1778 crate::config::MarkdownFlavor::Obsidian,
1779 );
1780 assert_eq!(cells.len(), 2);
1781
1782 let cells = TableUtils::split_table_row_with_flavor(
1784 "| Alice | [[White Rabbit | curious |",
1785 crate::config::MarkdownFlavor::Obsidian,
1786 );
1787 assert_eq!(
1788 cells.len(),
1789 3,
1790 "Unterminated wikilink should not mask pipes, got {cells:?}"
1791 );
1792 }
1793
1794 #[test]
1795 fn test_split_table_row_wikilink_only_for_obsidian() {
1796 for flavor in [
1798 crate::config::MarkdownFlavor::Standard,
1799 crate::config::MarkdownFlavor::MkDocs,
1800 ] {
1801 let cells = TableUtils::split_table_row_with_flavor("| Alice | [[White Rabbit|the Rabbit]] |", flavor);
1802 assert_eq!(
1803 cells.len(),
1804 3,
1805 "{flavor:?} should treat the wikilink pipe as a delimiter, got {cells:?}"
1806 );
1807 }
1808 }
1809
1810 #[test]
1811 fn test_mask_pipes_in_wikilinks_preserves_length() {
1812 for text in [
1814 "| Alice | [[White Rabbit|the Rabbit]] |",
1815 "| [[Mad Hatter|Hatter]] | [[March Hare|Hare]] |",
1816 "no wikilink here | just a pipe",
1817 "[[unterminated | still text",
1818 ] {
1819 assert_eq!(
1820 TableUtils::mask_pipes_in_wikilinks(text).len(),
1821 text.len(),
1822 "masking changed length of {text:?}"
1823 );
1824 }
1825 }
1826
1827 #[test]
1828 fn test_wikilink_brackets_in_a_code_span_stay_prose() {
1829 for row in [
1833 "| `[[` | mid | `]]` |",
1834 "| `[[Target` | mid | `Label]]` |",
1835 "| a | `[[` | b | `]]` |",
1836 "| `[[` and Target|Label]] |",
1839 ] {
1840 let obsidian = TableUtils::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Obsidian);
1841 let standard = TableUtils::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Standard);
1842 assert_eq!(
1843 obsidian, standard,
1844 "Obsidian disagreed with GFM about {row:?}: {obsidian:?} vs {standard:?}"
1845 );
1846 }
1847
1848 let cells = TableUtils::split_table_row_with_flavor(
1851 "| [[Target|Label with `a|b` inside]] |",
1852 crate::config::MarkdownFlavor::Obsidian,
1853 );
1854 assert_eq!(
1855 cells.len(),
1856 1,
1857 "Wikilink holding a code span should be one cell, got {cells:?}"
1858 );
1859
1860 let cells = TableUtils::split_table_row_with_flavor(
1863 "| [[Target | alias `]]` | tail |",
1864 crate::config::MarkdownFlavor::Obsidian,
1865 );
1866 assert_eq!(
1867 cells.len(),
1868 3,
1869 "A closer hidden in code should not close the link, got {cells:?}"
1870 );
1871 }
1872
1873 #[test]
1874 fn test_wikilink_with_a_blank_target_stays_prose() {
1875 for row in [
1878 "| [[ | ]] |",
1879 "| [[|Label]] |",
1880 "| starts [[ | ends ]] here |",
1881 "| [[\t|\tx]] |",
1882 ] {
1883 let obsidian = TableUtils::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Obsidian);
1884 let standard = TableUtils::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Standard);
1885 assert_eq!(
1886 obsidian, standard,
1887 "Obsidian disagreed with GFM about {row:?}: {obsidian:?} vs {standard:?}"
1888 );
1889 }
1890
1891 let cells = TableUtils::split_table_row_with_flavor("| [[x | y]] |", crate::config::MarkdownFlavor::Obsidian);
1893 assert_eq!(cells.len(), 1, "A named target should be one cell, got {cells:?}");
1894 }
1895
1896 #[test]
1897 fn test_inline_code_spans_agree_with_pipe_masking() {
1898 let text = "``x | y and `c|d`";
1901 let chars: Vec<char> = text.chars().collect();
1902 let spans = TableUtils::inline_code_spans(&chars);
1903 assert_eq!(spans.len(), 1, "Only the matched pair is a code span, got {spans:?}");
1904 let (start, end) = spans[0];
1905 assert_eq!(
1906 chars[start..end].iter().collect::<String>(),
1907 "`c|d`",
1908 "The span should start at the run that closes, not the unmatched opener"
1909 );
1910
1911 assert_eq!(TableUtils::mask_pipes_in_inline_code(text), "``x | y and `c_d`");
1913 }
1914
1915 #[test]
1918 fn test_extract_blockquote_prefix_no_blockquote() {
1919 let (prefix, content) = TableUtils::extract_blockquote_prefix("| H1 | H2 |");
1921 assert_eq!(prefix, "");
1922 assert_eq!(content, "| H1 | H2 |");
1923 }
1924
1925 #[test]
1926 fn test_extract_blockquote_prefix_single_level() {
1927 let (prefix, content) = TableUtils::extract_blockquote_prefix("> | H1 | H2 |");
1929 assert_eq!(prefix, "> ");
1930 assert_eq!(content, "| H1 | H2 |");
1931 }
1932
1933 #[test]
1934 fn test_extract_blockquote_prefix_double_level() {
1935 let (prefix, content) = TableUtils::extract_blockquote_prefix(">> | H1 | H2 |");
1937 assert_eq!(prefix, ">> ");
1938 assert_eq!(content, "| H1 | H2 |");
1939 }
1940
1941 #[test]
1942 fn test_extract_blockquote_prefix_triple_level() {
1943 let (prefix, content) = TableUtils::extract_blockquote_prefix(">>> | H1 | H2 |");
1945 assert_eq!(prefix, ">>> ");
1946 assert_eq!(content, "| H1 | H2 |");
1947 }
1948
1949 #[test]
1950 fn test_extract_blockquote_prefix_with_spaces() {
1951 let (prefix, content) = TableUtils::extract_blockquote_prefix("> > | H1 | H2 |");
1953 assert_eq!(prefix, "> > ");
1954 assert_eq!(content, "| H1 | H2 |");
1955 }
1956
1957 #[test]
1958 fn test_extract_blockquote_prefix_indented() {
1959 let (prefix, content) = TableUtils::extract_blockquote_prefix(" > | H1 | H2 |");
1961 assert_eq!(prefix, " > ");
1962 assert_eq!(content, "| H1 | H2 |");
1963 }
1964
1965 #[test]
1966 fn test_extract_blockquote_prefix_no_space_after() {
1967 let (prefix, content) = TableUtils::extract_blockquote_prefix(">| H1 | H2 |");
1969 assert_eq!(prefix, ">");
1970 assert_eq!(content, "| H1 | H2 |");
1971 }
1972
1973 #[test]
1974 fn test_determine_pipe_style_in_blockquote() {
1975 assert_eq!(
1977 TableUtils::determine_pipe_style("> | H1 | H2 |"),
1978 Some("leading_and_trailing")
1979 );
1980 assert_eq!(
1981 TableUtils::determine_pipe_style("> H1 | H2"),
1982 Some("no_leading_or_trailing")
1983 );
1984 assert_eq!(
1985 TableUtils::determine_pipe_style(">> | H1 | H2 |"),
1986 Some("leading_and_trailing")
1987 );
1988 assert_eq!(TableUtils::determine_pipe_style(">>> | H1 | H2"), Some("leading_only"));
1989 }
1990
1991 #[test]
1992 fn test_list_table_delimiter_requires_indentation() {
1993 let content = "- List item with | pipe\n|---|---|\n| Cell 1 | Cell 2 |";
1998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1999 let tables = TableUtils::find_table_blocks(content, &ctx);
2000
2001 assert_eq!(tables.len(), 1, "Should find exactly one table");
2004 assert!(
2005 tables[0].list_context.is_none(),
2006 "Should NOT have list context since delimiter has no indentation"
2007 );
2008 }
2009
2010 #[test]
2011 fn test_list_table_with_properly_indented_delimiter() {
2012 let content = "- | Header 1 | Header 2 |\n |----------|----------|\n | Cell 1 | Cell 2 |";
2015 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2016 let tables = TableUtils::find_table_blocks(content, &ctx);
2017
2018 assert_eq!(tables.len(), 1, "Should find exactly one table");
2020 assert_eq!(tables[0].start_line, 0, "Table should start at list item line");
2021 assert!(
2022 tables[0].list_context.is_some(),
2023 "Should be a list table since delimiter is properly indented"
2024 );
2025 }
2026
2027 #[test]
2028 fn test_mask_pipes_in_inline_code_regular_backticks() {
2029 let result = TableUtils::mask_pipes_in_inline_code("| `code | here` |");
2031 assert_eq!(result, "| `code _ here` |");
2032 }
2033
2034 #[test]
2035 fn test_mask_pipes_in_inline_code_escaped_backtick_not_code_span() {
2036 let result = TableUtils::mask_pipes_in_inline_code(r"| \`not code | still pipe\` |");
2039 assert_eq!(result, r"| \`not code | still pipe\` |");
2040 }
2041
2042 #[test]
2043 fn test_mask_pipes_in_inline_code_escaped_backslash_then_backtick() {
2044 let result = TableUtils::mask_pipes_in_inline_code(r"| \\`real code | masked\\` |");
2047 assert_eq!(result, r"| \\`real code _ masked\\` |");
2050 }
2051
2052 #[test]
2053 fn test_mask_pipes_in_inline_code_triple_backslash_before_backtick() {
2054 let result = TableUtils::mask_pipes_in_inline_code(r"| \\\`not code | pipe\\\` |");
2056 assert_eq!(result, r"| \\\`not code | pipe\\\` |");
2057 }
2058
2059 #[test]
2060 fn test_mask_pipes_in_inline_code_four_backslashes_before_backtick() {
2061 let result = TableUtils::mask_pipes_in_inline_code(r"| \\\\`code | here\\\\` |");
2063 assert_eq!(result, r"| \\\\`code _ here\\\\` |");
2064 }
2065
2066 #[test]
2067 fn test_mask_pipes_in_inline_code_no_backslash() {
2068 let result = TableUtils::mask_pipes_in_inline_code("before `a | b` after");
2070 assert_eq!(result, "before `a _ b` after");
2071 }
2072
2073 #[test]
2074 fn test_mask_pipes_in_inline_code_no_code_span() {
2075 let result = TableUtils::mask_pipes_in_inline_code("| col1 | col2 |");
2077 assert_eq!(result, "| col1 | col2 |");
2078 }
2079
2080 #[test]
2081 fn test_mask_pipes_in_inline_code_backslash_before_closing_backtick() {
2082 let result = TableUtils::mask_pipes_in_inline_code(r"| `foo\` | bar |");
2091 assert_eq!(result, r"| `foo\` | bar |");
2094 }
2095
2096 #[test]
2097 fn test_mask_pipes_in_inline_code_backslash_literal_with_pipe_inside() {
2098 let result = TableUtils::mask_pipes_in_inline_code(r"| `a\|b` | col2 |");
2102 assert_eq!(result, r"| `a\_b` | col2 |");
2103 }
2104
2105 #[test]
2106 fn test_count_preceding_backslashes() {
2107 let chars: Vec<char> = r"abc\\\`def".chars().collect();
2108 assert_eq!(TableUtils::count_preceding_backslashes(&chars, 6), 3);
2110
2111 let chars2: Vec<char> = r"abc\\`def".chars().collect();
2112 assert_eq!(TableUtils::count_preceding_backslashes(&chars2, 5), 2);
2114
2115 let chars3: Vec<char> = "`def".chars().collect();
2116 assert_eq!(TableUtils::count_preceding_backslashes(&chars3, 0), 0);
2118 }
2119
2120 #[test]
2121 fn test_has_unescaped_pipe_backslash_literal_in_code_span() {
2122 assert!(TableUtils::has_unescaped_pipe_outside_spans(r"`foo\` | bar"));
2125
2126 assert!(TableUtils::has_unescaped_pipe_outside_spans(r"\`foo | bar\`"));
2128
2129 assert!(!TableUtils::has_unescaped_pipe_outside_spans(r"`foo | bar`"));
2131 }
2132
2133 #[test]
2134 fn test_table_after_code_span_detected() {
2135 use crate::config::MarkdownFlavor;
2136
2137 let content = "`code`\n\n| A | B |\n|---|---|\n| 1 | 2 |\n";
2138 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2139 assert!(!ctx.table_blocks.is_empty(), "Table after code span should be detected");
2140 }
2141
2142 #[test]
2143 fn test_table_inside_html_comment_not_detected() {
2144 use crate::config::MarkdownFlavor;
2145
2146 let content = "<!--\n| A | B |\n|---|---|\n| 1 | 2 |\n-->\n";
2147 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2148 assert!(
2149 ctx.table_blocks.is_empty(),
2150 "Table inside HTML comment should not be detected"
2151 );
2152 }
2153}