Skip to main content

rumdl_lib/rules/
md058_blanks_around_tables.rs

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