Skip to main content

rumdl_lib/utils/
table_utils.rs

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