rumdl_lib/rules/
md058_blanks_around_tables.rs

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