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                    // The heading is recorded on the last line of its text, and a
226                    // setext heading's text is the paragraph its underline ends
227                    let first_line_num = line_num + 2 - heading.text_lines;
228
229                    // Generate fix for this heading
230                    let fix = {
231                        use crate::rules::heading_utils::HeadingUtils;
232
233                        // Convert heading to target style, preserving inline attribute lists
234                        let converted_heading =
235                            HeadingUtils::convert_heading_style(&heading.raw_text, level as u32, expected_style);
236
237                        // Preserve original indentation (including tabs) of the
238                        // line the heading text starts on
239                        let first_line_info = &ctx.lines[first_line_num - 1];
240                        let first_line = first_line_info.content(ctx.content);
241                        let original_indent = &first_line[..first_line_info.indent];
242                        let final_heading = format!("{original_indent}{converted_heading}");
243
244                        // A setext heading is the whole paragraph its underline
245                        // ends, so converting away from it replaces every line of
246                        // that paragraph. The underline goes with them, otherwise
247                        // it survives as a thematic break.
248                        let converting_from_setext =
249                            matches!(
250                                heading.style,
251                                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
252                            ) && !matches!(expected_style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
253                        let last_line = if converting_from_setext {
254                            line_num + 2
255                        } else {
256                            line_num + 1
257                        };
258
259                        let start = ctx.line_content_byte_range(first_line_num).start;
260                        let end = ctx.line_content_byte_range(last_line).end;
261
262                        Some(crate::rule::Fix::new(start..end, final_heading))
263                    };
264
265                    // Calculate precise character range for the heading marker
266                    let (start_line, start_col, end_line, end_col) =
267                        calculate_heading_range(first_line_num, line_num + 1, line_info.content(ctx.content));
268
269                    result.push(LintWarning {
270                        rule_name: Some(self.name().to_string()),
271                        line: start_line,
272                        column: start_col,
273                        end_line,
274                        end_column: end_col,
275                        message: format!(
276                            "Heading style should be {}, found {}",
277                            match expected_style {
278                                HeadingStyle::Atx => "# Heading",
279                                HeadingStyle::AtxClosed => "# Heading #",
280                                HeadingStyle::Setext1 => "Heading\n=======",
281                                HeadingStyle::Setext2 => "Heading\n-------",
282                                HeadingStyle::Consistent => "consistent with the first heading",
283                                HeadingStyle::SetextWithAtx => "setext-with-atx style",
284                                HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
285                            },
286                            match current_style {
287                                HeadingStyle::Atx => "# Heading",
288                                HeadingStyle::AtxClosed => "# Heading #",
289                                HeadingStyle::Setext1 => "Heading (underlined with =)",
290                                HeadingStyle::Setext2 => "Heading (underlined with -)",
291                                HeadingStyle::Consistent => "consistent style",
292                                HeadingStyle::SetextWithAtx => "setext-with-atx style",
293                                HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
294                            }
295                        ),
296                        severity: Severity::Warning,
297                        fix,
298                    });
299                }
300            }
301        }
302
303        Ok(result)
304    }
305
306    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
307        // Get all warnings with their fixes
308        let warnings = self.check(ctx)?;
309        let warnings =
310            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
311
312        // If no warnings, return original content
313        if warnings.is_empty() {
314            return Ok(ctx.content.to_string());
315        }
316
317        // Collect all fixes and sort by range start (descending) to apply from end to beginning
318        let mut fixes: Vec<_> = warnings
319            .iter()
320            .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
321            .collect();
322        fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
323
324        // Apply fixes from end to beginning to preserve byte offsets
325        let mut result = ctx.content.to_string();
326        for (start, end, replacement) in fixes {
327            if start < result.len() && end <= result.len() && start <= end {
328                result.replace_range(start..end, replacement);
329            }
330        }
331
332        Ok(result)
333    }
334
335    fn category(&self) -> RuleCategory {
336        RuleCategory::Heading
337    }
338
339    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
340        // Fast path: check if document likely has headings using character frequency
341        if ctx.content.is_empty() || !ctx.likely_has_headings() {
342            return true;
343        }
344        // Verify headings actually exist (handles false positives from character frequency)
345        !ctx.lines.iter().any(|line| line.heading.is_some())
346    }
347
348    fn as_any(&self) -> &dyn std::any::Any {
349        self
350    }
351
352    crate::impl_rule_config_sections!(MD003Config);
353
354    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
355    where
356        Self: Sized,
357    {
358        let rule_config = crate::rule_config_serde::load_rule_config::<MD003Config>(config);
359        let style_explicit = option_is_explicit(config, "MD003", "style");
360
361        Box::new(Self {
362            config: rule_config,
363            style_explicit,
364        })
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use crate::lint_context::LintContext;
372
373    #[test]
374    fn test_atx_heading_style() {
375        let rule = MD003HeadingStyle::default();
376        let content = "# Heading 1\n## Heading 2\n### Heading 3";
377        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
378        let result = rule.check(&ctx).unwrap();
379        assert!(result.is_empty());
380    }
381
382    #[test]
383    fn test_setext_heading_style() {
384        let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
385        let content = "Heading 1\n=========\n\nHeading 2\n---------";
386        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
387        let result = rule.check(&ctx).unwrap();
388        assert!(result.is_empty());
389    }
390
391    #[test]
392    fn test_front_matter() {
393        let rule = MD003HeadingStyle::default();
394        let content = "---\ntitle: Test\n---\n\n# Heading 1\n## Heading 2";
395
396        // Test should detect headings and apply consistent style
397        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
398        let result = rule.check(&ctx).unwrap();
399        assert!(
400            result.is_empty(),
401            "No warnings expected for content with front matter, found: {result:?}"
402        );
403    }
404
405    #[test]
406    fn test_consistent_heading_style() {
407        // Default rule uses Atx which serves as our "consistent" mode
408        let rule = MD003HeadingStyle::default();
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        assert!(result.is_empty());
413    }
414
415    #[test]
416    fn test_with_different_styles() {
417        // Test with consistent style (ATX)
418        let rule = MD003HeadingStyle::new(HeadingStyle::Consistent);
419        let content = "# Heading 1\n## Heading 2\n### Heading 3";
420        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
421        let result = rule.check(&ctx).unwrap();
422
423        // Make test more resilient
424        assert!(
425            result.is_empty(),
426            "No warnings expected for consistent ATX style, found: {result:?}"
427        );
428
429        // Test with incorrect style
430        let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
431        let content = "# Heading 1 #\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        assert!(
435            !result.is_empty(),
436            "Should have warnings for inconsistent heading styles"
437        );
438
439        // Test with setext style
440        let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
441        let content = "Heading 1\n=========\nHeading 2\n---------\n### Heading 3";
442        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
443        let result = rule.check(&ctx).unwrap();
444        // The level 3 heading can't be setext, so it's valid as ATX
445        assert!(
446            result.is_empty(),
447            "No warnings expected for setext style with ATX for level 3, found: {result:?}"
448        );
449    }
450
451    #[test]
452    fn test_setext_with_atx_style() {
453        let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtx);
454        // Setext for h1/h2, ATX for h3-h6
455        let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3\n\n#### Heading 4";
456        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
457        let result = rule.check(&ctx).unwrap();
458        assert!(
459            result.is_empty(),
460            "SesetxtWithAtx style should accept setext for h1/h2 and ATX for h3+"
461        );
462
463        // Test incorrect usage - ATX for h1/h2
464        let content_wrong = "# Heading 1\n## Heading 2\n### Heading 3";
465        let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
466        let result_wrong = rule.check(&ctx_wrong).unwrap();
467        assert_eq!(
468            result_wrong.len(),
469            2,
470            "Should flag ATX headings for h1/h2 with setext_with_atx style"
471        );
472    }
473
474    #[test]
475    fn test_fix_preserves_attribute_lists() {
476        // ATX closed heading with attribute list, converted to ATX
477        let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
478        let content = "# Heading { #custom-id .class } #";
479        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
480
481        // Should flag: found ATX closed, expected ATX
482        let warnings = rule.check(&ctx).unwrap();
483        assert_eq!(warnings.len(), 1);
484        let fix = warnings[0].fix.as_ref().expect("Should have a fix");
485        assert!(
486            fix.replacement.contains("{ #custom-id .class }"),
487            "check() fix should preserve attribute list, got: {}",
488            fix.replacement
489        );
490
491        // Verify fix() also preserves attribute list
492        let fixed = rule.fix(&ctx).unwrap();
493        assert!(
494            fixed.contains("{ #custom-id .class }"),
495            "fix() should preserve attribute list, got: {fixed}"
496        );
497        assert!(
498            !fixed.contains(" #\n") && !fixed.ends_with(" #"),
499            "fix() should remove ATX closed trailing hashes, got: {fixed}"
500        );
501    }
502
503    #[test]
504    fn test_setext_with_atx_closed_style() {
505        let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtxClosed);
506        // Setext for h1/h2, ATX closed for h3-h6
507        let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3 ###\n\n#### Heading 4 ####";
508        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
509        let result = rule.check(&ctx).unwrap();
510        assert!(
511            result.is_empty(),
512            "SetextWithAtxClosed style should accept setext for h1/h2 and ATX closed for h3+"
513        );
514
515        // Test incorrect usage - regular ATX for h3+
516        let content_wrong = "Heading 1\n=========\n\n### Heading 3\n\n#### Heading 4";
517        let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
518        let result_wrong = rule.check(&ctx_wrong).unwrap();
519        assert_eq!(
520            result_wrong.len(),
521            2,
522            "Should flag non-closed ATX headings for h3+ with setext_with_atx_closed style"
523        );
524    }
525
526    #[test]
527    fn test_mdg_steers_every_heading_to_plain_atx() {
528        // MDG parses `#{1,6} ` headings only, so setext headings are corrected
529        // into ATX and closing sequences are dropped rather than preserved.
530        let cases = [
531            (
532                MD003HeadingStyle::new(HeadingStyle::Atx),
533                "Checkout\n========\n\n## Scenario: Buy an item\n",
534                "# Checkout\n\n## Scenario: Buy an item\n",
535            ),
536            (
537                MD003HeadingStyle::new(HeadingStyle::AtxClosed),
538                "# Feature: Checkout\n\n## Scenario: Buy an item ##\n",
539                "# Feature: Checkout\n\n## Scenario: Buy an item\n",
540            ),
541            (
542                MD003HeadingStyle::new(HeadingStyle::Setext1),
543                "# Feature: Checkout\n\nScenario: Documentation\n-----------------------\n",
544                "# Feature: Checkout\n\n## Scenario: Documentation\n",
545            ),
546            (
547                MD003HeadingStyle::default(),
548                "Checkout\n========\n\nGuide\n-----\n\n## Scenario: Buy an item\n",
549                "# Checkout\n\n## Guide\n\n## Scenario: Buy an item\n",
550            ),
551        ];
552
553        for (rule, content, expected) in cases {
554            let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
555
556            assert!(!rule.should_skip(&mdg_ctx));
557            assert!(
558                !rule.check(&mdg_ctx).unwrap().is_empty(),
559                "MDG must still report non-ATX headings in {content:?}"
560            );
561            let fixed = rule.fix(&mdg_ctx).unwrap();
562            assert_eq!(fixed, expected, "MDG must steer {content:?} to plain ATX");
563
564            let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
565            assert!(rule.check(&fixed_ctx).unwrap().is_empty());
566            assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
567        }
568    }
569
570    #[test]
571    fn test_mdg_tracks_only_an_explicit_style_for_override_notices() {
572        let direct = MD003HeadingStyle::new(HeadingStyle::Setext1);
573        assert!(direct.style_explicit);
574
575        let defaulted = MD003HeadingStyle::from_config_struct(MD003Config {
576            style: HeadingStyle::Setext1,
577        });
578        assert!(!defaulted.style_explicit);
579
580        let mut config = crate::config::Config::default();
581        let mut rule_config = crate::config::RuleConfig::default();
582        rule_config
583            .values
584            .insert("style".to_string(), toml::Value::String("setext".to_string()));
585        config.rules.insert("MD003".to_string(), rule_config);
586        let configured = MD003HeadingStyle::from_config(&config);
587        let configured = configured
588            .as_any()
589            .downcast_ref::<MD003HeadingStyle>()
590            .expect("MD003::from_config builds MD003HeadingStyle");
591        assert!(configured.style_explicit);
592
593        let default_configured = MD003HeadingStyle::from_config(&crate::config::Config::default());
594        let default_configured = default_configured
595            .as_any()
596            .downcast_ref::<MD003HeadingStyle>()
597            .expect("MD003::from_config builds MD003HeadingStyle");
598        assert!(!default_configured.style_explicit);
599    }
600}