1use super::blockquote::{parse_blockquote_prefix, strip_blockquote_prefix};
6use crate::config::MarkdownFlavor;
7
8fn blockquote_depth(line: &str) -> usize {
10 parse_blockquote_prefix(line).map_or(0, |parsed| parsed.nesting_level)
11}
12
13fn header_container_prefix_len(line: &str, line_start: usize, items: &[(usize, usize, usize)]) -> usize {
20 let mut pos = 0;
21 loop {
22 let rest = &line[pos..];
23 let quote = TableUtils::extract_blockquote_prefix(rest).0.len();
24 if quote > 0 {
25 pos += quote;
26 continue;
27 }
28 let marker = TableUtils::extract_list_prefix(rest).0;
29 if marker.is_empty() {
30 return pos;
31 }
32 let marker_start = line_start + pos + (marker.len() - marker.trim_start().len());
33 let parsed_item = items
34 .binary_search_by_key(&marker_start, |&(start, _, _)| start)
35 .is_ok();
36 if !parsed_item && !opens_item_in_a_paragraph(marker) {
37 return pos;
38 }
39 pos += marker.len();
40 }
41}
42
43fn opens_item_in_a_paragraph(marker: &str) -> bool {
48 let indent = marker
49 .bytes()
50 .take_while(|&b| b == b' ' || b == b'\t')
51 .fold(0, |column, byte| {
52 if byte == b'\t' {
53 (column / 4 + 1) * 4
54 } else {
55 column + 1
56 }
57 });
58 let number = marker.trim_start().trim_end_matches(|c: char| !c.is_ascii_digit());
59 indent < 4 && (number.is_empty() || (number.len() <= 9 && number.trim_start_matches('0') == "1"))
60}
61
62pub(crate) fn header_quote_depth(line: &str, line_start: usize, items: &[(usize, usize, usize)]) -> usize {
65 let prefix = &line[..header_container_prefix_len(line, line_start, items)];
66 prefix.bytes().filter(|&byte| byte == b'>').count()
67}
68
69#[derive(Debug, Clone)]
71pub struct TableBlock {
72 pub start_line: usize,
73 pub end_line: usize,
74 pub header_line: usize,
75 pub delimiter_line: usize,
76 pub content_lines: Vec<usize>,
77 pub list_context: Option<ListTableContext>,
81 pub parser_confirmed: bool,
85 pub repairable: bool,
89 pub header_prefix_len: Option<usize>,
92}
93
94#[derive(Debug, Clone)]
96pub struct ListTableContext {
97 pub list_prefix: String,
99 pub content_indent: usize,
101}
102
103pub struct TableUtils;
105
106impl TableUtils {
107 fn has_unescaped_pipe_outside_spans(text: &str, pairs_backslashes: bool) -> bool {
119 let chars: Vec<char> = text.chars().collect();
120 let mut i = 0;
121 let mut in_code = false;
122 let mut code_delim_len = 0usize;
123 let mut in_math = false;
124 let mut math_delim_len = 0usize;
125
126 while i < chars.len() {
127 let ch = chars[i];
128
129 if ch == '\\' && !in_code && !in_math {
130 i += if i + 1 < chars.len() { 2 } else { 1 };
133 continue;
134 }
135
136 if ch == '`' && !in_math {
137 let mut run = 1usize;
138 while i + run < chars.len() && chars[i + run] == '`' {
139 run += 1;
140 }
141
142 if in_code {
143 if run == code_delim_len {
144 in_code = false;
145 code_delim_len = 0;
146 }
147 } else {
149 in_code = true;
150 code_delim_len = run;
151 }
152
153 i += run;
154 continue;
155 }
156
157 if ch == '$' && !in_code {
158 let mut run = 1usize;
159 while i + run < chars.len() && chars[i + run] == '$' {
160 run += 1;
161 }
162
163 if in_math {
164 if run == math_delim_len {
165 in_math = false;
166 math_delim_len = 0;
167 }
168 } else {
170 in_math = true;
171 math_delim_len = run;
172 }
173
174 i += run;
175 continue;
176 }
177
178 if ch == '|' && !in_code && !in_math {
179 if pairs_backslashes || i == 0 || chars[i - 1] != '\\' {
182 return true;
183 }
184 }
185
186 i += 1;
187 }
188
189 false
190 }
191
192 pub fn is_potential_table_row_with_flavor(line: &str, flavor: crate::config::MarkdownFlavor) -> bool {
198 let pairs = Self::pairs_backslashes(flavor);
201 if flavor == crate::config::MarkdownFlavor::Obsidian && line.contains("[[") {
202 return Self::potential_table_row(&Self::mask_pipes_in_wikilinks(line), pairs);
203 }
204 Self::potential_table_row(line, pairs)
205 }
206
207 pub fn is_potential_table_row(line: &str) -> bool {
208 Self::potential_table_row(line, false)
209 }
210
211 fn potential_table_row(line: &str, pairs_backslashes: bool) -> bool {
212 let trimmed = line.trim();
213 if trimmed.is_empty() || !trimmed.contains('|') {
214 return false;
215 }
216
217 if trimmed.starts_with("- ")
220 || trimmed.starts_with("* ")
221 || trimmed.starts_with("+ ")
222 || trimmed.starts_with("-\t")
223 || trimmed.starts_with("*\t")
224 || trimmed.starts_with("+\t")
225 {
226 return false;
227 }
228
229 if let Some(first_non_digit) = trimmed.find(|c: char| !c.is_ascii_digit())
231 && first_non_digit > 0
232 {
233 let after_digits = &trimmed[first_non_digit..];
234 if after_digits.starts_with(". ")
235 || after_digits.starts_with(".\t")
236 || after_digits.starts_with(") ")
237 || after_digits.starts_with(")\t")
238 {
239 return false;
240 }
241 }
242
243 if trimmed.starts_with('#') {
245 let hash_count = trimmed.bytes().take_while(|&b| b == b'#').count();
246 if hash_count <= 6 {
247 let after_hashes = &trimmed[hash_count..];
248 if after_hashes.is_empty() || after_hashes.starts_with(' ') || after_hashes.starts_with('\t') {
249 return false;
250 }
251 }
252 }
253
254 let has_outer_pipes = trimmed.starts_with('|') && trimmed.ends_with('|');
257 if !has_outer_pipes && !Self::has_unescaped_pipe_outside_spans(trimmed, pairs_backslashes) {
258 return false;
259 }
260
261 let parts: Vec<&str> = trimmed.split('|').collect();
263 if parts.len() < 2 {
264 return false;
265 }
266
267 let mut valid_parts = 0;
269 let mut total_non_empty_parts = 0;
270
271 for part in &parts {
272 let part_trimmed = part.trim();
273 if part_trimmed.is_empty() {
275 continue;
276 }
277 total_non_empty_parts += 1;
278
279 if !part_trimmed.contains('\n') {
281 valid_parts += 1;
282 }
283 }
284
285 if total_non_empty_parts > 0 && valid_parts != total_non_empty_parts {
287 return false;
289 }
290
291 if total_non_empty_parts == 0 {
294 return trimmed.starts_with('|') && trimmed.ends_with('|') && parts.len() >= 3;
296 }
297
298 if trimmed.starts_with('|') && trimmed.ends_with('|') {
301 valid_parts >= 1
303 } else {
304 valid_parts >= 2
306 }
307 }
308
309 pub fn is_delimiter_row(line: &str) -> bool {
311 let trimmed = line.trim();
312 if !trimmed.contains('|') || !trimmed.contains('-') {
313 return false;
314 }
315
316 let parts: Vec<&str> = trimmed.split('|').collect();
318 let mut valid_delimiter_parts = 0;
319 let mut total_non_empty_parts = 0;
320
321 for part in &parts {
322 let part_trimmed = part.trim();
323 if part_trimmed.is_empty() {
324 continue; }
326
327 total_non_empty_parts += 1;
328
329 if part_trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace()) && part_trimmed.contains('-') {
331 valid_delimiter_parts += 1;
332 }
333 }
334
335 total_non_empty_parts > 0 && valid_delimiter_parts == total_non_empty_parts
337 }
338
339 pub fn find_table_blocks_with_code_info(
344 content: &str,
345 code_blocks: &[(usize, usize)],
346 code_spans: &[crate::lint_context::CodeSpan],
347 html_comment_ranges: &[crate::utils::skip_context::ByteRange],
348 flavor: crate::config::MarkdownFlavor,
349 lazy_text: &[bool],
350 ) -> Vec<TableBlock> {
351 let lines: Vec<&str> = content.lines().collect();
352 let mut line_positions = Vec::with_capacity(lines.len());
359 let content_bytes = content.as_bytes();
360 let mut pos = 0;
361 for line in &lines {
362 line_positions.push(pos);
363 pos += line.len();
364 if content_bytes.get(pos) == Some(&b'\r') {
365 pos += 1;
366 }
367 if content_bytes.get(pos) == Some(&b'\n') {
368 pos += 1;
369 }
370 }
371
372 Self::find_table_blocks_with_layout(
373 &lines,
374 &line_positions,
375 code_blocks,
376 &crate::lint_context::code_span_byte_ranges(code_spans),
377 html_comment_ranges,
378 flavor,
379 lazy_text,
380 &[],
381 )
382 }
383
384 #[allow(clippy::too_many_arguments)]
388 pub(crate) fn find_table_blocks_with_layout(
389 lines: &[&str],
390 line_positions: &[usize],
391 code_blocks: &[(usize, usize)],
392 code_spans: &[(usize, usize)],
393 html_comment_ranges: &[crate::utils::skip_context::ByteRange],
394 flavor: crate::config::MarkdownFlavor,
395 lazy_text: &[bool],
396 list_items: &[(usize, usize, usize)],
397 ) -> Vec<TableBlock> {
398 let is_lazy_text = |line: usize| lazy_text.get(line).copied().unwrap_or(false);
399 let mut tables = Vec::new();
400 let mut i = 0;
401
402 let mut list_indent_stack: Vec<(usize, bool)> = Vec::new();
406
407 while i < lines.len() {
408 let line_start = line_positions[i];
410 let in_code =
411 crate::utils::code_block_utils::CodeBlockUtils::is_in_code_block_or_span(code_blocks, line_start) || {
412 let idx = code_spans.partition_point(|span| span.0 <= line_start);
414 idx > 0 && line_start < code_spans[idx - 1].1
415 };
416 let in_html_comment = {
417 let idx = html_comment_ranges.partition_point(|range| range.start <= line_start);
419 idx > 0 && line_start < html_comment_ranges[idx - 1].end
420 };
421
422 if in_code || in_html_comment {
423 i += 1;
424 continue;
425 }
426
427 let line_content = strip_blockquote_prefix(lines[i]);
429
430 let (list_prefix, list_content, content_indent, opened_levels) =
432 Self::extract_nested_list_prefix(line_content);
433 let line_is_quoted = line_content.len() != lines[i].len();
434 if !line_is_quoted {
435 list_indent_stack.retain(|&(_, opened_quoted)| !opened_quoted);
437 }
438 let quote_indent = if line_is_quoted {
443 lines[i].len() - lines[i].trim_start().len()
444 } else {
445 0
446 };
447 let text_indent = line_content.len() - line_content.trim_start().len();
448 let indent_for = |opened_quoted: bool| {
449 if opened_quoted {
450 text_indent
451 } else {
452 quote_indent + text_indent
453 }
454 };
455 if !list_prefix.is_empty() {
456 while list_indent_stack
460 .last()
461 .is_some_and(|&(top, opened_quoted)| top > indent_for(opened_quoted))
462 {
463 list_indent_stack.pop();
464 }
465 list_indent_stack.extend(opened_levels.into_iter().map(|level| (level, line_is_quoted)));
466 } else if !line_content.trim().is_empty() {
467 while list_indent_stack
469 .last()
470 .is_some_and(|&(top, opened_quoted)| indent_for(opened_quoted) < top)
471 {
472 list_indent_stack.pop();
473 }
474 }
475 let (is_same_line_list_table, effective_content) =
480 if !list_prefix.is_empty() && Self::is_potential_table_row_content(list_content, flavor) {
481 (true, list_content)
482 } else {
483 (false, line_content)
484 };
485
486 let continuation_indent = if !is_same_line_list_table && list_prefix.is_empty() {
489 let leading = line_content.len() - line_content.trim_start().len();
490 list_indent_stack
492 .iter()
493 .rev()
494 .filter(|&&(_, opened_quoted)| opened_quoted || !line_is_quoted)
497 .find(|&&(indent, _)| leading >= indent)
498 .map(|&(indent, _)| indent)
499 } else {
500 None
501 };
502
503 let is_continuation_list_table = continuation_indent.is_some()
504 && {
505 let indent = continuation_indent.unwrap();
506 let leading = line_content.len() - line_content.trim_start().len();
507 leading < indent + 4
509 }
510 && Self::is_potential_table_row_with_flavor(effective_content, flavor);
511
512 let is_any_list_table = is_same_line_list_table || is_continuation_list_table;
513
514 let effective_content_indent = if is_same_line_list_table {
516 content_indent
517 } else if is_continuation_list_table {
518 continuation_indent.unwrap()
519 } else {
520 0
521 };
522
523 if is_lazy_text(i) || is_lazy_text(i + 1) {
526 i += 1;
527 continue;
528 }
529
530 if is_any_list_table || Self::is_potential_table_row_with_flavor(effective_content, flavor) {
532 let (next_line_content, delimiter_has_valid_indent) = if i + 1 < lines.len() {
535 let next_raw = strip_blockquote_prefix(lines[i + 1]);
536 if is_any_list_table {
537 let leading_spaces = next_raw.len() - next_raw.trim_start().len();
539 if leading_spaces >= effective_content_indent {
540 (
542 Self::strip_list_continuation_indent(next_raw, effective_content_indent),
543 true,
544 )
545 } else {
546 (next_raw, false)
548 }
549 } else {
550 (next_raw, true)
551 }
552 } else {
553 ("", true)
554 };
555
556 let effective_is_list_table = is_any_list_table && delimiter_has_valid_indent;
558
559 if i + 1 < lines.len() && Self::is_delimiter_row(next_line_content) {
560 let table_start = i;
562 let header_line = i;
563 let delimiter_line = i + 1;
564 let mut table_end = i + 1; let mut content_lines = Vec::new();
566
567 let header_prefix_len = header_container_prefix_len(lines[i], line_positions[i], list_items);
569 let table_depth = lines[i][..header_prefix_len]
570 .bytes()
571 .filter(|&byte| byte == b'>')
572 .count();
573 let mut j = i + 2;
574 while j < lines.len() {
575 let line = lines[j];
576 let raw_content = strip_blockquote_prefix(line);
578
579 let line_content = if effective_is_list_table {
581 Self::strip_list_continuation_indent(raw_content, effective_content_indent)
582 } else {
583 raw_content
584 };
585
586 if line_content.trim().is_empty() {
587 break;
589 }
590
591 if effective_is_list_table {
593 let leading_spaces = raw_content.len() - raw_content.trim_start().len();
594 if leading_spaces < effective_content_indent {
595 break;
597 }
598 }
599
600 if blockquote_depth(line) != table_depth
606 || Self::starts_block_after_table(line_content)
607 || (flavor == crate::config::MarkdownFlavor::Obsidian
608 && !Self::is_potential_table_row_with_flavor(line_content, flavor))
609 {
610 break;
611 }
612 content_lines.push(j);
613 table_end = j;
614 j += 1;
615 }
616
617 let list_context = if effective_is_list_table {
618 if is_same_line_list_table {
619 Some(ListTableContext {
621 list_prefix: list_prefix.to_string(),
622 content_indent: effective_content_indent,
623 })
624 } else {
625 Some(ListTableContext {
627 list_prefix: " ".repeat(effective_content_indent),
628 content_indent: effective_content_indent,
629 })
630 }
631 } else {
632 None
633 };
634
635 tables.push(TableBlock {
636 start_line: table_start,
637 end_line: table_end,
638 header_line,
639 delimiter_line,
640 content_lines,
641 list_context,
642 parser_confirmed: false,
643 repairable: false,
644 header_prefix_len: Some(header_prefix_len),
645 });
646 i = table_end + 1;
647 } else {
648 i += 1;
649 }
650 } else {
651 i += 1;
652 }
653 }
654
655 tables
656 }
657
658 pub(crate) fn starts_block_after_table(line: &str) -> bool {
675 let indent = line.bytes().take_while(|&b| b == b' ').count();
676 if indent >= 4 || line[indent..].starts_with('\t') {
677 return false;
678 }
679 let text = line[indent..].trim_end();
680 let bytes = text.as_bytes();
681 let Some(&first) = bytes.first() else {
682 return false;
683 };
684 let space_or_end = |at: usize| bytes.get(at).is_none_or(|&b| b == b' ' || b == b'\t');
685 match first {
686 b'>' => true,
687 b'-' | b'*' | b'+' if space_or_end(1) => true,
688 b'-' | b'*' | b'_' => crate::utils::thematic_break::is_thematic_break(text),
689 b'0'..=b'9' => {
690 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
691 digits <= 9 && matches!(bytes.get(digits), Some(b'.' | b')')) && space_or_end(digits + 1)
692 }
693 b'#' => {
694 let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
695 hashes <= 6 && bytes.get(hashes).is_none_or(|&b| b == b' ')
696 }
697 b'`' | b'~' | b':' => bytes.iter().take_while(|&&b| b == first).count() >= 3,
698 b'<' => {
699 crate::utils::html_block::opens_untagged_html_block(text)
700 || crate::utils::html_block::parse_html_block_start(text).is_some_and(|(tag, _)| {
701 !crate::utils::html_block::NON_SPEC_BLOCK_ELEMENTS.contains(&tag.as_str())
702 })
703 }
704 b'[' => text
705 .strip_prefix("[^")
706 .and_then(|rest| rest.split_once("]:"))
707 .is_some_and(|(label, _)| {
708 !label.is_empty() && !label.contains(|c: char| c.is_whitespace() || c == ']')
709 }),
710 _ => false,
711 }
712 }
713
714 fn strip_list_continuation_indent(line: &str, expected_indent: usize) -> &str {
717 let bytes = line.as_bytes();
718 let mut spaces = 0;
719
720 for &b in bytes {
721 if b == b' ' {
722 spaces += 1;
723 } else if b == b'\t' {
724 spaces = (spaces / 4 + 1) * 4;
726 } else {
727 break;
728 }
729
730 if spaces >= expected_indent {
731 break;
732 }
733 }
734
735 let strip_count = spaces.min(expected_indent).min(line.len());
737 let mut byte_count = 0;
739 let mut counted_spaces = 0;
740 for &b in bytes {
741 if counted_spaces >= strip_count {
742 break;
743 }
744 if b == b' ' {
745 counted_spaces += 1;
746 byte_count += 1;
747 } else if b == b'\t' {
748 counted_spaces = (counted_spaces / 4 + 1) * 4;
749 byte_count += 1;
750 } else {
751 break;
752 }
753 }
754
755 &line[byte_count..]
756 }
757
758 pub fn find_table_blocks(content: &str, ctx: &crate::lint_context::LintContext) -> Vec<TableBlock> {
761 Self::find_table_blocks_with_code_info(
762 content,
763 &ctx.code_blocks,
764 &ctx.code_spans(),
765 ctx.html_comment_ranges(),
766 ctx.flavor,
767 &[],
768 )
769 }
770
771 pub fn count_cells(row: &str) -> usize {
773 Self::count_cells_with_flavor(row, crate::config::MarkdownFlavor::Standard)
774 }
775
776 pub fn count_cells_with_flavor(row: &str, flavor: crate::config::MarkdownFlavor) -> usize {
783 let (_, content) = Self::extract_blockquote_prefix(row);
785 Self::split_table_row_with_flavor(content, flavor).len()
786 }
787
788 fn count_preceding_backslashes(chars: &[char], pos: usize) -> usize {
790 let mut count = 0;
791 let mut k = pos;
792 while k > 0 {
793 k -= 1;
794 if chars[k] == '\\' {
795 count += 1;
796 } else {
797 break;
798 }
799 }
800 count
801 }
802
803 fn inline_code_spans(chars: &[char]) -> Vec<(usize, usize)> {
816 let mut spans = Vec::new();
817 let mut i = 0;
818
819 while i < chars.len() {
820 if chars[i] != '`' {
821 i += 1;
822 continue;
823 }
824
825 if Self::count_preceding_backslashes(chars, i) % 2 != 0 {
827 i += 1;
828 continue;
829 }
830
831 let start = i;
833 let mut backtick_count = 0;
834 while i < chars.len() && chars[i] == '`' {
835 backtick_count += 1;
836 i += 1;
837 }
838
839 let mut j = i;
843 while j < chars.len() {
844 if chars[j] == '`' {
845 let mut close_count = 0;
846 while j < chars.len() && chars[j] == '`' {
847 close_count += 1;
848 j += 1;
849 }
850
851 if close_count == backtick_count {
852 spans.push((start, j));
853 i = j;
854 break;
855 }
856 } else {
858 j += 1;
859 }
860 }
861 }
864
865 spans
866 }
867
868 pub fn mask_pipes_in_inline_code(text: &str) -> String {
873 if !text.contains('`') {
874 return text.to_string();
875 }
876
877 let chars: Vec<char> = text.chars().collect();
878 let spans = Self::inline_code_spans(&chars);
879 if spans.is_empty() {
880 return text.to_string();
881 }
882
883 let mut result = String::with_capacity(text.len());
884 let mut cursor = 0;
885 for (start, end) in spans {
886 result.extend(chars[cursor..start].iter());
887 for &ch in &chars[start..end] {
890 result.push(if ch == '|' { '_' } else { ch });
891 }
892 cursor = end;
893 }
894 result.extend(chars[cursor..].iter());
895
896 result
897 }
898
899 pub fn mask_pipes_in_wikilinks(text: &str) -> String {
917 if !text.contains("[[") || !text.contains('|') {
920 return text.to_string();
921 }
922
923 let chars: Vec<char> = text.chars().collect();
924 let code_spans = Self::inline_code_spans(&chars);
925 let code_span_at = |pos: usize| code_spans.iter().find(|&&(s, e)| pos >= s && pos < e).copied();
926
927 let mut result = String::with_capacity(text.len());
928 let mut i = 0;
929
930 while i < chars.len() {
931 if let Some((_, end)) = code_span_at(i) {
933 result.extend(chars[i..end].iter());
934 i = end;
935 continue;
936 }
937
938 if chars[i] == '['
939 && i + 1 < chars.len()
940 && chars[i + 1] == '['
941 && let Some(close) = Self::wikilink_close(&chars, &code_spans, i)
942 {
943 result.push_str("[[");
944 for &ch in &chars[i + 2..close] {
945 if ch == '|' {
946 result.push('_'); } else {
948 result.push(ch);
949 }
950 }
951 result.push_str("]]");
952 i = close + 2;
953 continue;
954 }
955
956 result.push(chars[i]);
957 i += 1;
958 }
959
960 result
961 }
962
963 fn wikilink_close(chars: &[char], code_spans: &[(usize, usize)], open: usize) -> Option<usize> {
969 let mut j = open + 2;
970 let mut first_pipe = None;
971
972 while j + 1 < chars.len() {
973 if let Some(&(_, end)) = code_spans.iter().find(|&&(s, _)| s == j) {
974 j = end;
975 continue;
976 }
977
978 if chars[j] == ']' && chars[j + 1] == ']' {
979 if let Some(pipe) = first_pipe
983 && chars[open + 2..pipe].iter().all(|c| c.is_whitespace())
984 {
985 return None;
986 }
987 return Some(j);
988 }
989
990 if chars[j] == '[' && chars[j + 1] == '[' {
992 return None;
993 }
994
995 if chars[j] == '|' && first_pipe.is_none() {
996 first_pipe = Some(j);
997 }
998
999 j += 1;
1000 }
1001
1002 None
1003 }
1004
1005 pub(crate) fn pairs_backslashes(flavor: crate::config::MarkdownFlavor) -> bool {
1012 flavor == crate::config::MarkdownFlavor::MkDocs
1013 }
1014
1015 pub fn mask_escaped_pipes(text: &str, flavor: crate::config::MarkdownFlavor) -> String {
1027 let pairs = Self::pairs_backslashes(flavor);
1028 let mut result = String::with_capacity(text.len());
1029 let mut chars = text.chars().peekable();
1030 let mut previous = None;
1031
1032 while let Some(ch) = chars.next() {
1033 if ch == '\\' && pairs && chars.peek() == Some(&'\\') {
1034 result.push_str("\\\\");
1036 chars.next();
1037 previous = None;
1038 continue;
1039 }
1040 result.push(if ch == '|' && previous == Some('\\') { '_' } else { ch });
1041 previous = Some(ch);
1042 }
1043
1044 result
1045 }
1046
1047 pub fn split_table_row_with_flavor(row: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
1054 let trimmed = row.trim_matches([' ', '\t', '\r']);
1056
1057 if !trimmed.contains('|') {
1058 return Vec::new();
1059 }
1060
1061 let masked = Self::mask_escaped_pipes(trimmed, flavor);
1063
1064 let mut final_masked = Self::mask_pipes_in_inline_code(&masked);
1066
1067 if flavor == crate::config::MarkdownFlavor::Obsidian {
1070 final_masked = Self::mask_pipes_in_wikilinks(&final_masked);
1071 }
1072
1073 let has_leading = final_masked.starts_with('|');
1074 let has_trailing = final_masked.ends_with('|');
1075
1076 let mut masked_content = final_masked.as_str();
1077 let mut orig_content = trimmed;
1078
1079 if has_leading {
1080 masked_content = &masked_content[1..];
1081 orig_content = &orig_content[1..];
1082 }
1083
1084 let stripped_trailing = has_trailing && !masked_content.is_empty();
1086 if stripped_trailing {
1087 masked_content = &masked_content[..masked_content.len() - 1];
1088 orig_content = &orig_content[..orig_content.len() - 1];
1089 }
1090
1091 if masked_content.is_empty() {
1093 if stripped_trailing {
1094 return vec![String::new()];
1096 } else {
1097 return Vec::new();
1099 }
1100 }
1101
1102 let masked_parts: Vec<&str> = masked_content.split('|').collect();
1103 let mut cells = Vec::new();
1104 let mut pos = 0;
1105
1106 for masked_cell in masked_parts {
1107 let cell_len = masked_cell.len();
1108 let orig_cell = if pos + cell_len <= orig_content.len() {
1109 &orig_content[pos..pos + cell_len]
1110 } else {
1111 masked_cell
1112 };
1113 cells.push(orig_cell.to_string());
1114 pos += cell_len + 1; }
1116
1117 cells
1118 }
1119
1120 pub(crate) fn rendering_masked_pipes(row: &str, flavor: MarkdownFlavor) -> Vec<usize> {
1122 let bytes = row.as_bytes();
1123 let mut masked = Vec::new();
1124 match flavor {
1125 MarkdownFlavor::MkDocs => {
1126 let hidden = Self::mask_pipes_in_inline_code(row);
1128 masked.extend(
1129 bytes
1130 .iter()
1131 .zip(hidden.as_bytes())
1132 .enumerate()
1133 .filter(|&(_, (&original, &shown))| original == b'|' && shown != b'|')
1134 .map(|(index, _)| index),
1135 );
1136 }
1137 MarkdownFlavor::Obsidian => {
1138 let hidden = Self::mask_pipes_in_wikilinks(row);
1140 masked.extend(
1141 bytes
1142 .iter()
1143 .zip(hidden.as_bytes())
1144 .enumerate()
1145 .filter(|&(_, (&original, &shown))| original == b'|' && shown != b'|')
1146 .map(|(index, _)| index),
1147 );
1148 }
1149 _ => {}
1150 }
1151 masked
1152 }
1153
1154 pub(crate) fn split_table_row_for_rendering(row: &str, flavor: MarkdownFlavor) -> Vec<String> {
1157 let row = row.trim_matches([' ', '\t']);
1158 let bytes = row.as_bytes();
1159 let masked = Self::rendering_masked_pipes(row, flavor);
1160 let unescaped = Self::mask_escaped_pipes(row, flavor);
1161 let separators: Vec<usize> = unescaped
1162 .bytes()
1163 .enumerate()
1164 .filter(|&(index, byte)| byte == b'|' && masked.binary_search(&index).is_err())
1165 .map(|(index, _)| index)
1166 .collect();
1167 let mut separators = separators;
1168 let mut from = 0;
1169 let mut to = bytes.len();
1170 if separators.first() == Some(&0) {
1171 separators.remove(0);
1172 from = 1;
1173 }
1174 if separators.last() == Some(&(bytes.len().wrapping_sub(1))) && bytes.len() > from {
1175 separators.pop();
1176 to = bytes.len() - 1;
1177 }
1178 if separators.is_empty() && from == 0 && to == bytes.len() {
1179 return Vec::new();
1181 }
1182 let mut cells = Vec::with_capacity(separators.len() + 1);
1183 let mut start = from;
1184 for separator in separators {
1185 cells.push(row[start..separator].to_string());
1186 start = separator + 1;
1187 }
1188 cells.push(row[start..to.max(start)].to_string());
1189 cells
1190 }
1191
1192 pub fn split_table_row(row: &str) -> Vec<String> {
1194 Self::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Standard)
1195 }
1196
1197 pub(crate) fn has_unescaped_trailing_pipe(line: &str, flavor: crate::config::MarkdownFlavor) -> bool {
1199 let bytes = line.trim_end_matches([' ', '\t', '\r']).as_bytes();
1200 if bytes.last() != Some(&b'|') {
1201 return false;
1202 }
1203 let backslashes = bytes[..bytes.len() - 1]
1204 .iter()
1205 .rev()
1206 .take_while(|&&byte| byte == b'\\')
1207 .count();
1208 if Self::pairs_backslashes(flavor) {
1209 backslashes % 2 == 0
1210 } else {
1211 backslashes == 0
1212 }
1213 }
1214
1215 pub fn determine_pipe_style(line: &str) -> Option<&'static str> {
1220 Self::determine_pipe_style_with_flavor(line, crate::config::MarkdownFlavor::Standard)
1221 }
1222
1223 pub fn determine_pipe_style_with_flavor(line: &str, flavor: crate::config::MarkdownFlavor) -> Option<&'static str> {
1226 let content = strip_blockquote_prefix(line);
1228 let trimmed = content.trim_matches([' ', '\t', '\r']);
1229 if !trimmed.contains('|') {
1230 return None;
1231 }
1232
1233 let has_leading = trimmed.starts_with('|');
1234 let has_trailing = Self::has_unescaped_trailing_pipe(trimmed, flavor);
1235
1236 match (has_leading, has_trailing) {
1237 (true, true) => Some("leading_and_trailing"),
1238 (true, false) => Some("leading_only"),
1239 (false, true) => Some("trailing_only"),
1240 (false, false) => Some("no_leading_or_trailing"),
1241 }
1242 }
1243
1244 pub fn extract_blockquote_prefix(line: &str) -> (&str, &str) {
1249 let bytes = line.as_bytes();
1251 let mut pos = 0;
1252
1253 while pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
1255 pos += 1;
1256 }
1257
1258 if pos >= bytes.len() || bytes[pos] != b'>' {
1260 return ("", line);
1261 }
1262
1263 while pos < bytes.len() {
1265 if bytes[pos] == b'>' {
1266 pos += 1;
1267 if pos < bytes.len() && bytes[pos] == b' ' {
1269 pos += 1;
1270 }
1271 } else if bytes[pos] == b' ' || bytes[pos] == b'\t' {
1272 pos += 1;
1273 } else {
1274 break;
1275 }
1276 }
1277
1278 (&line[..pos], &line[pos..])
1280 }
1281
1282 fn extract_nested_list_prefix(line: &str) -> (&str, &str, usize, Vec<usize>) {
1287 let mut offset = 0;
1288 let mut levels = Vec::new();
1289 let mut column = 0;
1290 let mut content_column = 0;
1291 loop {
1292 let rest = &line[offset..];
1293 if crate::utils::thematic_break::is_thematic_break(rest) {
1296 break;
1297 }
1298 let (prefix, content, indent) = Self::extract_list_prefix(rest);
1299 if prefix.is_empty() {
1300 break;
1301 }
1302 content_column = column + indent;
1303 levels.push(content_column);
1304 column = content_column;
1305 offset += prefix.len();
1306 if content.trim().is_empty() {
1307 break;
1308 }
1309 }
1310 (&line[..offset], &line[offset..], content_column, levels)
1311 }
1312
1313 pub fn extract_list_prefix(line: &str) -> (&str, &str, usize) {
1328 let bytes = line.as_bytes();
1329
1330 let leading_spaces = bytes.iter().take_while(|&&b| b == b' ' || b == b'\t').count();
1332 let mut pos = leading_spaces;
1333
1334 if pos >= bytes.len() {
1335 return ("", line, 0);
1336 }
1337
1338 if matches!(bytes[pos], b'-' | b'*' | b'+') {
1340 pos += 1;
1341
1342 if pos >= bytes.len() || bytes[pos] == b' ' || bytes[pos] == b'\t' {
1344 let marker_end = pos;
1345 if pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
1347 pos += 1;
1348 }
1349 let content_indent = Self::content_indent(line, marker_end);
1350 return (&line[..pos], &line[pos..], content_indent);
1351 }
1352 return ("", line, 0);
1354 }
1355
1356 if bytes[pos].is_ascii_digit() {
1358 let digit_start = pos;
1359 while pos < bytes.len() && bytes[pos].is_ascii_digit() {
1360 pos += 1;
1361 }
1362
1363 if pos > digit_start && pos < bytes.len() {
1365 if bytes[pos] == b'.' || bytes[pos] == b')' {
1367 pos += 1;
1368 if pos >= bytes.len() || bytes[pos] == b' ' || bytes[pos] == b'\t' {
1369 let marker_end = pos;
1370 if pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
1372 pos += 1;
1373 }
1374 let content_indent = Self::content_indent(line, marker_end);
1375 return (&line[..pos], &line[pos..], content_indent);
1376 }
1377 }
1378 }
1379 }
1380
1381 ("", line, 0)
1382 }
1383
1384 fn content_indent(line: &str, marker_end: usize) -> usize {
1388 let advance = |column: usize, byte: u8| match byte {
1389 b'\t' => (column / 4 + 1) * 4,
1390 _ => column + 1,
1391 };
1392 let marker_column = line.as_bytes()[..marker_end]
1393 .iter()
1394 .fold(0, |column, &b| advance(column, b));
1395 let rest = &line[marker_end..];
1396 if rest.trim().is_empty() {
1397 return marker_column + 1;
1398 }
1399 let content_column = rest
1400 .bytes()
1401 .take_while(|&b| b == b' ' || b == b'\t')
1402 .fold(marker_column, advance);
1403 match content_column - marker_column {
1404 1..=4 => content_column,
1405 _ => marker_column + 1,
1407 }
1408 }
1409
1410 pub fn extract_table_row_content<'a>(line: &'a str, table_block: &TableBlock, line_index: usize) -> &'a str {
1415 let (_, after_blockquote) = Self::extract_blockquote_prefix(line);
1417
1418 if let Some(ref list_ctx) = table_block.list_context {
1420 if line_index == 0 {
1421 after_blockquote
1423 .strip_prefix(&list_ctx.list_prefix)
1424 .unwrap_or_else(|| Self::extract_list_prefix(after_blockquote).1)
1425 } else {
1426 Self::strip_list_continuation_indent(after_blockquote, list_ctx.content_indent)
1428 }
1429 } else if line_index == 0 {
1430 &line[table_block
1434 .header_prefix_len
1435 .unwrap_or_else(|| header_container_prefix_len(line, 0, &[]))..]
1436 } else {
1437 after_blockquote
1438 }
1439 }
1440
1441 pub fn row_prefix<'a>(line: &'a str, header: &str, table_block: &TableBlock, line_index: usize) -> &'a str {
1445 let content = Self::extract_table_row_content(line, table_block, line_index);
1446 let prefix = &line[..line.len() - content.len()];
1447 let header_has_markers = Self::extract_table_row_content(header, table_block, 0).len() < header.len();
1448 if prefix.is_empty() && (header_has_markers || Self::is_container_indented(header, table_block)) {
1449 &line[..line.len() - line.trim_start_matches([' ', '\t']).len()]
1454 } else {
1455 prefix
1456 }
1457 }
1458
1459 pub fn is_container_indented(header: &str, table_block: &TableBlock) -> bool {
1464 if table_block.list_context.is_some() {
1465 return false;
1466 }
1467 let columns = header
1468 .bytes()
1469 .take_while(|&byte| byte == b' ' || byte == b'\t')
1470 .fold(0, |column, byte| {
1471 if byte == b'\t' {
1472 (column / 4 + 1) * 4
1473 } else {
1474 column + 1
1475 }
1476 });
1477 columns >= 4
1478 }
1479
1480 pub fn header_is_indented_as_code(header: &str, delimiter: &str, table_block: &TableBlock) -> bool {
1485 let columns = |text: &str| -> usize {
1486 text.bytes()
1487 .take_while(|&byte| byte == b' ' || byte == b'\t')
1488 .fold(0, |column, byte| {
1489 if byte == b'\t' {
1490 (column / 4 + 1) * 4
1491 } else {
1492 column + 1
1493 }
1494 })
1495 };
1496 let list_indent = table_block.list_context.as_ref().map_or(0, |list| list.content_indent);
1497 let inside_container = |line: &str| {
1498 let text = parse_blockquote_prefix(line).map_or(line, |quote| quote.content);
1499 columns(text).saturating_sub(list_indent)
1500 };
1501 inside_container(header) >= 4 && inside_container(delimiter) < 4
1502 }
1503
1504 pub fn is_list_item_with_table_row(line: &str, flavor: crate::config::MarkdownFlavor) -> bool {
1507 let (prefix, content, _) = Self::extract_list_prefix(line);
1508 if prefix.is_empty() {
1509 return false;
1510 }
1511
1512 let trimmed = content.trim();
1515 if !trimmed.starts_with('|') {
1516 return false;
1517 }
1518
1519 Self::is_potential_table_row_content(content, flavor)
1521 }
1522
1523 fn is_potential_table_row_content(content: &str, flavor: crate::config::MarkdownFlavor) -> bool {
1525 Self::is_potential_table_row_with_flavor(content, flavor)
1526 }
1527}
1528
1529#[cfg(test)]
1530mod tests {
1531 #[test]
1532 fn test_bare_list_marker_content_starts_one_column_past_the_marker() {
1533 assert_eq!(TableUtils::extract_list_prefix("-"), ("-", "", 2));
1534 assert_eq!(TableUtils::extract_list_prefix("1."), ("1.", "", 3));
1535 assert_eq!(TableUtils::extract_list_prefix(" - "), (" - ", "", 4));
1536 assert_eq!(TableUtils::extract_list_prefix("- x"), ("- ", "x", 2));
1537 assert_eq!(TableUtils::extract_list_prefix("10. x"), ("10. ", "x", 4));
1538 }
1539
1540 #[test]
1541 fn test_list_content_starts_after_up_to_four_spaces_following_the_marker() {
1542 assert_eq!(TableUtils::extract_list_prefix("- x").2, 4);
1543 assert_eq!(TableUtils::extract_list_prefix("1. x").2, 4);
1544 assert_eq!(TableUtils::extract_list_prefix("- x").2, 5);
1545 assert_eq!(TableUtils::extract_list_prefix("- x").2, 2);
1547 assert_eq!(TableUtils::extract_list_prefix(" - x").2, 6);
1548 assert_eq!(TableUtils::extract_list_prefix("-\tx").2, 4);
1549 assert_eq!(TableUtils::extract_list_prefix("1.\tx").2, 4);
1550 assert_eq!(TableUtils::extract_list_prefix("10.\tx").2, 4);
1551 }
1552
1553 use super::*;
1554 use crate::lint_context::LintContext;
1555
1556 #[test]
1557 fn context_table_layout_preserves_mixed_terminator_offsets() {
1558 for newline in ["\n", "\r\n"] {
1561 let source = format!(
1562 "é🦀\r\n\n```md{newline}| hidden |{newline}| --- |{newline}```{newline}\n\
1563 > | a | b |\r\n> | --- | --- |\n> | x\r | y |\r\n"
1564 );
1565 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1566 assert_eq!(ctx.table_blocks.len(), 1, "{source:?}");
1567 let table = &ctx.table_blocks[0];
1568 assert_eq!((table.header_line, table.delimiter_line, table.end_line), (7, 8, 9));
1569 assert_eq!(table.content_lines, vec![9]);
1570 }
1571 }
1572
1573 #[test]
1574 fn test_is_potential_table_row() {
1575 assert!(TableUtils::is_potential_table_row("| Header 1 | Header 2 |"));
1577 assert!(TableUtils::is_potential_table_row("| Cell 1 | Cell 2 |"));
1578 assert!(TableUtils::is_potential_table_row("Cell 1 | Cell 2"));
1579 assert!(TableUtils::is_potential_table_row("| Cell |")); assert!(TableUtils::is_potential_table_row("| A | B | C | D | E |"));
1583
1584 assert!(TableUtils::is_potential_table_row(" | Indented | Table | "));
1586 assert!(TableUtils::is_potential_table_row("| Spaces | Around |"));
1587
1588 assert!(!TableUtils::is_potential_table_row("- List item"));
1590 assert!(!TableUtils::is_potential_table_row("* Another list"));
1591 assert!(!TableUtils::is_potential_table_row("+ Plus list"));
1592 assert!(!TableUtils::is_potential_table_row("Regular text"));
1593 assert!(!TableUtils::is_potential_table_row(""));
1594 assert!(!TableUtils::is_potential_table_row(" "));
1595
1596 assert!(!TableUtils::is_potential_table_row("`code with | pipe`"));
1598 assert!(!TableUtils::is_potential_table_row("``multiple | backticks``"));
1599 assert!(!TableUtils::is_potential_table_row("Use ``a|b`` in prose"));
1600 assert!(TableUtils::is_potential_table_row("| `fenced` | Uses ``` and ~~~ |"));
1601 assert!(TableUtils::is_potential_table_row("`!foo && bar` | `(!foo) && bar`"));
1602 assert!(!TableUtils::is_potential_table_row("`echo a | sed 's/a/b/'`"));
1603
1604 assert!(!TableUtils::is_potential_table_row(
1606 "Text with $|S|$ math notation here."
1607 ));
1608 assert!(!TableUtils::is_potential_table_row(
1609 "Size $|S|$ was even, check $|T|$ too."
1610 ));
1611 assert!(!TableUtils::is_potential_table_row("Display $$|A| + |B|$$ math here."));
1612 assert!(TableUtils::is_potential_table_row("| cell with $|S|$ math |"));
1614 assert!(TableUtils::is_potential_table_row("$a$ | $b$"));
1616 assert!(TableUtils::is_potential_table_row("$f(x)$ and $g(x)$ | result"));
1617 assert!(!TableUtils::is_potential_table_row("$5 | $10"));
1621
1622 assert!(!TableUtils::is_potential_table_row("Just one |"));
1624 assert!(!TableUtils::is_potential_table_row("| Just one"));
1625
1626 let long_cell = "a".repeat(150);
1628 assert!(TableUtils::is_potential_table_row(&format!("| {long_cell} | b |")));
1629
1630 assert!(!TableUtils::is_potential_table_row("| Cell with\nnewline | Other |"));
1632
1633 assert!(TableUtils::is_potential_table_row("|||")); assert!(TableUtils::is_potential_table_row("||||")); assert!(TableUtils::is_potential_table_row("| | |")); }
1638
1639 #[test]
1640 fn test_list_items_with_pipes_not_table_rows() {
1641 assert!(!TableUtils::is_potential_table_row("1. Item with | pipe"));
1643 assert!(!TableUtils::is_potential_table_row("10. Item with | pipe"));
1644 assert!(!TableUtils::is_potential_table_row("999. Item with | pipe"));
1645 assert!(!TableUtils::is_potential_table_row("1) Item with | pipe"));
1646 assert!(!TableUtils::is_potential_table_row("10) Item with | pipe"));
1647
1648 assert!(!TableUtils::is_potential_table_row("-\tItem with | pipe"));
1650 assert!(!TableUtils::is_potential_table_row("*\tItem with | pipe"));
1651 assert!(!TableUtils::is_potential_table_row("+\tItem with | pipe"));
1652
1653 assert!(!TableUtils::is_potential_table_row(" - Indented | pipe"));
1655 assert!(!TableUtils::is_potential_table_row(" * Deep indent | pipe"));
1656 assert!(!TableUtils::is_potential_table_row(" 1. Ordered indent | pipe"));
1657
1658 assert!(!TableUtils::is_potential_table_row("- [ ] task | pipe"));
1660 assert!(!TableUtils::is_potential_table_row("- [x] done | pipe"));
1661
1662 assert!(!TableUtils::is_potential_table_row("1. foo | bar | baz"));
1664 assert!(!TableUtils::is_potential_table_row("- alpha | beta | gamma"));
1665
1666 assert!(TableUtils::is_potential_table_row("| cell | cell |"));
1668 assert!(TableUtils::is_potential_table_row("cell | cell"));
1669 assert!(TableUtils::is_potential_table_row("| Header | Header |"));
1670 }
1671
1672 #[test]
1673 fn test_atx_headings_with_pipes_not_table_rows() {
1674 assert!(!TableUtils::is_potential_table_row("# Heading | with pipe"));
1676 assert!(!TableUtils::is_potential_table_row("## Heading | with pipe"));
1677 assert!(!TableUtils::is_potential_table_row("### Heading | with pipe"));
1678 assert!(!TableUtils::is_potential_table_row("#### Heading | with pipe"));
1679 assert!(!TableUtils::is_potential_table_row("##### Heading | with pipe"));
1680 assert!(!TableUtils::is_potential_table_row("###### Heading | with pipe"));
1681
1682 assert!(!TableUtils::is_potential_table_row("### col1 | col2 | col3"));
1684 assert!(!TableUtils::is_potential_table_row("## a|b|c"));
1685
1686 assert!(!TableUtils::is_potential_table_row("#\tHeading | pipe"));
1688 assert!(!TableUtils::is_potential_table_row("##\tHeading | pipe"));
1689
1690 assert!(!TableUtils::is_potential_table_row("# |"));
1692 assert!(!TableUtils::is_potential_table_row("## |"));
1693
1694 assert!(!TableUtils::is_potential_table_row(" ## Heading | pipe"));
1696 assert!(!TableUtils::is_potential_table_row(" ### Heading | pipe"));
1697
1698 assert!(!TableUtils::is_potential_table_row("#### ®aAA|ᯗ"));
1700
1701 assert!(TableUtils::is_potential_table_row("####### text | pipe"));
1705
1706 assert!(TableUtils::is_potential_table_row("#nospc|pipe"));
1708
1709 assert!(TableUtils::is_potential_table_row("| # Header | Value |"));
1711 assert!(TableUtils::is_potential_table_row("text | #tag"));
1712 }
1713
1714 #[test]
1715 fn test_is_delimiter_row() {
1716 assert!(TableUtils::is_delimiter_row("|---|---|"));
1718 assert!(TableUtils::is_delimiter_row("| --- | --- |"));
1719 assert!(TableUtils::is_delimiter_row("|:---|---:|"));
1720 assert!(TableUtils::is_delimiter_row("|:---:|:---:|"));
1721
1722 assert!(TableUtils::is_delimiter_row("|-|--|"));
1724 assert!(TableUtils::is_delimiter_row("|-------|----------|"));
1725
1726 assert!(TableUtils::is_delimiter_row("| --- | --- |"));
1728 assert!(TableUtils::is_delimiter_row("| :--- | ---: |"));
1729
1730 assert!(TableUtils::is_delimiter_row("|---|---|---|---|"));
1732
1733 assert!(TableUtils::is_delimiter_row("--- | ---"));
1735 assert!(TableUtils::is_delimiter_row(":--- | ---:"));
1736
1737 assert!(!TableUtils::is_delimiter_row("| Header | Header |"));
1739 assert!(!TableUtils::is_delimiter_row("Regular text"));
1740 assert!(!TableUtils::is_delimiter_row(""));
1741 assert!(!TableUtils::is_delimiter_row("|||"));
1742 assert!(!TableUtils::is_delimiter_row("| | |"));
1743
1744 assert!(!TableUtils::is_delimiter_row("| : | : |"));
1746 assert!(!TableUtils::is_delimiter_row("| | |"));
1747
1748 assert!(!TableUtils::is_delimiter_row("| --- | text |"));
1750 assert!(!TableUtils::is_delimiter_row("| abc | --- |"));
1751 }
1752
1753 #[test]
1754 fn test_count_cells() {
1755 assert_eq!(TableUtils::count_cells("| Cell 1 | Cell 2 | Cell 3 |"), 3);
1757 assert_eq!(TableUtils::count_cells("Cell 1 | Cell 2 | Cell 3"), 3);
1758 assert_eq!(TableUtils::count_cells("| Cell 1 | Cell 2"), 2);
1759 assert_eq!(TableUtils::count_cells("Cell 1 | Cell 2 |"), 2);
1760
1761 assert_eq!(TableUtils::count_cells("| Cell |"), 1);
1763 assert_eq!(TableUtils::count_cells("Cell"), 0); assert_eq!(TableUtils::count_cells("| | | |"), 3);
1767 assert_eq!(TableUtils::count_cells("| | | |"), 3);
1768
1769 assert_eq!(TableUtils::count_cells("| A | B | C | D | E | F |"), 6);
1771
1772 assert_eq!(TableUtils::count_cells("||"), 1); assert_eq!(TableUtils::count_cells("|||"), 2); assert_eq!(TableUtils::count_cells("Regular text"), 0);
1778 assert_eq!(TableUtils::count_cells(""), 0);
1779 assert_eq!(TableUtils::count_cells(" "), 0);
1780
1781 assert_eq!(TableUtils::count_cells(" | A | B | "), 2);
1783 assert_eq!(TableUtils::count_cells("| A | B |"), 2);
1784 }
1785
1786 #[test]
1787 fn test_count_cells_with_escaped_pipes() {
1788 assert_eq!(TableUtils::count_cells("| Challenge | Solution |"), 2);
1793 assert_eq!(TableUtils::count_cells("| A | B | C |"), 3);
1794 assert_eq!(TableUtils::count_cells("| One | Two |"), 2);
1795
1796 assert_eq!(TableUtils::count_cells(r"| Command | echo \| grep |"), 2);
1798 assert_eq!(TableUtils::count_cells(r"| A | B \| C |"), 2); assert_eq!(TableUtils::count_cells(r"| Command | `echo \| grep` |"), 2);
1802
1803 for backslashes in 1..=4 {
1805 let row = format!("| A | B {}| C |", "\\".repeat(backslashes));
1806 assert_eq!(TableUtils::count_cells(&row), 2, "{row}");
1807 }
1808 assert_eq!(TableUtils::count_cells(r"| A | `B \\| C` |"), 2);
1810
1811 assert_eq!(TableUtils::count_cells("| Command | `echo | grep` |"), 2);
1813 assert_eq!(TableUtils::count_cells("| `code | one` | `code | two` |"), 2);
1814 assert_eq!(TableUtils::count_cells("| `single|pipe` |"), 1);
1815
1816 assert_eq!(TableUtils::count_cells(r"| Hour formats | `^([0-1]?\d|2[0-3])` |"), 2);
1818 assert_eq!(TableUtils::count_cells(r"| Hour formats | `^([0-1]?\d\|2[0-3])` |"), 2);
1820 }
1821
1822 #[test]
1823 fn test_determine_pipe_style() {
1824 assert_eq!(
1826 TableUtils::determine_pipe_style("| Cell 1 | Cell 2 |"),
1827 Some("leading_and_trailing")
1828 );
1829 assert_eq!(
1830 TableUtils::determine_pipe_style("| Cell 1 | Cell 2"),
1831 Some("leading_only")
1832 );
1833 assert_eq!(
1834 TableUtils::determine_pipe_style("Cell 1 | Cell 2 |"),
1835 Some("trailing_only")
1836 );
1837 assert_eq!(
1838 TableUtils::determine_pipe_style("Cell 1 | Cell 2"),
1839 Some("no_leading_or_trailing")
1840 );
1841
1842 assert_eq!(
1844 TableUtils::determine_pipe_style(" | Cell 1 | Cell 2 | "),
1845 Some("leading_and_trailing")
1846 );
1847 assert_eq!(
1848 TableUtils::determine_pipe_style(" | Cell 1 | Cell 2 "),
1849 Some("leading_only")
1850 );
1851
1852 assert_eq!(TableUtils::determine_pipe_style("Regular text"), None);
1854 assert_eq!(TableUtils::determine_pipe_style(""), None);
1855 assert_eq!(TableUtils::determine_pipe_style(" "), None);
1856
1857 assert_eq!(TableUtils::determine_pipe_style("|"), Some("leading_and_trailing"));
1859 assert_eq!(TableUtils::determine_pipe_style("| Cell"), Some("leading_only"));
1860 assert_eq!(TableUtils::determine_pipe_style("Cell |"), Some("trailing_only"));
1861 }
1862
1863 #[test]
1864 fn test_determine_pipe_style_distinguishes_escaped_cell_pipes() {
1865 for backslashes in 0..=6 {
1866 let row = format!("A | B {}|", "\\".repeat(backslashes));
1867 let expected = if backslashes == 0 {
1868 "trailing_only"
1869 } else {
1870 "no_leading_or_trailing"
1871 };
1872 for prefix in ["", "> ", ">> "] {
1873 assert_eq!(
1874 TableUtils::determine_pipe_style(&format!("{prefix}{row}")),
1875 Some(expected)
1876 );
1877 }
1878 let mkdocs_expected = if backslashes % 2 == 0 {
1880 "trailing_only"
1881 } else {
1882 "no_leading_or_trailing"
1883 };
1884 assert_eq!(
1885 TableUtils::determine_pipe_style_with_flavor(&row, crate::config::MarkdownFlavor::MkDocs),
1886 Some(mkdocs_expected)
1887 );
1888 }
1889 }
1890
1891 #[test]
1892 fn test_find_table_blocks_simple() {
1893 let content = "| Header 1 | Header 2 |
1894|-----------|-----------|
1895| Cell 1 | Cell 2 |
1896| Cell 3 | Cell 4 |";
1897
1898 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1899
1900 let tables = TableUtils::find_table_blocks(content, &ctx);
1901 assert_eq!(tables.len(), 1);
1902
1903 let table = &tables[0];
1904 assert_eq!(table.start_line, 0);
1905 assert_eq!(table.end_line, 3);
1906 assert_eq!(table.header_line, 0);
1907 assert_eq!(table.delimiter_line, 1);
1908 assert_eq!(table.content_lines, vec![2, 3]);
1909 }
1910
1911 #[test]
1912 fn test_find_table_blocks_multiple() {
1913 let content = "Some text
1914
1915| Table 1 | Col A |
1916|----------|-------|
1917| Data 1 | Val 1 |
1918
1919More text
1920
1921| Table 2 | Col 2 |
1922|----------|-------|
1923| Data 2 | Data |";
1924
1925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1926
1927 let tables = TableUtils::find_table_blocks(content, &ctx);
1928 assert_eq!(tables.len(), 2);
1929
1930 assert_eq!(tables[0].start_line, 2);
1932 assert_eq!(tables[0].end_line, 4);
1933 assert_eq!(tables[0].header_line, 2);
1934 assert_eq!(tables[0].delimiter_line, 3);
1935 assert_eq!(tables[0].content_lines, vec![4]);
1936
1937 assert_eq!(tables[1].start_line, 8);
1939 assert_eq!(tables[1].end_line, 10);
1940 assert_eq!(tables[1].header_line, 8);
1941 assert_eq!(tables[1].delimiter_line, 9);
1942 assert_eq!(tables[1].content_lines, vec![10]);
1943 }
1944
1945 #[test]
1946 fn test_find_table_blocks_no_content_rows() {
1947 let content = "| Header 1 | Header 2 |
1948|-----------|-----------|
1949
1950Next paragraph";
1951
1952 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1953
1954 let tables = TableUtils::find_table_blocks(content, &ctx);
1955 assert_eq!(tables.len(), 1);
1956
1957 let table = &tables[0];
1958 assert_eq!(table.start_line, 0);
1959 assert_eq!(table.end_line, 1); assert_eq!(table.content_lines.len(), 0);
1961 }
1962
1963 #[test]
1964 fn test_find_table_blocks_in_code_block() {
1965 let content = "```
1966| Not | A | Table |
1967|-----|---|-------|
1968| In | Code | Block |
1969```
1970
1971| Real | Table |
1972|------|-------|
1973| Data | Here |";
1974
1975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1976
1977 let tables = TableUtils::find_table_blocks(content, &ctx);
1978 assert_eq!(tables.len(), 1); let table = &tables[0];
1981 assert_eq!(table.header_line, 6);
1982 assert_eq!(table.delimiter_line, 7);
1983 }
1984
1985 #[test]
1986 fn test_find_table_blocks_no_tables() {
1987 let content = "Just regular text
1988No tables here
1989- List item with | pipe
1990* Another list item";
1991
1992 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1993
1994 let tables = TableUtils::find_table_blocks(content, &ctx);
1995 assert_eq!(tables.len(), 0);
1996 }
1997
1998 #[test]
1999 fn test_find_table_blocks_malformed() {
2000 let content = "| Header without delimiter |
2001| This looks like table |
2002But no delimiter row
2003
2004| Proper | Table |
2005|---------|-------|
2006| Data | Here |";
2007
2008 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2009
2010 let tables = TableUtils::find_table_blocks(content, &ctx);
2011 assert_eq!(tables.len(), 1); assert_eq!(tables[0].header_line, 4);
2013 }
2014
2015 #[test]
2016 fn test_find_table_blocks_keeps_obsidian_wikilink_prose_out_of_the_table() {
2017 let content = "| A | B |\n| - | - |\n| x | y |\n[[Foo|bar]] is a note.\n";
2018
2019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2020 let blocks = TableUtils::find_table_blocks(content, &ctx);
2021 assert_eq!(blocks.len(), 1, "Expected one table, got {blocks:?}");
2022 assert_eq!(
2023 blocks[0].end_line, 2,
2024 "Wikilink prose was absorbed into the table: {:?}",
2025 blocks[0]
2026 );
2027 assert_eq!(blocks[0].content_lines, vec![2]);
2028
2029 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2032 let blocks = TableUtils::find_table_blocks(content, &ctx);
2033 assert_eq!(blocks.len(), 1, "Expected one table, got {blocks:?}");
2034 assert_eq!(
2035 blocks[0].end_line, 3,
2036 "GFM should still read the pipe as a delimiter: {:?}",
2037 blocks[0]
2038 );
2039 }
2040
2041 #[test]
2042 fn test_edge_cases() {
2043 assert!(!TableUtils::is_potential_table_row(""));
2045 assert!(!TableUtils::is_delimiter_row(""));
2046 assert_eq!(TableUtils::count_cells(""), 0);
2047 assert_eq!(TableUtils::determine_pipe_style(""), None);
2048
2049 assert!(!TableUtils::is_potential_table_row(" "));
2051 assert!(!TableUtils::is_delimiter_row(" "));
2052 assert_eq!(TableUtils::count_cells(" "), 0);
2053 assert_eq!(TableUtils::determine_pipe_style(" "), None);
2054
2055 assert!(!TableUtils::is_potential_table_row("|"));
2057 assert!(!TableUtils::is_delimiter_row("|"));
2058 assert_eq!(TableUtils::count_cells("|"), 0); let long_single = format!("| {} |", "a".repeat(200));
2063 assert!(TableUtils::is_potential_table_row(&long_single)); let long_multi = format!("| {} | {} |", "a".repeat(200), "b".repeat(200));
2066 assert!(TableUtils::is_potential_table_row(&long_multi)); assert!(TableUtils::is_potential_table_row("| 你好 | 世界 |"));
2070 assert!(TableUtils::is_potential_table_row("| émoji | 🎉 |"));
2071 assert_eq!(TableUtils::count_cells("| 你好 | 世界 |"), 2);
2072 }
2073
2074 #[test]
2075 fn test_table_block_struct() {
2076 let block = TableBlock {
2077 start_line: 0,
2078 end_line: 5,
2079 header_line: 0,
2080 delimiter_line: 1,
2081 content_lines: vec![2, 3, 4, 5],
2082 list_context: None,
2083 parser_confirmed: true,
2084 repairable: false,
2085 header_prefix_len: None,
2086 };
2087
2088 let debug_str = format!("{block:?}");
2090 assert!(debug_str.contains("TableBlock"));
2091 assert!(debug_str.contains("start_line: 0"));
2092
2093 let cloned = block.clone();
2095 assert_eq!(cloned.start_line, block.start_line);
2096 assert_eq!(cloned.end_line, block.end_line);
2097 assert_eq!(cloned.header_line, block.header_line);
2098 assert_eq!(cloned.delimiter_line, block.delimiter_line);
2099 assert_eq!(cloned.content_lines, block.content_lines);
2100 assert!(cloned.list_context.is_none());
2101 }
2102
2103 #[test]
2104 fn test_split_table_row() {
2105 let cells = TableUtils::split_table_row("| Cell 1 | Cell 2 | Cell 3 |");
2107 assert_eq!(cells.len(), 3);
2108 assert_eq!(cells[0].trim(), "Cell 1");
2109 assert_eq!(cells[1].trim(), "Cell 2");
2110 assert_eq!(cells[2].trim(), "Cell 3");
2111
2112 let cells = TableUtils::split_table_row("| Cell 1 | Cell 2");
2114 assert_eq!(cells.len(), 2);
2115
2116 let cells = TableUtils::split_table_row("| | | |");
2118 assert_eq!(cells.len(), 3);
2119
2120 let cells = TableUtils::split_table_row("| Cell |");
2122 assert_eq!(cells.len(), 1);
2123 assert_eq!(cells[0].trim(), "Cell");
2124
2125 let cells = TableUtils::split_table_row("No pipes here");
2127 assert_eq!(cells.len(), 0);
2128 }
2129
2130 #[test]
2131 fn test_split_table_row_with_escaped_pipes() {
2132 let cells = TableUtils::split_table_row(r"| A | B \| C |");
2134 assert_eq!(cells.len(), 2);
2135 assert!(cells[1].contains(r"\|"), "Escaped pipe should be in cell content");
2136
2137 let cells = TableUtils::split_table_row(r"| A | B \\| C |");
2139 assert_eq!(cells.len(), 2);
2140 }
2141
2142 #[test]
2143 fn test_split_table_row_with_flavor_mkdocs() {
2144 let cells =
2146 TableUtils::split_table_row_with_flavor("| Type | `x | y` |", crate::config::MarkdownFlavor::MkDocs);
2147 assert_eq!(cells.len(), 2);
2148 assert!(
2149 cells[1].contains("`x | y`"),
2150 "Inline code with pipe should be single cell in MkDocs flavor"
2151 );
2152
2153 let cells =
2155 TableUtils::split_table_row_with_flavor("| Type | `a | b | c` |", crate::config::MarkdownFlavor::MkDocs);
2156 assert_eq!(cells.len(), 2);
2157 assert!(cells[1].contains("`a | b | c`"));
2158 }
2159
2160 #[test]
2161 fn test_split_table_row_with_flavor_standard() {
2162 let cells =
2164 TableUtils::split_table_row_with_flavor("| Type | `x | y` |", crate::config::MarkdownFlavor::Standard);
2165 assert_eq!(
2166 cells.len(),
2167 2,
2168 "Pipes in code spans should not be cell delimiters, got {cells:?}"
2169 );
2170 assert!(
2171 cells[1].contains("`x | y`"),
2172 "Inline code with pipe should be single cell"
2173 );
2174 }
2175
2176 #[test]
2177 fn test_split_table_row_with_flavor_obsidian_wikilink() {
2178 let cells = TableUtils::split_table_row_with_flavor(
2181 "| Alice | [[White Rabbit|the Rabbit]] |",
2182 crate::config::MarkdownFlavor::Obsidian,
2183 );
2184 assert_eq!(cells.len(), 2, "Aliased wikilink should be one cell, got {cells:?}");
2185 assert!(cells[1].contains("[[White Rabbit|the Rabbit]]"));
2186
2187 let cells = TableUtils::split_table_row_with_flavor(
2189 "| Guests | [[Mad Hatter|the Hatter]] and [[March Hare|the Hare]] |",
2190 crate::config::MarkdownFlavor::Obsidian,
2191 );
2192 assert_eq!(
2193 cells.len(),
2194 2,
2195 "Two aliased wikilinks should be one cell, got {cells:?}"
2196 );
2197
2198 let cells = TableUtils::split_table_row_with_flavor(
2200 "| Alice | [[Cheshire Cat]] |",
2201 crate::config::MarkdownFlavor::Obsidian,
2202 );
2203 assert_eq!(cells.len(), 2);
2204
2205 let cells = TableUtils::split_table_row_with_flavor(
2207 "| Alice | [[White Rabbit | curious |",
2208 crate::config::MarkdownFlavor::Obsidian,
2209 );
2210 assert_eq!(
2211 cells.len(),
2212 3,
2213 "Unterminated wikilink should not mask pipes, got {cells:?}"
2214 );
2215 }
2216
2217 #[test]
2218 fn test_split_table_row_wikilink_only_for_obsidian() {
2219 for flavor in [
2221 crate::config::MarkdownFlavor::Standard,
2222 crate::config::MarkdownFlavor::MkDocs,
2223 ] {
2224 let cells = TableUtils::split_table_row_with_flavor("| Alice | [[White Rabbit|the Rabbit]] |", flavor);
2225 assert_eq!(
2226 cells.len(),
2227 3,
2228 "{flavor:?} should treat the wikilink pipe as a delimiter, got {cells:?}"
2229 );
2230 }
2231 }
2232
2233 #[test]
2234 fn test_mask_pipes_in_wikilinks_preserves_length() {
2235 for text in [
2237 "| Alice | [[White Rabbit|the Rabbit]] |",
2238 "| [[Mad Hatter|Hatter]] | [[March Hare|Hare]] |",
2239 "no wikilink here | just a pipe",
2240 "[[unterminated | still text",
2241 ] {
2242 assert_eq!(
2243 TableUtils::mask_pipes_in_wikilinks(text).len(),
2244 text.len(),
2245 "masking changed length of {text:?}"
2246 );
2247 }
2248 }
2249
2250 #[test]
2251 fn test_wikilink_brackets_in_a_code_span_stay_prose() {
2252 for row in [
2256 "| `[[` | mid | `]]` |",
2257 "| `[[Target` | mid | `Label]]` |",
2258 "| a | `[[` | b | `]]` |",
2259 "| `[[` and Target|Label]] |",
2262 ] {
2263 let obsidian = TableUtils::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Obsidian);
2264 let standard = TableUtils::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Standard);
2265 assert_eq!(
2266 obsidian, standard,
2267 "Obsidian disagreed with GFM about {row:?}: {obsidian:?} vs {standard:?}"
2268 );
2269 }
2270
2271 let cells = TableUtils::split_table_row_with_flavor(
2274 "| [[Target|Label with `a|b` inside]] |",
2275 crate::config::MarkdownFlavor::Obsidian,
2276 );
2277 assert_eq!(
2278 cells.len(),
2279 1,
2280 "Wikilink holding a code span should be one cell, got {cells:?}"
2281 );
2282
2283 let cells = TableUtils::split_table_row_with_flavor(
2286 "| [[Target | alias `]]` | tail |",
2287 crate::config::MarkdownFlavor::Obsidian,
2288 );
2289 assert_eq!(
2290 cells.len(),
2291 3,
2292 "A closer hidden in code should not close the link, got {cells:?}"
2293 );
2294 }
2295
2296 #[test]
2297 fn test_wikilink_with_a_blank_target_stays_prose() {
2298 for row in [
2301 "| [[ | ]] |",
2302 "| [[|Label]] |",
2303 "| starts [[ | ends ]] here |",
2304 "| [[\t|\tx]] |",
2305 ] {
2306 let obsidian = TableUtils::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Obsidian);
2307 let standard = TableUtils::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Standard);
2308 assert_eq!(
2309 obsidian, standard,
2310 "Obsidian disagreed with GFM about {row:?}: {obsidian:?} vs {standard:?}"
2311 );
2312 }
2313
2314 let cells = TableUtils::split_table_row_with_flavor("| [[x | y]] |", crate::config::MarkdownFlavor::Obsidian);
2316 assert_eq!(cells.len(), 1, "A named target should be one cell, got {cells:?}");
2317 }
2318
2319 #[test]
2320 fn test_inline_code_spans_agree_with_pipe_masking() {
2321 let text = "``x | y and `c|d`";
2324 let chars: Vec<char> = text.chars().collect();
2325 let spans = TableUtils::inline_code_spans(&chars);
2326 assert_eq!(spans.len(), 1, "Only the matched pair is a code span, got {spans:?}");
2327 let (start, end) = spans[0];
2328 assert_eq!(
2329 chars[start..end].iter().collect::<String>(),
2330 "`c|d`",
2331 "The span should start at the run that closes, not the unmatched opener"
2332 );
2333
2334 assert_eq!(TableUtils::mask_pipes_in_inline_code(text), "``x | y and `c_d`");
2336 }
2337
2338 #[test]
2341 fn test_extract_blockquote_prefix_no_blockquote() {
2342 let (prefix, content) = TableUtils::extract_blockquote_prefix("| H1 | H2 |");
2344 assert_eq!(prefix, "");
2345 assert_eq!(content, "| H1 | H2 |");
2346 }
2347
2348 #[test]
2349 fn test_extract_blockquote_prefix_single_level() {
2350 let (prefix, content) = TableUtils::extract_blockquote_prefix("> | H1 | H2 |");
2352 assert_eq!(prefix, "> ");
2353 assert_eq!(content, "| H1 | H2 |");
2354 }
2355
2356 #[test]
2357 fn test_extract_blockquote_prefix_double_level() {
2358 let (prefix, content) = TableUtils::extract_blockquote_prefix(">> | H1 | H2 |");
2360 assert_eq!(prefix, ">> ");
2361 assert_eq!(content, "| H1 | H2 |");
2362 }
2363
2364 #[test]
2365 fn test_extract_blockquote_prefix_triple_level() {
2366 let (prefix, content) = TableUtils::extract_blockquote_prefix(">>> | H1 | H2 |");
2368 assert_eq!(prefix, ">>> ");
2369 assert_eq!(content, "| H1 | H2 |");
2370 }
2371
2372 #[test]
2373 fn test_extract_blockquote_prefix_with_spaces() {
2374 let (prefix, content) = TableUtils::extract_blockquote_prefix("> > | H1 | H2 |");
2376 assert_eq!(prefix, "> > ");
2377 assert_eq!(content, "| H1 | H2 |");
2378 }
2379
2380 #[test]
2381 fn test_extract_blockquote_prefix_indented() {
2382 let (prefix, content) = TableUtils::extract_blockquote_prefix(" > | H1 | H2 |");
2384 assert_eq!(prefix, " > ");
2385 assert_eq!(content, "| H1 | H2 |");
2386 }
2387
2388 #[test]
2389 fn test_extract_blockquote_prefix_no_space_after() {
2390 let (prefix, content) = TableUtils::extract_blockquote_prefix(">| H1 | H2 |");
2392 assert_eq!(prefix, ">");
2393 assert_eq!(content, "| H1 | H2 |");
2394 }
2395
2396 #[test]
2397 fn test_determine_pipe_style_in_blockquote() {
2398 assert_eq!(
2400 TableUtils::determine_pipe_style("> | H1 | H2 |"),
2401 Some("leading_and_trailing")
2402 );
2403 assert_eq!(
2404 TableUtils::determine_pipe_style("> H1 | H2"),
2405 Some("no_leading_or_trailing")
2406 );
2407 assert_eq!(
2408 TableUtils::determine_pipe_style(">> | H1 | H2 |"),
2409 Some("leading_and_trailing")
2410 );
2411 assert_eq!(TableUtils::determine_pipe_style(">>> | H1 | H2"), Some("leading_only"));
2412 }
2413
2414 #[test]
2415 fn test_list_table_delimiter_requires_indentation() {
2416 let content = "- List item with | pipe\n|---|---|\n| Cell 1 | Cell 2 |";
2421 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2422 let tables = TableUtils::find_table_blocks(content, &ctx);
2423
2424 assert_eq!(tables.len(), 1, "Should find exactly one table");
2427 assert!(
2428 tables[0].list_context.is_none(),
2429 "Should NOT have list context since delimiter has no indentation"
2430 );
2431 }
2432
2433 #[test]
2434 fn test_list_table_with_properly_indented_delimiter() {
2435 let content = "- | Header 1 | Header 2 |\n |----------|----------|\n | Cell 1 | Cell 2 |";
2438 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2439 let tables = TableUtils::find_table_blocks(content, &ctx);
2440
2441 assert_eq!(tables.len(), 1, "Should find exactly one table");
2443 assert_eq!(tables[0].start_line, 0, "Table should start at list item line");
2444 assert!(
2445 tables[0].list_context.is_some(),
2446 "Should be a list table since delimiter is properly indented"
2447 );
2448 }
2449
2450 #[test]
2451 fn test_mask_pipes_in_inline_code_regular_backticks() {
2452 let result = TableUtils::mask_pipes_in_inline_code("| `code | here` |");
2454 assert_eq!(result, "| `code _ here` |");
2455 }
2456
2457 #[test]
2458 fn test_mask_pipes_in_inline_code_escaped_backtick_not_code_span() {
2459 let result = TableUtils::mask_pipes_in_inline_code(r"| \`not code | still pipe\` |");
2462 assert_eq!(result, r"| \`not code | still pipe\` |");
2463 }
2464
2465 #[test]
2466 fn test_mask_pipes_in_inline_code_escaped_backslash_then_backtick() {
2467 let result = TableUtils::mask_pipes_in_inline_code(r"| \\`real code | masked\\` |");
2470 assert_eq!(result, r"| \\`real code _ masked\\` |");
2473 }
2474
2475 #[test]
2476 fn test_mask_pipes_in_inline_code_triple_backslash_before_backtick() {
2477 let result = TableUtils::mask_pipes_in_inline_code(r"| \\\`not code | pipe\\\` |");
2479 assert_eq!(result, r"| \\\`not code | pipe\\\` |");
2480 }
2481
2482 #[test]
2483 fn test_mask_pipes_in_inline_code_four_backslashes_before_backtick() {
2484 let result = TableUtils::mask_pipes_in_inline_code(r"| \\\\`code | here\\\\` |");
2486 assert_eq!(result, r"| \\\\`code _ here\\\\` |");
2487 }
2488
2489 #[test]
2490 fn test_mask_pipes_in_inline_code_no_backslash() {
2491 let result = TableUtils::mask_pipes_in_inline_code("before `a | b` after");
2493 assert_eq!(result, "before `a _ b` after");
2494 }
2495
2496 #[test]
2497 fn test_mask_pipes_in_inline_code_no_code_span() {
2498 let result = TableUtils::mask_pipes_in_inline_code("| col1 | col2 |");
2500 assert_eq!(result, "| col1 | col2 |");
2501 }
2502
2503 #[test]
2504 fn test_mask_pipes_in_inline_code_backslash_before_closing_backtick() {
2505 let result = TableUtils::mask_pipes_in_inline_code(r"| `foo\` | bar |");
2514 assert_eq!(result, r"| `foo\` | bar |");
2517 }
2518
2519 #[test]
2520 fn test_mask_pipes_in_inline_code_backslash_literal_with_pipe_inside() {
2521 let result = TableUtils::mask_pipes_in_inline_code(r"| `a\|b` | col2 |");
2525 assert_eq!(result, r"| `a\_b` | col2 |");
2526 }
2527
2528 #[test]
2529 fn test_count_preceding_backslashes() {
2530 let chars: Vec<char> = r"abc\\\`def".chars().collect();
2531 assert_eq!(TableUtils::count_preceding_backslashes(&chars, 6), 3);
2533
2534 let chars2: Vec<char> = r"abc\\`def".chars().collect();
2535 assert_eq!(TableUtils::count_preceding_backslashes(&chars2, 5), 2);
2537
2538 let chars3: Vec<char> = "`def".chars().collect();
2539 assert_eq!(TableUtils::count_preceding_backslashes(&chars3, 0), 0);
2541 }
2542
2543 #[test]
2544 fn test_has_unescaped_pipe_backslash_literal_in_code_span() {
2545 assert!(TableUtils::has_unescaped_pipe_outside_spans(r"`foo\` | bar", false));
2548
2549 assert!(TableUtils::has_unescaped_pipe_outside_spans(r"\`foo | bar\`", false));
2551
2552 assert!(!TableUtils::has_unescaped_pipe_outside_spans(r"`foo | bar`", false));
2554 }
2555
2556 #[test]
2557 fn test_table_after_code_span_detected() {
2558 use crate::config::MarkdownFlavor;
2559
2560 let content = "`code`\n\n| A | B |\n|---|---|\n| 1 | 2 |\n";
2561 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2562 assert!(!ctx.table_blocks.is_empty(), "Table after code span should be detected");
2563 }
2564
2565 #[test]
2566 fn test_table_inside_html_comment_not_detected() {
2567 use crate::config::MarkdownFlavor;
2568
2569 let content = "<!--\n| A | B |\n|---|---|\n| 1 | 2 |\n-->\n";
2570 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2571 assert!(
2572 ctx.table_blocks.is_empty(),
2573 "Table inside HTML comment should not be detected"
2574 );
2575 }
2576}