Skip to main content

rumdl_lib/utils/
table_utils.rs

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