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::{FlavorOverrideNotice, option_is_explicit};
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/// Reports an explicit MDG style override once per process.
16static MDG_STYLE_OVERRIDE: FlavorOverrideNotice = FlavorOverrideNotice::new();
17
18/// Rule MD003: Heading style
19#[derive(Clone, Default)]
20pub struct MD003HeadingStyle {
21    config: MD003Config,
22    /// Whether `style` was explicitly configured rather than defaulted.
23    style_explicit: bool,
24}
25
26impl MD003HeadingStyle {
27    pub fn new(style: HeadingStyle) -> Self {
28        Self {
29            config: MD003Config { style },
30            style_explicit: true,
31        }
32    }
33
34    pub fn from_config_struct(config: MD003Config) -> Self {
35        Self {
36            config,
37            style_explicit: false,
38        }
39    }
40
41    /// Check if we should use consistent mode (detect first style)
42    fn is_consistent_mode(&self) -> bool {
43        // Check for the Consistent variant explicitly
44        self.config.style == HeadingStyle::Consistent
45    }
46
47    /// Gets the target heading style based on configuration and document content
48    fn get_target_style(&self, ctx: &crate::lint_context::LintContext) -> HeadingStyle {
49        // MDG recognizes `#{1,6} ` headings only, so plain ATX is the single
50        // style a Gherkin document can be steered to.
51        if ctx.flavor == crate::config::MarkdownFlavor::MDG {
52            self.warn_once_about_overridden_style();
53            return HeadingStyle::Atx;
54        }
55
56        if !self.is_consistent_mode() {
57            return self.config.style;
58        }
59
60        // Count all heading styles to determine most prevalent (prevalence-based approach)
61        let mut style_counts = std::collections::HashMap::new();
62
63        for line_info in &ctx.lines {
64            if let Some(heading) = &line_info.heading {
65                // Skip invalid headings (e.g., `#NoSpace` which lacks required space after #)
66                if !heading.is_valid {
67                    continue;
68                }
69
70                // Map from LintContext heading style to rules heading style and count
71                let style = match heading.style {
72                    crate::lint_context::HeadingStyle::ATX => {
73                        if heading.has_closing_sequence {
74                            HeadingStyle::AtxClosed
75                        } else {
76                            HeadingStyle::Atx
77                        }
78                    }
79                    crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
80                    crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
81                };
82                *style_counts.entry(style).or_insert(0) += 1;
83            }
84        }
85
86        // Return most prevalent style
87        // In case of tie, prefer ATX as the default (deterministic tiebreaker)
88        style_counts
89            .into_iter()
90            .max_by(|(style_a, count_a), (style_b, count_b)| {
91                match count_a.cmp(count_b) {
92                    std::cmp::Ordering::Equal => {
93                        // Tiebreaker: prefer ATX (most common), then Setext1, then Setext2, then AtxClosed
94                        let priority = |s: &HeadingStyle| match s {
95                            HeadingStyle::Atx => 0,
96                            HeadingStyle::Setext1 => 1,
97                            HeadingStyle::Setext2 => 2,
98                            HeadingStyle::AtxClosed => 3,
99                            _ => 4,
100                        };
101                        priority(style_b).cmp(&priority(style_a)) // Reverse for min priority wins
102                    }
103                    other => other,
104                }
105            })
106            .map_or(HeadingStyle::Atx, |(style, _)| style)
107    }
108
109    /// Tell the user when an explicit fixed style cannot be honored by MDG.
110    /// `consistent` asks for no fixed spelling, and `atx` is already the form
111    /// the flavor enforces, so neither is an override worth reporting.
112    fn warn_once_about_overridden_style(&self) {
113        if !self.style_explicit || matches!(self.config.style, HeadingStyle::Atx | HeadingStyle::Consistent) {
114            return;
115        }
116
117        let configured = self.config.style.to_string();
118        MDG_STYLE_OVERRIDE.report(
119            "MD003",
120            "style",
121            &configured,
122            "atx",
123            "Markdown with Gherkin recognizes structure headings only in plain ATX form",
124        );
125    }
126}
127
128impl Rule for MD003HeadingStyle {
129    fn name(&self) -> &'static str {
130        "MD003"
131    }
132
133    fn description(&self) -> &'static str {
134        "Heading style"
135    }
136
137    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
138        let mut result = Vec::new();
139
140        // Get the target style using cached heading information
141        let target_style = self.get_target_style(ctx);
142
143        // Process headings using cached heading information
144        for (line_num, line_info) in ctx.lines.iter().enumerate() {
145            if let Some(heading) = &line_info.heading {
146                // Skip invalid headings (e.g., `#NoSpace` which lacks required space after #)
147                if !heading.is_valid {
148                    continue;
149                }
150
151                let level = heading.level;
152
153                // Map the cached heading style to the rule's HeadingStyle
154                let current_style = match heading.style {
155                    crate::lint_context::HeadingStyle::ATX => {
156                        if heading.has_closing_sequence {
157                            HeadingStyle::AtxClosed
158                        } else {
159                            HeadingStyle::Atx
160                        }
161                    }
162                    crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
163                    crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
164                };
165
166                // Determine expected style based on level and target
167                let expected_style = match target_style {
168                    HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
169                        if level > 2 {
170                            // Setext only supports levels 1-2. The heading cannot
171                            // comply at all, so keep the ATX flavor it already has
172                            // instead of restyling it to one the config never asked
173                            // for. That also keeps the fix idempotent under
174                            // `consistent`: rewriting only ever moves headings into
175                            // the target style, so it can never flip the prevalence
176                            // count that chose the target.
177                            current_style
178                        } else if level == 1 {
179                            HeadingStyle::Setext1
180                        } else {
181                            HeadingStyle::Setext2
182                        }
183                    }
184                    HeadingStyle::SetextWithAtx => {
185                        if level <= 2 {
186                            // Use Setext for h1/h2
187                            if level == 1 {
188                                HeadingStyle::Setext1
189                            } else {
190                                HeadingStyle::Setext2
191                            }
192                        } else {
193                            // Use ATX for h3-h6
194                            HeadingStyle::Atx
195                        }
196                    }
197                    HeadingStyle::SetextWithAtxClosed => {
198                        if level <= 2 {
199                            // Use Setext for h1/h2
200                            if level == 1 {
201                                HeadingStyle::Setext1
202                            } else {
203                                HeadingStyle::Setext2
204                            }
205                        } else {
206                            // Use ATX closed for h3-h6
207                            HeadingStyle::AtxClosed
208                        }
209                    }
210                    _ => target_style,
211                };
212
213                // MDG only recognizes plain ATX headings: a setext heading never
214                // becomes a Gherkin node and a closing sequence leaks into the
215                // node's name (`# Feature: F #` is named "F #"). Steering every
216                // heading to plain ATX keeps MD003 enforcing a style while never
217                // emitting a form Gherkin cannot parse.
218                let expected_style = if ctx.flavor == crate::config::MarkdownFlavor::MDG {
219                    HeadingStyle::Atx
220                } else {
221                    expected_style
222                };
223
224                if current_style != expected_style {
225                    // Generate fix for this heading
226                    let fix = {
227                        use crate::rules::heading_utils::HeadingUtils;
228
229                        // Convert heading to target style, preserving inline attribute lists
230                        let converted_heading =
231                            HeadingUtils::convert_heading_style(&heading.raw_text, level as u32, expected_style);
232
233                        // Preserve original indentation (including tabs)
234                        let line = line_info.content(ctx.content);
235                        let original_indent = &line[..line_info.indent];
236                        let final_heading = format!("{original_indent}{converted_heading}");
237
238                        // A setext heading spans two lines. When converting away
239                        // from it the underline has to be replaced too, otherwise
240                        // it survives as a thematic break.
241                        let converting_from_setext =
242                            matches!(
243                                heading.style,
244                                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
245                            ) && !matches!(expected_style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
246                        let last_line = if converting_from_setext {
247                            line_num + 2
248                        } else {
249                            line_num + 1
250                        };
251
252                        let start = ctx.line_content_byte_range(line_num + 1).start;
253                        let end = ctx.line_content_byte_range(last_line).end;
254
255                        Some(crate::rule::Fix::new(start..end, final_heading))
256                    };
257
258                    // Calculate precise character range for the heading marker
259                    let (start_line, start_col, end_line, end_col) =
260                        calculate_heading_range(line_num + 1, line_info.content(ctx.content));
261
262                    result.push(LintWarning {
263                        rule_name: Some(self.name().to_string()),
264                        line: start_line,
265                        column: start_col,
266                        end_line,
267                        end_column: end_col,
268                        message: format!(
269                            "Heading style should be {}, found {}",
270                            match expected_style {
271                                HeadingStyle::Atx => "# Heading",
272                                HeadingStyle::AtxClosed => "# Heading #",
273                                HeadingStyle::Setext1 => "Heading\n=======",
274                                HeadingStyle::Setext2 => "Heading\n-------",
275                                HeadingStyle::Consistent => "consistent with the first heading",
276                                HeadingStyle::SetextWithAtx => "setext-with-atx style",
277                                HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
278                            },
279                            match current_style {
280                                HeadingStyle::Atx => "# Heading",
281                                HeadingStyle::AtxClosed => "# Heading #",
282                                HeadingStyle::Setext1 => "Heading (underlined with =)",
283                                HeadingStyle::Setext2 => "Heading (underlined with -)",
284                                HeadingStyle::Consistent => "consistent style",
285                                HeadingStyle::SetextWithAtx => "setext-with-atx style",
286                                HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
287                            }
288                        ),
289                        severity: Severity::Warning,
290                        fix,
291                    });
292                }
293            }
294        }
295
296        Ok(result)
297    }
298
299    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
300        // Get all warnings with their fixes
301        let warnings = self.check(ctx)?;
302        let warnings =
303            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
304
305        // If no warnings, return original content
306        if warnings.is_empty() {
307            return Ok(ctx.content.to_string());
308        }
309
310        // Collect all fixes and sort by range start (descending) to apply from end to beginning
311        let mut fixes: Vec<_> = warnings
312            .iter()
313            .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
314            .collect();
315        fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
316
317        // Apply fixes from end to beginning to preserve byte offsets
318        let mut result = ctx.content.to_string();
319        for (start, end, replacement) in fixes {
320            if start < result.len() && end <= result.len() && start <= end {
321                result.replace_range(start..end, replacement);
322            }
323        }
324
325        Ok(result)
326    }
327
328    fn category(&self) -> RuleCategory {
329        RuleCategory::Heading
330    }
331
332    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
333        // Fast path: check if document likely has headings using character frequency
334        if ctx.content.is_empty() || !ctx.likely_has_headings() {
335            return true;
336        }
337        // Verify headings actually exist (handles false positives from character frequency)
338        !ctx.lines.iter().any(|line| line.heading.is_some())
339    }
340
341    fn as_any(&self) -> &dyn std::any::Any {
342        self
343    }
344
345    crate::impl_rule_config_sections!(MD003Config);
346
347    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
348    where
349        Self: Sized,
350    {
351        let rule_config = crate::rule_config_serde::load_rule_config::<MD003Config>(config);
352        let style_explicit = option_is_explicit(config, "MD003", "style");
353
354        Box::new(Self {
355            config: rule_config,
356            style_explicit,
357        })
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::lint_context::LintContext;
365
366    #[test]
367    fn test_atx_heading_style() {
368        let rule = MD003HeadingStyle::default();
369        let content = "# Heading 1\n## Heading 2\n### Heading 3";
370        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
371        let result = rule.check(&ctx).unwrap();
372        assert!(result.is_empty());
373    }
374
375    #[test]
376    fn test_setext_heading_style() {
377        let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
378        let content = "Heading 1\n=========\n\nHeading 2\n---------";
379        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
380        let result = rule.check(&ctx).unwrap();
381        assert!(result.is_empty());
382    }
383
384    #[test]
385    fn test_front_matter() {
386        let rule = MD003HeadingStyle::default();
387        let content = "---\ntitle: Test\n---\n\n# Heading 1\n## Heading 2";
388
389        // Test should detect headings and apply consistent style
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            "No warnings expected for content with front matter, found: {result:?}"
395        );
396    }
397
398    #[test]
399    fn test_consistent_heading_style() {
400        // Default rule uses Atx which serves as our "consistent" mode
401        let rule = MD003HeadingStyle::default();
402        let content = "# Heading 1\n## Heading 2\n### Heading 3";
403        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
404        let result = rule.check(&ctx).unwrap();
405        assert!(result.is_empty());
406    }
407
408    #[test]
409    fn test_with_different_styles() {
410        // Test with consistent style (ATX)
411        let rule = MD003HeadingStyle::new(HeadingStyle::Consistent);
412        let content = "# Heading 1\n## Heading 2\n### Heading 3";
413        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
414        let result = rule.check(&ctx).unwrap();
415
416        // Make test more resilient
417        assert!(
418            result.is_empty(),
419            "No warnings expected for consistent ATX style, found: {result:?}"
420        );
421
422        // Test with incorrect style
423        let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
424        let content = "# Heading 1 #\nHeading 2\n-----\n### Heading 3";
425        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
426        let result = rule.check(&ctx).unwrap();
427        assert!(
428            !result.is_empty(),
429            "Should have warnings for inconsistent heading styles"
430        );
431
432        // Test with setext style
433        let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
434        let content = "Heading 1\n=========\nHeading 2\n---------\n### Heading 3";
435        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
436        let result = rule.check(&ctx).unwrap();
437        // The level 3 heading can't be setext, so it's valid as ATX
438        assert!(
439            result.is_empty(),
440            "No warnings expected for setext style with ATX for level 3, found: {result:?}"
441        );
442    }
443
444    #[test]
445    fn test_setext_with_atx_style() {
446        let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtx);
447        // Setext for h1/h2, ATX for h3-h6
448        let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3\n\n#### Heading 4";
449        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
450        let result = rule.check(&ctx).unwrap();
451        assert!(
452            result.is_empty(),
453            "SesetxtWithAtx style should accept setext for h1/h2 and ATX for h3+"
454        );
455
456        // Test incorrect usage - ATX for h1/h2
457        let content_wrong = "# Heading 1\n## Heading 2\n### Heading 3";
458        let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
459        let result_wrong = rule.check(&ctx_wrong).unwrap();
460        assert_eq!(
461            result_wrong.len(),
462            2,
463            "Should flag ATX headings for h1/h2 with setext_with_atx style"
464        );
465    }
466
467    #[test]
468    fn test_fix_preserves_attribute_lists() {
469        // ATX closed heading with attribute list, converted to ATX
470        let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
471        let content = "# Heading { #custom-id .class } #";
472        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
473
474        // Should flag: found ATX closed, expected ATX
475        let warnings = rule.check(&ctx).unwrap();
476        assert_eq!(warnings.len(), 1);
477        let fix = warnings[0].fix.as_ref().expect("Should have a fix");
478        assert!(
479            fix.replacement.contains("{ #custom-id .class }"),
480            "check() fix should preserve attribute list, got: {}",
481            fix.replacement
482        );
483
484        // Verify fix() also preserves attribute list
485        let fixed = rule.fix(&ctx).unwrap();
486        assert!(
487            fixed.contains("{ #custom-id .class }"),
488            "fix() should preserve attribute list, got: {fixed}"
489        );
490        assert!(
491            !fixed.contains(" #\n") && !fixed.ends_with(" #"),
492            "fix() should remove ATX closed trailing hashes, got: {fixed}"
493        );
494    }
495
496    #[test]
497    fn test_setext_with_atx_closed_style() {
498        let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtxClosed);
499        // Setext for h1/h2, ATX closed for h3-h6
500        let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3 ###\n\n#### Heading 4 ####";
501        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
502        let result = rule.check(&ctx).unwrap();
503        assert!(
504            result.is_empty(),
505            "SetextWithAtxClosed style should accept setext for h1/h2 and ATX closed for h3+"
506        );
507
508        // Test incorrect usage - regular ATX for h3+
509        let content_wrong = "Heading 1\n=========\n\n### Heading 3\n\n#### Heading 4";
510        let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
511        let result_wrong = rule.check(&ctx_wrong).unwrap();
512        assert_eq!(
513            result_wrong.len(),
514            2,
515            "Should flag non-closed ATX headings for h3+ with setext_with_atx_closed style"
516        );
517    }
518
519    #[test]
520    fn test_mdg_steers_every_heading_to_plain_atx() {
521        // MDG parses `#{1,6} ` headings only, so setext headings are corrected
522        // into ATX and closing sequences are dropped rather than preserved.
523        let cases = [
524            (
525                MD003HeadingStyle::new(HeadingStyle::Atx),
526                "Checkout\n========\n\n## Scenario: Buy an item\n",
527                "# Checkout\n\n## Scenario: Buy an item\n",
528            ),
529            (
530                MD003HeadingStyle::new(HeadingStyle::AtxClosed),
531                "# Feature: Checkout\n\n## Scenario: Buy an item ##\n",
532                "# Feature: Checkout\n\n## Scenario: Buy an item\n",
533            ),
534            (
535                MD003HeadingStyle::new(HeadingStyle::Setext1),
536                "# Feature: Checkout\n\nScenario: Documentation\n-----------------------\n",
537                "# Feature: Checkout\n\n## Scenario: Documentation\n",
538            ),
539            (
540                MD003HeadingStyle::default(),
541                "Checkout\n========\n\nGuide\n-----\n\n## Scenario: Buy an item\n",
542                "# Checkout\n\n## Guide\n\n## Scenario: Buy an item\n",
543            ),
544        ];
545
546        for (rule, content, expected) in cases {
547            let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
548
549            assert!(!rule.should_skip(&mdg_ctx));
550            assert!(
551                !rule.check(&mdg_ctx).unwrap().is_empty(),
552                "MDG must still report non-ATX headings in {content:?}"
553            );
554            let fixed = rule.fix(&mdg_ctx).unwrap();
555            assert_eq!(fixed, expected, "MDG must steer {content:?} to plain ATX");
556
557            let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
558            assert!(rule.check(&fixed_ctx).unwrap().is_empty());
559            assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
560        }
561    }
562
563    #[test]
564    fn test_mdg_tracks_only_an_explicit_style_for_override_notices() {
565        let direct = MD003HeadingStyle::new(HeadingStyle::Setext1);
566        assert!(direct.style_explicit);
567
568        let defaulted = MD003HeadingStyle::from_config_struct(MD003Config {
569            style: HeadingStyle::Setext1,
570        });
571        assert!(!defaulted.style_explicit);
572
573        let mut config = crate::config::Config::default();
574        let mut rule_config = crate::config::RuleConfig::default();
575        rule_config
576            .values
577            .insert("style".to_string(), toml::Value::String("setext".to_string()));
578        config.rules.insert("MD003".to_string(), rule_config);
579        let configured = MD003HeadingStyle::from_config(&config);
580        let configured = configured
581            .as_any()
582            .downcast_ref::<MD003HeadingStyle>()
583            .expect("MD003::from_config builds MD003HeadingStyle");
584        assert!(configured.style_explicit);
585
586        let default_configured = MD003HeadingStyle::from_config(&crate::config::Config::default());
587        let default_configured = default_configured
588            .as_any()
589            .downcast_ref::<MD003HeadingStyle>()
590            .expect("MD003::from_config builds MD003HeadingStyle");
591        assert!(!default_configured.style_explicit);
592    }
593}