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