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
12fn header_container_prefix_len(line: &str, line_start: usize, items: &[(usize, usize, usize)]) -> usize {
19 let mut pos = 0;
20 loop {
21 let rest = &line[pos..];
22 let quote = TableUtils::extract_blockquote_prefix(rest).0.len();
23 if quote > 0 {
24 pos += quote;
25 continue;
26 }
27 let marker = TableUtils::extract_list_prefix(rest).0;
28 if marker.is_empty() {
29 return pos;
30 }
31 let marker_start = line_start + pos + (marker.len() - marker.trim_start().len());
32 let parsed_item = items
33 .binary_search_by_key(&marker_start, |&(start, _, _)| start)
34 .is_ok();
35 if !parsed_item && !opens_item_in_a_paragraph(marker) {
36 return pos;
37 }
38 pos += marker.len();
39 }
40}
41
42fn opens_item_in_a_paragraph(marker: &str) -> bool {
47 let indent = marker
48 .bytes()
49 .take_while(|&b| b == b' ' || b == b'\t')
50 .fold(0, |column, byte| {
51 if byte == b'\t' {
52 (column / 4 + 1) * 4
53 } else {
54 column + 1
55 }
56 });
57 let number = marker.trim_start().trim_end_matches(|c: char| !c.is_ascii_digit());
58 indent < 4 && (number.is_empty() || (number.len() <= 9 && number.trim_start_matches('0') == "1"))
59}
60
61pub(crate) fn header_quote_depth(line: &str, line_start: usize, items: &[(usize, usize, usize)]) -> usize {
64 let prefix = &line[..header_container_prefix_len(line, line_start, items)];
65 prefix.bytes().filter(|&byte| byte == b'>').count()
66}
67
68#[derive(Debug, Clone)]
70pub struct TableBlock {
71 pub start_line: usize,
72 pub end_line: usize,
73 pub header_line: usize,
74 pub delimiter_line: usize,
75 pub content_lines: Vec<usize>,
76 pub list_context: Option<ListTableContext>,
80 pub parser_confirmed: bool,
84 pub repairable: bool,
88 pub header_prefix_len: Option<usize>,
91}
92
93#[derive(Debug, Clone)]
95pub struct ListTableContext {
96 pub list_prefix: String,
98 pub content_indent: usize,
100}
101
102pub struct TableUtils;
104
105impl TableUtils {
106 fn has_unescaped_pipe_outside_spans(text: &str, pairs_backslashes: bool) -> bool {
118 let chars: Vec<char> = text.chars().collect();
119 let mut i = 0;
120 let mut in_code = false;
121 let mut code_delim_len = 0usize;
122 let mut in_math = false;
123 let mut math_delim_len = 0usize;
124
125 while i < chars.len() {
126 let ch = chars[i];
127
128 if ch == '\\' && !in_code && !in_math {
129 i += if i + 1 < chars.len() { 2 } else { 1 };
132 continue;
133 }
134
135 if ch == '`' && !in_math {
136 let mut run = 1usize;
137 while i + run < chars.len() && chars[i + run] == '`' {
138 run += 1;
139 }
140
141 if in_code {
142 if run == code_delim_len {
143 in_code = false;
144 code_delim_len = 0;
145 }
146 } else {
148 in_code = true;
149 code_delim_len = run;
150 }
151
152 i += run;
153 continue;
154 }
155
156 if ch == '$' && !in_code {
157 let mut run = 1usize;
158 while i + run < chars.len() && chars[i + run] == '$' {
159 run += 1;
160 }
161
162 if in_math {
163 if run == math_delim_len {
164 in_math = false;
165 math_delim_len = 0;
166 }
167 } else {
169 in_math = true;
170 math_delim_len = run;
171 }
172
173 i += run;
174 continue;
175 }
176
177 if ch == '|' && !in_code && !in_math {
178 if pairs_backslashes || i == 0 || chars[i - 1] != '\\' {
181 return true;
182 }
183 }
184
185 i += 1;
186 }
187
188 false
189 }
190
191 pub fn is_potential_table_row_with_flavor(line: &str, flavor: crate::config::MarkdownFlavor) -> bool {
197 let pairs = Self::pairs_backslashes(flavor);
200 if flavor == crate::config::MarkdownFlavor::Obsidian && line.contains("[[") {
201 return Self::potential_table_row(&Self::mask_pipes_in_wikilinks(line), pairs);
202 }
203 Self::potential_table_row(line, pairs)
204 }
205
206 pub fn is_potential_table_row(line: &str) -> bool {
207 Self::potential_table_row(line, false)
208 }
209
210 fn potential_table_row(line: &str, pairs_backslashes: bool) -> bool {
211 let trimmed = line.trim();
212 if trimmed.is_empty() || !trimmed.contains('|') {
213 return false;
214 }
215
216 if trimmed.starts_with("- ")
219 || trimmed.starts_with("* ")
220 || trimmed.starts_with("+ ")
221 || trimmed.starts_with("-\t")
222 || trimmed.starts_with("*\t")
223 || trimmed.starts_with("+\t")
224 {
225 return false;
226 }
227
228 if let Some(first_non_digit) = trimmed.find(|c: char| !c.is_ascii_digit())
230 && first_non_digit > 0
231 {
232 let after_digits = &trimmed[first_non_digit..];
233 if after_digits.starts_with(". ")
234 || after_digits.starts_with(".\t")
235 || after_digits.starts_with(") ")
236 || after_digits.starts_with(")\t")
237 {
238 return false;
239 }
240 }
241
242 if trimmed.starts_with('#') {
244 let hash_count = trimmed.bytes().take_while(|&b| b == b'#').count();
245 if hash_count <= 6 {
246 let after_hashes = &trimmed[hash_count..];
247 if after_hashes.is_empty() || after_hashes.starts_with(' ') || after_hashes.starts_with('\t') {
248 return false;
249 }
250 }
251 }
252
253 let has_outer_pipes = trimmed.starts_with('|') && trimmed.ends_with('|');
256 if !has_outer_pipes && !Self::has_unescaped_pipe_outside_spans(trimmed, pairs_backslashes) {
257 return false;
258 }
259
260 let parts: Vec<&str> = trimmed.split('|').collect();
262 if parts.len() < 2 {
263 return false;
264 }
265
266 let mut valid_parts = 0;
268 let mut total_non_empty_parts = 0;
269
270 for part in &parts {
271 let part_trimmed = part.trim();
272 if part_trimmed.is_empty() {
274 continue;
275 }
276 total_non_empty_parts += 1;
277
278 if !part_trimmed.contains('\n') {
280 valid_parts += 1;
281 }
282 }
283
284 if total_non_empty_parts > 0 && valid_parts != total_non_empty_parts {
286 return false;
288 }
289
290 if total_non_empty_parts == 0 {
293 return trimmed.starts_with('|') && trimmed.ends_with('|') && parts.len() >= 3;
295 }
296
297 if trimmed.starts_with('|') && trimmed.ends_with('|') {
300 valid_parts >= 1
302 } else {
303 valid_parts >= 2
305 }
306 }
307
308 pub fn is_delimiter_row(line: &str) -> bool {
310 let trimmed = line.trim();
311 if !trimmed.contains('|') || !trimmed.contains('-') {
312 return false;
313 }
314
315 let parts: Vec<&str> = trimmed.split('|').collect();
317 let mut valid_delimiter_parts = 0;
318 let mut total_non_empty_parts = 0;
319
320 for part in &parts {
321 let part_trimmed = part.trim();
322 if part_trimmed.is_empty() {
323 continue; }
325
326 total_non_empty_parts += 1;
327
328 if part_trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace()) && part_trimmed.contains('-') {
330 valid_delimiter_parts += 1;
331 }
332 }
333
334 total_non_empty_parts > 0 && valid_delimiter_parts == total_non_empty_parts
336 }
337
338 pub fn find_table_blocks_with_code_info(
343 content: &str,
344 code_blocks: &[(usize, usize)],
345 code_spans: &[crate::lint_context::CodeSpan],
346 html_comment_ranges: &[crate::utils::skip_context::ByteRange],
347 flavor: crate::config::MarkdownFlavor,
348 lazy_text: &[bool],
349 ) -> Vec<TableBlock> {
350 let lines: Vec<&str> = content.lines().collect();
351 let mut line_positions = Vec::with_capacity(lines.len());
358 let content_bytes = content.as_bytes();
359 let mut pos = 0;
360 for line in &lines {
361 line_positions.push(pos);
362 pos += line.len();
363 if content_bytes.get(pos) == Some(&b'\r') {
364 pos += 1;
365 }
366 if content_bytes.get(pos) == Some(&b'\n') {
367 pos += 1;
368 }
369 }
370
371 Self::find_table_blocks_with_layout(
372 &lines,
373 &line_positions,
374 code_blocks,
375 &crate::lint_context::code_span_byte_ranges(code_spans),
376 html_comment_ranges,
377 flavor,
378 lazy_text,
379 &[],
380 )
381 }
382
383 #[allow(clippy::too_many_arguments)]
387 pub(crate) fn find_table_blocks_with_layout(
388 lines: &[&str],
389 line_positions: &[usize],
390 code_blocks: &[(usize, usize)],
391 code_spans: &[(usize, usize)],
392 html_comment_ranges: &[crate::utils::skip_context::ByteRange],
393 flavor: crate::config::MarkdownFlavor,
394 lazy_text: &[bool],
395 list_items: &[(usize, usize, usize)],
396 ) -> Vec<TableBlock> {
397 let is_lazy_text = |line: usize| lazy_text.get(line).copied().unwrap_or(false);
398 let mut tables = Vec::new();
399 let mut i = 0;
400
401 let mut list_indent_stack: Vec<(usize, bool)> = Vec::new();
405
406 while i < lines.len() {
407 let line_start = line_positions[i];
409 let in_code =
410 crate::utils::code_block_utils::CodeBlockUtils::is_in_code_block_or_span(code_blocks, line_start) || {
411 let idx = code_spans.partition_point(|span| span.0 <= line_start);
413 idx > 0 && line_start < code_spans[idx - 1].1
414 };
415 let in_html_comment = {
416 let idx = html_comment_ranges.partition_point(|range| range.start <= line_start);
418 idx > 0 && line_start < html_comment_ranges[idx - 1].end
419 };
420
421 if in_code || in_html_comment {
422 i += 1;
423 continue;
424 }
425
426 let line_content = strip_blockquote_prefix(lines[i]);
428
429 let (list_prefix, list_content, content_indent, opened_levels) =
431 Self::extract_nested_list_prefix(line_content);
432 let line_is_quoted = line_content.len() != lines[i].len();
433 if !line_is_quoted {
434 list_indent_stack.retain(|&(_, opened_quoted)| !opened_quoted);
436 }
437 let quote_indent = if line_is_quoted {
442 lines[i].len() - lines[i].trim_start().len()
443 } else {
444 0
445 };
446 let text_indent = line_content.len() - line_content.trim_start().len();
447 let indent_for = |opened_quoted: bool| {
448 if opened_quoted {
449 text_indent
450 } else {
451 quote_indent + text_indent
452 }
453 };
454 if !list_prefix.is_empty() {
455 while list_indent_stack
459 .last()
460 .is_some_and(|&(top, opened_quoted)| top > indent_for(opened_quoted))
461 {
462 list_indent_stack.pop();
463 }
464 list_indent_stack.extend(opened_levels.into_iter().map(|level| (level, line_is_quoted)));
465 } else if !line_content.trim().is_empty() {
466 while list_indent_stack
468 .last()
469 .is_some_and(|&(top, opened_quoted)| indent_for(opened_quoted) < top)
470 {
471 list_indent_stack.pop();
472 }
473 }
474 let (is_same_line_list_table, effective_content) =
479 if !list_prefix.is_empty() && Self::is_potential_table_row_content(list_content, flavor) {
480 (true, list_content)
481 } else {
482 (false, line_content)
483 };
484
485 let continuation_indent = if !is_same_line_list_table && list_prefix.is_empty() {
488 let leading = line_content.len() - line_content.trim_start().len();
489 list_indent_stack
491 .iter()
492 .rev()
493 .filter(|&&(_, opened_quoted)| opened_quoted || !line_is_quoted)
496 .find(|&&(indent, _)| leading >= indent)
497 .map(|&(indent, _)| indent)
498 } else {
499 None
500 };
501
502 let is_continuation_list_table = continuation_indent.is_some()
503 && {
504 let indent = continuation_indent.unwrap();
505 let leading = line_content.len() - line_content.trim_start().len();
506 leading < indent + 4
508 }
509 && Self::is_potential_table_row_with_flavor(effective_content, flavor);
510
511 let is_any_list_table = is_same_line_list_table || is_continuation_list_table;
512
513 let effective_content_indent = if is_same_line_list_table {
515 content_indent
516 } else if is_continuation_list_table {
517 continuation_indent.unwrap()
518 } else {
519 0
520 };
521
522 if is_lazy_text(i) || is_lazy_text(i + 1) {
525 i += 1;
526 continue;
527 }
528
529 if is_any_list_table || Self::is_potential_table_row_with_flavor(effective_content, flavor) {
531 let (next_line_content, delimiter_has_valid_indent) = if i + 1 < lines.len() {
534 let next_raw = strip_blockquote_prefix(lines[i + 1]);
535 if is_any_list_table {
536 let leading_spaces = next_raw.len() - next_raw.trim_start().len();
538 if leading_spaces >= effective_content_indent {
539 (
541 Self::strip_list_continuation_indent(next_raw, effective_content_indent),
542 true,
543 )
544 } else {
545 (next_raw, false)
547 }
548 } else {
549 (next_raw, true)
550 }
551 } else {
552 ("", true)
553 };
554
555 let effective_is_list_table = is_any_list_table && delimiter_has_valid_indent;
557
558 if i + 1 < lines.len() && Self::is_delimiter_row(next_line_content) {
559 let table_start = i;
561 let header_line = i;
562 let delimiter_line = i + 1;
563 let mut table_end = i + 1; let mut content_lines = Vec::new();
565
566 let header_prefix_len = header_container_prefix_len(lines[i], line_positions[i], list_items);
568 let table_depth = lines[i][..header_prefix_len]
569 .bytes()
570 .filter(|&byte| byte == b'>')
571 .count();
572 let mut j = i + 2;
573 while j < lines.len() {
574 let line = lines[j];
575 let raw_content = strip_blockquote_prefix(line);
577
578 let line_content = if effective_is_list_table {
580 Self::strip_list_continuation_indent(raw_content, effective_content_indent)
581 } else {
582 raw_content
583 };
584
585 if line_content.trim().is_empty() {
586 break;
588 }
589
590 if effective_is_list_table {
592 let leading_spaces = raw_content.len() - raw_content.trim_start().len();
593 if leading_spaces < effective_content_indent {
594 break;
596 }
597 }
598
599 if blockquote_depth(line) != table_depth
605 || Self::starts_block_after_table(line_content)
606 || (flavor == crate::config::MarkdownFlavor::Obsidian
607 && !Self::is_potential_table_row_with_flavor(line_content, flavor))
608 {
609 break;
610 }
611 content_lines.push(j);
612 table_end = j;
613 j += 1;
614 }
615
616 let list_context = if effective_is_list_table {
617 if is_same_line_list_table {
618 Some(ListTableContext {
620 list_prefix: list_prefix.to_string(),
621 content_indent: effective_content_indent,
622 })
623 } else {
624 Some(ListTableContext {
626 list_prefix: " ".repeat(effective_content_indent),
627 content_indent: effective_content_indent,
628 })
629 }
630 } else {
631 None
632 };
633
634 tables.push(TableBlock {
635 start_line: table_start,
636 end_line: table_end,
637 header_line,
638 delimiter_line,
639 content_lines,
640 list_context,
641 parser_confirmed: false,
642 repairable: false,
643 header_prefix_len: Some(header_prefix_len),
644 });
645 i = table_end + 1;
646 } else {
647 i += 1;
648 }
649 } else {
650 i += 1;
651 }
652 }
653
654 tables
655 }
656
657 pub(crate) fn starts_block_after_table(line: &str) -> bool {
674 let indent = line.bytes().take_while(|&b| b == b' ').count();
675 if indent >= 4 || line[indent..].starts_with('\t') {
676 return false;
677 }
678 let text = line[indent..].trim_end();
679 let bytes = text.as_bytes();
680 let Some(&first) = bytes.first() else {
681 return false;
682 };
683 let space_or_end = |at: usize| bytes.get(at).is_none_or(|&b| b == b' ' || b == b'\t');
684 match first {
685 b'>' => true,
686 b'-' | b'*' | b'+' if space_or_end(1) => true,
687 b'-' | b'*' | b'_' => crate::utils::thematic_break::is_thematic_break(text),
688 b'0'..=b'9' => {
689 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
690 digits <= 9 && matches!(bytes.get(digits), Some(b'.' | b')')) && space_or_end(digits + 1)
691 }
692 b'#' => {
693 let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
694 hashes <= 6 && bytes.get(hashes).is_none_or(|&b| b == b' ')
695 }
696 b'`' | b'~' | b':' => bytes.iter().take_while(|&&b| b == first).count() >= 3,
697 b'<' => {
698 crate::utils::html_block::opens_untagged_html_block(text)
699 || crate::utils::html_block::parse_html_block_start(text).is_some_and(|(tag, _)| {
700 !crate::utils::html_block::NON_SPEC_BLOCK_ELEMENTS.contains(&tag.as_str())
701 })
702 }
703 b'[' => text
704 .strip_prefix("[^")
705 .and_then(|rest| rest.split_once("]:"))
706 .is_some_and(|(label, _)| {
707 !label.is_empty() && !label.contains(|c: char| c.is_whitespace() || c == ']')
708 }),
709 _ => false,
710 }
711 }
712
713 fn strip_list_continuation_indent(line: &str, expected_indent: usize) -> &str {
716 let bytes = line.as_bytes();
717 let mut spaces = 0;
718
719 for &b in bytes {
720 if b == b' ' {
721 spaces += 1;
722 } else if b == b'\t' {
723 spaces = (spaces / 4 + 1) * 4;
725 } else {
726 break;
727 }
728
729 if spaces >= expected_indent {
730 break;
731 }
732 }
733
734 let strip_count = spaces.min(expected_indent).min(line.len());
736 let mut byte_count = 0;
738 let mut counted_spaces = 0;
739 for &b in bytes {
740 if counted_spaces >= strip_count {
741 break;
742 }
743 if b == b' ' {
744 counted_spaces += 1;
745 byte_count += 1;
746 } else if b == b'\t' {
747 counted_spaces = (counted_spaces / 4 + 1) * 4;
748 byte_count += 1;
749 } else {
750 break;
751 }
752 }
753
754 &line[byte_count..]
755 }
756
757 pub fn find_table_blocks(content: &str, ctx: &crate::lint_context::LintContext) -> Vec<TableBlock> {
760 Self::find_table_blocks_with_code_info(
761 content,
762 &ctx.code_blocks,
763 &ctx.code_spans(),
764 ctx.html_comment_ranges(),
765 ctx.flavor,
766 &[],
767 )
768 }
769
770 pub fn count_cells(row: &str) -> usize {
772 Self::count_cells_with_flavor(row, crate::config::MarkdownFlavor::Standard)
773 }
774
775 pub fn count_cells_with_flavor(row: &str, flavor: crate::config::MarkdownFlavor) -> usize {
782 let (_, content) = Self::extract_blockquote_prefix(row);
784 Self::split_table_row_with_flavor(content, flavor).len()
785 }
786
787 fn count_preceding_backslashes(chars: &[char], pos: usize) -> usize {
789 let mut count = 0;
790 let mut k = pos;
791 while k > 0 {
792 k -= 1;
793 if chars[k] == '\\' {
794 count += 1;
795 } else {
796 break;
797 }
798 }
799 count
800 }
801
802 fn inline_code_spans(chars: &[char]) -> Vec<(usize, usize)> {
815 let mut spans = Vec::new();
816 let mut i = 0;
817
818 while i < chars.len() {
819 if chars[i] != '`' {
820 i += 1;
821 continue;
822 }
823
824 if Self::count_preceding_backslashes(chars, i) % 2 != 0 {
826 i += 1;
827 continue;
828 }
829
830 let start = i;
832 let mut backtick_count = 0;
833 while i < chars.len() && chars[i] == '`' {
834 backtick_count += 1;
835 i += 1;
836 }
837
838 let mut j = i;
842 while j < chars.len() {
843 if chars[j] == '`' {
844 let mut close_count = 0;
845 while j < chars.len() && chars[j] == '`' {
846 close_count += 1;
847 j += 1;
848 }
849
850 if close_count == backtick_count {
851 spans.push((start, j));
852 i = j;
853 break;
854 }
855 } else {
857 j += 1;
858 }
859 }
860 }
863
864 spans
865 }
866
867 pub fn mask_pipes_in_inline_code(text: &str) -> String {
872 if !text.contains('`') {
873 return text.to_string();
874 }
875
876 let chars: Vec<char> = text.chars().collect();
877 let spans = Self::inline_code_spans(&chars);
878 if spans.is_empty() {
879 return text.to_string();
880 }
881
882 let mut result = String::with_capacity(text.len());
883 let mut cursor = 0;
884 for (start, end) in spans {
885 result.extend(chars[cursor..start].iter());
886 for &ch in &chars[start..end] {
889 result.push(if ch == '|' { '_' } else { ch });
890 }
891 cursor = end;
892 }
893 result.extend(chars[cursor..].iter());
894
895 result
896 }
897
898 pub fn mask_pipes_in_wikilinks(text: &str) -> String {
916 if !text.contains("[[") || !text.contains('|') {
919 return text.to_string();
920 }
921
922 let chars: Vec<char> = text.chars().collect();
923 let code_spans = Self::inline_code_spans(&chars);
924 let code_span_at = |pos: usize| code_spans.iter().find(|&&(s, e)| pos >= s && pos < e).copied();
925
926 let mut result = String::with_capacity(text.len());
927 let mut i = 0;
928
929 while i < chars.len() {
930 if let Some((_, end)) = code_span_at(i) {
932 result.extend(chars[i..end].iter());
933 i = end;
934 continue;
935 }
936
937 if chars[i] == '['
938 && i + 1 < chars.len()
939 && chars[i + 1] == '['
940 && let Some(close) = Self::wikilink_close(&chars, &code_spans, i)
941 {
942 result.push_str("[[");
943 for &ch in &chars[i + 2..close] {
944 if ch == '|' {
945 result.push('_'); } else {
947 result.push(ch);
948 }
949 }
950 result.push_str("]]");
951 i = close + 2;
952 continue;
953 }
954
955 result.push(chars[i]);
956 i += 1;
957 }
958
959 result
960 }
961
962 fn wikilink_close(chars: &[char], code_spans: &[(usize, usize)], open: usize) -> Option<usize> {
968 let mut j = open + 2;
969 let mut first_pipe = None;
970
971 while j + 1 < chars.len() {
972 if let Some(&(_, end)) = code_spans.iter().find(|&&(s, _)| s == j) {
973 j = end;
974 continue;
975 }
976
977 if chars[j] == ']' && chars[j + 1] == ']' {
978 if let Some(pipe) = first_pipe
982 && chars[open + 2..pipe].iter().all(|c| c.is_whitespace())
983 {
984 return None;
985 }
986 return Some(j);
987 }
988
989 if chars[j] == '[' && chars[j + 1] == '[' {
991 return None;
992 }
993
994 if chars[j] == '|' && first_pipe.is_none() {
995 first_pipe = Some(j);
996 }
997
998 j += 1;
999 }
1000
1001 None
1002 }
1003
1004 pub(crate) fn pairs_backslashes(flavor: crate::config::MarkdownFlavor) -> bool {
1011 flavor == crate::config::MarkdownFlavor::MkDocs
1012 }
1013
1014 pub fn mask_escaped_pipes(text: &str, flavor: crate::config::MarkdownFlavor) -> String {
1026 let pairs = Self::pairs_backslashes(flavor);
1027 let mut result = String::with_capacity(text.len());
1028 let mut chars = text.chars().peekable();
1029 let mut previous = None;
1030
1031 while let Some(ch) = chars.next() {
1032 if ch == '\\' && pairs && chars.peek() == Some(&'\\') {
1033 result.push_str("\\\\");
1035 chars.next();
1036 previous = None;
1037 continue;
1038 }
1039 result.push(if ch == '|' && previous == Some('\\') { '_' } else { ch });
1040 previous = Some(ch);
1041 }
1042
1043 result
1044 }
1045
1046 pub fn split_table_row_with_flavor(row: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
1053 let trimmed = row.trim_matches([' ', '\t', '\r']);
1055
1056 if !trimmed.contains('|') {
1057 return Vec::new();
1058 }
1059
1060 let masked = Self::mask_escaped_pipes(trimmed, flavor);
1062
1063 let mut final_masked = Self::mask_pipes_in_inline_code(&masked);
1065
1066 if flavor == crate::config::MarkdownFlavor::Obsidian {
1069 final_masked = Self::mask_pipes_in_wikilinks(&final_masked);
1070 }
1071
1072 let has_leading = final_masked.starts_with('|');
1073 let has_trailing = final_masked.ends_with('|');
1074
1075 let mut masked_content = final_masked.as_str();
1076 let mut orig_content = trimmed;
1077
1078 if has_leading {
1079 masked_content = &masked_content[1..];
1080 orig_content = &orig_content[1..];
1081 }
1082
1083 let stripped_trailing = has_trailing && !masked_content.is_empty();
1085 if stripped_trailing {
1086 masked_content = &masked_content[..masked_content.len() - 1];
1087 orig_content = &orig_content[..orig_content.len() - 1];
1088 }
1089
1090 if masked_content.is_empty() {
1092 if stripped_trailing {
1093 return vec![String::new()];
1095 } else {
1096 return Vec::new();
1098 }
1099 }
1100
1101 let masked_parts: Vec<&str> = masked_content.split('|').collect();
1102 let mut cells = Vec::new();
1103 let mut pos = 0;
1104
1105 for masked_cell in masked_parts {
1106 let cell_len = masked_cell.len();
1107 let orig_cell = if pos + cell_len <= orig_content.len() {
1108 &orig_content[pos..pos + cell_len]
1109 } else {
1110 masked_cell
1111 };
1112 cells.push(orig_cell.to_string());
1113 pos += cell_len + 1; }
1115
1116 cells
1117 }
1118
1119 pub fn split_table_row(row: &str) -> Vec<String> {
1121 Self::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Standard)
1122 }
1123
1124 pub(crate) fn has_unescaped_trailing_pipe(line: &str, flavor: crate::config::MarkdownFlavor) -> bool {
1126 let bytes = line.trim_end_matches([' ', '\t', '\r']).as_bytes();
1127 if bytes.last() != Some(&b'|') {
1128 return false;
1129 }
1130 let backslashes = bytes[..bytes.len() - 1]
1131 .iter()
1132 .rev()
1133 .take_while(|&&byte| byte == b'\\')
1134 .count();
1135 if Self::pairs_backslashes(flavor) {
1136 backslashes % 2 == 0
1137 } else {
1138 backslashes == 0
1139 }
1140 }
1141
1142 pub fn determine_pipe_style(line: &str) -> Option<&'static str> {
1147 Self::determine_pipe_style_with_flavor(line, crate::config::MarkdownFlavor::Standard)
1148 }
1149
1150 pub fn determine_pipe_style_with_flavor(line: &str, flavor: crate::config::MarkdownFlavor) -> Option<&'static str> {
1153 let content = strip_blockquote_prefix(line);
1155 let trimmed = content.trim_matches([' ', '\t', '\r']);
1156 if !trimmed.contains('|') {
1157 return None;
1158 }
1159
1160 let has_leading = trimmed.starts_with('|');
1161 let has_trailing = Self::has_unescaped_trailing_pipe(trimmed, flavor);
1162
1163 match (has_leading, has_trailing) {
1164 (true, true) => Some("leading_and_trailing"),
1165 (true, false) => Some("leading_only"),
1166 (false, true) => Some("trailing_only"),
1167 (false, false) => Some("no_leading_or_trailing"),
1168 }
1169 }
1170
1171 pub fn extract_blockquote_prefix(line: &str) -> (&str, &str) {
1176 let bytes = line.as_bytes();
1178 let mut pos = 0;
1179
1180 while pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
1182 pos += 1;
1183 }
1184
1185 if pos >= bytes.len() || bytes[pos] != b'>' {
1187 return ("", line);
1188 }
1189
1190 while pos < bytes.len() {
1192 if bytes[pos] == b'>' {
1193 pos += 1;
1194 if pos < bytes.len() && bytes[pos] == b' ' {
1196 pos += 1;
1197 }
1198 } else if bytes[pos] == b' ' || bytes[pos] == b'\t' {
1199 pos += 1;
1200 } else {
1201 break;
1202 }
1203 }
1204
1205 (&line[..pos], &line[pos..])
1207 }
1208
1209 fn extract_nested_list_prefix(line: &str) -> (&str, &str, usize, Vec<usize>) {
1214 let mut offset = 0;
1215 let mut levels = Vec::new();
1216 let mut column = 0;
1217 let mut content_column = 0;
1218 loop {
1219 let rest = &line[offset..];
1220 if crate::utils::thematic_break::is_thematic_break(rest) {
1223 break;
1224 }
1225 let (prefix, content, indent) = Self::extract_list_prefix(rest);
1226 if prefix.is_empty() {
1227 break;
1228 }
1229 content_column = column + indent;
1230 levels.push(content_column);
1231 column = content_column;
1232 offset += prefix.len();
1233 if content.trim().is_empty() {
1234 break;
1235 }
1236 }
1237 (&line[..offset], &line[offset..], content_column, levels)
1238 }
1239
1240 pub fn extract_list_prefix(line: &str) -> (&str, &str, usize) {
1255 let bytes = line.as_bytes();
1256
1257 let leading_spaces = bytes.iter().take_while(|&&b| b == b' ' || b == b'\t').count();
1259 let mut pos = leading_spaces;
1260
1261 if pos >= bytes.len() {
1262 return ("", line, 0);
1263 }
1264
1265 if matches!(bytes[pos], b'-' | b'*' | b'+') {
1267 pos += 1;
1268
1269 if pos >= bytes.len() || bytes[pos] == b' ' || bytes[pos] == b'\t' {
1271 let marker_end = pos;
1272 if pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
1274 pos += 1;
1275 }
1276 let content_indent = Self::content_indent(line, marker_end);
1277 return (&line[..pos], &line[pos..], content_indent);
1278 }
1279 return ("", line, 0);
1281 }
1282
1283 if bytes[pos].is_ascii_digit() {
1285 let digit_start = pos;
1286 while pos < bytes.len() && bytes[pos].is_ascii_digit() {
1287 pos += 1;
1288 }
1289
1290 if pos > digit_start && pos < bytes.len() {
1292 if bytes[pos] == b'.' || bytes[pos] == b')' {
1294 pos += 1;
1295 if pos >= bytes.len() || bytes[pos] == b' ' || bytes[pos] == b'\t' {
1296 let marker_end = pos;
1297 if pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
1299 pos += 1;
1300 }
1301 let content_indent = Self::content_indent(line, marker_end);
1302 return (&line[..pos], &line[pos..], content_indent);
1303 }
1304 }
1305 }
1306 }
1307
1308 ("", line, 0)
1309 }
1310
1311 fn content_indent(line: &str, marker_end: usize) -> usize {
1315 let advance = |column: usize, byte: u8| match byte {
1316 b'\t' => (column / 4 + 1) * 4,
1317 _ => column + 1,
1318 };
1319 let marker_column = line.as_bytes()[..marker_end]
1320 .iter()
1321 .fold(0, |column, &b| advance(column, b));
1322 let rest = &line[marker_end..];
1323 if rest.trim().is_empty() {
1324 return marker_column + 1;
1325 }
1326 let content_column = rest
1327 .bytes()
1328 .take_while(|&b| b == b' ' || b == b'\t')
1329 .fold(marker_column, advance);
1330 match content_column - marker_column {
1331 1..=4 => content_column,
1332 _ => marker_column + 1,
1334 }
1335 }
1336
1337 pub fn extract_table_row_content<'a>(line: &'a str, table_block: &TableBlock, line_index: usize) -> &'a str {
1342 let (_, after_blockquote) = Self::extract_blockquote_prefix(line);
1344
1345 if let Some(ref list_ctx) = table_block.list_context {
1347 if line_index == 0 {
1348 after_blockquote
1350 .strip_prefix(&list_ctx.list_prefix)
1351 .unwrap_or_else(|| Self::extract_list_prefix(after_blockquote).1)
1352 } else {
1353 Self::strip_list_continuation_indent(after_blockquote, list_ctx.content_indent)
1355 }
1356 } else if line_index == 0 {
1357 &line[table_block
1361 .header_prefix_len
1362 .unwrap_or_else(|| header_container_prefix_len(line, 0, &[]))..]
1363 } else {
1364 after_blockquote
1365 }
1366 }
1367
1368 pub fn row_prefix<'a>(line: &'a str, header: &str, table_block: &TableBlock, line_index: usize) -> &'a str {
1372 let content = Self::extract_table_row_content(line, table_block, line_index);
1373 let prefix = &line[..line.len() - content.len()];
1374 let header_has_markers = Self::extract_table_row_content(header, table_block, 0).len() < header.len();
1375 if prefix.is_empty() && (header_has_markers || Self::is_container_indented(header, table_block)) {
1376 &line[..line.len() - line.trim_start_matches([' ', '\t']).len()]
1381 } else {
1382 prefix
1383 }
1384 }
1385
1386 pub fn is_container_indented(header: &str, table_block: &TableBlock) -> bool {
1391 if table_block.list_context.is_some() {
1392 return false;
1393 }
1394 let columns = header
1395 .bytes()
1396 .take_while(|&byte| byte == b' ' || byte == b'\t')
1397 .fold(0, |column, byte| {
1398 if byte == b'\t' {
1399 (column / 4 + 1) * 4
1400 } else {
1401 column + 1
1402 }
1403 });
1404 columns >= 4
1405 }
1406
1407 pub fn header_is_indented_as_code(header: &str, delimiter: &str, table_block: &TableBlock) -> bool {
1412 let columns = |text: &str| -> usize {
1413 text.bytes()
1414 .take_while(|&byte| byte == b' ' || byte == b'\t')
1415 .fold(0, |column, byte| {
1416 if byte == b'\t' {
1417 (column / 4 + 1) * 4
1418 } else {
1419 column + 1
1420 }
1421 })
1422 };
1423 let list_indent = table_block.list_context.as_ref().map_or(0, |list| list.content_indent);
1424 let inside_container = |line: &str| {
1425 let text = parse_blockquote_prefix(line).map_or(line, |quote| quote.content);
1426 columns(text).saturating_sub(list_indent)
1427 };
1428 inside_container(header) >= 4 && inside_container(delimiter) < 4
1429 }
1430
1431 pub fn is_list_item_with_table_row(line: &str, flavor: crate::config::MarkdownFlavor) -> bool {
1434 let (prefix, content, _) = Self::extract_list_prefix(line);
1435 if prefix.is_empty() {
1436 return false;
1437 }
1438
1439 let trimmed = content.trim();
1442 if !trimmed.starts_with('|') {
1443 return false;
1444 }
1445
1446 Self::is_potential_table_row_content(content, flavor)
1448 }
1449
1450 fn is_potential_table_row_content(content: &str, flavor: crate::config::MarkdownFlavor) -> bool {
1452 Self::is_potential_table_row_with_flavor(content, flavor)
1453 }
1454}
1455
1456#[cfg(test)]
1457mod tests {
1458 #[test]
1459 fn test_bare_list_marker_content_starts_one_column_past_the_marker() {
1460 assert_eq!(TableUtils::extract_list_prefix("-"), ("-", "", 2));
1461 assert_eq!(TableUtils::extract_list_prefix("1."), ("1.", "", 3));
1462 assert_eq!(TableUtils::extract_list_prefix(" - "), (" - ", "", 4));
1463 assert_eq!(TableUtils::extract_list_prefix("- x"), ("- ", "x", 2));
1464 assert_eq!(TableUtils::extract_list_prefix("10. x"), ("10. ", "x", 4));
1465 }
1466
1467 #[test]
1468 fn test_list_content_starts_after_up_to_four_spaces_following_the_marker() {
1469 assert_eq!(TableUtils::extract_list_prefix("- x").2, 4);
1470 assert_eq!(TableUtils::extract_list_prefix("1. x").2, 4);
1471 assert_eq!(TableUtils::extract_list_prefix("- x").2, 5);
1472 assert_eq!(TableUtils::extract_list_prefix("- x").2, 2);
1474 assert_eq!(TableUtils::extract_list_prefix(" - x").2, 6);
1475 assert_eq!(TableUtils::extract_list_prefix("-\tx").2, 4);
1476 assert_eq!(TableUtils::extract_list_prefix("1.\tx").2, 4);
1477 assert_eq!(TableUtils::extract_list_prefix("10.\tx").2, 4);
1478 }
1479
1480 use super::*;
1481 use crate::lint_context::LintContext;
1482
1483 #[test]
1484 fn context_table_layout_preserves_mixed_terminator_offsets() {
1485 for newline in ["\n", "\r\n"] {
1488 let source = format!(
1489 "é🦀\r\n\n```md{newline}| hidden |{newline}| --- |{newline}```{newline}\n\
1490 > | a | b |\r\n> | --- | --- |\n> | x\r | y |\r\n"
1491 );
1492 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1493 assert_eq!(ctx.table_blocks.len(), 1, "{source:?}");
1494 let table = &ctx.table_blocks[0];
1495 assert_eq!((table.header_line, table.delimiter_line, table.end_line), (7, 8, 9));
1496 assert_eq!(table.content_lines, vec![9]);
1497 }
1498 }
1499
1500 #[test]
1501 fn test_is_potential_table_row() {
1502 assert!(TableUtils::is_potential_table_row("| Header 1 | Header 2 |"));
1504 assert!(TableUtils::is_potential_table_row("| Cell 1 | Cell 2 |"));
1505 assert!(TableUtils::is_potential_table_row("Cell 1 | Cell 2"));
1506 assert!(TableUtils::is_potential_table_row("| Cell |")); assert!(TableUtils::is_potential_table_row("| A | B | C | D | E |"));
1510
1511 assert!(TableUtils::is_potential_table_row(" | Indented | Table | "));
1513 assert!(TableUtils::is_potential_table_row("| Spaces | Around |"));
1514
1515 assert!(!TableUtils::is_potential_table_row("- List item"));
1517 assert!(!TableUtils::is_potential_table_row("* Another list"));
1518 assert!(!TableUtils::is_potential_table_row("+ Plus list"));
1519 assert!(!TableUtils::is_potential_table_row("Regular text"));
1520 assert!(!TableUtils::is_potential_table_row(""));
1521 assert!(!TableUtils::is_potential_table_row(" "));
1522
1523 assert!(!TableUtils::is_potential_table_row("`code with | pipe`"));
1525 assert!(!TableUtils::is_potential_table_row("``multiple | backticks``"));
1526 assert!(!TableUtils::is_potential_table_row("Use ``a|b`` in prose"));
1527 assert!(TableUtils::is_potential_table_row("| `fenced` | Uses ``` and ~~~ |"));
1528 assert!(TableUtils::is_potential_table_row("`!foo && bar` | `(!foo) && bar`"));
1529 assert!(!TableUtils::is_potential_table_row("`echo a | sed 's/a/b/'`"));
1530
1531 assert!(!TableUtils::is_potential_table_row(
1533 "Text with $|S|$ math notation here."
1534 ));
1535 assert!(!TableUtils::is_potential_table_row(
1536 "Size $|S|$ was even, check $|T|$ too."
1537 ));
1538 assert!(!TableUtils::is_potential_table_row("Display $$|A| + |B|$$ math here."));
1539 assert!(TableUtils::is_potential_table_row("| cell with $|S|$ math |"));
1541 assert!(TableUtils::is_potential_table_row("$a$ | $b$"));
1543 assert!(TableUtils::is_potential_table_row("$f(x)$ and $g(x)$ | result"));
1544 assert!(!TableUtils::is_potential_table_row("$5 | $10"));
1548
1549 assert!(!TableUtils::is_potential_table_row("Just one |"));
1551 assert!(!TableUtils::is_potential_table_row("| Just one"));
1552
1553 let long_cell = "a".repeat(150);
1555 assert!(TableUtils::is_potential_table_row(&format!("| {long_cell} | b |")));
1556
1557 assert!(!TableUtils::is_potential_table_row("| Cell with\nnewline | Other |"));
1559
1560 assert!(TableUtils::is_potential_table_row("|||")); assert!(TableUtils::is_potential_table_row("||||")); assert!(TableUtils::is_potential_table_row("| | |")); }
1565
1566 #[test]
1567 fn test_list_items_with_pipes_not_table_rows() {
1568 assert!(!TableUtils::is_potential_table_row("1. Item with | pipe"));
1570 assert!(!TableUtils::is_potential_table_row("10. Item with | pipe"));
1571 assert!(!TableUtils::is_potential_table_row("999. Item with | pipe"));
1572 assert!(!TableUtils::is_potential_table_row("1) Item with | pipe"));
1573 assert!(!TableUtils::is_potential_table_row("10) Item with | pipe"));
1574
1575 assert!(!TableUtils::is_potential_table_row("-\tItem with | pipe"));
1577 assert!(!TableUtils::is_potential_table_row("*\tItem with | pipe"));
1578 assert!(!TableUtils::is_potential_table_row("+\tItem with | pipe"));
1579
1580 assert!(!TableUtils::is_potential_table_row(" - Indented | pipe"));
1582 assert!(!TableUtils::is_potential_table_row(" * Deep indent | pipe"));
1583 assert!(!TableUtils::is_potential_table_row(" 1. Ordered indent | pipe"));
1584
1585 assert!(!TableUtils::is_potential_table_row("- [ ] task | pipe"));
1587 assert!(!TableUtils::is_potential_table_row("- [x] done | pipe"));
1588
1589 assert!(!TableUtils::is_potential_table_row("1. foo | bar | baz"));
1591 assert!(!TableUtils::is_potential_table_row("- alpha | beta | gamma"));
1592
1593 assert!(TableUtils::is_potential_table_row("| cell | cell |"));
1595 assert!(TableUtils::is_potential_table_row("cell | cell"));
1596 assert!(TableUtils::is_potential_table_row("| Header | Header |"));
1597 }
1598
1599 #[test]
1600 fn test_atx_headings_with_pipes_not_table_rows() {
1601 assert!(!TableUtils::is_potential_table_row("# Heading | with pipe"));
1603 assert!(!TableUtils::is_potential_table_row("## Heading | with pipe"));
1604 assert!(!TableUtils::is_potential_table_row("### Heading | with pipe"));
1605 assert!(!TableUtils::is_potential_table_row("#### Heading | with pipe"));
1606 assert!(!TableUtils::is_potential_table_row("##### Heading | with pipe"));
1607 assert!(!TableUtils::is_potential_table_row("###### Heading | with pipe"));
1608
1609 assert!(!TableUtils::is_potential_table_row("### col1 | col2 | col3"));
1611 assert!(!TableUtils::is_potential_table_row("## a|b|c"));
1612
1613 assert!(!TableUtils::is_potential_table_row("#\tHeading | pipe"));
1615 assert!(!TableUtils::is_potential_table_row("##\tHeading | pipe"));
1616
1617 assert!(!TableUtils::is_potential_table_row("# |"));
1619 assert!(!TableUtils::is_potential_table_row("## |"));
1620
1621 assert!(!TableUtils::is_potential_table_row(" ## Heading | pipe"));
1623 assert!(!TableUtils::is_potential_table_row(" ### Heading | pipe"));
1624
1625 assert!(!TableUtils::is_potential_table_row("#### ®aAA|ᯗ"));
1627
1628 assert!(TableUtils::is_potential_table_row("####### text | pipe"));
1632
1633 assert!(TableUtils::is_potential_table_row("#nospc|pipe"));
1635
1636 assert!(TableUtils::is_potential_table_row("| # Header | Value |"));
1638 assert!(TableUtils::is_potential_table_row("text | #tag"));
1639 }
1640
1641 #[test]
1642 fn test_is_delimiter_row() {
1643 assert!(TableUtils::is_delimiter_row("|---|---|"));
1645 assert!(TableUtils::is_delimiter_row("| --- | --- |"));
1646 assert!(TableUtils::is_delimiter_row("|:---|---:|"));
1647 assert!(TableUtils::is_delimiter_row("|:---:|:---:|"));
1648
1649 assert!(TableUtils::is_delimiter_row("|-|--|"));
1651 assert!(TableUtils::is_delimiter_row("|-------|----------|"));
1652
1653 assert!(TableUtils::is_delimiter_row("| --- | --- |"));
1655 assert!(TableUtils::is_delimiter_row("| :--- | ---: |"));
1656
1657 assert!(TableUtils::is_delimiter_row("|---|---|---|---|"));
1659
1660 assert!(TableUtils::is_delimiter_row("--- | ---"));
1662 assert!(TableUtils::is_delimiter_row(":--- | ---:"));
1663
1664 assert!(!TableUtils::is_delimiter_row("| Header | Header |"));
1666 assert!(!TableUtils::is_delimiter_row("Regular text"));
1667 assert!(!TableUtils::is_delimiter_row(""));
1668 assert!(!TableUtils::is_delimiter_row("|||"));
1669 assert!(!TableUtils::is_delimiter_row("| | |"));
1670
1671 assert!(!TableUtils::is_delimiter_row("| : | : |"));
1673 assert!(!TableUtils::is_delimiter_row("| | |"));
1674
1675 assert!(!TableUtils::is_delimiter_row("| --- | text |"));
1677 assert!(!TableUtils::is_delimiter_row("| abc | --- |"));
1678 }
1679
1680 #[test]
1681 fn test_count_cells() {
1682 assert_eq!(TableUtils::count_cells("| Cell 1 | Cell 2 | Cell 3 |"), 3);
1684 assert_eq!(TableUtils::count_cells("Cell 1 | Cell 2 | Cell 3"), 3);
1685 assert_eq!(TableUtils::count_cells("| Cell 1 | Cell 2"), 2);
1686 assert_eq!(TableUtils::count_cells("Cell 1 | Cell 2 |"), 2);
1687
1688 assert_eq!(TableUtils::count_cells("| Cell |"), 1);
1690 assert_eq!(TableUtils::count_cells("Cell"), 0); assert_eq!(TableUtils::count_cells("| | | |"), 3);
1694 assert_eq!(TableUtils::count_cells("| | | |"), 3);
1695
1696 assert_eq!(TableUtils::count_cells("| A | B | C | D | E | F |"), 6);
1698
1699 assert_eq!(TableUtils::count_cells("||"), 1); assert_eq!(TableUtils::count_cells("|||"), 2); assert_eq!(TableUtils::count_cells("Regular text"), 0);
1705 assert_eq!(TableUtils::count_cells(""), 0);
1706 assert_eq!(TableUtils::count_cells(" "), 0);
1707
1708 assert_eq!(TableUtils::count_cells(" | A | B | "), 2);
1710 assert_eq!(TableUtils::count_cells("| A | B |"), 2);
1711 }
1712
1713 #[test]
1714 fn test_count_cells_with_escaped_pipes() {
1715 assert_eq!(TableUtils::count_cells("| Challenge | Solution |"), 2);
1720 assert_eq!(TableUtils::count_cells("| A | B | C |"), 3);
1721 assert_eq!(TableUtils::count_cells("| One | Two |"), 2);
1722
1723 assert_eq!(TableUtils::count_cells(r"| Command | echo \| grep |"), 2);
1725 assert_eq!(TableUtils::count_cells(r"| A | B \| C |"), 2); assert_eq!(TableUtils::count_cells(r"| Command | `echo \| grep` |"), 2);
1729
1730 for backslashes in 1..=4 {
1732 let row = format!("| A | B {}| C |", "\\".repeat(backslashes));
1733 assert_eq!(TableUtils::count_cells(&row), 2, "{row}");
1734 }
1735 assert_eq!(TableUtils::count_cells(r"| A | `B \\| C` |"), 2);
1737
1738 assert_eq!(TableUtils::count_cells("| Command | `echo | grep` |"), 2);
1740 assert_eq!(TableUtils::count_cells("| `code | one` | `code | two` |"), 2);
1741 assert_eq!(TableUtils::count_cells("| `single|pipe` |"), 1);
1742
1743 assert_eq!(TableUtils::count_cells(r"| Hour formats | `^([0-1]?\d|2[0-3])` |"), 2);
1745 assert_eq!(TableUtils::count_cells(r"| Hour formats | `^([0-1]?\d\|2[0-3])` |"), 2);
1747 }
1748
1749 #[test]
1750 fn test_determine_pipe_style() {
1751 assert_eq!(
1753 TableUtils::determine_pipe_style("| Cell 1 | Cell 2 |"),
1754 Some("leading_and_trailing")
1755 );
1756 assert_eq!(
1757 TableUtils::determine_pipe_style("| Cell 1 | Cell 2"),
1758 Some("leading_only")
1759 );
1760 assert_eq!(
1761 TableUtils::determine_pipe_style("Cell 1 | Cell 2 |"),
1762 Some("trailing_only")
1763 );
1764 assert_eq!(
1765 TableUtils::determine_pipe_style("Cell 1 | Cell 2"),
1766 Some("no_leading_or_trailing")
1767 );
1768
1769 assert_eq!(
1771 TableUtils::determine_pipe_style(" | Cell 1 | Cell 2 | "),
1772 Some("leading_and_trailing")
1773 );
1774 assert_eq!(
1775 TableUtils::determine_pipe_style(" | Cell 1 | Cell 2 "),
1776 Some("leading_only")
1777 );
1778
1779 assert_eq!(TableUtils::determine_pipe_style("Regular text"), None);
1781 assert_eq!(TableUtils::determine_pipe_style(""), None);
1782 assert_eq!(TableUtils::determine_pipe_style(" "), None);
1783
1784 assert_eq!(TableUtils::determine_pipe_style("|"), Some("leading_and_trailing"));
1786 assert_eq!(TableUtils::determine_pipe_style("| Cell"), Some("leading_only"));
1787 assert_eq!(TableUtils::determine_pipe_style("Cell |"), Some("trailing_only"));
1788 }
1789
1790 #[test]
1791 fn test_determine_pipe_style_distinguishes_escaped_cell_pipes() {
1792 for backslashes in 0..=6 {
1793 let row = format!("A | B {}|", "\\".repeat(backslashes));
1794 let expected = if backslashes == 0 {
1795 "trailing_only"
1796 } else {
1797 "no_leading_or_trailing"
1798 };
1799 for prefix in ["", "> ", ">> "] {
1800 assert_eq!(
1801 TableUtils::determine_pipe_style(&format!("{prefix}{row}")),
1802 Some(expected)
1803 );
1804 }
1805 let mkdocs_expected = if backslashes % 2 == 0 {
1807 "trailing_only"
1808 } else {
1809 "no_leading_or_trailing"
1810 };
1811 assert_eq!(
1812 TableUtils::determine_pipe_style_with_flavor(&row, crate::config::MarkdownFlavor::MkDocs),
1813 Some(mkdocs_expected)
1814 );
1815 }
1816 }
1817
1818 #[test]
1819 fn test_find_table_blocks_simple() {
1820 let content = "| Header 1 | Header 2 |
1821|-----------|-----------|
1822| Cell 1 | Cell 2 |
1823| Cell 3 | Cell 4 |";
1824
1825 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1826
1827 let tables = TableUtils::find_table_blocks(content, &ctx);
1828 assert_eq!(tables.len(), 1);
1829
1830 let table = &tables[0];
1831 assert_eq!(table.start_line, 0);
1832 assert_eq!(table.end_line, 3);
1833 assert_eq!(table.header_line, 0);
1834 assert_eq!(table.delimiter_line, 1);
1835 assert_eq!(table.content_lines, vec![2, 3]);
1836 }
1837
1838 #[test]
1839 fn test_find_table_blocks_multiple() {
1840 let content = "Some text
1841
1842| Table 1 | Col A |
1843|----------|-------|
1844| Data 1 | Val 1 |
1845
1846More text
1847
1848| Table 2 | Col 2 |
1849|----------|-------|
1850| Data 2 | Data |";
1851
1852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1853
1854 let tables = TableUtils::find_table_blocks(content, &ctx);
1855 assert_eq!(tables.len(), 2);
1856
1857 assert_eq!(tables[0].start_line, 2);
1859 assert_eq!(tables[0].end_line, 4);
1860 assert_eq!(tables[0].header_line, 2);
1861 assert_eq!(tables[0].delimiter_line, 3);
1862 assert_eq!(tables[0].content_lines, vec![4]);
1863
1864 assert_eq!(tables[1].start_line, 8);
1866 assert_eq!(tables[1].end_line, 10);
1867 assert_eq!(tables[1].header_line, 8);
1868 assert_eq!(tables[1].delimiter_line, 9);
1869 assert_eq!(tables[1].content_lines, vec![10]);
1870 }
1871
1872 #[test]
1873 fn test_find_table_blocks_no_content_rows() {
1874 let content = "| Header 1 | Header 2 |
1875|-----------|-----------|
1876
1877Next paragraph";
1878
1879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1880
1881 let tables = TableUtils::find_table_blocks(content, &ctx);
1882 assert_eq!(tables.len(), 1);
1883
1884 let table = &tables[0];
1885 assert_eq!(table.start_line, 0);
1886 assert_eq!(table.end_line, 1); assert_eq!(table.content_lines.len(), 0);
1888 }
1889
1890 #[test]
1891 fn test_find_table_blocks_in_code_block() {
1892 let content = "```
1893| Not | A | Table |
1894|-----|---|-------|
1895| In | Code | Block |
1896```
1897
1898| Real | Table |
1899|------|-------|
1900| Data | Here |";
1901
1902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1903
1904 let tables = TableUtils::find_table_blocks(content, &ctx);
1905 assert_eq!(tables.len(), 1); let table = &tables[0];
1908 assert_eq!(table.header_line, 6);
1909 assert_eq!(table.delimiter_line, 7);
1910 }
1911
1912 #[test]
1913 fn test_find_table_blocks_no_tables() {
1914 let content = "Just regular text
1915No tables here
1916- List item with | pipe
1917* Another list item";
1918
1919 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1920
1921 let tables = TableUtils::find_table_blocks(content, &ctx);
1922 assert_eq!(tables.len(), 0);
1923 }
1924
1925 #[test]
1926 fn test_find_table_blocks_malformed() {
1927 let content = "| Header without delimiter |
1928| This looks like table |
1929But no delimiter row
1930
1931| Proper | Table |
1932|---------|-------|
1933| Data | Here |";
1934
1935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1936
1937 let tables = TableUtils::find_table_blocks(content, &ctx);
1938 assert_eq!(tables.len(), 1); assert_eq!(tables[0].header_line, 4);
1940 }
1941
1942 #[test]
1943 fn test_find_table_blocks_keeps_obsidian_wikilink_prose_out_of_the_table() {
1944 let content = "| A | B |\n| - | - |\n| x | y |\n[[Foo|bar]] is a note.\n";
1945
1946 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1947 let blocks = TableUtils::find_table_blocks(content, &ctx);
1948 assert_eq!(blocks.len(), 1, "Expected one table, got {blocks:?}");
1949 assert_eq!(
1950 blocks[0].end_line, 2,
1951 "Wikilink prose was absorbed into the table: {:?}",
1952 blocks[0]
1953 );
1954 assert_eq!(blocks[0].content_lines, vec![2]);
1955
1956 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1959 let blocks = TableUtils::find_table_blocks(content, &ctx);
1960 assert_eq!(blocks.len(), 1, "Expected one table, got {blocks:?}");
1961 assert_eq!(
1962 blocks[0].end_line, 3,
1963 "GFM should still read the pipe as a delimiter: {:?}",
1964 blocks[0]
1965 );
1966 }
1967
1968 #[test]
1969 fn test_edge_cases() {
1970 assert!(!TableUtils::is_potential_table_row(""));
1972 assert!(!TableUtils::is_delimiter_row(""));
1973 assert_eq!(TableUtils::count_cells(""), 0);
1974 assert_eq!(TableUtils::determine_pipe_style(""), None);
1975
1976 assert!(!TableUtils::is_potential_table_row(" "));
1978 assert!(!TableUtils::is_delimiter_row(" "));
1979 assert_eq!(TableUtils::count_cells(" "), 0);
1980 assert_eq!(TableUtils::determine_pipe_style(" "), None);
1981
1982 assert!(!TableUtils::is_potential_table_row("|"));
1984 assert!(!TableUtils::is_delimiter_row("|"));
1985 assert_eq!(TableUtils::count_cells("|"), 0); let long_single = format!("| {} |", "a".repeat(200));
1990 assert!(TableUtils::is_potential_table_row(&long_single)); let long_multi = format!("| {} | {} |", "a".repeat(200), "b".repeat(200));
1993 assert!(TableUtils::is_potential_table_row(&long_multi)); assert!(TableUtils::is_potential_table_row("| 你好 | 世界 |"));
1997 assert!(TableUtils::is_potential_table_row("| émoji | 🎉 |"));
1998 assert_eq!(TableUtils::count_cells("| 你好 | 世界 |"), 2);
1999 }
2000
2001 #[test]
2002 fn test_table_block_struct() {
2003 let block = TableBlock {
2004 start_line: 0,
2005 end_line: 5,
2006 header_line: 0,
2007 delimiter_line: 1,
2008 content_lines: vec![2, 3, 4, 5],
2009 list_context: None,
2010 parser_confirmed: true,
2011 repairable: false,
2012 header_prefix_len: None,
2013 };
2014
2015 let debug_str = format!("{block:?}");
2017 assert!(debug_str.contains("TableBlock"));
2018 assert!(debug_str.contains("start_line: 0"));
2019
2020 let cloned = block.clone();
2022 assert_eq!(cloned.start_line, block.start_line);
2023 assert_eq!(cloned.end_line, block.end_line);
2024 assert_eq!(cloned.header_line, block.header_line);
2025 assert_eq!(cloned.delimiter_line, block.delimiter_line);
2026 assert_eq!(cloned.content_lines, block.content_lines);
2027 assert!(cloned.list_context.is_none());
2028 }
2029
2030 #[test]
2031 fn test_split_table_row() {
2032 let cells = TableUtils::split_table_row("| Cell 1 | Cell 2 | Cell 3 |");
2034 assert_eq!(cells.len(), 3);
2035 assert_eq!(cells[0].trim(), "Cell 1");
2036 assert_eq!(cells[1].trim(), "Cell 2");
2037 assert_eq!(cells[2].trim(), "Cell 3");
2038
2039 let cells = TableUtils::split_table_row("| Cell 1 | Cell 2");
2041 assert_eq!(cells.len(), 2);
2042
2043 let cells = TableUtils::split_table_row("| | | |");
2045 assert_eq!(cells.len(), 3);
2046
2047 let cells = TableUtils::split_table_row("| Cell |");
2049 assert_eq!(cells.len(), 1);
2050 assert_eq!(cells[0].trim(), "Cell");
2051
2052 let cells = TableUtils::split_table_row("No pipes here");
2054 assert_eq!(cells.len(), 0);
2055 }
2056
2057 #[test]
2058 fn test_split_table_row_with_escaped_pipes() {
2059 let cells = TableUtils::split_table_row(r"| A | B \| C |");
2061 assert_eq!(cells.len(), 2);
2062 assert!(cells[1].contains(r"\|"), "Escaped pipe should be in cell content");
2063
2064 let cells = TableUtils::split_table_row(r"| A | B \\| C |");
2066 assert_eq!(cells.len(), 2);
2067 }
2068
2069 #[test]
2070 fn test_split_table_row_with_flavor_mkdocs() {
2071 let cells =
2073 TableUtils::split_table_row_with_flavor("| Type | `x | y` |", crate::config::MarkdownFlavor::MkDocs);
2074 assert_eq!(cells.len(), 2);
2075 assert!(
2076 cells[1].contains("`x | y`"),
2077 "Inline code with pipe should be single cell in MkDocs flavor"
2078 );
2079
2080 let cells =
2082 TableUtils::split_table_row_with_flavor("| Type | `a | b | c` |", crate::config::MarkdownFlavor::MkDocs);
2083 assert_eq!(cells.len(), 2);
2084 assert!(cells[1].contains("`a | b | c`"));
2085 }
2086
2087 #[test]
2088 fn test_split_table_row_with_flavor_standard() {
2089 let cells =
2091 TableUtils::split_table_row_with_flavor("| Type | `x | y` |", crate::config::MarkdownFlavor::Standard);
2092 assert_eq!(
2093 cells.len(),
2094 2,
2095 "Pipes in code spans should not be cell delimiters, got {cells:?}"
2096 );
2097 assert!(
2098 cells[1].contains("`x | y`"),
2099 "Inline code with pipe should be single cell"
2100 );
2101 }
2102
2103 #[test]
2104 fn test_split_table_row_with_flavor_obsidian_wikilink() {
2105 let cells = TableUtils::split_table_row_with_flavor(
2108 "| Alice | [[White Rabbit|the Rabbit]] |",
2109 crate::config::MarkdownFlavor::Obsidian,
2110 );
2111 assert_eq!(cells.len(), 2, "Aliased wikilink should be one cell, got {cells:?}");
2112 assert!(cells[1].contains("[[White Rabbit|the Rabbit]]"));
2113
2114 let cells = TableUtils::split_table_row_with_flavor(
2116 "| Guests | [[Mad Hatter|the Hatter]] and [[March Hare|the Hare]] |",
2117 crate::config::MarkdownFlavor::Obsidian,
2118 );
2119 assert_eq!(
2120 cells.len(),
2121 2,
2122 "Two aliased wikilinks should be one cell, got {cells:?}"
2123 );
2124
2125 let cells = TableUtils::split_table_row_with_flavor(
2127 "| Alice | [[Cheshire Cat]] |",
2128 crate::config::MarkdownFlavor::Obsidian,
2129 );
2130 assert_eq!(cells.len(), 2);
2131
2132 let cells = TableUtils::split_table_row_with_flavor(
2134 "| Alice | [[White Rabbit | curious |",
2135 crate::config::MarkdownFlavor::Obsidian,
2136 );
2137 assert_eq!(
2138 cells.len(),
2139 3,
2140 "Unterminated wikilink should not mask pipes, got {cells:?}"
2141 );
2142 }
2143
2144 #[test]
2145 fn test_split_table_row_wikilink_only_for_obsidian() {
2146 for flavor in [
2148 crate::config::MarkdownFlavor::Standard,
2149 crate::config::MarkdownFlavor::MkDocs,
2150 ] {
2151 let cells = TableUtils::split_table_row_with_flavor("| Alice | [[White Rabbit|the Rabbit]] |", flavor);
2152 assert_eq!(
2153 cells.len(),
2154 3,
2155 "{flavor:?} should treat the wikilink pipe as a delimiter, got {cells:?}"
2156 );
2157 }
2158 }
2159
2160 #[test]
2161 fn test_mask_pipes_in_wikilinks_preserves_length() {
2162 for text in [
2164 "| Alice | [[White Rabbit|the Rabbit]] |",
2165 "| [[Mad Hatter|Hatter]] | [[March Hare|Hare]] |",
2166 "no wikilink here | just a pipe",
2167 "[[unterminated | still text",
2168 ] {
2169 assert_eq!(
2170 TableUtils::mask_pipes_in_wikilinks(text).len(),
2171 text.len(),
2172 "masking changed length of {text:?}"
2173 );
2174 }
2175 }
2176
2177 #[test]
2178 fn test_wikilink_brackets_in_a_code_span_stay_prose() {
2179 for row in [
2183 "| `[[` | mid | `]]` |",
2184 "| `[[Target` | mid | `Label]]` |",
2185 "| a | `[[` | b | `]]` |",
2186 "| `[[` and Target|Label]] |",
2189 ] {
2190 let obsidian = TableUtils::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Obsidian);
2191 let standard = TableUtils::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Standard);
2192 assert_eq!(
2193 obsidian, standard,
2194 "Obsidian disagreed with GFM about {row:?}: {obsidian:?} vs {standard:?}"
2195 );
2196 }
2197
2198 let cells = TableUtils::split_table_row_with_flavor(
2201 "| [[Target|Label with `a|b` inside]] |",
2202 crate::config::MarkdownFlavor::Obsidian,
2203 );
2204 assert_eq!(
2205 cells.len(),
2206 1,
2207 "Wikilink holding a code span should be one cell, got {cells:?}"
2208 );
2209
2210 let cells = TableUtils::split_table_row_with_flavor(
2213 "| [[Target | alias `]]` | tail |",
2214 crate::config::MarkdownFlavor::Obsidian,
2215 );
2216 assert_eq!(
2217 cells.len(),
2218 3,
2219 "A closer hidden in code should not close the link, got {cells:?}"
2220 );
2221 }
2222
2223 #[test]
2224 fn test_wikilink_with_a_blank_target_stays_prose() {
2225 for row in [
2228 "| [[ | ]] |",
2229 "| [[|Label]] |",
2230 "| starts [[ | ends ]] here |",
2231 "| [[\t|\tx]] |",
2232 ] {
2233 let obsidian = TableUtils::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Obsidian);
2234 let standard = TableUtils::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Standard);
2235 assert_eq!(
2236 obsidian, standard,
2237 "Obsidian disagreed with GFM about {row:?}: {obsidian:?} vs {standard:?}"
2238 );
2239 }
2240
2241 let cells = TableUtils::split_table_row_with_flavor("| [[x | y]] |", crate::config::MarkdownFlavor::Obsidian);
2243 assert_eq!(cells.len(), 1, "A named target should be one cell, got {cells:?}");
2244 }
2245
2246 #[test]
2247 fn test_inline_code_spans_agree_with_pipe_masking() {
2248 let text = "``x | y and `c|d`";
2251 let chars: Vec<char> = text.chars().collect();
2252 let spans = TableUtils::inline_code_spans(&chars);
2253 assert_eq!(spans.len(), 1, "Only the matched pair is a code span, got {spans:?}");
2254 let (start, end) = spans[0];
2255 assert_eq!(
2256 chars[start..end].iter().collect::<String>(),
2257 "`c|d`",
2258 "The span should start at the run that closes, not the unmatched opener"
2259 );
2260
2261 assert_eq!(TableUtils::mask_pipes_in_inline_code(text), "``x | y and `c_d`");
2263 }
2264
2265 #[test]
2268 fn test_extract_blockquote_prefix_no_blockquote() {
2269 let (prefix, content) = TableUtils::extract_blockquote_prefix("| H1 | H2 |");
2271 assert_eq!(prefix, "");
2272 assert_eq!(content, "| H1 | H2 |");
2273 }
2274
2275 #[test]
2276 fn test_extract_blockquote_prefix_single_level() {
2277 let (prefix, content) = TableUtils::extract_blockquote_prefix("> | H1 | H2 |");
2279 assert_eq!(prefix, "> ");
2280 assert_eq!(content, "| H1 | H2 |");
2281 }
2282
2283 #[test]
2284 fn test_extract_blockquote_prefix_double_level() {
2285 let (prefix, content) = TableUtils::extract_blockquote_prefix(">> | H1 | H2 |");
2287 assert_eq!(prefix, ">> ");
2288 assert_eq!(content, "| H1 | H2 |");
2289 }
2290
2291 #[test]
2292 fn test_extract_blockquote_prefix_triple_level() {
2293 let (prefix, content) = TableUtils::extract_blockquote_prefix(">>> | H1 | H2 |");
2295 assert_eq!(prefix, ">>> ");
2296 assert_eq!(content, "| H1 | H2 |");
2297 }
2298
2299 #[test]
2300 fn test_extract_blockquote_prefix_with_spaces() {
2301 let (prefix, content) = TableUtils::extract_blockquote_prefix("> > | H1 | H2 |");
2303 assert_eq!(prefix, "> > ");
2304 assert_eq!(content, "| H1 | H2 |");
2305 }
2306
2307 #[test]
2308 fn test_extract_blockquote_prefix_indented() {
2309 let (prefix, content) = TableUtils::extract_blockquote_prefix(" > | H1 | H2 |");
2311 assert_eq!(prefix, " > ");
2312 assert_eq!(content, "| H1 | H2 |");
2313 }
2314
2315 #[test]
2316 fn test_extract_blockquote_prefix_no_space_after() {
2317 let (prefix, content) = TableUtils::extract_blockquote_prefix(">| H1 | H2 |");
2319 assert_eq!(prefix, ">");
2320 assert_eq!(content, "| H1 | H2 |");
2321 }
2322
2323 #[test]
2324 fn test_determine_pipe_style_in_blockquote() {
2325 assert_eq!(
2327 TableUtils::determine_pipe_style("> | H1 | H2 |"),
2328 Some("leading_and_trailing")
2329 );
2330 assert_eq!(
2331 TableUtils::determine_pipe_style("> H1 | H2"),
2332 Some("no_leading_or_trailing")
2333 );
2334 assert_eq!(
2335 TableUtils::determine_pipe_style(">> | H1 | H2 |"),
2336 Some("leading_and_trailing")
2337 );
2338 assert_eq!(TableUtils::determine_pipe_style(">>> | H1 | H2"), Some("leading_only"));
2339 }
2340
2341 #[test]
2342 fn test_list_table_delimiter_requires_indentation() {
2343 let content = "- List item with | pipe\n|---|---|\n| Cell 1 | Cell 2 |";
2348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2349 let tables = TableUtils::find_table_blocks(content, &ctx);
2350
2351 assert_eq!(tables.len(), 1, "Should find exactly one table");
2354 assert!(
2355 tables[0].list_context.is_none(),
2356 "Should NOT have list context since delimiter has no indentation"
2357 );
2358 }
2359
2360 #[test]
2361 fn test_list_table_with_properly_indented_delimiter() {
2362 let content = "- | Header 1 | Header 2 |\n |----------|----------|\n | Cell 1 | Cell 2 |";
2365 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2366 let tables = TableUtils::find_table_blocks(content, &ctx);
2367
2368 assert_eq!(tables.len(), 1, "Should find exactly one table");
2370 assert_eq!(tables[0].start_line, 0, "Table should start at list item line");
2371 assert!(
2372 tables[0].list_context.is_some(),
2373 "Should be a list table since delimiter is properly indented"
2374 );
2375 }
2376
2377 #[test]
2378 fn test_mask_pipes_in_inline_code_regular_backticks() {
2379 let result = TableUtils::mask_pipes_in_inline_code("| `code | here` |");
2381 assert_eq!(result, "| `code _ here` |");
2382 }
2383
2384 #[test]
2385 fn test_mask_pipes_in_inline_code_escaped_backtick_not_code_span() {
2386 let result = TableUtils::mask_pipes_in_inline_code(r"| \`not code | still pipe\` |");
2389 assert_eq!(result, r"| \`not code | still pipe\` |");
2390 }
2391
2392 #[test]
2393 fn test_mask_pipes_in_inline_code_escaped_backslash_then_backtick() {
2394 let result = TableUtils::mask_pipes_in_inline_code(r"| \\`real code | masked\\` |");
2397 assert_eq!(result, r"| \\`real code _ masked\\` |");
2400 }
2401
2402 #[test]
2403 fn test_mask_pipes_in_inline_code_triple_backslash_before_backtick() {
2404 let result = TableUtils::mask_pipes_in_inline_code(r"| \\\`not code | pipe\\\` |");
2406 assert_eq!(result, r"| \\\`not code | pipe\\\` |");
2407 }
2408
2409 #[test]
2410 fn test_mask_pipes_in_inline_code_four_backslashes_before_backtick() {
2411 let result = TableUtils::mask_pipes_in_inline_code(r"| \\\\`code | here\\\\` |");
2413 assert_eq!(result, r"| \\\\`code _ here\\\\` |");
2414 }
2415
2416 #[test]
2417 fn test_mask_pipes_in_inline_code_no_backslash() {
2418 let result = TableUtils::mask_pipes_in_inline_code("before `a | b` after");
2420 assert_eq!(result, "before `a _ b` after");
2421 }
2422
2423 #[test]
2424 fn test_mask_pipes_in_inline_code_no_code_span() {
2425 let result = TableUtils::mask_pipes_in_inline_code("| col1 | col2 |");
2427 assert_eq!(result, "| col1 | col2 |");
2428 }
2429
2430 #[test]
2431 fn test_mask_pipes_in_inline_code_backslash_before_closing_backtick() {
2432 let result = TableUtils::mask_pipes_in_inline_code(r"| `foo\` | bar |");
2441 assert_eq!(result, r"| `foo\` | bar |");
2444 }
2445
2446 #[test]
2447 fn test_mask_pipes_in_inline_code_backslash_literal_with_pipe_inside() {
2448 let result = TableUtils::mask_pipes_in_inline_code(r"| `a\|b` | col2 |");
2452 assert_eq!(result, r"| `a\_b` | col2 |");
2453 }
2454
2455 #[test]
2456 fn test_count_preceding_backslashes() {
2457 let chars: Vec<char> = r"abc\\\`def".chars().collect();
2458 assert_eq!(TableUtils::count_preceding_backslashes(&chars, 6), 3);
2460
2461 let chars2: Vec<char> = r"abc\\`def".chars().collect();
2462 assert_eq!(TableUtils::count_preceding_backslashes(&chars2, 5), 2);
2464
2465 let chars3: Vec<char> = "`def".chars().collect();
2466 assert_eq!(TableUtils::count_preceding_backslashes(&chars3, 0), 0);
2468 }
2469
2470 #[test]
2471 fn test_has_unescaped_pipe_backslash_literal_in_code_span() {
2472 assert!(TableUtils::has_unescaped_pipe_outside_spans(r"`foo\` | bar", false));
2475
2476 assert!(TableUtils::has_unescaped_pipe_outside_spans(r"\`foo | bar\`", false));
2478
2479 assert!(!TableUtils::has_unescaped_pipe_outside_spans(r"`foo | bar`", false));
2481 }
2482
2483 #[test]
2484 fn test_table_after_code_span_detected() {
2485 use crate::config::MarkdownFlavor;
2486
2487 let content = "`code`\n\n| A | B |\n|---|---|\n| 1 | 2 |\n";
2488 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2489 assert!(!ctx.table_blocks.is_empty(), "Table after code span should be detected");
2490 }
2491
2492 #[test]
2493 fn test_table_inside_html_comment_not_detected() {
2494 use crate::config::MarkdownFlavor;
2495
2496 let content = "<!--\n| A | B |\n|---|---|\n| 1 | 2 |\n-->\n";
2497 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2498 assert!(
2499 ctx.table_blocks.is_empty(),
2500 "Table inside HTML comment should not be detected"
2501 );
2502 }
2503}