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