Skip to main content

rumdl_lib/rules/
md058_blanks_around_tables.rs

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