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