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