Skip to main content

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::rules::heading_utils::HeadingStyle;
8use crate::utils::range_utils::calculate_heading_range;
9use toml;
10
11mod md003_config;
12use md003_config::MD003Config;
13
14/// Rule MD003: Heading style
15#[derive(Clone, Default)]
16pub struct MD003HeadingStyle {
17    config: MD003Config,
18}
19
20impl MD003HeadingStyle {
21    pub fn new(style: HeadingStyle) -> Self {
22        Self {
23            config: MD003Config { style },
24        }
25    }
26
27    pub fn from_config_struct(config: MD003Config) -> Self {
28        Self { config }
29    }
30
31    /// Check if we should use consistent mode (detect first style)
32    fn is_consistent_mode(&self) -> bool {
33        // Check for the Consistent variant explicitly
34        self.config.style == HeadingStyle::Consistent
35    }
36
37    /// Gets the target heading style based on configuration and document content
38    fn get_target_style(&self, ctx: &crate::lint_context::LintContext) -> HeadingStyle {
39        if !self.is_consistent_mode() {
40            return self.config.style;
41        }
42
43        // Count all heading styles to determine most prevalent (prevalence-based approach)
44        let mut style_counts = std::collections::HashMap::new();
45
46        for line_info in &ctx.lines {
47            if let Some(heading) = &line_info.heading {
48                // Skip invalid headings (e.g., `#NoSpace` which lacks required space after #)
49                if !heading.is_valid {
50                    continue;
51                }
52
53                // Map from LintContext heading style to rules heading style and count
54                let style = match heading.style {
55                    crate::lint_context::HeadingStyle::ATX => {
56                        if heading.has_closing_sequence {
57                            HeadingStyle::AtxClosed
58                        } else {
59                            HeadingStyle::Atx
60                        }
61                    }
62                    crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
63                    crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
64                };
65                *style_counts.entry(style).or_insert(0) += 1;
66            }
67        }
68
69        // Return most prevalent style
70        // In case of tie, prefer ATX as the default (deterministic tiebreaker)
71        style_counts
72            .into_iter()
73            .max_by(|(style_a, count_a), (style_b, count_b)| {
74                match count_a.cmp(count_b) {
75                    std::cmp::Ordering::Equal => {
76                        // Tiebreaker: prefer ATX (most common), then Setext1, then Setext2, then AtxClosed
77                        let priority = |s: &HeadingStyle| match s {
78                            HeadingStyle::Atx => 0,
79                            HeadingStyle::Setext1 => 1,
80                            HeadingStyle::Setext2 => 2,
81                            HeadingStyle::AtxClosed => 3,
82                            _ => 4,
83                        };
84                        priority(style_b).cmp(&priority(style_a)) // Reverse for min priority wins
85                    }
86                    other => other,
87                }
88            })
89            .map_or(HeadingStyle::Atx, |(style, _)| style)
90    }
91}
92
93impl Rule for MD003HeadingStyle {
94    fn name(&self) -> &'static str {
95        "MD003"
96    }
97
98    fn description(&self) -> &'static str {
99        "Heading style"
100    }
101
102    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
103        let mut result = Vec::new();
104
105        // Get the target style using cached heading information
106        let target_style = self.get_target_style(ctx);
107
108        // Process headings using cached heading information
109        for (line_num, line_info) in ctx.lines.iter().enumerate() {
110            if let Some(heading) = &line_info.heading {
111                // Skip invalid headings (e.g., `#NoSpace` which lacks required space after #)
112                if !heading.is_valid {
113                    continue;
114                }
115
116                let level = heading.level;
117
118                // Map the cached heading style to the rule's HeadingStyle
119                let current_style = match heading.style {
120                    crate::lint_context::HeadingStyle::ATX => {
121                        if heading.has_closing_sequence {
122                            HeadingStyle::AtxClosed
123                        } else {
124                            HeadingStyle::Atx
125                        }
126                    }
127                    crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
128                    crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
129                };
130
131                // Determine expected style based on level and target
132                let expected_style = match target_style {
133                    HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
134                        if level > 2 {
135                            // Setext only supports levels 1-2. The heading cannot
136                            // comply at all, so keep the ATX flavor it already has
137                            // instead of restyling it to one the config never asked
138                            // for. That also keeps the fix idempotent under
139                            // `consistent`: rewriting only ever moves headings into
140                            // the target style, so it can never flip the prevalence
141                            // count that chose the target.
142                            current_style
143                        } else if level == 1 {
144                            HeadingStyle::Setext1
145                        } else {
146                            HeadingStyle::Setext2
147                        }
148                    }
149                    HeadingStyle::SetextWithAtx => {
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 for h3-h6
159                            HeadingStyle::Atx
160                        }
161                    }
162                    HeadingStyle::SetextWithAtxClosed => {
163                        if level <= 2 {
164                            // Use Setext for h1/h2
165                            if level == 1 {
166                                HeadingStyle::Setext1
167                            } else {
168                                HeadingStyle::Setext2
169                            }
170                        } else {
171                            // Use ATX closed for h3-h6
172                            HeadingStyle::AtxClosed
173                        }
174                    }
175                    _ => target_style,
176                };
177
178                if current_style != expected_style {
179                    // Generate fix for this heading
180                    let fix = {
181                        use crate::rules::heading_utils::HeadingUtils;
182
183                        // Convert heading to target style, preserving inline attribute lists
184                        let converted_heading =
185                            HeadingUtils::convert_heading_style(&heading.raw_text, level as u32, expected_style);
186
187                        // Preserve original indentation (including tabs)
188                        let line = line_info.content(ctx.content);
189                        let original_indent = &line[..line_info.indent];
190                        let final_heading = format!("{original_indent}{converted_heading}");
191
192                        // A setext heading spans two lines. When converting away
193                        // from it the underline has to be replaced too, otherwise
194                        // it survives as a thematic break.
195                        let converting_from_setext =
196                            matches!(
197                                heading.style,
198                                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
199                            ) && !matches!(expected_style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
200                        let last_line = if converting_from_setext {
201                            line_num + 2
202                        } else {
203                            line_num + 1
204                        };
205
206                        let start = ctx.line_content_byte_range(line_num + 1).start;
207                        let end = ctx.line_content_byte_range(last_line).end;
208
209                        Some(crate::rule::Fix::new(start..end, final_heading))
210                    };
211
212                    // Calculate precise character range for the heading marker
213                    let (start_line, start_col, end_line, end_col) =
214                        calculate_heading_range(line_num + 1, line_info.content(ctx.content));
215
216                    result.push(LintWarning {
217                        rule_name: Some(self.name().to_string()),
218                        line: start_line,
219                        column: start_col,
220                        end_line,
221                        end_column: end_col,
222                        message: format!(
223                            "Heading style should be {}, found {}",
224                            match expected_style {
225                                HeadingStyle::Atx => "# Heading",
226                                HeadingStyle::AtxClosed => "# Heading #",
227                                HeadingStyle::Setext1 => "Heading\n=======",
228                                HeadingStyle::Setext2 => "Heading\n-------",
229                                HeadingStyle::Consistent => "consistent with the first heading",
230                                HeadingStyle::SetextWithAtx => "setext-with-atx style",
231                                HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
232                            },
233                            match current_style {
234                                HeadingStyle::Atx => "# Heading",
235                                HeadingStyle::AtxClosed => "# Heading #",
236                                HeadingStyle::Setext1 => "Heading (underlined with =)",
237                                HeadingStyle::Setext2 => "Heading (underlined with -)",
238                                HeadingStyle::Consistent => "consistent style",
239                                HeadingStyle::SetextWithAtx => "setext-with-atx style",
240                                HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
241                            }
242                        ),
243                        severity: Severity::Warning,
244                        fix,
245                    });
246                }
247            }
248        }
249
250        Ok(result)
251    }
252
253    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
254        // Get all warnings with their fixes
255        let warnings = self.check(ctx)?;
256        let warnings =
257            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
258
259        // If no warnings, return original content
260        if warnings.is_empty() {
261            return Ok(ctx.content.to_string());
262        }
263
264        // Collect all fixes and sort by range start (descending) to apply from end to beginning
265        let mut fixes: Vec<_> = warnings
266            .iter()
267            .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
268            .collect();
269        fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
270
271        // Apply fixes from end to beginning to preserve byte offsets
272        let mut result = ctx.content.to_string();
273        for (start, end, replacement) in fixes {
274            if start < result.len() && end <= result.len() && start <= end {
275                result.replace_range(start..end, replacement);
276            }
277        }
278
279        Ok(result)
280    }
281
282    fn category(&self) -> RuleCategory {
283        RuleCategory::Heading
284    }
285
286    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
287        // Fast path: check if document likely has headings using character frequency
288        if ctx.content.is_empty() || !ctx.likely_has_headings() {
289            return true;
290        }
291        // Verify headings actually exist (handles false positives from character frequency)
292        !ctx.lines.iter().any(|line| line.heading.is_some())
293    }
294
295    fn as_any(&self) -> &dyn std::any::Any {
296        self
297    }
298
299    crate::impl_rule_config_methods!(MD003Config);
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use crate::lint_context::LintContext;
306
307    #[test]
308    fn test_atx_heading_style() {
309        let rule = MD003HeadingStyle::default();
310        let content = "# Heading 1\n## Heading 2\n### Heading 3";
311        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
312        let result = rule.check(&ctx).unwrap();
313        assert!(result.is_empty());
314    }
315
316    #[test]
317    fn test_setext_heading_style() {
318        let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
319        let content = "Heading 1\n=========\n\nHeading 2\n---------";
320        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
321        let result = rule.check(&ctx).unwrap();
322        assert!(result.is_empty());
323    }
324
325    #[test]
326    fn test_front_matter() {
327        let rule = MD003HeadingStyle::default();
328        let content = "---\ntitle: Test\n---\n\n# Heading 1\n## Heading 2";
329
330        // Test should detect headings and apply consistent style
331        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
332        let result = rule.check(&ctx).unwrap();
333        assert!(
334            result.is_empty(),
335            "No warnings expected for content with front matter, found: {result:?}"
336        );
337    }
338
339    #[test]
340    fn test_consistent_heading_style() {
341        // Default rule uses Atx which serves as our "consistent" mode
342        let rule = MD003HeadingStyle::default();
343        let content = "# Heading 1\n## Heading 2\n### Heading 3";
344        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
345        let result = rule.check(&ctx).unwrap();
346        assert!(result.is_empty());
347    }
348
349    #[test]
350    fn test_with_different_styles() {
351        // Test with consistent style (ATX)
352        let rule = MD003HeadingStyle::new(HeadingStyle::Consistent);
353        let content = "# Heading 1\n## Heading 2\n### Heading 3";
354        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
355        let result = rule.check(&ctx).unwrap();
356
357        // Make test more resilient
358        assert!(
359            result.is_empty(),
360            "No warnings expected for consistent ATX style, found: {result:?}"
361        );
362
363        // Test with incorrect style
364        let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
365        let content = "# Heading 1 #\nHeading 2\n-----\n### Heading 3";
366        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
367        let result = rule.check(&ctx).unwrap();
368        assert!(
369            !result.is_empty(),
370            "Should have warnings for inconsistent heading styles"
371        );
372
373        // Test with setext style
374        let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
375        let content = "Heading 1\n=========\nHeading 2\n---------\n### Heading 3";
376        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
377        let result = rule.check(&ctx).unwrap();
378        // The level 3 heading can't be setext, so it's valid as ATX
379        assert!(
380            result.is_empty(),
381            "No warnings expected for setext style with ATX for level 3, found: {result:?}"
382        );
383    }
384
385    #[test]
386    fn test_setext_with_atx_style() {
387        let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtx);
388        // Setext for h1/h2, ATX for h3-h6
389        let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3\n\n#### Heading 4";
390        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
391        let result = rule.check(&ctx).unwrap();
392        assert!(
393            result.is_empty(),
394            "SesetxtWithAtx style should accept setext for h1/h2 and ATX for h3+"
395        );
396
397        // Test incorrect usage - ATX for h1/h2
398        let content_wrong = "# Heading 1\n## Heading 2\n### Heading 3";
399        let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
400        let result_wrong = rule.check(&ctx_wrong).unwrap();
401        assert_eq!(
402            result_wrong.len(),
403            2,
404            "Should flag ATX headings for h1/h2 with setext_with_atx style"
405        );
406    }
407
408    #[test]
409    fn test_fix_preserves_attribute_lists() {
410        // ATX closed heading with attribute list, converted to ATX
411        let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
412        let content = "# Heading { #custom-id .class } #";
413        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
414
415        // Should flag: found ATX closed, expected ATX
416        let warnings = rule.check(&ctx).unwrap();
417        assert_eq!(warnings.len(), 1);
418        let fix = warnings[0].fix.as_ref().expect("Should have a fix");
419        assert!(
420            fix.replacement.contains("{ #custom-id .class }"),
421            "check() fix should preserve attribute list, got: {}",
422            fix.replacement
423        );
424
425        // Verify fix() also preserves attribute list
426        let fixed = rule.fix(&ctx).unwrap();
427        assert!(
428            fixed.contains("{ #custom-id .class }"),
429            "fix() should preserve attribute list, got: {fixed}"
430        );
431        assert!(
432            !fixed.contains(" #\n") && !fixed.ends_with(" #"),
433            "fix() should remove ATX closed trailing hashes, got: {fixed}"
434        );
435    }
436
437    #[test]
438    fn test_setext_with_atx_closed_style() {
439        let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtxClosed);
440        // Setext for h1/h2, ATX closed for h3-h6
441        let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3 ###\n\n#### Heading 4 ####";
442        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
443        let result = rule.check(&ctx).unwrap();
444        assert!(
445            result.is_empty(),
446            "SetextWithAtxClosed style should accept setext for h1/h2 and ATX closed for h3+"
447        );
448
449        // Test incorrect usage - regular ATX for h3+
450        let content_wrong = "Heading 1\n=========\n\n### Heading 3\n\n#### Heading 4";
451        let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
452        let result_wrong = rule.check(&ctx_wrong).unwrap();
453        assert_eq!(
454            result_wrong.len(),
455            2,
456            "Should flag non-closed ATX headings for h3+ with setext_with_atx_closed style"
457        );
458    }
459}