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