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