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