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