rumdl_lib/rules/
md043_required_headings.rs

1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::utils::range_utils::calculate_heading_range;
4use lazy_static::lazy_static;
5use regex::Regex;
6use serde::{Deserialize, Serialize};
7
8lazy_static! {
9    // Pattern for ATX headings
10    static ref ATX_HEADING: Regex = Regex::new(r"^(#+)\s+(.+)$").unwrap();
11    // Pattern for setext heading underlines
12    static ref SETEXT_UNDERLINE: Regex = Regex::new(r"^([=-]+)$").unwrap();
13}
14
15/// Configuration for MD043 rule
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17#[serde(rename_all = "kebab-case")]
18pub struct MD043Config {
19    /// Required heading patterns
20    #[serde(default = "default_headings")]
21    pub headings: Vec<String>,
22    /// Case-sensitive matching (default: false)
23    #[serde(default = "default_match_case")]
24    pub match_case: bool,
25}
26
27impl Default for MD043Config {
28    fn default() -> Self {
29        Self {
30            headings: default_headings(),
31            match_case: default_match_case(),
32        }
33    }
34}
35
36fn default_headings() -> Vec<String> {
37    Vec::new()
38}
39
40fn default_match_case() -> bool {
41    false
42}
43
44impl RuleConfig for MD043Config {
45    const RULE_NAME: &'static str = "MD043";
46}
47
48/// Rule MD043: Required headings present
49///
50/// See [docs/md043.md](../../docs/md043.md) for full documentation, configuration, and examples.
51#[derive(Clone, Default)]
52pub struct MD043RequiredHeadings {
53    config: MD043Config,
54}
55
56impl MD043RequiredHeadings {
57    pub fn new(headings: Vec<String>) -> Self {
58        Self {
59            config: MD043Config {
60                headings,
61                match_case: default_match_case(),
62            },
63        }
64    }
65
66    /// Create a new instance with the given configuration
67    pub fn from_config_struct(config: MD043Config) -> Self {
68        Self { config }
69    }
70
71    /// Compare two headings based on the match_case configuration
72    fn headings_match(&self, expected: &str, actual: &str) -> bool {
73        if self.config.match_case {
74            expected == actual
75        } else {
76            expected.to_lowercase() == actual.to_lowercase()
77        }
78    }
79
80    fn extract_headings(&self, ctx: &crate::lint_context::LintContext) -> Vec<String> {
81        let mut result = Vec::new();
82
83        for line_info in &ctx.lines {
84            if let Some(heading) = &line_info.heading {
85                // Reconstruct the full heading format with the hash symbols
86                let full_heading = format!("{} {}", heading.marker, heading.text.trim());
87                result.push(full_heading);
88            }
89        }
90
91        result
92    }
93
94    fn is_heading(&self, line_index: usize, ctx: &crate::lint_context::LintContext) -> bool {
95        if line_index < ctx.lines.len() {
96            ctx.lines[line_index].heading.is_some()
97        } else {
98            false
99        }
100    }
101}
102
103impl Rule for MD043RequiredHeadings {
104    fn name(&self) -> &'static str {
105        "MD043"
106    }
107
108    fn description(&self) -> &'static str {
109        "Required heading structure"
110    }
111
112    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
113        let mut warnings = Vec::new();
114        let actual_headings = self.extract_headings(ctx);
115
116        // If no required headings are specified, the rule is disabled
117        if self.config.headings.is_empty() {
118            return Ok(warnings);
119        }
120
121        // Check if headings match based on case sensitivity configuration
122        let headings_match = if actual_headings.len() != self.config.headings.len() {
123            false
124        } else {
125            actual_headings
126                .iter()
127                .zip(self.config.headings.iter())
128                .all(|(actual, expected)| self.headings_match(expected, actual))
129        };
130
131        if !headings_match {
132            // If no headings found but we have required headings, create a warning
133            if actual_headings.is_empty() && !self.config.headings.is_empty() {
134                warnings.push(LintWarning {
135                    rule_name: Some(self.name()),
136                    line: 1,
137                    column: 1,
138                    end_line: 1,
139                    end_column: 2,
140                    message: format!("Required headings not found: {:?}", self.config.headings),
141                    severity: Severity::Warning,
142                    fix: None, // No automatic fix to prevent destructive changes
143                });
144                return Ok(warnings);
145            }
146
147            // Create warnings for each heading that doesn't match
148            for (i, line_info) in ctx.lines.iter().enumerate() {
149                if self.is_heading(i, ctx) {
150                    // Calculate precise character range for the entire heading
151                    let (start_line, start_col, end_line, end_col) = calculate_heading_range(i + 1, &line_info.content);
152
153                    warnings.push(LintWarning {
154                        rule_name: Some(self.name()),
155                        line: start_line,
156                        column: start_col,
157                        end_line,
158                        end_column: end_col,
159                        message: "Heading structure does not match the required structure".to_string(),
160                        severity: Severity::Warning,
161                        fix: None, // Cannot automatically fix as we don't know the intended structure
162                    });
163                }
164            }
165
166            // If we have no warnings but headings don't match (could happen if we have no headings),
167            // add a warning at the beginning of the file
168            if warnings.is_empty() {
169                warnings.push(LintWarning {
170                    rule_name: Some(self.name()),
171                    line: 1,
172                    column: 1,
173                    end_line: 1,
174                    end_column: 2,
175                    message: format!(
176                        "Heading structure does not match required structure. Expected: {:?}, Found: {:?}",
177                        self.config.headings, actual_headings
178                    ),
179                    severity: Severity::Warning,
180                    fix: None, // No automatic fix to prevent destructive changes
181                });
182            }
183        }
184
185        Ok(warnings)
186    }
187
188    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
189        let content = ctx.content;
190        // If no required headings are specified, return content as is
191        if self.config.headings.is_empty() {
192            return Ok(content.to_string());
193        }
194
195        let actual_headings = self.extract_headings(ctx);
196
197        // Check if headings already match - if so, no fix needed
198        if actual_headings.len() == self.config.headings.len()
199            && actual_headings
200                .iter()
201                .zip(self.config.headings.iter())
202                .all(|(actual, expected)| self.headings_match(expected, actual))
203        {
204            return Ok(content.to_string());
205        }
206
207        // IMPORTANT: MD043 fixes are inherently risky as they require restructuring the document.
208        // Instead of making destructive changes, we should be conservative and only make
209        // minimal changes when we're confident about the user's intent.
210
211        // For now, we'll avoid making destructive fixes and preserve the original content.
212        // This prevents data loss while still allowing the rule to identify issues.
213
214        // TODO: In the future, this could be enhanced to:
215        // 1. Insert missing required headings at appropriate positions
216        // 2. Rename existing headings to match requirements (when structure is similar)
217        // 3. Provide more granular fixes based on the specific mismatch
218
219        // Return original content unchanged to prevent data loss
220        Ok(content.to_string())
221    }
222
223    /// Get the category of this rule for selective processing
224    fn category(&self) -> RuleCategory {
225        RuleCategory::Heading
226    }
227
228    /// Check if this rule should be skipped
229    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
230        // Skip if no heading requirements or content is empty
231        if self.config.headings.is_empty() || ctx.content.is_empty() {
232            return true;
233        }
234
235        // Check if any heading exists using cached information
236        let has_heading = ctx.lines.iter().any(|line| line.heading.is_some());
237
238        !has_heading
239    }
240
241    fn as_any(&self) -> &dyn std::any::Any {
242        self
243    }
244
245    fn default_config_section(&self) -> Option<(String, toml::Value)> {
246        let default_config = MD043Config::default();
247        let json_value = serde_json::to_value(&default_config).ok()?;
248        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
249        if let toml::Value::Table(table) = toml_value {
250            if !table.is_empty() {
251                Some((MD043Config::RULE_NAME.to_string(), toml::Value::Table(table)))
252            } else {
253                None
254            }
255        } else {
256            None
257        }
258    }
259
260    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
261    where
262        Self: Sized,
263    {
264        let rule_config = crate::rule_config_serde::load_rule_config::<MD043Config>(config);
265        Box::new(MD043RequiredHeadings::from_config_struct(rule_config))
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use crate::lint_context::LintContext;
273
274    #[test]
275    fn test_extract_headings_code_blocks() {
276        // Create rule with required headings (now with hash symbols)
277        let required = vec!["# Test Document".to_string(), "## Real heading 2".to_string()];
278        let rule = MD043RequiredHeadings::new(required);
279
280        // Test 1: Basic content with code block
281        let content = "# Test Document\n\nThis is regular content.\n\n```markdown\n# This is a heading in a code block\n## Another heading in code block\n```\n\n## Real heading 2\n\nSome content.";
282        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
283        let actual_headings = rule.extract_headings(&ctx);
284        assert_eq!(
285            actual_headings,
286            vec!["# Test Document".to_string(), "## Real heading 2".to_string()],
287            "Should extract correct headings and ignore code blocks"
288        );
289
290        // Test 2: Content with invalid headings
291        let content = "# Test Document\n\nThis is regular content.\n\n```markdown\n# This is a heading in a code block\n## This should be ignored\n```\n\n## Not Real heading 2\n\nSome content.";
292        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
293        let actual_headings = rule.extract_headings(&ctx);
294        assert_eq!(
295            actual_headings,
296            vec!["# Test Document".to_string(), "## Not Real heading 2".to_string()],
297            "Should extract actual headings including mismatched ones"
298        );
299    }
300
301    #[test]
302    fn test_with_document_structure() {
303        // Test with required headings (now with hash symbols)
304        let required = vec![
305            "# Introduction".to_string(),
306            "# Method".to_string(),
307            "# Results".to_string(),
308        ];
309        let rule = MD043RequiredHeadings::new(required);
310
311        // Test with matching headings
312        let content = "# Introduction\n\nContent\n\n# Method\n\nMore content\n\n# Results\n\nFinal content";
313        let warnings = rule
314            .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
315            .unwrap();
316        assert!(warnings.is_empty(), "Expected no warnings for matching headings");
317
318        // Test with mismatched headings
319        let content = "# Introduction\n\nContent\n\n# Results\n\nSkipped method";
320        let warnings = rule
321            .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
322            .unwrap();
323        assert!(!warnings.is_empty(), "Expected warnings for mismatched headings");
324
325        // Test with no headings but requirements exist
326        let content = "No headings here, just plain text";
327        let warnings = rule
328            .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
329            .unwrap();
330        assert!(!warnings.is_empty(), "Expected warnings when headings are missing");
331
332        // Test with setext headings - use the correct format (marker text)
333        let required_setext = vec![
334            "=========== Introduction".to_string(),
335            "------ Method".to_string(),
336            "======= Results".to_string(),
337        ];
338        let rule_setext = MD043RequiredHeadings::new(required_setext);
339        let content = "Introduction\n===========\n\nContent\n\nMethod\n------\n\nMore content\n\nResults\n=======\n\nFinal content";
340        let warnings = rule_setext
341            .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
342            .unwrap();
343        assert!(warnings.is_empty(), "Expected no warnings for matching setext headings");
344    }
345
346    #[test]
347    fn test_should_skip_no_false_positives() {
348        // Create rule with required headings
349        let required = vec!["Test".to_string()];
350        let rule = MD043RequiredHeadings::new(required);
351
352        // Test 1: Content with '#' character in normal text (not a heading)
353        let content = "This paragraph contains a # character but is not a heading";
354        assert!(
355            rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
356            "Should skip content with # in normal text"
357        );
358
359        // Test 2: Content with code block containing heading-like syntax
360        let content = "Regular paragraph\n\n```markdown\n# This is not a real heading\n```\n\nMore text";
361        assert!(
362            rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
363            "Should skip content with heading-like syntax in code blocks"
364        );
365
366        // Test 3: Content with list items using '-' character
367        let content = "Some text\n\n- List item 1\n- List item 2\n\nMore text";
368        assert!(
369            rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
370            "Should skip content with list items using dash"
371        );
372
373        // Test 4: Content with horizontal rule that uses '---'
374        let content = "Some text\n\n---\n\nMore text below the horizontal rule";
375        assert!(
376            rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
377            "Should skip content with horizontal rule"
378        );
379
380        // Test 5: Content with equals sign in normal text
381        let content = "This is a normal paragraph with equals sign x = y + z";
382        assert!(
383            rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
384            "Should skip content with equals sign in normal text"
385        );
386
387        // Test 6: Content with dash/minus in normal text
388        let content = "This is a normal paragraph with minus sign x - y = z";
389        assert!(
390            rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
391            "Should skip content with minus sign in normal text"
392        );
393    }
394
395    #[test]
396    fn test_should_skip_heading_detection() {
397        // Create rule with required headings
398        let required = vec!["Test".to_string()];
399        let rule = MD043RequiredHeadings::new(required);
400
401        // Test 1: Content with ATX heading
402        let content = "# This is a heading\n\nAnd some content";
403        assert!(
404            !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
405            "Should not skip content with ATX heading"
406        );
407
408        // Test 2: Content with Setext heading (equals sign)
409        let content = "This is a heading\n================\n\nAnd some content";
410        assert!(
411            !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
412            "Should not skip content with Setext heading (=)"
413        );
414
415        // Test 3: Content with Setext heading (dash)
416        let content = "This is a subheading\n------------------\n\nAnd some content";
417        assert!(
418            !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
419            "Should not skip content with Setext heading (-)"
420        );
421
422        // Test 4: Content with ATX heading with closing hashes
423        let content = "## This is a heading ##\n\nAnd some content";
424        assert!(
425            !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
426            "Should not skip content with ATX heading with closing hashes"
427        );
428    }
429
430    #[test]
431    fn test_config_match_case_sensitive() {
432        let config = MD043Config {
433            headings: vec!["# Introduction".to_string(), "# Method".to_string()],
434            match_case: true,
435        };
436        let rule = MD043RequiredHeadings::from_config_struct(config);
437
438        // Should fail with different case
439        let content = "# introduction\n\n# method";
440        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
441        let result = rule.check(&ctx).unwrap();
442
443        assert!(
444            !result.is_empty(),
445            "Should detect case mismatch when match_case is true"
446        );
447    }
448
449    #[test]
450    fn test_config_match_case_insensitive() {
451        let config = MD043Config {
452            headings: vec!["# Introduction".to_string(), "# Method".to_string()],
453            match_case: false,
454        };
455        let rule = MD043RequiredHeadings::from_config_struct(config);
456
457        // Should pass with different case
458        let content = "# introduction\n\n# method";
459        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
460        let result = rule.check(&ctx).unwrap();
461
462        assert!(result.is_empty(), "Should allow case mismatch when match_case is false");
463    }
464
465    #[test]
466    fn test_config_case_insensitive_mixed() {
467        let config = MD043Config {
468            headings: vec!["# Introduction".to_string(), "# METHOD".to_string()],
469            match_case: false,
470        };
471        let rule = MD043RequiredHeadings::from_config_struct(config);
472
473        // Should pass with mixed case variations
474        let content = "# INTRODUCTION\n\n# method";
475        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
476        let result = rule.check(&ctx).unwrap();
477
478        assert!(
479            result.is_empty(),
480            "Should allow mixed case variations when match_case is false"
481        );
482    }
483
484    #[test]
485    fn test_config_case_sensitive_exact_match() {
486        let config = MD043Config {
487            headings: vec!["# Introduction".to_string(), "# Method".to_string()],
488            match_case: true,
489        };
490        let rule = MD043RequiredHeadings::from_config_struct(config);
491
492        // Should pass with exact case match
493        let content = "# Introduction\n\n# Method";
494        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
495        let result = rule.check(&ctx).unwrap();
496
497        assert!(
498            result.is_empty(),
499            "Should pass with exact case match when match_case is true"
500        );
501    }
502
503    #[test]
504    fn test_default_config() {
505        let rule = MD043RequiredHeadings::default();
506
507        // Should be disabled with empty headings
508        let content = "# Any heading\n\n# Another heading";
509        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
510        let result = rule.check(&ctx).unwrap();
511
512        assert!(result.is_empty(), "Should be disabled with default empty headings");
513    }
514
515    #[test]
516    fn test_default_config_section() {
517        let rule = MD043RequiredHeadings::default();
518        let config_section = rule.default_config_section();
519
520        assert!(config_section.is_some());
521        let (name, value) = config_section.unwrap();
522        assert_eq!(name, "MD043");
523
524        // Should contain both headings and match_case options with default values
525        if let toml::Value::Table(table) = value {
526            assert!(table.contains_key("headings"));
527            assert!(table.contains_key("match-case"));
528            assert_eq!(table["headings"], toml::Value::Array(vec![]));
529            assert_eq!(table["match-case"], toml::Value::Boolean(false));
530        } else {
531            panic!("Expected TOML table");
532        }
533    }
534
535    #[test]
536    fn test_headings_match_case_sensitive() {
537        let config = MD043Config {
538            headings: vec![],
539            match_case: true,
540        };
541        let rule = MD043RequiredHeadings::from_config_struct(config);
542
543        assert!(rule.headings_match("Test", "Test"));
544        assert!(!rule.headings_match("Test", "test"));
545        assert!(!rule.headings_match("test", "Test"));
546    }
547
548    #[test]
549    fn test_headings_match_case_insensitive() {
550        let config = MD043Config {
551            headings: vec![],
552            match_case: false,
553        };
554        let rule = MD043RequiredHeadings::from_config_struct(config);
555
556        assert!(rule.headings_match("Test", "Test"));
557        assert!(rule.headings_match("Test", "test"));
558        assert!(rule.headings_match("test", "Test"));
559        assert!(rule.headings_match("TEST", "test"));
560    }
561
562    #[test]
563    fn test_config_empty_headings() {
564        let config = MD043Config {
565            headings: vec![],
566            match_case: true,
567        };
568        let rule = MD043RequiredHeadings::from_config_struct(config);
569
570        // Should skip processing when no headings are required
571        let content = "# Any heading\n\n# Another heading";
572        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
573        let result = rule.check(&ctx).unwrap();
574
575        assert!(result.is_empty(), "Should be disabled with empty headings list");
576    }
577
578    #[test]
579    fn test_fix_respects_configuration() {
580        let config = MD043Config {
581            headings: vec!["# Title".to_string(), "# Content".to_string()],
582            match_case: false,
583        };
584        let rule = MD043RequiredHeadings::from_config_struct(config);
585
586        let content = "Wrong content";
587        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
588        let fixed = rule.fix(&ctx).unwrap();
589
590        // MD043 now preserves original content to prevent data loss
591        let expected = "Wrong content";
592        assert_eq!(fixed, expected);
593    }
594}