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