Skip to main content

rumdl_lib/rules/
md058_blanks_around_tables.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::utils::mkdocs_attr_list::is_block_attribute_line;
4use serde::{Deserialize, Serialize};
5
6/// Rule MD058: Blanks around tables
7///
8/// See [docs/md058.md](../../docs/md058.md) for full documentation, configuration, and examples.
9///
10/// Ensures tables have blank lines before and after them
11///
12/// Configuration for MD058 rule
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(rename_all = "kebab-case")]
15pub struct MD058Config {
16    /// Minimum number of blank lines before tables
17    #[serde(default = "default_minimum_before")]
18    pub minimum_before: usize,
19    /// Minimum number of blank lines after tables
20    #[serde(default = "default_minimum_after")]
21    pub minimum_after: usize,
22}
23
24impl Default for MD058Config {
25    fn default() -> Self {
26        Self {
27            minimum_before: default_minimum_before(),
28            minimum_after: default_minimum_after(),
29        }
30    }
31}
32
33fn default_minimum_before() -> usize {
34    1
35}
36
37fn default_minimum_after() -> usize {
38    1
39}
40
41impl RuleConfig for MD058Config {
42    const RULE_NAME: &'static str = "MD058";
43}
44
45#[derive(Clone, Default)]
46pub struct MD058BlanksAroundTables {
47    config: MD058Config,
48}
49
50impl MD058BlanksAroundTables {
51    /// Create a new instance with the given configuration
52    pub fn from_config_struct(config: MD058Config) -> Self {
53        Self { config }
54    }
55
56    /// Check if a line is blank (including blockquote continuation lines and
57    /// lines holding nothing but HTML comments)
58    ///
59    /// Delegates to the shared `is_blank_in_blockquote_context` utility function.
60    /// This ensures consistent blank line detection across all rules that need
61    /// to handle blockquote-prefixed blank lines (MD058, MD065, etc.), and to
62    /// `is_blank_or_comment_only` for the comment convention the blank-line
63    /// rules share (#866).
64    fn is_blank_line(&self, line: &str) -> bool {
65        crate::utils::regex_cache::is_blank_in_blockquote_context(line)
66            || crate::utils::blank_lines::is_blank_or_comment_only(line)
67    }
68
69    /// Count the number of blank lines before a given line index
70    fn count_blank_lines_before(&self, lines: &[&str], line_index: usize) -> usize {
71        let mut count = 0;
72        let mut i = line_index;
73        while i > 0 {
74            i -= 1;
75            if self.is_blank_line(lines[i]) {
76                count += 1;
77            } else {
78                break;
79            }
80        }
81        count
82    }
83
84    /// Count the number of blank lines after a given line index
85    fn count_blank_lines_after(&self, lines: &[&str], line_index: usize) -> usize {
86        let mut count = 0;
87        let mut i = line_index + 1;
88        while i < lines.len() {
89            if self.is_blank_line(lines[i]) {
90                count += 1;
91                i += 1;
92            } else {
93                break;
94            }
95        }
96        count
97    }
98}
99
100/// Three or more `-`, `*` or `_` of one kind, spaces and tabs allowed between.
101fn is_thematic_break(text: &str) -> bool {
102    let mut chars = text.chars().filter(|c| !matches!(c, ' ' | '\t'));
103    let Some(first) = chars.next().filter(|c| matches!(c, '-' | '*' | '_')) else {
104        return false;
105    };
106    let mut count = 1;
107    for c in chars {
108        if c != first {
109            return false;
110        }
111        count += 1;
112    }
113    count >= 3
114}
115
116/// True when `line` holds only container markers (blockquote `>` and list
117/// markers) and the last one is a list marker, e.g. `-`, `> 1.` or `- -`.
118fn is_bare_list_marker_line(line: &str) -> bool {
119    let mut rest = line;
120    let mut last_is_list = false;
121    let mut markers = 0usize;
122    loop {
123        rest = rest.trim_start_matches([' ', '\t']);
124        let Some(first) = rest.chars().next() else {
125            break;
126        };
127        if is_thematic_break(rest) {
128            return false;
129        }
130        let after_marker = |len: usize| rest[len..].chars().next().is_none_or(|c| c == ' ' || c == '\t');
131        if first == '>' {
132            rest = &rest[1..];
133            last_is_list = false;
134        } else if matches!(first, '-' | '*' | '+') && after_marker(1) {
135            rest = &rest[1..];
136            last_is_list = true;
137        } else {
138            let digits = rest.chars().take_while(char::is_ascii_digit).count();
139            let terminator = rest[digits..].chars().next();
140            if (1..=9).contains(&digits) && matches!(terminator, Some('.' | ')')) && after_marker(digits + 1) {
141                rest = &rest[digits + 1..];
142                last_is_list = true;
143            } else {
144                return false;
145            }
146        }
147        markers += 1;
148    }
149    last_is_list && markers > 0
150}
151
152impl Rule for MD058BlanksAroundTables {
153    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
154        (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(58))
155    }
156
157    fn name(&self) -> &'static str {
158        "MD058"
159    }
160
161    fn description(&self) -> &'static str {
162        "Tables should be surrounded by blank lines"
163    }
164
165    fn category(&self) -> RuleCategory {
166        RuleCategory::Table
167    }
168
169    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
170        // Skip if no tables present
171        !ctx.likely_has_tables()
172    }
173
174    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
175        let content = ctx.content;
176        let mut warnings = Vec::new();
177
178        // Early return for empty content or content without tables
179        if content.is_empty() || !content.contains('|') {
180            return Ok(Vec::new());
181        }
182
183        let lines = ctx.raw_lines();
184
185        // Use pre-computed table blocks from context
186        let table_blocks = &ctx.table_blocks;
187
188        for table_block in table_blocks.iter().filter(|block| block.parser_confirmed) {
189            // Check for sufficient blank lines before table
190            // A table that is the content of a bare marker's item has no line to put
191            // a blank before. Only a marker the parser reads as an item counts: an
192            // empty item cannot interrupt a paragraph, so under paragraph text the
193            // marker underlines it as a setext heading and the table follows it.
194            let item_content = table_block.list_context.is_some()
195                && table_block.start_line > 0
196                && is_bare_list_marker_line(lines[table_block.start_line - 1])
197                && ctx.list_item_on_line(table_block.start_line).is_some();
198            // The opening tag of a `markdown="1"` element starts the block its content
199            // belongs to, so a table right under it follows no other block.
200            // In MDX `markdown` is an ordinary JSX prop and opens nothing.
201            let opens_container = table_block.start_line > 0
202                && ctx.flavor != crate::config::MarkdownFlavor::MDX
203                && ctx.lines[table_block.start_line - 1].in_mkdocs_html_markdown
204                && crate::utils::mkdocs_html_markdown::opens_markdown_block_alone(lines[table_block.start_line - 1]);
205            // The first line of an admonition body has no blank line to put before it:
206            // the opener line is the container edge, and the table renders inside it.
207            let admonition_first_line = ctx.flavor == crate::config::MarkdownFlavor::MkDocs
208                && table_block.start_line > 0
209                && crate::utils::mkdocs_admonitions::is_admonition_start(lines[table_block.start_line - 1])
210                && crate::utils::mkdocs_admonitions::is_admonition_content(
211                    lines[table_block.start_line],
212                    lines[table_block.start_line - 1].len() - lines[table_block.start_line - 1].trim_start().len(),
213                );
214            if table_block.start_line > 0 && !item_content && !opens_container && !admonition_first_line {
215                let blank_lines_before = self.count_blank_lines_before(lines, table_block.start_line);
216                if blank_lines_before < self.config.minimum_before {
217                    let needed = self.config.minimum_before - blank_lines_before;
218                    let message = if self.config.minimum_before == 1 {
219                        "Missing blank line before table".to_string()
220                    } else {
221                        format!("Missing {needed} blank lines before table")
222                    };
223
224                    let bq_prefix = ctx.blockquote_prefix_for_blank_line(table_block.start_line);
225                    let replacement = format!("{bq_prefix}\n").repeat(needed);
226                    let fix = (ctx.flavor == crate::config::MarkdownFlavor::MDG
227                        || !crate::utils::table_utils::TableUtils::header_is_indented_as_code(
228                            lines[table_block.start_line],
229                            lines[table_block.delimiter_line],
230                            table_block,
231                        ))
232                    .then(|| Fix::new(ctx.line_column_byte_range(table_block.start_line + 1, 1), replacement));
233                    warnings.push(LintWarning {
234                        rule_name: Some(self.name().to_string()),
235                        message,
236                        line: table_block.start_line + 1,
237                        column: 1,
238                        end_line: table_block.start_line + 1,
239                        end_column: 2,
240                        severity: Severity::Warning,
241                        fix,
242                    });
243                }
244            }
245
246            // Check for sufficient blank lines after table
247            if table_block.end_line < lines.len() - 1 {
248                // Check if the next line is a block attribute list attached to the table
249                let next_line_is_attribute = if table_block.end_line + 1 < lines.len() {
250                    is_block_attribute_line(lines[table_block.end_line + 1], ctx.flavor)
251                        // The closing tag of a `markdown` element ends the block its table is in.
252                        || (ctx.flavor != crate::config::MarkdownFlavor::MDX
253                            && ctx.lines[table_block.end_line + 1].closes_markdown_element)
254                } else {
255                    false
256                };
257
258                // Skip check if next line is a block attribute
259                if !next_line_is_attribute {
260                    let blank_lines_after = self.count_blank_lines_after(lines, table_block.end_line);
261                    if blank_lines_after < self.config.minimum_after {
262                        let needed = self.config.minimum_after - blank_lines_after;
263                        let message = if self.config.minimum_after == 1 {
264                            "Missing blank line after table".to_string()
265                        } else {
266                            format!("Missing {needed} blank lines after table")
267                        };
268
269                        let bq_prefix = ctx.blockquote_prefix_for_blank_line(table_block.end_line);
270                        let replacement = format!("{bq_prefix}\n").repeat(needed);
271                        warnings.push(LintWarning {
272                            rule_name: Some(self.name().to_string()),
273                            message,
274                            line: table_block.end_line + 1,
275                            column: lines[table_block.end_line].chars().count() + 1,
276                            end_line: table_block.end_line + 1,
277                            end_column: lines[table_block.end_line].chars().count() + 2,
278                            severity: Severity::Warning,
279                            fix: Some(Fix::new(
280                                ctx.line_column_byte_range(
281                                    table_block.end_line + 1,
282                                    lines[table_block.end_line].len() + 1,
283                                ),
284                                replacement,
285                            )),
286                        });
287                    }
288                }
289            }
290        }
291
292        Ok(warnings)
293    }
294
295    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
296        let content = ctx.content;
297
298        let warnings = self.check(ctx)?;
299        let mut warnings =
300            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
301        warnings.retain(|warning| warning.fix.is_some());
302        if warnings.is_empty() {
303            return Ok(content.to_string());
304        }
305
306        let lines = ctx.raw_lines();
307        let mut result = Vec::new();
308        let mut i = 0;
309
310        while i < lines.len() {
311            // Check for warnings about missing blank lines before table
312            let warning_before = warnings
313                .iter()
314                .position(|w| w.line == i + 1 && w.message.contains("before table"));
315
316            if let Some(idx) = warning_before {
317                let warning = &warnings[idx];
318                // Extract number of needed blank lines from the message or use config default
319                let needed_blanks = if warning.message.contains("Missing blank line before") {
320                    1
321                } else if let Some(start) = warning.message.find("Missing ") {
322                    if let Some(end) = warning.message.find(" blank lines before") {
323                        warning.message[start + 8..end].parse::<usize>().unwrap_or(1)
324                    } else {
325                        1
326                    }
327                } else {
328                    1
329                };
330
331                // Add the required number of blank lines with blockquote prefix
332                let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
333                for _ in 0..needed_blanks {
334                    result.push(bq_prefix.clone());
335                }
336                warnings.remove(idx);
337            }
338
339            result.push(lines[i].to_string());
340
341            // Check for warnings about missing blank lines after table
342            let warning_after = warnings
343                .iter()
344                .position(|w| w.line == i + 1 && w.message.contains("after table"));
345
346            if let Some(idx) = warning_after {
347                let warning = &warnings[idx];
348                // Extract number of needed blank lines from the message or use config default
349                let needed_blanks = if warning.message.contains("Missing blank line after") {
350                    1
351                } else if let Some(start) = warning.message.find("Missing ") {
352                    if let Some(end) = warning.message.find(" blank lines after") {
353                        warning.message[start + 8..end].parse::<usize>().unwrap_or(1)
354                    } else {
355                        1
356                    }
357                } else {
358                    1
359                };
360
361                // Add the required number of blank lines with blockquote prefix
362                let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
363                for _ in 0..needed_blanks {
364                    result.push(bq_prefix.clone());
365                }
366                warnings.remove(idx);
367            }
368
369            i += 1;
370        }
371
372        let mut fixed = result.join("\n");
373        if content.ends_with('\n') {
374            fixed.push('\n');
375        }
376
377        Ok(fixed)
378    }
379
380    fn as_any(&self) -> &dyn std::any::Any {
381        self
382    }
383
384    crate::impl_rule_config_methods!(MD058Config);
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::lint_context::LintContext;
391    use crate::utils::table_utils::TableUtils;
392
393    #[test]
394    fn test_table_with_blanks() {
395        let rule = MD058BlanksAroundTables::default();
396        let content = "Some text before.
397
398| Header 1 | Header 2 |
399|----------|----------|
400| Cell 1   | Cell 2   |
401
402Some text after.";
403        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
404        let result = rule.check(&ctx).unwrap();
405
406        assert_eq!(result.len(), 0);
407    }
408
409    #[test]
410    fn test_table_missing_blank_before() {
411        let rule = MD058BlanksAroundTables::default();
412        let content = "Some text before.
413| Header 1 | Header 2 |
414|----------|----------|
415| Cell 1   | Cell 2   |
416
417Some text after.";
418        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
419        let result = rule.check(&ctx).unwrap();
420
421        assert_eq!(result.len(), 1);
422        assert_eq!(result[0].line, 2);
423        assert!(result[0].message.contains("Missing blank line before table"));
424    }
425
426    #[test]
427    fn test_table_missing_blank_after() {
428        let rule = MD058BlanksAroundTables::default();
429        let content = "Some text before.
430
431| Header 1 | Header 2 |
432|----------|----------|
433| Cell 1   | Cell 2   |
434## Some text after";
435        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
436        let result = rule.check(&ctx).unwrap();
437
438        assert_eq!(result.len(), 1);
439        assert_eq!(result[0].line, 5);
440        assert!(result[0].message.contains("Missing blank line after table"));
441    }
442
443    #[test]
444    fn test_table_missing_both_blanks() {
445        let rule = MD058BlanksAroundTables::default();
446        let content = "Some text before.
447| Header 1 | Header 2 |
448|----------|----------|
449| Cell 1   | Cell 2   |
450## Some text after";
451        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
452        let result = rule.check(&ctx).unwrap();
453
454        assert_eq!(result.len(), 2);
455        assert!(result[0].message.contains("Missing blank line before table"));
456        assert!(result[1].message.contains("Missing blank line after table"));
457    }
458
459    #[test]
460    fn test_table_at_start_of_document() {
461        let rule = MD058BlanksAroundTables::default();
462        let content = "| Header 1 | Header 2 |
463|----------|----------|
464| Cell 1   | Cell 2   |
465
466Some text after.";
467        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
468        let result = rule.check(&ctx).unwrap();
469
470        // No blank line needed before table at start of document
471        assert_eq!(result.len(), 0);
472    }
473
474    #[test]
475    fn test_table_at_end_of_document() {
476        let rule = MD058BlanksAroundTables::default();
477        let content = "Some text before.
478
479| Header 1 | Header 2 |
480|----------|----------|
481| Cell 1   | Cell 2   |";
482        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
483        let result = rule.check(&ctx).unwrap();
484
485        // No blank line needed after table at end of document
486        assert_eq!(result.len(), 0);
487    }
488
489    #[test]
490    fn test_multiple_tables() {
491        let rule = MD058BlanksAroundTables::default();
492        let content = "Text before first table.
493| Col 1 | Col 2 |
494|--------|-------|
495| Data 1 | Val 1 |
496## Between tables
497| Col A | Col B |
498|--------|-------|
499| Data 2 | Val 2 |
500## After the tables";
501        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
502        let result = rule.check(&ctx).unwrap();
503
504        assert_eq!(result.len(), 4);
505        // First table missing blanks
506        assert!(result[0].message.contains("Missing blank line before table"));
507        assert!(result[1].message.contains("Missing blank line after table"));
508        // Second table missing blanks
509        assert!(result[2].message.contains("Missing blank line before table"));
510        assert!(result[3].message.contains("Missing blank line after table"));
511    }
512
513    #[test]
514    fn test_consecutive_tables() {
515        let rule = MD058BlanksAroundTables::default();
516        let content = "Some text.
517
518| Col 1 | Col 2 |
519|--------|-------|
520| Data 1 | Val 1 |
521
522| Col A | Col B |
523|--------|-------|
524| Data 2 | Val 2 |
525
526More text.";
527        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
528        let result = rule.check(&ctx).unwrap();
529
530        // Tables separated by blank line should be OK
531        assert_eq!(result.len(), 0);
532    }
533
534    #[test]
535    fn test_consecutive_tables_no_blank() {
536        let rule = MD058BlanksAroundTables::default();
537        // Add a block between tables to force detection as separate tables
538        let content = "Some text.
539
540| Col 1 | Col 2 |
541|--------|-------|
542| Data 1 | Val 1 |
543## Between
544| Col A | Col B |
545|--------|-------|
546| Data 2 | Val 2 |
547
548More text.";
549        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
550        let result = rule.check(&ctx).unwrap();
551
552        // Should flag missing blanks around both tables
553        assert_eq!(result.len(), 2);
554        assert!(result[0].message.contains("Missing blank line after table"));
555        assert!(result[1].message.contains("Missing blank line before table"));
556    }
557
558    #[test]
559    fn test_fix_missing_blanks() {
560        let rule = MD058BlanksAroundTables::default();
561        let content = "Text before.
562| Header | Col 2 |
563|--------|-------|
564| Cell   | Data  |
565# Text after";
566        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
567        let fixed = rule.fix(&ctx).unwrap();
568
569        let expected = "Text before.
570
571| Header | Col 2 |
572|--------|-------|
573| Cell   | Data  |
574
575# Text after";
576        assert_eq!(fixed, expected);
577    }
578
579    #[test]
580    fn test_fix_multiple_tables() {
581        let rule = MD058BlanksAroundTables::default();
582        let content = "Start
583| T1 | C1 |
584|----|----|
585| D1 | V1 |
586# Middle
587| T2 | C2 |
588|----|----|
589| D2 | V2 |
590# End";
591        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
592        let fixed = rule.fix(&ctx).unwrap();
593
594        let expected = "Start
595
596| T1 | C1 |
597|----|----|
598| D1 | V1 |
599
600# Middle
601
602| T2 | C2 |
603|----|----|
604| D2 | V2 |
605
606# End";
607        assert_eq!(fixed, expected);
608    }
609
610    #[test]
611    fn test_empty_content() {
612        let rule = MD058BlanksAroundTables::default();
613        let content = "";
614        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
615        let result = rule.check(&ctx).unwrap();
616
617        assert_eq!(result.len(), 0);
618    }
619
620    #[test]
621    fn test_no_tables() {
622        let rule = MD058BlanksAroundTables::default();
623        let content = "Just regular text.
624No tables here.
625Only paragraphs.";
626        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
627        let result = rule.check(&ctx).unwrap();
628
629        assert_eq!(result.len(), 0);
630    }
631
632    #[test]
633    fn test_code_block_with_table() {
634        let rule = MD058BlanksAroundTables::default();
635        let content = "Text before.
636```
637| Not | A | Table |
638|-----|---|-------|
639| In  | Code | Block |
640```
641Text after.";
642        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643        let result = rule.check(&ctx).unwrap();
644
645        // Tables in code blocks should be ignored
646        assert_eq!(result.len(), 0);
647    }
648
649    #[test]
650    fn test_table_with_complex_content() {
651        let rule = MD058BlanksAroundTables::default();
652        let content = "# Heading
653| Column 1 | Column 2 | Column 3 |
654|:---------|:--------:|---------:|
655| Left     | Center   | Right    |
656| Data     | More     | Info     |
657## Another Heading";
658        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
659        let result = rule.check(&ctx).unwrap();
660
661        assert_eq!(result.len(), 2);
662        assert!(result[0].message.contains("Missing blank line before table"));
663        assert!(result[1].message.contains("Missing blank line after table"));
664    }
665
666    #[test]
667    fn test_table_with_empty_cells() {
668        let rule = MD058BlanksAroundTables::default();
669        let content = "Text.
670
671|     |     |     |
672|-----|-----|-----|
673|     | X   |     |
674| O   |     | X   |
675
676More text.";
677        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
678        let result = rule.check(&ctx).unwrap();
679
680        assert_eq!(result.len(), 0);
681    }
682
683    #[test]
684    fn test_table_with_unicode() {
685        let rule = MD058BlanksAroundTables::default();
686        let content = "Unicode test.
687| 名前 | 年齢 | 都市 |
688|------|------|------|
689| 田中 | 25   | 東京 |
690| 佐藤 | 30   | 大阪 |
691## End";
692        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
693        let result = rule.check(&ctx).unwrap();
694
695        assert_eq!(result.len(), 2);
696    }
697
698    #[test]
699    fn test_table_with_long_cells() {
700        let rule = MD058BlanksAroundTables::default();
701        let content = "Before.
702
703| Short | Very very very very very very very very long header |
704|-------|-----------------------------------------------------|
705| Data  | This is an extremely long cell content that goes on |
706
707After.";
708        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
709        let result = rule.check(&ctx).unwrap();
710
711        assert_eq!(result.len(), 0);
712    }
713
714    #[test]
715    fn test_table_without_content_rows() {
716        let rule = MD058BlanksAroundTables::default();
717        let content = "Text.
718| Header 1 | Header 2 |
719|----------|----------|
720## Next section";
721        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
722        let result = rule.check(&ctx).unwrap();
723
724        // Should still require blanks around header-only table
725        assert_eq!(result.len(), 2);
726    }
727
728    #[test]
729    fn test_indented_table() {
730        let rule = MD058BlanksAroundTables::default();
731        let content = "List item:
732
733    | Indented | Table |
734    |----------|-------|
735    | Data     | Here  |
736
737    More content.";
738        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739        let result = rule.check(&ctx).unwrap();
740
741        // Indented tables should be detected
742        assert_eq!(result.len(), 0);
743    }
744
745    #[test]
746    fn test_single_column_table_not_detected() {
747        let rule = MD058BlanksAroundTables::default();
748        let content = "Text before.
749| Single |
750|--------|
751| Column |
752## Text after";
753        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
754        let result = rule.check(&ctx).unwrap();
755
756        // Single column tables ARE now detected (fixed to support 1+ columns)
757        // Expects 2 warnings: missing blank before and after table
758        assert_eq!(result.len(), 2);
759        assert!(result[0].message.contains("before"));
760        assert!(result[1].message.contains("after"));
761    }
762
763    #[test]
764    fn test_config_minimum_before() {
765        let config = MD058Config {
766            minimum_before: 2,
767            minimum_after: 1,
768        };
769        let rule = MD058BlanksAroundTables::from_config_struct(config);
770
771        let content = "Text before.
772
773| Header | Col 2 |
774|--------|-------|
775| Cell   | Data  |
776
777Text after.";
778        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
779        let result = rule.check(&ctx).unwrap();
780
781        // Should pass with 1 blank line before (but we configured to require 2)
782        assert_eq!(result.len(), 1);
783        assert!(result[0].message.contains("Missing 1 blank lines before table"));
784    }
785
786    #[test]
787    fn test_config_minimum_after() {
788        let config = MD058Config {
789            minimum_before: 1,
790            minimum_after: 3,
791        };
792        let rule = MD058BlanksAroundTables::from_config_struct(config);
793
794        let content = "Text before.
795
796| Header | Col 2 |
797|--------|-------|
798| Cell   | Data  |
799
800More text.";
801        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
802        let result = rule.check(&ctx).unwrap();
803
804        // Should fail with only 1 blank line after (but we configured to require 3)
805        assert_eq!(result.len(), 1);
806        assert!(result[0].message.contains("Missing 2 blank lines after table"));
807    }
808
809    #[test]
810    fn test_config_both_minimum() {
811        let config = MD058Config {
812            minimum_before: 2,
813            minimum_after: 2,
814        };
815        let rule = MD058BlanksAroundTables::from_config_struct(config);
816
817        let content = "Text before.
818| Header | Col 2 |
819|--------|-------|
820| Cell   | Data  |
821## More text";
822        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
823        let result = rule.check(&ctx).unwrap();
824
825        // Should fail both before and after
826        assert_eq!(result.len(), 2);
827        assert!(result[0].message.contains("Missing 2 blank lines before table"));
828        assert!(result[1].message.contains("Missing 2 blank lines after table"));
829    }
830
831    #[test]
832    fn test_config_zero_minimum() {
833        let config = MD058Config {
834            minimum_before: 0,
835            minimum_after: 0,
836        };
837        let rule = MD058BlanksAroundTables::from_config_struct(config);
838
839        let content = "Text before.
840| Header | Col 2 |
841|--------|-------|
842| Cell   | Data  |
843More text.";
844        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
845        let result = rule.check(&ctx).unwrap();
846
847        // Should pass with zero blank lines required
848        assert_eq!(result.len(), 0);
849    }
850
851    #[test]
852    fn test_fix_with_custom_config() {
853        let config = MD058Config {
854            minimum_before: 2,
855            minimum_after: 3,
856        };
857        let rule = MD058BlanksAroundTables::from_config_struct(config);
858
859        let content = "Text before.
860| Header | Col 2 |
861|--------|-------|
862| Cell   | Data  |
863# Text after";
864        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
865        let fixed = rule.fix(&ctx).unwrap();
866
867        let expected = "Text before.
868
869
870| Header | Col 2 |
871|--------|-------|
872| Cell   | Data  |
873
874
875
876# Text after";
877        assert_eq!(fixed, expected);
878    }
879
880    #[test]
881    fn test_default_config_section() {
882        let rule = MD058BlanksAroundTables::default();
883        let config_section = rule.default_config_section();
884
885        assert!(config_section.is_some());
886        let (name, value) = config_section.unwrap();
887        assert_eq!(name, "MD058");
888
889        // Should contain both minimum_before and minimum_after options with default values
890        if let toml::Value::Table(table) = value {
891            assert!(table.contains_key("minimum-before"));
892            assert!(table.contains_key("minimum-after"));
893            assert_eq!(table["minimum-before"], toml::Value::Integer(1));
894            assert_eq!(table["minimum-after"], toml::Value::Integer(1));
895        } else {
896            panic!("Expected TOML table");
897        }
898    }
899
900    #[test]
901    fn test_blank_lines_counting() {
902        let rule = MD058BlanksAroundTables::default();
903        let lines = vec!["text", "", "", "table", "more", "", "end"];
904
905        // Test counting blank lines before line index 3 (table)
906        assert_eq!(rule.count_blank_lines_before(&lines, 3), 2);
907
908        // Test counting blank lines after line index 4 (more)
909        assert_eq!(rule.count_blank_lines_after(&lines, 4), 1);
910
911        // Test at beginning
912        assert_eq!(rule.count_blank_lines_before(&lines, 0), 0);
913
914        // Test at end
915        assert_eq!(rule.count_blank_lines_after(&lines, 6), 0);
916    }
917
918    #[test]
919    fn test_issue_25_table_with_long_line() {
920        // Test case from issue #25 - table with very long line
921        let rule = MD058BlanksAroundTables::default();
922        let content = "# Title\n\nThis is a table:\n\n| Name          | Query                                                    |\n| ------------- | -------------------------------------------------------- |\n| b             | a                                                        |\n| c             | a                                                        |\n| d             | a                                                        |\n| long          | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |\n| e             | a                                                        |\n| f             | a                                                        |\n| g             | a                                                        |";
923        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
924
925        // Debug: Print detected table blocks
926        let table_blocks = TableUtils::find_table_blocks(content, &ctx);
927        for (i, block) in table_blocks.iter().enumerate() {
928            eprintln!(
929                "Table {}: start={}, end={}, header={}, delimiter={}, content_lines={:?}",
930                i + 1,
931                block.start_line + 1,
932                block.end_line + 1,
933                block.header_line + 1,
934                block.delimiter_line + 1,
935                block.content_lines.iter().map(|x| x + 1).collect::<Vec<_>>()
936            );
937        }
938
939        let result = rule.check(&ctx).unwrap();
940
941        // This should detect one table, not multiple tables
942        assert_eq!(table_blocks.len(), 1, "Should detect exactly one table block");
943
944        // Should not flag any issues since table is complete and doesn't need blanks
945        assert_eq!(result.len(), 0, "Should not flag any MD058 issues for a complete table");
946    }
947
948    #[test]
949    fn test_fix_preserves_blockquote_prefix_before_table() {
950        // Issue #268: Fix should insert blockquote-prefixed blank lines inside blockquotes
951        let rule = MD058BlanksAroundTables::default();
952
953        let content = "> Text before
954> | H1 | H2 |
955> |----|---|
956> | a  | b |";
957        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
958        let fixed = rule.fix(&ctx).unwrap();
959
960        // The blank line inserted before the table should have the blockquote prefix
961        let expected = "> Text before
962>
963> | H1 | H2 |
964> |----|---|
965> | a  | b |";
966        assert_eq!(
967            fixed, expected,
968            "Fix should insert '>' blank line before table, not plain blank line"
969        );
970    }
971
972    #[test]
973    fn test_fix_preserves_blockquote_prefix_after_table() {
974        // Issue #268: Fix should insert blockquote-prefixed blank lines inside blockquotes
975        let rule = MD058BlanksAroundTables::default();
976
977        let content = "> | H1 | H2 |
978> |----|---|
979> | a  | b |
980> # Text after";
981        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
982        let fixed = rule.fix(&ctx).unwrap();
983
984        // The blank line inserted after the table should have the blockquote prefix
985        let expected = "> | H1 | H2 |
986> |----|---|
987> | a  | b |
988>
989> # Text after";
990        assert_eq!(
991            fixed, expected,
992            "Fix should insert '>' blank line after table, not plain blank line"
993        );
994    }
995
996    #[test]
997    fn test_fix_preserves_nested_blockquote_prefix_for_table() {
998        // Nested blockquotes should preserve the full prefix
999        let rule = MD058BlanksAroundTables::default();
1000
1001        let content = ">> Nested quote
1002>> | H1 |
1003>> |----|
1004>> | a  |
1005>> # More text";
1006        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1007        let fixed = rule.fix(&ctx).unwrap();
1008
1009        // Should insert ">>" blank lines
1010        let expected = ">> Nested quote
1011>>
1012>> | H1 |
1013>> |----|
1014>> | a  |
1015>>
1016>> # More text";
1017        assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
1018    }
1019
1020    #[test]
1021    fn test_fix_preserves_triple_nested_blockquote_prefix_for_table() {
1022        // Triple-nested blockquotes should preserve full prefix
1023        let rule = MD058BlanksAroundTables::default();
1024
1025        let content = ">>> Triple nested
1026>>> | A | B |
1027>>> |---|---|
1028>>> | 1 | 2 |
1029>>> # More text";
1030        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1031        let fixed = rule.fix(&ctx).unwrap();
1032
1033        let expected = ">>> Triple nested
1034>>>
1035>>> | A | B |
1036>>> |---|---|
1037>>> | 1 | 2 |
1038>>>
1039>>> # More text";
1040        assert_eq!(
1041            fixed, expected,
1042            "Fix should preserve triple-nested blockquote prefix '>>>'"
1043        );
1044    }
1045
1046    // =========================================================================
1047    // Issue #305: Tables inside blockquotes with existing blank lines
1048    // These tests verify that MD058 correctly recognizes blockquote continuation
1049    // lines (e.g., ">") as "blank" lines for table spacing purposes.
1050    // =========================================================================
1051
1052    #[test]
1053    fn test_is_blank_line_with_blockquote_continuation() {
1054        // Unit tests for is_blank_line recognizing blockquote blanks
1055        let rule = MD058BlanksAroundTables::default();
1056
1057        // Regular blank lines
1058        assert!(rule.is_blank_line(""));
1059        assert!(rule.is_blank_line("   "));
1060        assert!(rule.is_blank_line("\t"));
1061        assert!(rule.is_blank_line("  \t  "));
1062
1063        // Blockquote continuation lines (should be treated as blank)
1064        assert!(rule.is_blank_line(">"));
1065        assert!(rule.is_blank_line("> "));
1066        assert!(rule.is_blank_line(">  "));
1067        assert!(rule.is_blank_line(">>"));
1068        assert!(rule.is_blank_line(">> "));
1069        assert!(rule.is_blank_line(">>>"));
1070        assert!(rule.is_blank_line("> > "));
1071        assert!(rule.is_blank_line("> > > "));
1072        assert!(rule.is_blank_line("  >  ")); // With leading/trailing whitespace
1073
1074        // Lines with content (should NOT be treated as blank)
1075        assert!(!rule.is_blank_line("text"));
1076        assert!(!rule.is_blank_line("> text"));
1077        assert!(!rule.is_blank_line(">> text"));
1078        assert!(!rule.is_blank_line("> | table |"));
1079        assert!(!rule.is_blank_line("| table |"));
1080    }
1081
1082    #[test]
1083    fn test_issue_305_no_warning_blockquote_with_existing_blank_before_table() {
1084        // Issue #305: Table inside blockquote with existing blank line before
1085        // should NOT trigger MD058 warning
1086        let rule = MD058BlanksAroundTables::default();
1087
1088        let content = "> Text before
1089>
1090> | H1 | H2 |
1091> |----|---|
1092> | a  | b |";
1093        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1094        let result = rule.check(&ctx).unwrap();
1095
1096        assert_eq!(
1097            result.len(),
1098            0,
1099            "Should not warn when blockquote already has blank line before table"
1100        );
1101    }
1102
1103    #[test]
1104    fn test_issue_305_no_warning_blockquote_with_existing_blank_after_table() {
1105        // Issue #305: Table inside blockquote with existing blank line after
1106        // should NOT trigger MD058 warning
1107        let rule = MD058BlanksAroundTables::default();
1108
1109        let content = "> | H1 | H2 |
1110> |----|---|
1111> | a  | b |
1112>
1113> Text after";
1114        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1115        let result = rule.check(&ctx).unwrap();
1116
1117        assert_eq!(
1118            result.len(),
1119            0,
1120            "Should not warn when blockquote already has blank line after table"
1121        );
1122    }
1123
1124    #[test]
1125    fn test_issue_305_no_warning_blockquote_with_both_blank_lines() {
1126        // Issue #305: Complete example from the issue report
1127        let rule = MD058BlanksAroundTables::default();
1128
1129        let content = "> The following options are available:
1130>
1131> | Option | Default   | Description       |
1132> |--------|-----------|-------------------|
1133> | port   | 3000      | Server port       |
1134> | host   | localhost | Server host       |";
1135        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1136        let result = rule.check(&ctx).unwrap();
1137
1138        assert_eq!(
1139            result.len(),
1140            0,
1141            "Issue #305: Should not warn for valid table inside blockquote with blank line"
1142        );
1143    }
1144
1145    #[test]
1146    fn test_issue_305_no_warning_nested_blockquote_with_blank_lines() {
1147        // Nested blockquote with blank lines should not warn
1148        let rule = MD058BlanksAroundTables::default();
1149
1150        let content = ">> Nested text
1151>>
1152>> | Col1 | Col2 |
1153>> |------|------|
1154>> | val1 | val2 |
1155>>
1156>> More text";
1157        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1158        let result = rule.check(&ctx).unwrap();
1159
1160        assert_eq!(
1161            result.len(),
1162            0,
1163            "Should not warn for nested blockquote table with blank lines"
1164        );
1165    }
1166
1167    #[test]
1168    fn test_issue_305_no_warning_triple_nested_blockquote_with_blank_lines() {
1169        // Triple-nested blockquote with blank lines should not warn
1170        let rule = MD058BlanksAroundTables::default();
1171
1172        let content = ">>> Deep nesting
1173>>>
1174>>> | A | B |
1175>>> |---|---|
1176>>> | 1 | 2 |
1177>>>
1178>>> End";
1179        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1180        let result = rule.check(&ctx).unwrap();
1181
1182        assert_eq!(
1183            result.len(),
1184            0,
1185            "Should not warn for triple-nested blockquote table with blank lines"
1186        );
1187    }
1188
1189    #[test]
1190    fn test_issue_305_fix_does_not_corrupt_valid_blockquote_table() {
1191        // Critical: Verify that fix() doesn't corrupt already-valid content
1192        let rule = MD058BlanksAroundTables::default();
1193
1194        let content = "> Text before
1195>
1196> | H1 | H2 |
1197> |----|---|
1198> | a  | b |
1199>
1200> Text after";
1201        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1202        let fixed = rule.fix(&ctx).unwrap();
1203
1204        assert_eq!(fixed, content, "Fix should not modify already-valid blockquote table");
1205    }
1206
1207    #[test]
1208    fn test_issue_305_blockquote_blank_with_trailing_space() {
1209        // Blockquote blank line with trailing space ("> ") should be recognized
1210        let rule = MD058BlanksAroundTables::default();
1211
1212        // Note: The "> " has a trailing space
1213        let content = "> Text before
1214>
1215> | H1 | H2 |
1216> |----|---|
1217> | a  | b |";
1218        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1219        let result = rule.check(&ctx).unwrap();
1220
1221        assert_eq!(
1222            result.len(),
1223            0,
1224            "Should recognize '> ' (with trailing space) as blank line"
1225        );
1226    }
1227
1228    #[test]
1229    fn test_issue_305_spaced_nested_blockquote() {
1230        // "> > " style nested blockquote should be recognized
1231        let rule = MD058BlanksAroundTables::default();
1232
1233        let content = "> > Nested text
1234> >
1235> > | H1 |
1236> > |----|
1237> > | a  |";
1238        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1239        let result = rule.check(&ctx).unwrap();
1240
1241        assert_eq!(
1242            result.len(),
1243            0,
1244            "Should recognize '> > ' style nested blockquote blank line"
1245        );
1246    }
1247
1248    #[test]
1249    fn test_mixed_regular_and_blockquote_tables() {
1250        // Document with both regular tables and blockquote tables
1251        let rule = MD058BlanksAroundTables::default();
1252
1253        let content = "# Mixed Content
1254
1255Regular table:
1256
1257| A | B |
1258|---|---|
1259| 1 | 2 |
1260
1261And a blockquote table:
1262
1263> Quote text
1264>
1265> | X | Y |
1266> |---|---|
1267> | 3 | 4 |
1268>
1269> End quote
1270
1271Final paragraph.";
1272        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1273        let result = rule.check(&ctx).unwrap();
1274
1275        assert_eq!(
1276            result.len(),
1277            0,
1278            "Should handle mixed regular and blockquote tables correctly"
1279        );
1280    }
1281
1282    #[test]
1283    fn test_blockquote_table_at_document_start() {
1284        // Table in blockquote at very start of document
1285        let rule = MD058BlanksAroundTables::default();
1286
1287        let content = "> | H1 | H2 |
1288> |----|---|
1289> | a  | b |
1290>
1291> Text after";
1292        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1293        let result = rule.check(&ctx).unwrap();
1294
1295        assert_eq!(
1296            result.len(),
1297            0,
1298            "Should not require blank line before table at document start (even in blockquote)"
1299        );
1300    }
1301
1302    #[test]
1303    fn test_blockquote_table_at_document_end() {
1304        // Table in blockquote at very end of document
1305        let rule = MD058BlanksAroundTables::default();
1306
1307        let content = "> Text before
1308>
1309> | H1 | H2 |
1310> |----|---|
1311> | a  | b |";
1312        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1313        let result = rule.check(&ctx).unwrap();
1314
1315        assert_eq!(
1316            result.len(),
1317            0,
1318            "Should not require blank line after table at document end"
1319        );
1320    }
1321
1322    #[test]
1323    fn test_blockquote_table_missing_blank_still_detected() {
1324        // Ensure we still detect ACTUAL missing blank lines in blockquotes
1325        let rule = MD058BlanksAroundTables::default();
1326
1327        let content = "> Text before
1328> | H1 | H2 |
1329> |----|---|
1330> | a  | b |
1331> # Text after";
1332        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1333        let result = rule.check(&ctx).unwrap();
1334
1335        // Should have 2 warnings: missing blank before AND after table
1336        assert_eq!(
1337            result.len(),
1338            2,
1339            "Should still detect missing blank lines in blockquote tables"
1340        );
1341        assert!(result[0].message.contains("before table"));
1342        assert!(result[1].message.contains("after table"));
1343    }
1344
1345    #[test]
1346    fn test_blockquote_table_fix_adds_correct_prefix() {
1347        // Verify fix adds blockquote-prefixed blank lines when needed
1348        let rule = MD058BlanksAroundTables::default();
1349
1350        let content = "> Text before
1351> | H1 | H2 |
1352> |----|---|
1353> | a  | b |
1354> # Text after";
1355        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1356        let fixed = rule.fix(&ctx).unwrap();
1357
1358        let expected = "> Text before
1359>
1360> | H1 | H2 |
1361> |----|---|
1362> | a  | b |
1363>
1364> # Text after";
1365        assert_eq!(fixed, expected, "Fix should add blockquote-prefixed blank lines");
1366    }
1367
1368    #[test]
1369    fn test_multiple_blockquote_tables_with_valid_spacing() {
1370        // Multiple tables in same blockquote, all with proper spacing
1371        let rule = MD058BlanksAroundTables::default();
1372
1373        let content = "> First table:
1374>
1375> | A | B |
1376> |---|---|
1377> | 1 | 2 |
1378>
1379> Second table:
1380>
1381> | X | Y |
1382> |---|---|
1383> | 3 | 4 |
1384>
1385> End";
1386        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1387        let result = rule.check(&ctx).unwrap();
1388
1389        assert_eq!(
1390            result.len(),
1391            0,
1392            "Should handle multiple blockquote tables with valid spacing"
1393        );
1394    }
1395
1396    #[test]
1397    fn test_blockquote_table_with_minimum_before_config() {
1398        // Test with custom minimum_before config
1399        let config = MD058Config {
1400            minimum_before: 2,
1401            minimum_after: 1,
1402        };
1403        let rule = MD058BlanksAroundTables::from_config_struct(config);
1404
1405        let content = "> Text
1406>
1407> | H1 |
1408> |----|
1409> | a  |";
1410        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1411        let result = rule.check(&ctx).unwrap();
1412
1413        // Should warn because only 1 blank line, but config requires 2
1414        assert_eq!(result.len(), 1);
1415        assert!(result[0].message.contains("before table"));
1416    }
1417
1418    // === Pandoc construct reachability tests ===
1419    //
1420    // These tests document that MD058 does not flag Pandoc-specific constructs
1421    // because `ctx.table_blocks` excludes them at the source:
1422    //
1423    // - Grid table delimiters use `+---+---+` (no `|`), so `is_delimiter_row`
1424    //   returns false and no `TableBlock` is created.
1425    // - Multi-line table separators have no `|`, same exclusion.
1426    // - Line blocks (`| First line`) end without `|`; `is_potential_table_row`
1427    //   requires `valid_parts >= 2` for non-outer-piped lines (only 1 found).
1428    // - Pipe-table captions (`: caption`) have no `|` — excluded.
1429    //
1430    // No production guard is needed. If `find_table_blocks` ever changes to
1431    // include these constructs, these tests will surface that.
1432
1433    #[test]
1434    fn md058_pandoc_grid_tables_not_flagged() {
1435        let rule = MD058BlanksAroundTables::default();
1436        // Grid table with no surrounding blank lines.
1437        // If grid tables were in table_blocks, MD058 would flag missing blanks.
1438        let content = "Some text before.
1439+---+---+
1440| a | b |
1441+===+===+
1442| 1 | 2 |
1443+---+---+
1444Some text after.";
1445
1446        // Under Pandoc: grid tables excluded from table_blocks — no warnings.
1447        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1448        let result = rule.check(&ctx).unwrap();
1449        assert!(
1450            result.is_empty(),
1451            "MD058 should not flag blank lines around Pandoc grid tables (excluded by table_blocks): {result:?}"
1452        );
1453
1454        // Under Standard: same content — grid table not recognized as pipe table.
1455        let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1456        let result_std = rule.check(&ctx_std).unwrap();
1457        assert!(
1458            result_std.is_empty(),
1459            "MD058 should not flag grid-table-like content under Standard: {result_std:?}"
1460        );
1461    }
1462
1463    #[test]
1464    fn md058_pandoc_multi_line_tables_not_flagged() {
1465        let rule = MD058BlanksAroundTables::default();
1466        let content = "Some text.
1467--------- -----------
1468Header 1   Header 2
1469--------- -----------
1470Cell 1     Cell 2
1471--------- -----------
1472More text.";
1473
1474        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1475        let result = rule.check(&ctx).unwrap();
1476        assert!(
1477            result.is_empty(),
1478            "MD058 should not flag Pandoc multi-line tables: {result:?}"
1479        );
1480
1481        let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1482        let result_std = rule.check(&ctx_std).unwrap();
1483        assert!(
1484            result_std.is_empty(),
1485            "MD058 should not flag multi-line table content under Standard: {result_std:?}"
1486        );
1487    }
1488
1489    #[test]
1490    fn md058_pandoc_line_blocks_not_flagged() {
1491        let rule = MD058BlanksAroundTables::default();
1492        // Pandoc line blocks are not recognized as pipe tables (no trailing `|`).
1493        let content = "Some text.
1494| First line
1495| Second line
1496More text.";
1497
1498        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1499        let result = rule.check(&ctx).unwrap();
1500        assert!(
1501            result.is_empty(),
1502            "MD058 should not treat Pandoc line blocks as tables: {result:?}"
1503        );
1504
1505        let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1506        let result_std = rule.check(&ctx_std).unwrap();
1507        assert!(
1508            result_std.is_empty(),
1509            "MD058 should not treat line-block-like content as tables under Standard: {result_std:?}"
1510        );
1511    }
1512
1513    #[test]
1514    fn md058_pandoc_pipe_table_captions_not_flagged() {
1515        let rule = MD058BlanksAroundTables::default();
1516        // Pipe-table captions (`: caption`) have no `|` — they are not table rows
1517        // and are never included in table_blocks, so MD058 ignores them.
1518        let content = "\
1519Some text.
1520
1521| H1 | H2 |
1522|----|-----|
1523| a  | b  |
1524
1525: My table caption
1526More text.";
1527
1528        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1529        let result = rule.check(&ctx).unwrap();
1530        assert!(
1531            result.is_empty(),
1532            "MD058 should not flag the pipe-table caption line as needing blank lines: {result:?}"
1533        );
1534
1535        // Under Standard: caption line has no `|` — excluded from table_blocks.
1536        // Table itself has proper blanks before and after.
1537        let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1538        let result_std = rule.check(&ctx_std).unwrap();
1539        assert!(
1540            result_std.is_empty(),
1541            "MD058 table with caption — caption not a table row under Standard: {result_std:?}"
1542        );
1543    }
1544
1545    #[test]
1546    fn md058_hugo_block_attribute_after_table_not_flagged() {
1547        // Issue #756: a Goldmark/Hugo block attribute list directly under a table
1548        // describes that table, so MD058 must not treat it as content needing a blank.
1549        let rule = MD058BlanksAroundTables::default();
1550        let content = "\
1551Some text.
1552
1553| H1 | H2 |
1554|----|-----|
1555| a  | b  |
1556{class=\"table table-striped\"}
1557
1558More text.";
1559
1560        for flavor in [
1561            crate::config::MarkdownFlavor::Hugo,
1562            crate::config::MarkdownFlavor::MkDocs,
1563            crate::config::MarkdownFlavor::Kramdown,
1564        ] {
1565            let ctx = LintContext::new(content, flavor, None);
1566            let result = rule.check(&ctx).unwrap();
1567            assert!(
1568                result.is_empty(),
1569                "MD058 should not flag the block attribute line under {flavor:?}: {result:?}"
1570            );
1571        }
1572
1573        // Under Standard the attribute line is literal text, and GFM reads it as
1574        // one more row of the table, so no blank line is missing there either.
1575        let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1576        let result_std = rule.check(&ctx_std).unwrap();
1577        assert!(
1578            result_std.is_empty(),
1579            "the attribute line is a table row under Standard: {result_std:?}"
1580        );
1581    }
1582
1583    #[test]
1584    fn test_fix_preserves_trailing_newline() {
1585        let rule = MD058BlanksAroundTables::default();
1586
1587        let content = "Intro\n| a | b |\n| --- | --- |\n| 1 | 2 |\n# After\n";
1588        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1589        let fixed = rule.fix(&ctx).unwrap();
1590
1591        assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
1592        assert_eq!(fixed, "Intro\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n# After\n");
1593    }
1594
1595    #[test]
1596    fn test_fix_preserves_no_trailing_newline() {
1597        let rule = MD058BlanksAroundTables::default();
1598
1599        let content = "Intro\n| a | b |\n| --- | --- |\n| 1 | 2 |\n# After";
1600        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1601        let fixed = rule.fix(&ctx).unwrap();
1602
1603        assert!(
1604            !fixed.ends_with('\n'),
1605            "Fix should not add trailing newline if original didn't have one"
1606        );
1607        assert_eq!(fixed, "Intro\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n# After");
1608    }
1609
1610    #[test]
1611    fn test_indented_lazy_rows_in_blockquote_are_not_a_table() {
1612        let rule = MD058BlanksAroundTables::default();
1613        for content in [
1614            "> o\n>     |a|b|\n>     |-|-|\n",
1615            ">o\n>     |a|b|\n>     |-|-|\n",
1616            "> > o\n> >     |a|b|\n> >     |-|-|\n",
1617            "> o\n> |a|b|\n>     |-|-|\n",
1618        ] {
1619            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1620            assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1621            assert_eq!(rule.fix(&ctx).unwrap(), content, "{content:?}");
1622        }
1623    }
1624
1625    #[test]
1626    fn test_real_table_after_lazy_rows_in_blockquote_is_still_checked() {
1627        let rule = MD058BlanksAroundTables::default();
1628        let content = "> o\n>     |a|b|\n>     |-|-|\n> |c|d|\n> |-|-|\n";
1629        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1630        assert_eq!(
1631            rule.fix(&ctx).unwrap(),
1632            "> o\n>     |a|b|\n>     |-|-|\n>\n> |c|d|\n> |-|-|\n"
1633        );
1634    }
1635
1636    #[test]
1637    fn test_admonition_first_body_line_table_needs_no_blank_before() {
1638        let rule = MD058BlanksAroundTables::default();
1639        for opener in ["!!! note", "??? note", "???+ note", "!!! note \"Title\"", "  !!! note"] {
1640            let indent = if opener.starts_with("  ") { "      " } else { "    " };
1641            let content = format!("{opener}\n{indent}| a | b |\n{indent}|---|---|\n{indent}| 1 | 2 |\n\nafter\n");
1642            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MkDocs, None);
1643            assert!(
1644                rule.check(&ctx).unwrap().is_empty(),
1645                "table opening an admonition body is not missing a blank line: {content:?}"
1646            );
1647            assert_eq!(rule.fix(&ctx).unwrap(), content, "fix must not touch {content:?}");
1648        }
1649    }
1650
1651    #[test]
1652    fn test_admonition_table_after_body_text_still_flagged() {
1653        let rule = MD058BlanksAroundTables::default();
1654        // Python-Markdown renders this as one paragraph, so the blank line is missing.
1655        let content = "!!! note\n\n    text\n    | a | b |\n    |---|---|\n    | 1 | 2 |\n";
1656        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1657        let result = rule.check(&ctx).unwrap();
1658        assert!(result.iter().any(|w| w.message.contains("before table")), "{result:?}");
1659    }
1660}