rumdl_lib/rules/
md003_heading_style.rs

1//!
2//! Rule MD003: Heading style
3//!
4//! See [docs/md003.md](../../docs/md003.md) for full documentation, configuration, and examples.
5
6use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::rule_config_serde::RuleConfig;
8use crate::rules::heading_utils::HeadingStyle;
9use crate::utils::range_utils::calculate_heading_range;
10use toml;
11
12mod md003_config;
13use md003_config::MD003Config;
14
15/// Rule MD003: Heading style
16#[derive(Clone, Default)]
17pub struct MD003HeadingStyle {
18    config: MD003Config,
19}
20
21impl MD003HeadingStyle {
22    pub fn new(style: HeadingStyle) -> Self {
23        Self {
24            config: MD003Config { style },
25        }
26    }
27
28    pub fn from_config_struct(config: MD003Config) -> Self {
29        Self { config }
30    }
31
32    /// Check if we should use consistent mode (detect first style)
33    fn is_consistent_mode(&self) -> bool {
34        // Check for the Consistent variant explicitly
35        self.config.style == HeadingStyle::Consistent
36    }
37
38    /// Gets the target heading style based on configuration and document content
39    fn get_target_style(&self, ctx: &crate::lint_context::LintContext) -> HeadingStyle {
40        if !self.is_consistent_mode() {
41            return self.config.style;
42        }
43
44        // Find the first heading from cached info
45        for line_info in &ctx.lines {
46            if let Some(heading) = &line_info.heading {
47                // Map from LintContext heading style to rules heading style
48                return match heading.style {
49                    crate::lint_context::HeadingStyle::ATX => {
50                        if heading.has_closing_sequence {
51                            HeadingStyle::AtxClosed
52                        } else {
53                            HeadingStyle::Atx
54                        }
55                    }
56                    crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
57                    crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
58                };
59            }
60        }
61
62        // Default to ATX if no headings found
63        HeadingStyle::Atx
64    }
65}
66
67impl Rule for MD003HeadingStyle {
68    fn name(&self) -> &'static str {
69        "MD003"
70    }
71
72    fn description(&self) -> &'static str {
73        "Heading style"
74    }
75
76    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
77        let mut result = Vec::new();
78
79        // Get the target style using cached heading information
80        let target_style = self.get_target_style(ctx);
81
82        // Create LineIndex once outside the loop
83        let line_index = crate::utils::range_utils::LineIndex::new(ctx.content.to_string());
84
85        // Process headings using cached heading information
86        for (line_num, line_info) in ctx.lines.iter().enumerate() {
87            if let Some(heading) = &line_info.heading {
88                let level = heading.level;
89
90                // Map the cached heading style to the rule's HeadingStyle
91                let current_style = match heading.style {
92                    crate::lint_context::HeadingStyle::ATX => {
93                        if heading.has_closing_sequence {
94                            HeadingStyle::AtxClosed
95                        } else {
96                            HeadingStyle::Atx
97                        }
98                    }
99                    crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
100                    crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
101                };
102
103                // Determine expected style based on level and target
104                let expected_style = match target_style {
105                    HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
106                        if level > 2 {
107                            // Setext only supports levels 1-2, so levels 3+ must be ATX
108                            HeadingStyle::Atx
109                        } else if level == 1 {
110                            HeadingStyle::Setext1
111                        } else {
112                            HeadingStyle::Setext2
113                        }
114                    }
115                    HeadingStyle::SetextWithAtx => {
116                        if level <= 2 {
117                            // Use Setext for h1/h2
118                            if level == 1 {
119                                HeadingStyle::Setext1
120                            } else {
121                                HeadingStyle::Setext2
122                            }
123                        } else {
124                            // Use ATX for h3-h6
125                            HeadingStyle::Atx
126                        }
127                    }
128                    HeadingStyle::SetextWithAtxClosed => {
129                        if level <= 2 {
130                            // Use Setext for h1/h2
131                            if level == 1 {
132                                HeadingStyle::Setext1
133                            } else {
134                                HeadingStyle::Setext2
135                            }
136                        } else {
137                            // Use ATX closed for h3-h6
138                            HeadingStyle::AtxClosed
139                        }
140                    }
141                    _ => target_style,
142                };
143
144                if current_style != expected_style {
145                    // Generate fix for this heading
146                    let fix = {
147                        use crate::rules::heading_utils::HeadingUtils;
148
149                        // Convert heading to target style
150                        let converted_heading =
151                            HeadingUtils::convert_heading_style(&heading.text, level as u32, expected_style);
152
153                        // Add indentation
154                        let final_heading = format!("{}{}", " ".repeat(line_info.indent), converted_heading);
155
156                        // Calculate the correct range for the heading
157                        let range = line_index.line_content_range(line_num + 1);
158
159                        Some(crate::rule::Fix {
160                            range,
161                            replacement: final_heading,
162                        })
163                    };
164
165                    // Calculate precise character range for the heading marker
166                    let (start_line, start_col, end_line, end_col) =
167                        calculate_heading_range(line_num + 1, &line_info.content);
168
169                    result.push(LintWarning {
170                        rule_name: Some(self.name()),
171                        line: start_line,
172                        column: start_col,
173                        end_line,
174                        end_column: end_col,
175                        message: format!(
176                            "Heading style should be {}, found {}",
177                            match expected_style {
178                                HeadingStyle::Atx => "# Heading",
179                                HeadingStyle::AtxClosed => "# Heading #",
180                                HeadingStyle::Setext1 => "Heading\n=======",
181                                HeadingStyle::Setext2 => "Heading\n-------",
182                                HeadingStyle::Consistent => "consistent with the first heading",
183                                HeadingStyle::SetextWithAtx => "setext_with_atx style",
184                                HeadingStyle::SetextWithAtxClosed => "setext_with_atx_closed style",
185                            },
186                            match current_style {
187                                HeadingStyle::Atx => "# Heading",
188                                HeadingStyle::AtxClosed => "# Heading #",
189                                HeadingStyle::Setext1 => "Heading (underlined with =)",
190                                HeadingStyle::Setext2 => "Heading (underlined with -)",
191                                HeadingStyle::Consistent => "consistent style",
192                                HeadingStyle::SetextWithAtx => "setext_with_atx style",
193                                HeadingStyle::SetextWithAtxClosed => "setext_with_atx_closed style",
194                            }
195                        ),
196                        severity: Severity::Warning,
197                        fix,
198                    });
199                }
200            }
201        }
202
203        Ok(result)
204    }
205
206    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
207        // Get all warnings with their fixes
208        let warnings = self.check(ctx)?;
209
210        // If no warnings, return original content
211        if warnings.is_empty() {
212            return Ok(ctx.content.to_string());
213        }
214
215        // Collect all fixes and sort by range start (descending) to apply from end to beginning
216        let mut fixes: Vec<_> = warnings
217            .iter()
218            .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
219            .collect();
220        fixes.sort_by(|a, b| b.0.cmp(&a.0));
221
222        // Apply fixes from end to beginning to preserve byte offsets
223        let mut result = ctx.content.to_string();
224        for (start, end, replacement) in fixes {
225            if start < result.len() && end <= result.len() && start <= end {
226                result.replace_range(start..end, replacement);
227            }
228        }
229
230        Ok(result)
231    }
232
233    fn category(&self) -> RuleCategory {
234        RuleCategory::Heading
235    }
236
237    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
238        // Fast path: check if document likely has headings using character frequency
239        if ctx.content.is_empty() || !ctx.likely_has_headings() {
240            return true;
241        }
242        // Verify headings actually exist (handles false positives from character frequency)
243        !ctx.lines.iter().any(|line| line.heading.is_some())
244    }
245
246    fn as_any(&self) -> &dyn std::any::Any {
247        self
248    }
249
250    fn default_config_section(&self) -> Option<(String, toml::Value)> {
251        let default_config = MD003Config::default();
252        let json_value = serde_json::to_value(&default_config).ok()?;
253        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
254
255        if let toml::Value::Table(table) = toml_value {
256            if !table.is_empty() {
257                Some((MD003Config::RULE_NAME.to_string(), toml::Value::Table(table)))
258            } else {
259                None
260            }
261        } else {
262            None
263        }
264    }
265
266    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
267    where
268        Self: Sized,
269    {
270        let rule_config = crate::rule_config_serde::load_rule_config::<MD003Config>(config);
271        Box::new(Self::from_config_struct(rule_config))
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::lint_context::LintContext;
279
280    #[test]
281    fn test_atx_heading_style() {
282        let rule = MD003HeadingStyle::default();
283        let content = "# Heading 1\n## Heading 2\n### Heading 3";
284        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
285        let result = rule.check(&ctx).unwrap();
286        assert!(result.is_empty());
287    }
288
289    #[test]
290    fn test_setext_heading_style() {
291        let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
292        let content = "Heading 1\n=========\n\nHeading 2\n---------";
293        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
294        let result = rule.check(&ctx).unwrap();
295        assert!(result.is_empty());
296    }
297
298    #[test]
299    fn test_front_matter() {
300        let rule = MD003HeadingStyle::default();
301        let content = "---\ntitle: Test\n---\n\n# Heading 1\n## Heading 2";
302
303        // Test should detect headings and apply consistent style
304        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
305        let result = rule.check(&ctx).unwrap();
306        assert!(
307            result.is_empty(),
308            "No warnings expected for content with front matter, found: {result:?}"
309        );
310    }
311
312    #[test]
313    fn test_consistent_heading_style() {
314        // Default rule uses Atx which serves as our "consistent" mode
315        let rule = MD003HeadingStyle::default();
316        let content = "# Heading 1\n## Heading 2\n### Heading 3";
317        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
318        let result = rule.check(&ctx).unwrap();
319        assert!(result.is_empty());
320    }
321
322    #[test]
323    fn test_with_different_styles() {
324        // Test with consistent style (ATX)
325        let rule = MD003HeadingStyle::new(HeadingStyle::Consistent);
326        let content = "# Heading 1\n## Heading 2\n### Heading 3";
327        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
328        let result = rule.check(&ctx).unwrap();
329
330        // Make test more resilient
331        assert!(
332            result.is_empty(),
333            "No warnings expected for consistent ATX style, found: {result:?}"
334        );
335
336        // Test with incorrect style
337        let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
338        let content = "# Heading 1 #\nHeading 2\n-----\n### Heading 3";
339        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
340        let result = rule.check(&ctx).unwrap();
341        assert!(
342            !result.is_empty(),
343            "Should have warnings for inconsistent heading styles"
344        );
345
346        // Test with setext style
347        let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
348        let content = "Heading 1\n=========\nHeading 2\n---------\n### Heading 3";
349        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
350        let result = rule.check(&ctx).unwrap();
351        // The level 3 heading can't be setext, so it's valid as ATX
352        assert!(
353            result.is_empty(),
354            "No warnings expected for setext style with ATX for level 3, found: {result:?}"
355        );
356    }
357
358    #[test]
359    fn test_setext_with_atx_style() {
360        let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtx);
361        // Setext for h1/h2, ATX for h3-h6
362        let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3\n\n#### Heading 4";
363        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
364        let result = rule.check(&ctx).unwrap();
365        assert!(
366            result.is_empty(),
367            "SesetxtWithAtx style should accept setext for h1/h2 and ATX for h3+"
368        );
369
370        // Test incorrect usage - ATX for h1/h2
371        let content_wrong = "# Heading 1\n## Heading 2\n### Heading 3";
372        let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard);
373        let result_wrong = rule.check(&ctx_wrong).unwrap();
374        assert_eq!(
375            result_wrong.len(),
376            2,
377            "Should flag ATX headings for h1/h2 with setext_with_atx style"
378        );
379    }
380
381    #[test]
382    fn test_setext_with_atx_closed_style() {
383        let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtxClosed);
384        // Setext for h1/h2, ATX closed for h3-h6
385        let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3 ###\n\n#### Heading 4 ####";
386        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
387        let result = rule.check(&ctx).unwrap();
388        assert!(
389            result.is_empty(),
390            "SetextWithAtxClosed style should accept setext for h1/h2 and ATX closed for h3+"
391        );
392
393        // Test incorrect usage - regular ATX for h3+
394        let content_wrong = "Heading 1\n=========\n\n### Heading 3\n\n#### Heading 4";
395        let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard);
396        let result_wrong = rule.check(&ctx_wrong).unwrap();
397        assert_eq!(
398            result_wrong.len(),
399            2,
400            "Should flag non-closed ATX headings for h3+ with setext_with_atx_closed style"
401        );
402    }
403}