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