Skip to main content

rumdl_lib/utils/
table_utils.rs

1/// Shared table detection and processing utilities for markdown linting rules
2///
3/// This module provides optimized table detection and processing functionality
4/// that can be shared across multiple table-related rules (MD055, MD056, MD058).
5use super::blockquote::strip_blockquote_prefix;
6
7/// Represents a table block in the document
8#[derive(Debug, Clone)]
9pub struct TableBlock {
10    pub start_line: usize,
11    pub end_line: usize,
12    pub header_line: usize,
13    pub delimiter_line: usize,
14    pub content_lines: Vec<usize>,
15    /// If the table is inside a list item, this contains:
16    /// - The list marker prefix for the header line (e.g., "- ", "1. ")
17    /// - The content indent (number of spaces for continuation lines)
18    pub list_context: Option<ListTableContext>,
19}
20
21/// Context information for tables inside list items
22#[derive(Debug, Clone)]
23pub struct ListTableContext {
24    /// The list marker prefix including any leading whitespace (e.g., "- ", "  1. ")
25    pub list_prefix: String,
26    /// Number of spaces for continuation lines to align with content
27    pub content_indent: usize,
28}
29
30/// Shared table detection utilities
31pub struct TableUtils;
32
33impl TableUtils {
34    /// Returns true if the line has at least one unescaped pipe separator outside inline code and
35    /// math spans.
36    ///
37    /// Skips pipes inside backtick code spans (`` `...` ``) and dollar-sign math spans (`$...$`,
38    /// `$$...$$`) to avoid false positives from prose like `` `echo a | sed 's/a/b/'` `` or math
39    /// like `$|S|$` (absolute value notation).
40    ///
41    /// Note: a bare `$` that opens a span without a matching closing `$` keeps the scanner in
42    /// math mode for the rest of the line, suppressing any subsequent pipes. This is conservative
43    /// and means that `$5 | $10`-style price comparisons (without outer pipes) are not detected
44    /// as table separators — an accepted trade-off to avoid false positives from real math.
45    fn has_unescaped_pipe_outside_spans(text: &str) -> bool {
46        let chars: Vec<char> = text.chars().collect();
47        let mut i = 0;
48        let mut in_code = false;
49        let mut code_delim_len = 0usize;
50        let mut in_math = false;
51        let mut math_delim_len = 0usize;
52
53        while i < chars.len() {
54            let ch = chars[i];
55
56            if ch == '\\' && !in_code && !in_math {
57                // Skip escaped character (only outside code and math spans —
58                // backslashes are literal inside code spans per CommonMark).
59                i += if i + 1 < chars.len() { 2 } else { 1 };
60                continue;
61            }
62
63            if ch == '`' && !in_math {
64                let mut run = 1usize;
65                while i + run < chars.len() && chars[i + run] == '`' {
66                    run += 1;
67                }
68
69                if in_code {
70                    if run == code_delim_len {
71                        in_code = false;
72                        code_delim_len = 0;
73                    }
74                    // Mismatched backtick run inside a code span: consumed but span stays open.
75                } else {
76                    in_code = true;
77                    code_delim_len = run;
78                }
79
80                i += run;
81                continue;
82            }
83
84            if ch == '$' && !in_code {
85                let mut run = 1usize;
86                while i + run < chars.len() && chars[i + run] == '$' {
87                    run += 1;
88                }
89
90                if in_math {
91                    if run == math_delim_len {
92                        in_math = false;
93                        math_delim_len = 0;
94                    }
95                    // Mismatched $-run inside a math span: consumed but span stays open.
96                } else {
97                    in_math = true;
98                    math_delim_len = run;
99                }
100
101                i += run;
102                continue;
103            }
104
105            if ch == '|' && !in_code && !in_math {
106                return true;
107            }
108
109            i += 1;
110        }
111
112        false
113    }
114
115    /// Check if a line looks like a potential table row
116    /// Flavor-aware form of [`Self::is_potential_table_row`]
117    ///
118    /// Under Obsidian, a line whose only pipes sit inside wikilink aliases is
119    /// prose rather than a table row, so those pipes are masked before the check.
120    pub fn is_potential_table_row_with_flavor(line: &str, flavor: crate::config::MarkdownFlavor) -> bool {
121        // Masking is the identity without an opener to mask inside, and this runs on
122        // every line of the document.
123        if flavor == crate::config::MarkdownFlavor::Obsidian && line.contains("[[") {
124            return Self::is_potential_table_row(&Self::mask_pipes_in_wikilinks(line));
125        }
126        Self::is_potential_table_row(line)
127    }
128
129    pub fn is_potential_table_row(line: &str) -> bool {
130        let trimmed = line.trim();
131        if trimmed.is_empty() || !trimmed.contains('|') {
132            return false;
133        }
134
135        // Skip lines that are clearly not table rows
136        // Unordered list items with space or tab after marker
137        if trimmed.starts_with("- ")
138            || trimmed.starts_with("* ")
139            || trimmed.starts_with("+ ")
140            || trimmed.starts_with("-\t")
141            || trimmed.starts_with("*\t")
142            || trimmed.starts_with("+\t")
143        {
144            return false;
145        }
146
147        // Skip ordered list items: digits followed by . or ) then space/tab
148        if let Some(first_non_digit) = trimmed.find(|c: char| !c.is_ascii_digit())
149            && first_non_digit > 0
150        {
151            let after_digits = &trimmed[first_non_digit..];
152            if after_digits.starts_with(". ")
153                || after_digits.starts_with(".\t")
154                || after_digits.starts_with(") ")
155                || after_digits.starts_with(")\t")
156            {
157                return false;
158            }
159        }
160
161        // Skip ATX headings (# through ######)
162        if trimmed.starts_with('#') {
163            let hash_count = trimmed.bytes().take_while(|&b| b == b'#').count();
164            if hash_count <= 6 {
165                let after_hashes = &trimmed[hash_count..];
166                if after_hashes.is_empty() || after_hashes.starts_with(' ') || after_hashes.starts_with('\t') {
167                    return false;
168                }
169            }
170        }
171
172        // For rows without explicit outer pipes, require a real separator outside
173        // inline code and math spans to avoid prose/command false positives.
174        let has_outer_pipes = trimmed.starts_with('|') && trimmed.ends_with('|');
175        if !has_outer_pipes && !Self::has_unescaped_pipe_outside_spans(trimmed) {
176            return false;
177        }
178
179        // Must have at least 2 parts when split by |
180        let parts: Vec<&str> = trimmed.split('|').collect();
181        if parts.len() < 2 {
182            return false;
183        }
184
185        // Check if it looks like a table row by having reasonable content between pipes
186        let mut valid_parts = 0;
187        let mut total_non_empty_parts = 0;
188
189        for part in &parts {
190            let part_trimmed = part.trim();
191            // Skip empty parts (from leading/trailing pipes)
192            if part_trimmed.is_empty() {
193                continue;
194            }
195            total_non_empty_parts += 1;
196
197            // Count parts that look like table cells (reasonable content, no newlines)
198            if !part_trimmed.contains('\n') {
199                valid_parts += 1;
200            }
201        }
202
203        // Check if all non-empty parts are valid (no newlines)
204        if total_non_empty_parts > 0 && valid_parts != total_non_empty_parts {
205            // Some cells contain newlines, not a valid table row
206            return false;
207        }
208
209        // GFM allows tables with all empty cells (e.g., |||)
210        // These are valid if they have proper table formatting (leading and trailing pipes)
211        if total_non_empty_parts == 0 {
212            // Empty cells are only valid with proper pipe formatting
213            return trimmed.starts_with('|') && trimmed.ends_with('|') && parts.len() >= 3;
214        }
215
216        // GFM allows single-column tables, so >= 1 valid part is enough
217        // when the line has proper table formatting (pipes)
218        if trimmed.starts_with('|') && trimmed.ends_with('|') {
219            // Properly formatted table row with pipes on both ends
220            valid_parts >= 1
221        } else {
222            // For rows without proper pipe formatting, require at least 2 cells
223            valid_parts >= 2
224        }
225    }
226
227    /// Check if a line is a table delimiter row (e.g., |---|---|)
228    pub fn is_delimiter_row(line: &str) -> bool {
229        let trimmed = line.trim();
230        if !trimmed.contains('|') || !trimmed.contains('-') {
231            return false;
232        }
233
234        // Split by pipes and check each part
235        let parts: Vec<&str> = trimmed.split('|').collect();
236        let mut valid_delimiter_parts = 0;
237        let mut total_non_empty_parts = 0;
238
239        for part in &parts {
240            let part_trimmed = part.trim();
241            if part_trimmed.is_empty() {
242                continue; // Skip empty parts from leading/trailing pipes
243            }
244
245            total_non_empty_parts += 1;
246
247            // Check if this part looks like a delimiter (contains dashes and optionally colons)
248            if part_trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace()) && part_trimmed.contains('-') {
249                valid_delimiter_parts += 1;
250            }
251        }
252
253        // All non-empty parts must be valid delimiters, and there must be at least one
254        total_non_empty_parts > 0 && valid_delimiter_parts == total_non_empty_parts
255    }
256
257    /// Find all table blocks in the content with optimized detection
258    /// This version accepts code_blocks and code_spans directly for use during LintContext construction
259    pub fn find_table_blocks_with_code_info(
260        content: &str,
261        code_blocks: &[(usize, usize)],
262        code_spans: &[crate::lint_context::CodeSpan],
263        html_comment_ranges: &[crate::utils::skip_context::ByteRange],
264        flavor: crate::config::MarkdownFlavor,
265    ) -> Vec<TableBlock> {
266        let lines: Vec<&str> = content.lines().collect();
267        let mut tables = Vec::new();
268        let mut i = 0;
269
270        // Pre-compute line positions for efficient code block checking.
271        // `str::lines()` strips the trailing `\r` from CRLF lines, so advancing by
272        // `line.len() + 1` undercounts by one byte per CRLF line; the positions then
273        // drift out of sync with the raw byte offsets that `code_blocks` uses, which
274        // can misclassify a later table header as being inside a code block. Walk the
275        // actual line terminator (`\n` or `\r\n`) from the raw bytes instead.
276        let mut line_positions = Vec::with_capacity(lines.len());
277        let content_bytes = content.as_bytes();
278        let mut pos = 0;
279        for line in &lines {
280            line_positions.push(pos);
281            pos += line.len();
282            if content_bytes.get(pos) == Some(&b'\r') {
283                pos += 1;
284            }
285            if content_bytes.get(pos) == Some(&b'\n') {
286                pos += 1;
287            }
288        }
289
290        // Stack of active list content indents for continuation table tracking.
291        // Supports nested lists: when a child list is seen, we push; when we
292        // dedent past a level, we pop back to the enclosing list.
293        let mut list_indent_stack: Vec<usize> = Vec::new();
294
295        while i < lines.len() {
296            // Skip lines in code blocks, code spans, or HTML comments
297            let line_start = line_positions[i];
298            let in_code =
299                crate::utils::code_block_utils::CodeBlockUtils::is_in_code_block_or_span(code_blocks, line_start) || {
300                    // Binary search on sorted code spans
301                    let idx = code_spans.partition_point(|span| span.byte_offset <= line_start);
302                    idx > 0 && line_start < code_spans[idx - 1].byte_end
303                };
304            let in_html_comment = {
305                // Binary search on sorted HTML comment ranges
306                let idx = html_comment_ranges.partition_point(|range| range.start <= line_start);
307                idx > 0 && line_start < html_comment_ranges[idx - 1].end
308            };
309
310            if in_code || in_html_comment {
311                i += 1;
312                continue;
313            }
314
315            // Strip blockquote prefix for table detection
316            let line_content = strip_blockquote_prefix(lines[i]);
317
318            // Update active list tracking
319            let (list_prefix, list_content, content_indent) = Self::extract_list_prefix(line_content);
320            if !list_prefix.is_empty() {
321                // Line has a list marker. Pop any deeper/equal levels, then push this one.
322                while list_indent_stack.last().is_some_and(|&top| top >= content_indent) {
323                    list_indent_stack.pop();
324                }
325                list_indent_stack.push(content_indent);
326            } else if !line_content.trim().is_empty() {
327                // Non-blank line without a marker: pop any levels we've dedented past
328                let leading = line_content.len() - line_content.trim_start().len();
329                while list_indent_stack.last().is_some_and(|&top| leading < top) {
330                    list_indent_stack.pop();
331                }
332            }
333            // Blank lines keep the stack unchanged (blank lines don't end list items)
334
335            // Check if this is a list item that contains a table row on the same line,
336            // or a continuation table indented under an active list item
337            let (is_same_line_list_table, effective_content) =
338                if !list_prefix.is_empty() && Self::is_potential_table_row_content(list_content, flavor) {
339                    (true, list_content)
340                } else {
341                    (false, line_content)
342                };
343
344            // Detect continuation list tables: no marker on this line, but indented
345            // under an active list item (e.g., "- Text\n  | h1 | h2 |")
346            let continuation_indent = if !is_same_line_list_table && list_prefix.is_empty() {
347                let leading = line_content.len() - line_content.trim_start().len();
348                // Find the deepest list level this line is indented under
349                list_indent_stack
350                    .iter()
351                    .rev()
352                    .find(|&&indent| leading >= indent)
353                    .copied()
354            } else {
355                None
356            };
357
358            let is_continuation_list_table = continuation_indent.is_some()
359                && {
360                    let indent = continuation_indent.unwrap();
361                    let leading = line_content.len() - line_content.trim_start().len();
362                    // Per CommonMark, 4+ spaces beyond content indent is a code block
363                    leading < indent + 4
364                }
365                && Self::is_potential_table_row_with_flavor(effective_content, flavor);
366
367            let is_any_list_table = is_same_line_list_table || is_continuation_list_table;
368
369            // For continuation list tables, use the matched list indent
370            let effective_content_indent = if is_same_line_list_table {
371                content_indent
372            } else if is_continuation_list_table {
373                continuation_indent.unwrap()
374            } else {
375                0
376            };
377
378            // Look for potential table start
379            if is_any_list_table || Self::is_potential_table_row_with_flavor(effective_content, flavor) {
380                // For list tables (same-line or continuation), check indented continuation lines
381                // For regular tables, check the next line directly
382                let (next_line_content, delimiter_has_valid_indent) = if i + 1 < lines.len() {
383                    let next_raw = strip_blockquote_prefix(lines[i + 1]);
384                    if is_any_list_table {
385                        // Verify the delimiter line has proper indentation
386                        let leading_spaces = next_raw.len() - next_raw.trim_start().len();
387                        if leading_spaces >= effective_content_indent {
388                            // Has proper indentation, strip it and check as delimiter
389                            (
390                                Self::strip_list_continuation_indent(next_raw, effective_content_indent),
391                                true,
392                            )
393                        } else {
394                            // Not enough indentation - not a list table
395                            (next_raw, false)
396                        }
397                    } else {
398                        (next_raw, true)
399                    }
400                } else {
401                    ("", true)
402                };
403
404                // For list tables, only accept if delimiter has valid indentation
405                let effective_is_list_table = is_any_list_table && delimiter_has_valid_indent;
406
407                if i + 1 < lines.len() && Self::is_delimiter_row(next_line_content) {
408                    // Found a table! Find its end
409                    let table_start = i;
410                    let header_line = i;
411                    let delimiter_line = i + 1;
412                    let mut table_end = i + 1; // Include the delimiter row
413                    let mut content_lines = Vec::new();
414
415                    // Continue while we have table rows
416                    let mut j = i + 2;
417                    while j < lines.len() {
418                        let line = lines[j];
419                        // Strip blockquote prefix for checking
420                        let raw_content = strip_blockquote_prefix(line);
421
422                        // For list tables, strip expected indentation
423                        let line_content = if effective_is_list_table {
424                            Self::strip_list_continuation_indent(raw_content, effective_content_indent)
425                        } else {
426                            raw_content
427                        };
428
429                        if line_content.trim().is_empty() {
430                            // Empty line ends the table
431                            break;
432                        }
433
434                        // For list tables, the continuation line must have proper indentation
435                        if effective_is_list_table {
436                            let leading_spaces = raw_content.len() - raw_content.trim_start().len();
437                            if leading_spaces < effective_content_indent {
438                                // Not enough indentation - end of table
439                                break;
440                            }
441                        }
442
443                        if Self::is_potential_table_row_with_flavor(line_content, flavor) {
444                            content_lines.push(j);
445                            table_end = j;
446                            j += 1;
447                        } else {
448                            // Non-table line ends the table
449                            break;
450                        }
451                    }
452
453                    let list_context = if effective_is_list_table {
454                        if is_same_line_list_table {
455                            // Same-line: prefix is the actual list marker (e.g., "- ")
456                            Some(ListTableContext {
457                                list_prefix: list_prefix.to_string(),
458                                content_indent: effective_content_indent,
459                            })
460                        } else {
461                            // Continuation: prefix is the indentation spaces
462                            Some(ListTableContext {
463                                list_prefix: " ".repeat(effective_content_indent),
464                                content_indent: effective_content_indent,
465                            })
466                        }
467                    } else {
468                        None
469                    };
470
471                    tables.push(TableBlock {
472                        start_line: table_start,
473                        end_line: table_end,
474                        header_line,
475                        delimiter_line,
476                        content_lines,
477                        list_context,
478                    });
479                    i = table_end + 1;
480                } else {
481                    i += 1;
482                }
483            } else {
484                i += 1;
485            }
486        }
487
488        tables
489    }
490
491    /// Strip list continuation indentation from a line.
492    /// For lines that are continuations of a list item's content, strip the expected indent.
493    fn strip_list_continuation_indent(line: &str, expected_indent: usize) -> &str {
494        let bytes = line.as_bytes();
495        let mut spaces = 0;
496
497        for &b in bytes {
498            if b == b' ' {
499                spaces += 1;
500            } else if b == b'\t' {
501                // Tab counts as up to 4 spaces, rounding up to next multiple of 4
502                spaces = (spaces / 4 + 1) * 4;
503            } else {
504                break;
505            }
506
507            if spaces >= expected_indent {
508                break;
509            }
510        }
511
512        // Strip at most expected_indent characters
513        let strip_count = spaces.min(expected_indent).min(line.len());
514        // Count actual bytes to strip (handling tabs)
515        let mut byte_count = 0;
516        let mut counted_spaces = 0;
517        for &b in bytes {
518            if counted_spaces >= strip_count {
519                break;
520            }
521            if b == b' ' {
522                counted_spaces += 1;
523                byte_count += 1;
524            } else if b == b'\t' {
525                counted_spaces = (counted_spaces / 4 + 1) * 4;
526                byte_count += 1;
527            } else {
528                break;
529            }
530        }
531
532        &line[byte_count..]
533    }
534
535    /// Find all table blocks in the content with optimized detection
536    /// This is a backward-compatible wrapper that accepts LintContext
537    pub fn find_table_blocks(content: &str, ctx: &crate::lint_context::LintContext) -> Vec<TableBlock> {
538        Self::find_table_blocks_with_code_info(
539            content,
540            &ctx.code_blocks,
541            &ctx.code_spans(),
542            ctx.html_comment_ranges(),
543            ctx.flavor,
544        )
545    }
546
547    /// Count the number of cells in a table row
548    pub fn count_cells(row: &str) -> usize {
549        Self::count_cells_with_flavor(row, crate::config::MarkdownFlavor::Standard)
550    }
551
552    /// Count the number of cells in a table row with flavor-specific behavior
553    ///
554    /// Pipes inside code spans are treated as content, not cell delimiters.
555    ///
556    /// This function strips blockquote prefixes before counting cells, so it works
557    /// correctly for tables inside blockquotes.
558    pub fn count_cells_with_flavor(row: &str, flavor: crate::config::MarkdownFlavor) -> usize {
559        // Strip blockquote prefix if present before counting cells
560        let (_, content) = Self::extract_blockquote_prefix(row);
561        Self::split_table_row_with_flavor(content, flavor).len()
562    }
563
564    /// Count the number of consecutive backslashes immediately preceding `pos` in `chars`.
565    fn count_preceding_backslashes(chars: &[char], pos: usize) -> usize {
566        let mut count = 0;
567        let mut k = pos;
568        while k > 0 {
569            k -= 1;
570            if chars[k] == '\\' {
571                count += 1;
572            } else {
573                break;
574            }
575        }
576        count
577    }
578
579    /// Locate the inline code spans in `chars` as half-open char ranges covering
580    /// the opening delimiter, the content and the closing delimiter.
581    ///
582    /// Backticks preceded by an odd number of backslashes are escaped (literal text)
583    /// and do not open or close code spans. An even number of backslashes means the
584    /// backslashes themselves are escaped, so the backtick is a real delimiter. A run
585    /// of backticks with no matching closing run of the same length is literal text,
586    /// and scanning resumes just after it.
587    ///
588    /// This is the single definition of a code span for table parsing: both pipe
589    /// masking and wikilink detection read it, so they cannot disagree about where
590    /// code starts and ends.
591    fn inline_code_spans(chars: &[char]) -> Vec<(usize, usize)> {
592        let mut spans = Vec::new();
593        let mut i = 0;
594
595        while i < chars.len() {
596            if chars[i] != '`' {
597                i += 1;
598                continue;
599            }
600
601            // A backtick preceded by an odd number of backslashes is escaped
602            if Self::count_preceding_backslashes(chars, i) % 2 != 0 {
603                i += 1;
604                continue;
605            }
606
607            // Count consecutive backticks at start
608            let start = i;
609            let mut backtick_count = 0;
610            while i < chars.len() && chars[i] == '`' {
611                backtick_count += 1;
612                i += 1;
613            }
614
615            // Look for a closing run of exactly the same length. Per CommonMark,
616            // backslash escapes do NOT work inside code spans -- all characters
617            // including backslashes are literal -- so no escape check applies here.
618            let mut j = i;
619            while j < chars.len() {
620                if chars[j] == '`' {
621                    let mut close_count = 0;
622                    while j < chars.len() && chars[j] == '`' {
623                        close_count += 1;
624                        j += 1;
625                    }
626
627                    if close_count == backtick_count {
628                        spans.push((start, j));
629                        i = j;
630                        break;
631                    }
632                    // Run of a different length: keep searching (j is already past it)
633                } else {
634                    j += 1;
635                }
636            }
637            // With no matching closing run the opener is literal text; `i` already
638            // sits just past it, so a later backtick can still open a span.
639        }
640
641        spans
642    }
643
644    /// Mask pipes inside inline code blocks with a placeholder character.
645    ///
646    /// The mask is the same byte width as what it replaces, so offsets into the
647    /// masked string still address the original text.
648    pub fn mask_pipes_in_inline_code(text: &str) -> String {
649        if !text.contains('`') {
650            return text.to_string();
651        }
652
653        let chars: Vec<char> = text.chars().collect();
654        let spans = Self::inline_code_spans(&chars);
655        if spans.is_empty() {
656            return text.to_string();
657        }
658
659        let mut result = String::with_capacity(text.len());
660        let mut cursor = 0;
661        for (start, end) in spans {
662            result.extend(chars[cursor..start].iter());
663            // The delimiters are backticks, so masking every pipe across the whole
664            // span leaves them untouched and only rewrites the content.
665            for &ch in &chars[start..end] {
666                result.push(if ch == '|' { '_' } else { ch });
667            }
668            cursor = end;
669        }
670        result.extend(chars[cursor..].iter());
671
672        result
673    }
674
675    /// Mask pipes inside wikilink aliases for accurate table cell parsing
676    ///
677    /// In Obsidian, `[[Target|Label]]` renders `Label` as a link to `Target`, so
678    /// the pipe separates the two halves of one link rather than two table cells.
679    /// Only a pipe between `[[` and a closing `]]` on the same line is masked.
680    ///
681    /// Two shapes are deliberately left as prose, because reading them as a link
682    /// would merge cells in a table that is already well formed and so would report
683    /// a column-count mismatch against a document that has none:
684    ///
685    /// - Brackets inside an inline code span. Code binds tighter than links, so
686    ///   ``` `[[` | mid | `]]` ``` is three cells of prose, not one cell with a link.
687    /// - A blank link target. `[[ | ]]` and `[[|Label]]` name no note, so the
688    ///   brackets are prose that happens to straddle a cell divider.
689    ///
690    /// The mask is the same byte width as what it replaces, so offsets into the
691    /// masked string still address the original text.
692    pub fn mask_pipes_in_wikilinks(text: &str) -> String {
693        // Without both halves of an opener and a pipe to hide there is nothing to do,
694        // and this runs over every line of an Obsidian document.
695        if !text.contains("[[") || !text.contains('|') {
696            return text.to_string();
697        }
698
699        let chars: Vec<char> = text.chars().collect();
700        let code_spans = Self::inline_code_spans(&chars);
701        let code_span_at = |pos: usize| code_spans.iter().find(|&&(s, e)| pos >= s && pos < e).copied();
702
703        let mut result = String::with_capacity(text.len());
704        let mut i = 0;
705
706        while i < chars.len() {
707            // Copy a code span through untouched; nothing inside it is link syntax.
708            if let Some((_, end)) = code_span_at(i) {
709                result.extend(chars[i..end].iter());
710                i = end;
711                continue;
712            }
713
714            if chars[i] == '['
715                && i + 1 < chars.len()
716                && chars[i + 1] == '['
717                && let Some(close) = Self::wikilink_close(&chars, &code_spans, i)
718            {
719                result.push_str("[[");
720                for &ch in &chars[i + 2..close] {
721                    if ch == '|' {
722                        result.push('_'); // Mask pipe with underscore
723                    } else {
724                        result.push(ch);
725                    }
726                }
727                result.push_str("]]");
728                i = close + 2;
729                continue;
730            }
731
732            result.push(chars[i]);
733            i += 1;
734        }
735
736        result
737    }
738
739    /// Find the `]]` closing the wikilink opened by the `[[` at `open`, or `None`
740    /// when the brackets do not delimit one.
741    ///
742    /// `code_spans` are the ranges from [`Self::inline_code_spans`]; brackets and
743    /// pipes inside one are content, so the scan steps over them whole.
744    fn wikilink_close(chars: &[char], code_spans: &[(usize, usize)], open: usize) -> Option<usize> {
745        let mut j = open + 2;
746        let mut first_pipe = None;
747
748        while j + 1 < chars.len() {
749            if let Some(&(_, end)) = code_spans.iter().find(|&&(s, _)| s == j) {
750                j = end;
751                continue;
752            }
753
754            if chars[j] == ']' && chars[j + 1] == ']' {
755                // The half before the pipe names the note the link points at, so a
756                // blank one means these brackets are prose that happens to straddle
757                // a cell divider rather than a link holding one.
758                if let Some(pipe) = first_pipe
759                    && chars[open + 2..pipe].iter().all(|c| c.is_whitespace())
760                {
761                    return None;
762                }
763                return Some(j);
764            }
765
766            // A wikilink does not span a nested "[["
767            if chars[j] == '[' && chars[j + 1] == '[' {
768                return None;
769            }
770
771            if chars[j] == '|' && first_pipe.is_none() {
772                first_pipe = Some(j);
773            }
774
775            j += 1;
776        }
777
778        None
779    }
780
781    /// Mask escaped pipes for accurate table cell parsing
782    ///
783    /// In GFM tables, escape handling happens BEFORE cell boundary detection:
784    /// - `\|` → escaped pipe → masked (stays as cell content)
785    /// - `\\|` → escaped backslash + pipe → NOT masked (pipe is a delimiter)
786    ///
787    /// This function only handles escaped pipes. Pipes inside inline code spans
788    /// are handled separately by `mask_pipes_in_inline_code`.
789    pub fn mask_pipes_for_table_parsing(text: &str) -> String {
790        let mut result = String::new();
791        let chars: Vec<char> = text.chars().collect();
792        let mut i = 0;
793
794        while i < chars.len() {
795            if chars[i] == '\\' {
796                if i + 1 < chars.len() && chars[i + 1] == '\\' {
797                    // Escaped backslash: \\ → push both and continue
798                    // The next character (if it's a pipe) will be a real delimiter
799                    result.push('\\');
800                    result.push('\\');
801                    i += 2;
802                } else if i + 1 < chars.len() && chars[i + 1] == '|' {
803                    // Escaped pipe: \| → mask the pipe
804                    result.push('\\');
805                    result.push('_'); // Mask the pipe
806                    i += 2;
807                } else {
808                    // Single backslash not followed by \ or | → just push it
809                    result.push(chars[i]);
810                    i += 1;
811                }
812            } else {
813                result.push(chars[i]);
814                i += 1;
815            }
816        }
817
818        result
819    }
820
821    /// Split a table row into individual cell contents with flavor-specific behavior.
822    ///
823    /// Returns a Vec of cell content strings (not trimmed - preserves original spacing).
824    /// This is the foundation for both cell counting and cell content extraction.
825    ///
826    /// Pipes inside code spans are treated as content, not cell delimiters.
827    pub fn split_table_row_with_flavor(row: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
828        let trimmed = row.trim();
829
830        if !trimmed.contains('|') {
831            return Vec::new();
832        }
833
834        // First, mask escaped pipes (same for all flavors)
835        let masked = Self::mask_pipes_for_table_parsing(trimmed);
836
837        // Mask pipes inside inline code for all flavors
838        let mut final_masked = Self::mask_pipes_in_inline_code(&masked);
839
840        // In Obsidian, a pipe inside [[Target|Label]] separates the link from its
841        // alias rather than one cell from the next
842        if flavor == crate::config::MarkdownFlavor::Obsidian {
843            final_masked = Self::mask_pipes_in_wikilinks(&final_masked);
844        }
845
846        let has_leading = final_masked.starts_with('|');
847        let has_trailing = final_masked.ends_with('|');
848
849        let mut masked_content = final_masked.as_str();
850        let mut orig_content = trimmed;
851
852        if has_leading {
853            masked_content = &masked_content[1..];
854            orig_content = &orig_content[1..];
855        }
856
857        // Track whether we actually strip a trailing pipe
858        let stripped_trailing = has_trailing && !masked_content.is_empty();
859        if stripped_trailing {
860            masked_content = &masked_content[..masked_content.len() - 1];
861            orig_content = &orig_content[..orig_content.len() - 1];
862        }
863
864        // Handle edge cases for degenerate inputs
865        if masked_content.is_empty() {
866            if stripped_trailing {
867                // "||" case: two pipes with empty content between = one empty cell
868                return vec![String::new()];
869            } else {
870                // "|" case: single pipe, not a valid table row
871                return Vec::new();
872            }
873        }
874
875        let masked_parts: Vec<&str> = masked_content.split('|').collect();
876        let mut cells = Vec::new();
877        let mut pos = 0;
878
879        for masked_cell in masked_parts {
880            let cell_len = masked_cell.len();
881            let orig_cell = if pos + cell_len <= orig_content.len() {
882                &orig_content[pos..pos + cell_len]
883            } else {
884                masked_cell
885            };
886            cells.push(orig_cell.to_string());
887            pos += cell_len + 1; // +1 for the pipe delimiter
888        }
889
890        cells
891    }
892
893    /// Split a table row into individual cell contents using Standard/GFM behavior.
894    pub fn split_table_row(row: &str) -> Vec<String> {
895        Self::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Standard)
896    }
897
898    /// Determine the pipe style of a table row
899    ///
900    /// Handles tables inside blockquotes by stripping the blockquote prefix
901    /// before analyzing the pipe style.
902    pub fn determine_pipe_style(line: &str) -> Option<&'static str> {
903        // Strip blockquote prefix if present before analyzing pipe style
904        let content = strip_blockquote_prefix(line);
905        let trimmed = content.trim();
906        if !trimmed.contains('|') {
907            return None;
908        }
909
910        let has_leading = trimmed.starts_with('|');
911        let has_trailing = trimmed.ends_with('|');
912
913        match (has_leading, has_trailing) {
914            (true, true) => Some("leading_and_trailing"),
915            (true, false) => Some("leading_only"),
916            (false, true) => Some("trailing_only"),
917            (false, false) => Some("no_leading_or_trailing"),
918        }
919    }
920
921    /// Extract blockquote prefix from a line, returning (prefix, content).
922    ///
923    /// This is useful for stripping the prefix before processing, then restoring it after.
924    /// For example: `"> | H1 | H2 |"` returns `("> ", "| H1 | H2 |")`.
925    pub fn extract_blockquote_prefix(line: &str) -> (&str, &str) {
926        // Find where the actual content starts (after blockquote markers and spaces)
927        let bytes = line.as_bytes();
928        let mut pos = 0;
929
930        // Skip leading whitespace (indent before blockquote marker)
931        while pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
932            pos += 1;
933        }
934
935        // If no blockquote marker, return empty prefix
936        if pos >= bytes.len() || bytes[pos] != b'>' {
937            return ("", line);
938        }
939
940        // Skip all blockquote markers and spaces
941        while pos < bytes.len() {
942            if bytes[pos] == b'>' {
943                pos += 1;
944                // Skip optional space after >
945                if pos < bytes.len() && bytes[pos] == b' ' {
946                    pos += 1;
947                }
948            } else if bytes[pos] == b' ' || bytes[pos] == b'\t' {
949                pos += 1;
950            } else {
951                break;
952            }
953        }
954
955        // Split at the position where content starts
956        (&line[..pos], &line[pos..])
957    }
958
959    /// Extract list marker prefix from a line, returning (prefix, content, content_indent).
960    ///
961    /// This handles unordered list markers (`-`, `*`, `+`) and ordered list markers (`1.`, `10)`, etc.)
962    /// Returns:
963    /// - prefix: The list marker including any leading whitespace and trailing space (e.g., "- ", "  1. ")
964    /// - content: The content after the list marker
965    /// - content_indent: The number of spaces needed for continuation lines to align with content
966    ///
967    /// For example:
968    /// - `"- | H1 | H2 |"` returns `("- ", "| H1 | H2 |", 2)`
969    /// - `"1. | H1 | H2 |"` returns `("1. ", "| H1 | H2 |", 3)`
970    /// - `"  - table"` returns `("  - ", "table", 4)`
971    ///
972    /// Returns `("", line, 0)` if the line doesn't start with a list marker.
973    pub fn extract_list_prefix(line: &str) -> (&str, &str, usize) {
974        let bytes = line.as_bytes();
975
976        // Skip leading whitespace
977        let leading_spaces = bytes.iter().take_while(|&&b| b == b' ' || b == b'\t').count();
978        let mut pos = leading_spaces;
979
980        if pos >= bytes.len() {
981            return ("", line, 0);
982        }
983
984        // Check for unordered list marker: -, *, +
985        if matches!(bytes[pos], b'-' | b'*' | b'+') {
986            pos += 1;
987
988            // Must be followed by space or tab (or end of line for marker-only lines)
989            if pos >= bytes.len() || bytes[pos] == b' ' || bytes[pos] == b'\t' {
990                // Skip the space after marker if present
991                if pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
992                    pos += 1;
993                }
994                let content_indent = pos;
995                return (&line[..pos], &line[pos..], content_indent);
996            }
997            // Not a list marker (e.g., "-word" or "--")
998            return ("", line, 0);
999        }
1000
1001        // Check for ordered list marker: digits followed by . or ) then space
1002        if bytes[pos].is_ascii_digit() {
1003            let digit_start = pos;
1004            while pos < bytes.len() && bytes[pos].is_ascii_digit() {
1005                pos += 1;
1006            }
1007
1008            // Must have at least one digit
1009            if pos > digit_start && pos < bytes.len() {
1010                // Check for . or ) followed by space/tab
1011                if bytes[pos] == b'.' || bytes[pos] == b')' {
1012                    pos += 1;
1013                    if pos >= bytes.len() || bytes[pos] == b' ' || bytes[pos] == b'\t' {
1014                        // Skip the space after marker if present
1015                        if pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
1016                            pos += 1;
1017                        }
1018                        let content_indent = pos;
1019                        return (&line[..pos], &line[pos..], content_indent);
1020                    }
1021                }
1022            }
1023        }
1024
1025        ("", line, 0)
1026    }
1027
1028    /// Extract the table row content from a line, stripping any list/blockquote prefix.
1029    ///
1030    /// This is useful for processing table rows that may be inside list items or blockquotes.
1031    /// The line_index indicates which line of the table this is (0 = header, 1 = delimiter, etc.)
1032    pub fn extract_table_row_content<'a>(line: &'a str, table_block: &TableBlock, line_index: usize) -> &'a str {
1033        // First strip blockquote prefix
1034        let (_, after_blockquote) = Self::extract_blockquote_prefix(line);
1035
1036        // Then handle list prefix if present
1037        if let Some(ref list_ctx) = table_block.list_context {
1038            if line_index == 0 {
1039                // Header line: strip list prefix (handles both markers and indentation)
1040                after_blockquote
1041                    .strip_prefix(&list_ctx.list_prefix)
1042                    .unwrap_or_else(|| Self::extract_list_prefix(after_blockquote).1)
1043            } else {
1044                // Continuation lines: strip indentation
1045                Self::strip_list_continuation_indent(after_blockquote, list_ctx.content_indent)
1046            }
1047        } else {
1048            after_blockquote
1049        }
1050    }
1051
1052    /// Check if the content after a list marker looks like a table row.
1053    /// This is used to detect tables that start on the same line as a list marker.
1054    pub fn is_list_item_with_table_row(line: &str, flavor: crate::config::MarkdownFlavor) -> bool {
1055        let (prefix, content, _) = Self::extract_list_prefix(line);
1056        if prefix.is_empty() {
1057            return false;
1058        }
1059
1060        // Check if the content after the list marker is a table row
1061        // It must start with | (proper table format within a list)
1062        let trimmed = content.trim();
1063        if !trimmed.starts_with('|') {
1064            return false;
1065        }
1066
1067        // Use our table row detection on the content
1068        Self::is_potential_table_row_content(content, flavor)
1069    }
1070
1071    /// Internal helper: Check if content (without list/blockquote prefix) looks like a table row.
1072    fn is_potential_table_row_content(content: &str, flavor: crate::config::MarkdownFlavor) -> bool {
1073        Self::is_potential_table_row_with_flavor(content, flavor)
1074    }
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079    use super::*;
1080    use crate::lint_context::LintContext;
1081
1082    #[test]
1083    fn test_is_potential_table_row() {
1084        // Basic valid table rows
1085        assert!(TableUtils::is_potential_table_row("| Header 1 | Header 2 |"));
1086        assert!(TableUtils::is_potential_table_row("| Cell 1 | Cell 2 |"));
1087        assert!(TableUtils::is_potential_table_row("Cell 1 | Cell 2"));
1088        assert!(TableUtils::is_potential_table_row("| Cell |")); // Single-column tables are valid in GFM
1089
1090        // Multiple cells
1091        assert!(TableUtils::is_potential_table_row("| A | B | C | D | E |"));
1092
1093        // With whitespace
1094        assert!(TableUtils::is_potential_table_row("  | Indented | Table |  "));
1095        assert!(TableUtils::is_potential_table_row("| Spaces | Around |"));
1096
1097        // Not table rows
1098        assert!(!TableUtils::is_potential_table_row("- List item"));
1099        assert!(!TableUtils::is_potential_table_row("* Another list"));
1100        assert!(!TableUtils::is_potential_table_row("+ Plus list"));
1101        assert!(!TableUtils::is_potential_table_row("Regular text"));
1102        assert!(!TableUtils::is_potential_table_row(""));
1103        assert!(!TableUtils::is_potential_table_row("   "));
1104
1105        // Code blocks
1106        assert!(!TableUtils::is_potential_table_row("`code with | pipe`"));
1107        assert!(!TableUtils::is_potential_table_row("``multiple | backticks``"));
1108        assert!(!TableUtils::is_potential_table_row("Use ``a|b`` in prose"));
1109        assert!(TableUtils::is_potential_table_row("| `fenced` | Uses ``` and ~~~ |"));
1110        assert!(TableUtils::is_potential_table_row("`!foo && bar` | `(!foo) && bar`"));
1111        assert!(!TableUtils::is_potential_table_row("`echo a | sed 's/a/b/'`"));
1112
1113        // Math spans: pipes inside $...$ are not table separators
1114        assert!(!TableUtils::is_potential_table_row(
1115            "Text with $|S|$ math notation here."
1116        ));
1117        assert!(!TableUtils::is_potential_table_row(
1118            "Size $|S|$ was even, check $|T|$ too."
1119        ));
1120        assert!(!TableUtils::is_potential_table_row("Display $$|A| + |B|$$ math here."));
1121        // Math pipe in cell with outer pipes is still a table row
1122        assert!(TableUtils::is_potential_table_row("| cell with $|S|$ math |"));
1123        // Pipe after fully closed math spans is still detected
1124        assert!(TableUtils::is_potential_table_row("$a$ | $b$"));
1125        assert!(TableUtils::is_potential_table_row("$f(x)$ and $g(x)$ | result"));
1126        // $5 | $10 style price comparisons are suppressed as a deliberate trade-off:
1127        // the leading $ opens a math span, consuming the pipe. Tables with bare dollar
1128        // amounts should use outer pipes (| $5 | $10 |) to be correctly detected.
1129        assert!(!TableUtils::is_potential_table_row("$5 | $10"));
1130
1131        // Single pipe not enough
1132        assert!(!TableUtils::is_potential_table_row("Just one |"));
1133        assert!(!TableUtils::is_potential_table_row("| Just one"));
1134
1135        // Very long cells are valid in tables (no length limit for cell content)
1136        let long_cell = "a".repeat(150);
1137        assert!(TableUtils::is_potential_table_row(&format!("| {long_cell} | b |")));
1138
1139        // Cells with newlines
1140        assert!(!TableUtils::is_potential_table_row("| Cell with\nnewline | Other |"));
1141
1142        // Empty cells (Issue #129)
1143        assert!(TableUtils::is_potential_table_row("|||")); // Two empty cells
1144        assert!(TableUtils::is_potential_table_row("||||")); // Three empty cells
1145        assert!(TableUtils::is_potential_table_row("| | |")); // Two empty cells with spaces
1146    }
1147
1148    #[test]
1149    fn test_list_items_with_pipes_not_table_rows() {
1150        // Ordered list items should NOT be detected as table rows
1151        assert!(!TableUtils::is_potential_table_row("1. Item with | pipe"));
1152        assert!(!TableUtils::is_potential_table_row("10. Item with | pipe"));
1153        assert!(!TableUtils::is_potential_table_row("999. Item with | pipe"));
1154        assert!(!TableUtils::is_potential_table_row("1) Item with | pipe"));
1155        assert!(!TableUtils::is_potential_table_row("10) Item with | pipe"));
1156
1157        // Unordered list items with tabs
1158        assert!(!TableUtils::is_potential_table_row("-\tItem with | pipe"));
1159        assert!(!TableUtils::is_potential_table_row("*\tItem with | pipe"));
1160        assert!(!TableUtils::is_potential_table_row("+\tItem with | pipe"));
1161
1162        // Indented list items (the trim_start normalizes indentation)
1163        assert!(!TableUtils::is_potential_table_row("  - Indented | pipe"));
1164        assert!(!TableUtils::is_potential_table_row("    * Deep indent | pipe"));
1165        assert!(!TableUtils::is_potential_table_row("  1. Ordered indent | pipe"));
1166
1167        // Task list items
1168        assert!(!TableUtils::is_potential_table_row("- [ ] task | pipe"));
1169        assert!(!TableUtils::is_potential_table_row("- [x] done | pipe"));
1170
1171        // Multiple pipes in list items
1172        assert!(!TableUtils::is_potential_table_row("1. foo | bar | baz"));
1173        assert!(!TableUtils::is_potential_table_row("- alpha | beta | gamma"));
1174
1175        // These SHOULD still be detected as potential table rows
1176        assert!(TableUtils::is_potential_table_row("| cell | cell |"));
1177        assert!(TableUtils::is_potential_table_row("cell | cell"));
1178        assert!(TableUtils::is_potential_table_row("| Header | Header |"));
1179    }
1180
1181    #[test]
1182    fn test_atx_headings_with_pipes_not_table_rows() {
1183        // All 6 ATX heading levels with pipes
1184        assert!(!TableUtils::is_potential_table_row("# Heading | with pipe"));
1185        assert!(!TableUtils::is_potential_table_row("## Heading | with pipe"));
1186        assert!(!TableUtils::is_potential_table_row("### Heading | with pipe"));
1187        assert!(!TableUtils::is_potential_table_row("#### Heading | with pipe"));
1188        assert!(!TableUtils::is_potential_table_row("##### Heading | with pipe"));
1189        assert!(!TableUtils::is_potential_table_row("###### Heading | with pipe"));
1190
1191        // Multiple pipes in headings
1192        assert!(!TableUtils::is_potential_table_row("### col1 | col2 | col3"));
1193        assert!(!TableUtils::is_potential_table_row("## a|b|c"));
1194
1195        // Headings with tab after hashes
1196        assert!(!TableUtils::is_potential_table_row("#\tHeading | pipe"));
1197        assert!(!TableUtils::is_potential_table_row("##\tHeading | pipe"));
1198
1199        // Heading with only hashes and pipe (empty heading text)
1200        assert!(!TableUtils::is_potential_table_row("# |"));
1201        assert!(!TableUtils::is_potential_table_row("## |"));
1202
1203        // Indented headings (spaces before #)
1204        assert!(!TableUtils::is_potential_table_row("  ## Heading | pipe"));
1205        assert!(!TableUtils::is_potential_table_row("   ### Heading | pipe"));
1206
1207        // Unicode content in headings (the original proptest failure case)
1208        assert!(!TableUtils::is_potential_table_row("#### ®aAA|ᯗ"));
1209
1210        // 7+ hashes are NOT headings — should follow normal table detection
1211        // "####### text|pipe" has no space after 7 hashes if treated as non-heading
1212        // but with a space it still has 7+ hashes so not a heading
1213        assert!(TableUtils::is_potential_table_row("####### text | pipe"));
1214
1215        // Hash without space is NOT a heading, so pipe detection applies
1216        assert!(TableUtils::is_potential_table_row("#nospc|pipe"));
1217
1218        // These SHOULD still be detected as potential table rows
1219        assert!(TableUtils::is_potential_table_row("| # Header | Value |"));
1220        assert!(TableUtils::is_potential_table_row("text | #tag"));
1221    }
1222
1223    #[test]
1224    fn test_is_delimiter_row() {
1225        // Basic delimiter rows
1226        assert!(TableUtils::is_delimiter_row("|---|---|"));
1227        assert!(TableUtils::is_delimiter_row("| --- | --- |"));
1228        assert!(TableUtils::is_delimiter_row("|:---|---:|"));
1229        assert!(TableUtils::is_delimiter_row("|:---:|:---:|"));
1230
1231        // With varying dash counts
1232        assert!(TableUtils::is_delimiter_row("|-|--|"));
1233        assert!(TableUtils::is_delimiter_row("|-------|----------|"));
1234
1235        // With whitespace
1236        assert!(TableUtils::is_delimiter_row("|  ---  |  ---  |"));
1237        assert!(TableUtils::is_delimiter_row("| :--- | ---: |"));
1238
1239        // Multiple columns
1240        assert!(TableUtils::is_delimiter_row("|---|---|---|---|"));
1241
1242        // Without leading/trailing pipes
1243        assert!(TableUtils::is_delimiter_row("--- | ---"));
1244        assert!(TableUtils::is_delimiter_row(":--- | ---:"));
1245
1246        // Not delimiter rows
1247        assert!(!TableUtils::is_delimiter_row("| Header | Header |"));
1248        assert!(!TableUtils::is_delimiter_row("Regular text"));
1249        assert!(!TableUtils::is_delimiter_row(""));
1250        assert!(!TableUtils::is_delimiter_row("|||"));
1251        assert!(!TableUtils::is_delimiter_row("| | |"));
1252
1253        // Must have dashes
1254        assert!(!TableUtils::is_delimiter_row("| : | : |"));
1255        assert!(!TableUtils::is_delimiter_row("|    |    |"));
1256
1257        // Mixed content
1258        assert!(!TableUtils::is_delimiter_row("| --- | text |"));
1259        assert!(!TableUtils::is_delimiter_row("| abc | --- |"));
1260    }
1261
1262    #[test]
1263    fn test_count_cells() {
1264        // Basic counts
1265        assert_eq!(TableUtils::count_cells("| Cell 1 | Cell 2 | Cell 3 |"), 3);
1266        assert_eq!(TableUtils::count_cells("Cell 1 | Cell 2 | Cell 3"), 3);
1267        assert_eq!(TableUtils::count_cells("| Cell 1 | Cell 2"), 2);
1268        assert_eq!(TableUtils::count_cells("Cell 1 | Cell 2 |"), 2);
1269
1270        // Single cell
1271        assert_eq!(TableUtils::count_cells("| Cell |"), 1);
1272        assert_eq!(TableUtils::count_cells("Cell"), 0); // No pipe
1273
1274        // Empty cells
1275        assert_eq!(TableUtils::count_cells("|  |  |  |"), 3);
1276        assert_eq!(TableUtils::count_cells("| | | |"), 3);
1277
1278        // Many cells
1279        assert_eq!(TableUtils::count_cells("| A | B | C | D | E | F |"), 6);
1280
1281        // Edge cases
1282        assert_eq!(TableUtils::count_cells("||"), 1); // One empty cell
1283        assert_eq!(TableUtils::count_cells("|||"), 2); // Two empty cells
1284
1285        // No table
1286        assert_eq!(TableUtils::count_cells("Regular text"), 0);
1287        assert_eq!(TableUtils::count_cells(""), 0);
1288        assert_eq!(TableUtils::count_cells("   "), 0);
1289
1290        // Whitespace handling
1291        assert_eq!(TableUtils::count_cells("  | A | B |  "), 2);
1292        assert_eq!(TableUtils::count_cells("|   A   |   B   |"), 2);
1293    }
1294
1295    #[test]
1296    fn test_count_cells_with_escaped_pipes() {
1297        // Pipes inside code spans are treated as content, not cell delimiters.
1298        // To include a literal pipe outside code spans, escape it with \|.
1299
1300        // Basic table structure
1301        assert_eq!(TableUtils::count_cells("| Challenge | Solution |"), 2);
1302        assert_eq!(TableUtils::count_cells("| A | B | C |"), 3);
1303        assert_eq!(TableUtils::count_cells("| One | Two |"), 2);
1304
1305        // Escaped pipes: \| keeps the pipe as content
1306        assert_eq!(TableUtils::count_cells(r"| Command | echo \| grep |"), 2);
1307        assert_eq!(TableUtils::count_cells(r"| A | B \| C |"), 2); // B | C is one cell
1308
1309        // Escaped pipes inside backticks
1310        assert_eq!(TableUtils::count_cells(r"| Command | `echo \| grep` |"), 2);
1311
1312        // Double backslash + pipe: \\| means escaped backslash followed by pipe delimiter
1313        assert_eq!(TableUtils::count_cells(r"| A | B \\| C |"), 3); // \\| is NOT escaped pipe
1314        // Double backslash inside backticks: pipe is still masked by code span
1315        assert_eq!(TableUtils::count_cells(r"| A | `B \\| C` |"), 2);
1316
1317        // Pipes inside code spans are content, not delimiters
1318        assert_eq!(TableUtils::count_cells("| Command | `echo | grep` |"), 2);
1319        assert_eq!(TableUtils::count_cells("| `code | one` | `code | two` |"), 2);
1320        assert_eq!(TableUtils::count_cells("| `single|pipe` |"), 1);
1321
1322        // Regex example - pipes in code spans are masked
1323        assert_eq!(TableUtils::count_cells(r"| Hour formats | `^([0-1]?\d|2[0-3])` |"), 2);
1324        // Escaped pipe inside code is also masked (escape is redundant here)
1325        assert_eq!(TableUtils::count_cells(r"| Hour formats | `^([0-1]?\d\|2[0-3])` |"), 2);
1326    }
1327
1328    #[test]
1329    fn test_determine_pipe_style() {
1330        // All pipe styles
1331        assert_eq!(
1332            TableUtils::determine_pipe_style("| Cell 1 | Cell 2 |"),
1333            Some("leading_and_trailing")
1334        );
1335        assert_eq!(
1336            TableUtils::determine_pipe_style("| Cell 1 | Cell 2"),
1337            Some("leading_only")
1338        );
1339        assert_eq!(
1340            TableUtils::determine_pipe_style("Cell 1 | Cell 2 |"),
1341            Some("trailing_only")
1342        );
1343        assert_eq!(
1344            TableUtils::determine_pipe_style("Cell 1 | Cell 2"),
1345            Some("no_leading_or_trailing")
1346        );
1347
1348        // With whitespace
1349        assert_eq!(
1350            TableUtils::determine_pipe_style("  | Cell 1 | Cell 2 |  "),
1351            Some("leading_and_trailing")
1352        );
1353        assert_eq!(
1354            TableUtils::determine_pipe_style("  | Cell 1 | Cell 2  "),
1355            Some("leading_only")
1356        );
1357
1358        // No pipes
1359        assert_eq!(TableUtils::determine_pipe_style("Regular text"), None);
1360        assert_eq!(TableUtils::determine_pipe_style(""), None);
1361        assert_eq!(TableUtils::determine_pipe_style("   "), None);
1362
1363        // Single pipe cases
1364        assert_eq!(TableUtils::determine_pipe_style("|"), Some("leading_and_trailing"));
1365        assert_eq!(TableUtils::determine_pipe_style("| Cell"), Some("leading_only"));
1366        assert_eq!(TableUtils::determine_pipe_style("Cell |"), Some("trailing_only"));
1367    }
1368
1369    #[test]
1370    fn test_find_table_blocks_simple() {
1371        let content = "| Header 1 | Header 2 |
1372|-----------|-----------|
1373| Cell 1    | Cell 2    |
1374| Cell 3    | Cell 4    |";
1375
1376        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1377
1378        let tables = TableUtils::find_table_blocks(content, &ctx);
1379        assert_eq!(tables.len(), 1);
1380
1381        let table = &tables[0];
1382        assert_eq!(table.start_line, 0);
1383        assert_eq!(table.end_line, 3);
1384        assert_eq!(table.header_line, 0);
1385        assert_eq!(table.delimiter_line, 1);
1386        assert_eq!(table.content_lines, vec![2, 3]);
1387    }
1388
1389    #[test]
1390    fn test_find_table_blocks_multiple() {
1391        let content = "Some text
1392
1393| Table 1 | Col A |
1394|----------|-------|
1395| Data 1   | Val 1 |
1396
1397More text
1398
1399| Table 2 | Col 2 |
1400|----------|-------|
1401| Data 2   | Data  |";
1402
1403        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1404
1405        let tables = TableUtils::find_table_blocks(content, &ctx);
1406        assert_eq!(tables.len(), 2);
1407
1408        // First table
1409        assert_eq!(tables[0].start_line, 2);
1410        assert_eq!(tables[0].end_line, 4);
1411        assert_eq!(tables[0].header_line, 2);
1412        assert_eq!(tables[0].delimiter_line, 3);
1413        assert_eq!(tables[0].content_lines, vec![4]);
1414
1415        // Second table
1416        assert_eq!(tables[1].start_line, 8);
1417        assert_eq!(tables[1].end_line, 10);
1418        assert_eq!(tables[1].header_line, 8);
1419        assert_eq!(tables[1].delimiter_line, 9);
1420        assert_eq!(tables[1].content_lines, vec![10]);
1421    }
1422
1423    #[test]
1424    fn test_find_table_blocks_no_content_rows() {
1425        let content = "| Header 1 | Header 2 |
1426|-----------|-----------|
1427
1428Next paragraph";
1429
1430        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1431
1432        let tables = TableUtils::find_table_blocks(content, &ctx);
1433        assert_eq!(tables.len(), 1);
1434
1435        let table = &tables[0];
1436        assert_eq!(table.start_line, 0);
1437        assert_eq!(table.end_line, 1); // Just header and delimiter
1438        assert_eq!(table.content_lines.len(), 0);
1439    }
1440
1441    #[test]
1442    fn test_find_table_blocks_in_code_block() {
1443        let content = "```
1444| Not | A | Table |
1445|-----|---|-------|
1446| In  | Code | Block |
1447```
1448
1449| Real | Table |
1450|------|-------|
1451| Data | Here  |";
1452
1453        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1454
1455        let tables = TableUtils::find_table_blocks(content, &ctx);
1456        assert_eq!(tables.len(), 1); // Only the table outside code block
1457
1458        let table = &tables[0];
1459        assert_eq!(table.header_line, 6);
1460        assert_eq!(table.delimiter_line, 7);
1461    }
1462
1463    #[test]
1464    fn test_find_table_blocks_no_tables() {
1465        let content = "Just regular text
1466No tables here
1467- List item with | pipe
1468* Another list item";
1469
1470        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1471
1472        let tables = TableUtils::find_table_blocks(content, &ctx);
1473        assert_eq!(tables.len(), 0);
1474    }
1475
1476    #[test]
1477    fn test_find_table_blocks_malformed() {
1478        let content = "| Header without delimiter |
1479| This looks like table |
1480But no delimiter row
1481
1482| Proper | Table |
1483|---------|-------|
1484| Data    | Here  |";
1485
1486        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1487
1488        let tables = TableUtils::find_table_blocks(content, &ctx);
1489        assert_eq!(tables.len(), 1); // Only the proper table
1490        assert_eq!(tables[0].header_line, 4);
1491    }
1492
1493    #[test]
1494    fn test_find_table_blocks_keeps_obsidian_wikilink_prose_out_of_the_table() {
1495        let content = "| A | B |\n| - | - |\n| x | y |\n[[Foo|bar]] is a note.\n";
1496
1497        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1498        let blocks = TableUtils::find_table_blocks(content, &ctx);
1499        assert_eq!(blocks.len(), 1, "Expected one table, got {blocks:?}");
1500        assert_eq!(
1501            blocks[0].end_line, 2,
1502            "Wikilink prose was absorbed into the table: {:?}",
1503            blocks[0]
1504        );
1505        assert_eq!(blocks[0].content_lines, vec![2]);
1506
1507        // Control: under GFM the same pipe is a cell delimiter, so the line really
1508        // is a table row and belongs to the block.
1509        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1510        let blocks = TableUtils::find_table_blocks(content, &ctx);
1511        assert_eq!(blocks.len(), 1, "Expected one table, got {blocks:?}");
1512        assert_eq!(
1513            blocks[0].end_line, 3,
1514            "GFM should still read the pipe as a delimiter: {:?}",
1515            blocks[0]
1516        );
1517    }
1518
1519    #[test]
1520    fn test_edge_cases() {
1521        // Test empty content
1522        assert!(!TableUtils::is_potential_table_row(""));
1523        assert!(!TableUtils::is_delimiter_row(""));
1524        assert_eq!(TableUtils::count_cells(""), 0);
1525        assert_eq!(TableUtils::determine_pipe_style(""), None);
1526
1527        // Test whitespace only
1528        assert!(!TableUtils::is_potential_table_row("   "));
1529        assert!(!TableUtils::is_delimiter_row("   "));
1530        assert_eq!(TableUtils::count_cells("   "), 0);
1531        assert_eq!(TableUtils::determine_pipe_style("   "), None);
1532
1533        // Test single character
1534        assert!(!TableUtils::is_potential_table_row("|"));
1535        assert!(!TableUtils::is_delimiter_row("|"));
1536        assert_eq!(TableUtils::count_cells("|"), 0); // Need at least 2 parts
1537
1538        // Test very long lines are valid table rows (no length limit)
1539        // Test both single-column and multi-column long lines
1540        let long_single = format!("| {} |", "a".repeat(200));
1541        assert!(TableUtils::is_potential_table_row(&long_single)); // Single-column table with long content
1542
1543        let long_multi = format!("| {} | {} |", "a".repeat(200), "b".repeat(200));
1544        assert!(TableUtils::is_potential_table_row(&long_multi)); // Multi-column table with long content
1545
1546        // Test unicode
1547        assert!(TableUtils::is_potential_table_row("| 你好 | 世界 |"));
1548        assert!(TableUtils::is_potential_table_row("| émoji | 🎉 |"));
1549        assert_eq!(TableUtils::count_cells("| 你好 | 世界 |"), 2);
1550    }
1551
1552    #[test]
1553    fn test_table_block_struct() {
1554        let block = TableBlock {
1555            start_line: 0,
1556            end_line: 5,
1557            header_line: 0,
1558            delimiter_line: 1,
1559            content_lines: vec![2, 3, 4, 5],
1560            list_context: None,
1561        };
1562
1563        // Test Debug trait
1564        let debug_str = format!("{block:?}");
1565        assert!(debug_str.contains("TableBlock"));
1566        assert!(debug_str.contains("start_line: 0"));
1567
1568        // Test Clone trait
1569        let cloned = block.clone();
1570        assert_eq!(cloned.start_line, block.start_line);
1571        assert_eq!(cloned.end_line, block.end_line);
1572        assert_eq!(cloned.header_line, block.header_line);
1573        assert_eq!(cloned.delimiter_line, block.delimiter_line);
1574        assert_eq!(cloned.content_lines, block.content_lines);
1575        assert!(cloned.list_context.is_none());
1576    }
1577
1578    #[test]
1579    fn test_split_table_row() {
1580        // Basic split
1581        let cells = TableUtils::split_table_row("| Cell 1 | Cell 2 | Cell 3 |");
1582        assert_eq!(cells.len(), 3);
1583        assert_eq!(cells[0].trim(), "Cell 1");
1584        assert_eq!(cells[1].trim(), "Cell 2");
1585        assert_eq!(cells[2].trim(), "Cell 3");
1586
1587        // Without trailing pipe
1588        let cells = TableUtils::split_table_row("| Cell 1 | Cell 2");
1589        assert_eq!(cells.len(), 2);
1590
1591        // Empty cells
1592        let cells = TableUtils::split_table_row("| | | |");
1593        assert_eq!(cells.len(), 3);
1594
1595        // Single cell
1596        let cells = TableUtils::split_table_row("| Cell |");
1597        assert_eq!(cells.len(), 1);
1598        assert_eq!(cells[0].trim(), "Cell");
1599
1600        // No pipes
1601        let cells = TableUtils::split_table_row("No pipes here");
1602        assert_eq!(cells.len(), 0);
1603    }
1604
1605    #[test]
1606    fn test_split_table_row_with_escaped_pipes() {
1607        // Escaped pipes should be preserved in cell content
1608        let cells = TableUtils::split_table_row(r"| A | B \| C |");
1609        assert_eq!(cells.len(), 2);
1610        assert!(cells[1].contains(r"\|"), "Escaped pipe should be in cell content");
1611
1612        // Double backslash + pipe is NOT escaped
1613        let cells = TableUtils::split_table_row(r"| A | B \\| C |");
1614        assert_eq!(cells.len(), 3);
1615    }
1616
1617    #[test]
1618    fn test_split_table_row_with_flavor_mkdocs() {
1619        // MkDocs flavor: pipes in inline code are NOT cell delimiters
1620        let cells =
1621            TableUtils::split_table_row_with_flavor("| Type | `x | y` |", crate::config::MarkdownFlavor::MkDocs);
1622        assert_eq!(cells.len(), 2);
1623        assert!(
1624            cells[1].contains("`x | y`"),
1625            "Inline code with pipe should be single cell in MkDocs flavor"
1626        );
1627
1628        // Multiple pipes in inline code
1629        let cells =
1630            TableUtils::split_table_row_with_flavor("| Type | `a | b | c` |", crate::config::MarkdownFlavor::MkDocs);
1631        assert_eq!(cells.len(), 2);
1632        assert!(cells[1].contains("`a | b | c`"));
1633    }
1634
1635    #[test]
1636    fn test_split_table_row_with_flavor_standard() {
1637        // Pipes in inline code are NOT cell delimiters for any flavor
1638        let cells =
1639            TableUtils::split_table_row_with_flavor("| Type | `x | y` |", crate::config::MarkdownFlavor::Standard);
1640        assert_eq!(
1641            cells.len(),
1642            2,
1643            "Pipes in code spans should not be cell delimiters, got {cells:?}"
1644        );
1645        assert!(
1646            cells[1].contains("`x | y`"),
1647            "Inline code with pipe should be single cell"
1648        );
1649    }
1650
1651    #[test]
1652    fn test_split_table_row_with_flavor_obsidian_wikilink() {
1653        // Obsidian flavor: the pipe in [[Target|Label]] separates a link from its
1654        // alias, not one cell from the next
1655        let cells = TableUtils::split_table_row_with_flavor(
1656            "| Alice | [[White Rabbit|the Rabbit]] |",
1657            crate::config::MarkdownFlavor::Obsidian,
1658        );
1659        assert_eq!(cells.len(), 2, "Aliased wikilink should be one cell, got {cells:?}");
1660        assert!(cells[1].contains("[[White Rabbit|the Rabbit]]"));
1661
1662        // Two aliased wikilinks in one cell
1663        let cells = TableUtils::split_table_row_with_flavor(
1664            "| Guests | [[Mad Hatter|the Hatter]] and [[March Hare|the Hare]] |",
1665            crate::config::MarkdownFlavor::Obsidian,
1666        );
1667        assert_eq!(
1668            cells.len(),
1669            2,
1670            "Two aliased wikilinks should be one cell, got {cells:?}"
1671        );
1672
1673        // A plain wikilink has no pipe to mask
1674        let cells = TableUtils::split_table_row_with_flavor(
1675            "| Alice | [[Cheshire Cat]] |",
1676            crate::config::MarkdownFlavor::Obsidian,
1677        );
1678        assert_eq!(cells.len(), 2);
1679
1680        // An unterminated wikilink must not swallow the rest of the row
1681        let cells = TableUtils::split_table_row_with_flavor(
1682            "| Alice | [[White Rabbit | curious |",
1683            crate::config::MarkdownFlavor::Obsidian,
1684        );
1685        assert_eq!(
1686            cells.len(),
1687            3,
1688            "Unterminated wikilink should not mask pipes, got {cells:?}"
1689        );
1690    }
1691
1692    #[test]
1693    fn test_split_table_row_wikilink_only_for_obsidian() {
1694        // Other flavors keep GFM behaviour: an unescaped pipe is a cell delimiter
1695        for flavor in [
1696            crate::config::MarkdownFlavor::Standard,
1697            crate::config::MarkdownFlavor::MkDocs,
1698        ] {
1699            let cells = TableUtils::split_table_row_with_flavor("| Alice | [[White Rabbit|the Rabbit]] |", flavor);
1700            assert_eq!(
1701                cells.len(),
1702                3,
1703                "{flavor:?} should treat the wikilink pipe as a delimiter, got {cells:?}"
1704            );
1705        }
1706    }
1707
1708    #[test]
1709    fn test_mask_pipes_in_wikilinks_preserves_length() {
1710        // Masking must not change length, or cell offsets drift
1711        for text in [
1712            "| Alice | [[White Rabbit|the Rabbit]] |",
1713            "| [[Mad Hatter|Hatter]] | [[March Hare|Hare]] |",
1714            "no wikilink here | just a pipe",
1715            "[[unterminated | still text",
1716        ] {
1717            assert_eq!(
1718                TableUtils::mask_pipes_in_wikilinks(text).len(),
1719                text.len(),
1720                "masking changed length of {text:?}"
1721            );
1722        }
1723    }
1724
1725    #[test]
1726    fn test_wikilink_brackets_in_a_code_span_stay_prose() {
1727        // Code binds tighter than a link, so brackets quoted as code open nothing.
1728        // Reading them as a link would merge four well-formed cells into fewer and
1729        // report a column-count mismatch against a table that has none.
1730        for row in [
1731            "| `[[` | mid | `]]` |",
1732            "| `[[Target` | mid | `Label]]` |",
1733            "| a | `[[` | b | `]]` |",
1734            // A quoted "[[" is not an opener even when a real "]]" follows it
1735            // outside the span, so the pipe between them stays a delimiter.
1736            "| `[[` and Target|Label]] |",
1737        ] {
1738            let obsidian = TableUtils::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Obsidian);
1739            let standard = TableUtils::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Standard);
1740            assert_eq!(
1741                obsidian, standard,
1742                "Obsidian disagreed with GFM about {row:?}: {obsidian:?} vs {standard:?}"
1743            );
1744        }
1745
1746        // A code span inside a genuine wikilink is content of the alias, so the
1747        // link still closes at its own "]]" and the row is one cell.
1748        let cells = TableUtils::split_table_row_with_flavor(
1749            "| [[Target|Label with `a|b` inside]] |",
1750            crate::config::MarkdownFlavor::Obsidian,
1751        );
1752        assert_eq!(
1753            cells.len(),
1754            1,
1755            "Wikilink holding a code span should be one cell, got {cells:?}"
1756        );
1757
1758        // A "]]" that only exists inside a code span does not close the link, so
1759        // there is nothing to mask and the pipes stay delimiters.
1760        let cells = TableUtils::split_table_row_with_flavor(
1761            "| [[Target | alias `]]` | tail |",
1762            crate::config::MarkdownFlavor::Obsidian,
1763        );
1764        assert_eq!(
1765            cells.len(),
1766            3,
1767            "A closer hidden in code should not close the link, got {cells:?}"
1768        );
1769    }
1770
1771    #[test]
1772    fn test_wikilink_with_a_blank_target_stays_prose() {
1773        // The half before the pipe names the note, so a blank one means these
1774        // brackets are prose that happens to straddle a cell divider.
1775        for row in [
1776            "| [[ | ]] |",
1777            "| [[|Label]] |",
1778            "| starts [[ | ends ]] here |",
1779            "| [[\t|\tx]] |",
1780        ] {
1781            let obsidian = TableUtils::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Obsidian);
1782            let standard = TableUtils::split_table_row_with_flavor(row, crate::config::MarkdownFlavor::Standard);
1783            assert_eq!(
1784                obsidian, standard,
1785                "Obsidian disagreed with GFM about {row:?}: {obsidian:?} vs {standard:?}"
1786            );
1787        }
1788
1789        // Positive control: one non-whitespace character of target is a link.
1790        let cells = TableUtils::split_table_row_with_flavor("| [[x | y]] |", crate::config::MarkdownFlavor::Obsidian);
1791        assert_eq!(cells.len(), 1, "A named target should be one cell, got {cells:?}");
1792    }
1793
1794    #[test]
1795    fn test_inline_code_spans_agree_with_pipe_masking() {
1796        // Both maskers read one definition of a code span, so an unmatched run of
1797        // backticks is literal text to both and scanning resumes just after it.
1798        let text = "``x | y and `c|d`";
1799        let chars: Vec<char> = text.chars().collect();
1800        let spans = TableUtils::inline_code_spans(&chars);
1801        assert_eq!(spans.len(), 1, "Only the matched pair is a code span, got {spans:?}");
1802        let (start, end) = spans[0];
1803        assert_eq!(
1804            chars[start..end].iter().collect::<String>(),
1805            "`c|d`",
1806            "The span should start at the run that closes, not the unmatched opener"
1807        );
1808
1809        // The pipe outside every span survives; the one inside is masked.
1810        assert_eq!(TableUtils::mask_pipes_in_inline_code(text), "``x | y and `c_d`");
1811    }
1812
1813    // === extract_blockquote_prefix tests ===
1814
1815    #[test]
1816    fn test_extract_blockquote_prefix_no_blockquote() {
1817        // Regular table row without blockquote
1818        let (prefix, content) = TableUtils::extract_blockquote_prefix("| H1 | H2 |");
1819        assert_eq!(prefix, "");
1820        assert_eq!(content, "| H1 | H2 |");
1821    }
1822
1823    #[test]
1824    fn test_extract_blockquote_prefix_single_level() {
1825        // Single blockquote level
1826        let (prefix, content) = TableUtils::extract_blockquote_prefix("> | H1 | H2 |");
1827        assert_eq!(prefix, "> ");
1828        assert_eq!(content, "| H1 | H2 |");
1829    }
1830
1831    #[test]
1832    fn test_extract_blockquote_prefix_double_level() {
1833        // Double blockquote level
1834        let (prefix, content) = TableUtils::extract_blockquote_prefix(">> | H1 | H2 |");
1835        assert_eq!(prefix, ">> ");
1836        assert_eq!(content, "| H1 | H2 |");
1837    }
1838
1839    #[test]
1840    fn test_extract_blockquote_prefix_triple_level() {
1841        // Triple blockquote level
1842        let (prefix, content) = TableUtils::extract_blockquote_prefix(">>> | H1 | H2 |");
1843        assert_eq!(prefix, ">>> ");
1844        assert_eq!(content, "| H1 | H2 |");
1845    }
1846
1847    #[test]
1848    fn test_extract_blockquote_prefix_with_spaces() {
1849        // Blockquote with spaces between markers
1850        let (prefix, content) = TableUtils::extract_blockquote_prefix("> > | H1 | H2 |");
1851        assert_eq!(prefix, "> > ");
1852        assert_eq!(content, "| H1 | H2 |");
1853    }
1854
1855    #[test]
1856    fn test_extract_blockquote_prefix_indented() {
1857        // Indented blockquote
1858        let (prefix, content) = TableUtils::extract_blockquote_prefix("  > | H1 | H2 |");
1859        assert_eq!(prefix, "  > ");
1860        assert_eq!(content, "| H1 | H2 |");
1861    }
1862
1863    #[test]
1864    fn test_extract_blockquote_prefix_no_space_after() {
1865        // Blockquote without space after marker
1866        let (prefix, content) = TableUtils::extract_blockquote_prefix(">| H1 | H2 |");
1867        assert_eq!(prefix, ">");
1868        assert_eq!(content, "| H1 | H2 |");
1869    }
1870
1871    #[test]
1872    fn test_determine_pipe_style_in_blockquote() {
1873        // determine_pipe_style should handle blockquotes correctly
1874        assert_eq!(
1875            TableUtils::determine_pipe_style("> | H1 | H2 |"),
1876            Some("leading_and_trailing")
1877        );
1878        assert_eq!(
1879            TableUtils::determine_pipe_style("> H1 | H2"),
1880            Some("no_leading_or_trailing")
1881        );
1882        assert_eq!(
1883            TableUtils::determine_pipe_style(">> | H1 | H2 |"),
1884            Some("leading_and_trailing")
1885        );
1886        assert_eq!(TableUtils::determine_pipe_style(">>> | H1 | H2"), Some("leading_only"));
1887    }
1888
1889    #[test]
1890    fn test_list_table_delimiter_requires_indentation() {
1891        // Test case: list item contains pipe, but delimiter line is at column 1
1892        // This should NOT be detected as a list table since the delimiter has no indentation.
1893        // The result is a non-list table starting at line 0 (the list item becomes the header)
1894        // but list_context should be None.
1895        let content = "- List item with | pipe\n|---|---|\n| Cell 1 | Cell 2 |";
1896        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1897        let tables = TableUtils::find_table_blocks(content, &ctx);
1898
1899        // The table will be detected starting at line 0, but crucially it should NOT have
1900        // list_context set, meaning it won't be treated as a list-table for column count purposes
1901        assert_eq!(tables.len(), 1, "Should find exactly one table");
1902        assert!(
1903            tables[0].list_context.is_none(),
1904            "Should NOT have list context since delimiter has no indentation"
1905        );
1906    }
1907
1908    #[test]
1909    fn test_list_table_with_properly_indented_delimiter() {
1910        // Test case: list item with table header, delimiter properly indented
1911        // This SHOULD be detected as a list table
1912        let content = "- | Header 1 | Header 2 |\n  |----------|----------|\n  | Cell 1   | Cell 2   |";
1913        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1914        let tables = TableUtils::find_table_blocks(content, &ctx);
1915
1916        // Should find exactly one list-table starting at line 0
1917        assert_eq!(tables.len(), 1, "Should find exactly one table");
1918        assert_eq!(tables[0].start_line, 0, "Table should start at list item line");
1919        assert!(
1920            tables[0].list_context.is_some(),
1921            "Should be a list table since delimiter is properly indented"
1922        );
1923    }
1924
1925    #[test]
1926    fn test_mask_pipes_in_inline_code_regular_backticks() {
1927        // Regular backtick code span: pipe should be masked
1928        let result = TableUtils::mask_pipes_in_inline_code("| `code | here` |");
1929        assert_eq!(result, "| `code _ here` |");
1930    }
1931
1932    #[test]
1933    fn test_mask_pipes_in_inline_code_escaped_backtick_not_code_span() {
1934        // Escaped backtick (\`) is literal text, not a code span opener.
1935        // The pipe should NOT be masked.
1936        let result = TableUtils::mask_pipes_in_inline_code(r"| \`not code | still pipe\` |");
1937        assert_eq!(result, r"| \`not code | still pipe\` |");
1938    }
1939
1940    #[test]
1941    fn test_mask_pipes_in_inline_code_escaped_backslash_then_backtick() {
1942        // Escaped backslash (\\) followed by backtick: the backtick IS a code span opener.
1943        // The pipe inside the code span SHOULD be masked.
1944        let result = TableUtils::mask_pipes_in_inline_code(r"| \\`real code | masked\\` |");
1945        // \\` = escaped backslash + real backtick (code span opener)
1946        // The pipe between the backticks should be masked
1947        assert_eq!(result, r"| \\`real code _ masked\\` |");
1948    }
1949
1950    #[test]
1951    fn test_mask_pipes_in_inline_code_triple_backslash_before_backtick() {
1952        // Three backslashes before backtick: odd count means backtick is escaped
1953        let result = TableUtils::mask_pipes_in_inline_code(r"| \\\`not code | pipe\\\` |");
1954        assert_eq!(result, r"| \\\`not code | pipe\\\` |");
1955    }
1956
1957    #[test]
1958    fn test_mask_pipes_in_inline_code_four_backslashes_before_backtick() {
1959        // Four backslashes before backtick: even count means backtick is a real delimiter
1960        let result = TableUtils::mask_pipes_in_inline_code(r"| \\\\`code | here\\\\` |");
1961        assert_eq!(result, r"| \\\\`code _ here\\\\` |");
1962    }
1963
1964    #[test]
1965    fn test_mask_pipes_in_inline_code_no_backslash() {
1966        // No backslashes at all: standard behavior, pipe inside code span is masked
1967        let result = TableUtils::mask_pipes_in_inline_code("before `a | b` after");
1968        assert_eq!(result, "before `a _ b` after");
1969    }
1970
1971    #[test]
1972    fn test_mask_pipes_in_inline_code_no_code_span() {
1973        // No backticks at all: nothing should be masked
1974        let result = TableUtils::mask_pipes_in_inline_code("| col1 | col2 |");
1975        assert_eq!(result, "| col1 | col2 |");
1976    }
1977
1978    #[test]
1979    fn test_mask_pipes_in_inline_code_backslash_before_closing_backtick() {
1980        // Per CommonMark spec, backslash escapes do NOT work inside code spans.
1981        // Inside a code span, `\` is a literal character. So `foo\` is a valid
1982        // code span containing "foo\", and the closing backtick is NOT escaped.
1983        //
1984        // Input: | `foo\` | bar |
1985        // The code span is `foo\` (backtick opens, backslash is literal, backtick closes).
1986        // The pipe after the code span is a real delimiter, producing 2 cells.
1987        // The pipe inside the code span should be left alone (there isn't one here).
1988        let result = TableUtils::mask_pipes_in_inline_code(r"| `foo\` | bar |");
1989        // The backslash before closing backtick is literal inside the code span,
1990        // so the code span closes at that backtick. The pipe between cells is NOT masked.
1991        assert_eq!(result, r"| `foo\` | bar |");
1992    }
1993
1994    #[test]
1995    fn test_mask_pipes_in_inline_code_backslash_literal_with_pipe_inside() {
1996        // Code span contains a backslash and a pipe: `a\|b`
1997        // The backslash is literal inside the code span (CommonMark spec).
1998        // The pipe is inside the code span, so it should be masked.
1999        let result = TableUtils::mask_pipes_in_inline_code(r"| `a\|b` | col2 |");
2000        assert_eq!(result, r"| `a\_b` | col2 |");
2001    }
2002
2003    #[test]
2004    fn test_count_preceding_backslashes() {
2005        let chars: Vec<char> = r"abc\\\`def".chars().collect();
2006        // Position of backtick is at index 6 (a=0, b=1, c=2, \=3, \=4, \=5, `=6)
2007        assert_eq!(TableUtils::count_preceding_backslashes(&chars, 6), 3);
2008
2009        let chars2: Vec<char> = r"abc\\`def".chars().collect();
2010        // Position of backtick is at index 5
2011        assert_eq!(TableUtils::count_preceding_backslashes(&chars2, 5), 2);
2012
2013        let chars3: Vec<char> = "`def".chars().collect();
2014        // Position of backtick is at index 0 -- no preceding chars
2015        assert_eq!(TableUtils::count_preceding_backslashes(&chars3, 0), 0);
2016    }
2017
2018    #[test]
2019    fn test_has_unescaped_pipe_backslash_literal_in_code_span() {
2020        // Per CommonMark: backslashes are literal inside code spans.
2021        // `foo\` is a complete code span, so the pipe after it is outside code.
2022        assert!(TableUtils::has_unescaped_pipe_outside_spans(r"`foo\` | bar"));
2023
2024        // Escaped backtick outside code span: \` is not a code span opener
2025        assert!(TableUtils::has_unescaped_pipe_outside_spans(r"\`foo | bar\`"));
2026
2027        // Pipe inside code span should not count
2028        assert!(!TableUtils::has_unescaped_pipe_outside_spans(r"`foo | bar`"));
2029    }
2030
2031    #[test]
2032    fn test_table_after_code_span_detected() {
2033        use crate::config::MarkdownFlavor;
2034
2035        let content = "`code`\n\n| A | B |\n|---|---|\n| 1 | 2 |\n";
2036        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2037        assert!(!ctx.table_blocks.is_empty(), "Table after code span should be detected");
2038    }
2039
2040    #[test]
2041    fn test_table_inside_html_comment_not_detected() {
2042        use crate::config::MarkdownFlavor;
2043
2044        let content = "<!--\n| A | B |\n|---|---|\n| 1 | 2 |\n-->\n";
2045        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2046        assert!(
2047            ctx.table_blocks.is_empty(),
2048            "Table inside HTML comment should not be detected"
2049        );
2050    }
2051}