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