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    fn heading_is_in_literal_code(
48        ctx: &crate::lint_context::LintContext,
49        heading_idx: usize,
50        heading: &crate::lint_context::HeadingInfo,
51    ) -> bool {
52        let marker_idx = heading_idx
53            + usize::from(matches!(
54                heading.style,
55                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
56            ));
57        ctx.lines.get(marker_idx).is_some_and(|line| {
58            let marker_offset = line.byte_offset + line.indent;
59            ctx.is_inside_template_code(marker_offset) || ctx.is_inside_mdx_code(marker_offset)
60        })
61    }
62
63    fn heading_style(
64        ctx: &crate::lint_context::LintContext,
65        heading_idx: usize,
66        heading: &crate::lint_context::HeadingInfo,
67    ) -> HeadingStyle {
68        match heading.style {
69            crate::lint_context::HeadingStyle::ATX => {
70                if !heading.has_closing_sequence {
71                    return HeadingStyle::Atx;
72                }
73                let line = &ctx.lines[heading_idx];
74                let content = line.content(ctx.content);
75                // Closing hashes precede a trailing inline attribute list.
76                let before_id = content
77                    .rsplit_once(" {#")
78                    .filter(|(_, id)| id.trim_end().ends_with('}'))
79                    .map_or(content, |(text, _)| text);
80                let marker = line.byte_offset + before_id.trim_end().len() - heading.closing_sequence.len();
81                if ctx.is_inside_template_code(marker) || ctx.is_inside_mdx_code(marker) {
82                    HeadingStyle::Atx
83                } else {
84                    HeadingStyle::AtxClosed
85                }
86            }
87            crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
88            crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
89        }
90    }
91
92    fn conversion_is_safe(
93        ctx: &crate::lint_context::LintContext,
94        heading_idx: usize,
95        heading: &crate::lint_context::HeadingInfo,
96    ) -> bool {
97        if matches!(
98            heading.style,
99            crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
100        ) {
101            return crate::rules::heading_utils::setext_to_atx_is_safe(ctx, heading_idx, heading);
102        }
103        let line = &ctx.lines[heading_idx];
104        let boundary = line.byte_offset + line.byte_len;
105        !ctx.is_inside_template_code(boundary) && !ctx.is_inside_mdx_code(boundary)
106    }
107
108    /// Gets the target heading style based on configuration and document content
109    fn get_target_style(&self, ctx: &crate::lint_context::LintContext) -> HeadingStyle {
110        // MDG recognizes `#{1,6} ` headings only, so plain ATX is the single
111        // style a Gherkin document can be steered to.
112        if ctx.flavor == crate::config::MarkdownFlavor::MDG {
113            self.warn_once_about_overridden_style();
114            return HeadingStyle::Atx;
115        }
116
117        if !self.is_consistent_mode() {
118            return self.config.style;
119        }
120
121        // Count all heading styles to determine most prevalent (prevalence-based approach)
122        let mut style_counts = std::collections::HashMap::new();
123
124        for (line_num, line_info) in ctx.lines.iter().enumerate() {
125            if let Some(heading) = &line_info.heading {
126                if Self::heading_is_in_literal_code(ctx, line_num, heading) {
127                    continue;
128                }
129                // Map from LintContext heading style to rules heading style and count
130                let style = Self::heading_style(ctx, line_num, heading);
131                *style_counts.entry(style).or_insert(0) += 1;
132            }
133        }
134
135        // Return most prevalent style
136        // In case of tie, prefer ATX as the default (deterministic tiebreaker)
137        style_counts
138            .into_iter()
139            .max_by(|(style_a, count_a), (style_b, count_b)| {
140                match count_a.cmp(count_b) {
141                    std::cmp::Ordering::Equal => {
142                        // Tiebreaker: prefer ATX (most common), then Setext1, then Setext2, then AtxClosed
143                        let priority = |s: &HeadingStyle| match s {
144                            HeadingStyle::Atx => 0,
145                            HeadingStyle::Setext1 => 1,
146                            HeadingStyle::Setext2 => 2,
147                            HeadingStyle::AtxClosed => 3,
148                            _ => 4,
149                        };
150                        priority(style_b).cmp(&priority(style_a)) // Reverse for min priority wins
151                    }
152                    other => other,
153                }
154            })
155            .map_or(HeadingStyle::Atx, |(style, _)| style)
156    }
157
158    /// Tell the user when an explicit fixed style cannot be honored by MDG.
159    /// `consistent` asks for no fixed spelling, and `atx` is already the form
160    /// the flavor enforces, so neither is an override worth reporting.
161    fn warn_once_about_overridden_style(&self) {
162        if !self.style_explicit || matches!(self.config.style, HeadingStyle::Atx | HeadingStyle::Consistent) {
163            return;
164        }
165
166        let configured = self.config.style.to_string();
167        MDG_STYLE_OVERRIDE.report(
168            "MD003",
169            "style",
170            &configured,
171            "atx",
172            "Markdown with Gherkin recognizes structure headings only in plain ATX form",
173        );
174    }
175}
176
177impl Rule for MD003HeadingStyle {
178    fn name(&self) -> &'static str {
179        "MD003"
180    }
181
182    fn description(&self) -> &'static str {
183        "Heading style"
184    }
185
186    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
187        let mut result = Vec::new();
188
189        // Get the target style using cached heading information
190        let target_style = self.get_target_style(ctx);
191
192        // Process headings using cached heading information
193        for (line_num, line_info) in ctx.lines.iter().enumerate() {
194            if let Some(heading) = &line_info.heading {
195                if Self::heading_is_in_literal_code(ctx, line_num, heading) {
196                    continue;
197                }
198                let level = heading.level;
199
200                // Map the cached heading style to the rule's HeadingStyle
201                let current_style = Self::heading_style(ctx, line_num, heading);
202
203                // Determine expected style based on level and target
204                let expected_style = match target_style {
205                    HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
206                        if level > 2 {
207                            // Setext only supports levels 1-2. The heading cannot
208                            // comply at all, so keep the ATX flavor it already has
209                            // instead of restyling it to one the config never asked
210                            // for. That also keeps the fix idempotent under
211                            // `consistent`: rewriting only ever moves headings into
212                            // the target style, so it can never flip the prevalence
213                            // count that chose the target.
214                            current_style
215                        } else if level == 1 {
216                            HeadingStyle::Setext1
217                        } else {
218                            HeadingStyle::Setext2
219                        }
220                    }
221                    HeadingStyle::SetextWithAtx => {
222                        if level <= 2 {
223                            // Use Setext for h1/h2
224                            if level == 1 {
225                                HeadingStyle::Setext1
226                            } else {
227                                HeadingStyle::Setext2
228                            }
229                        } else {
230                            // Use ATX for h3-h6
231                            HeadingStyle::Atx
232                        }
233                    }
234                    HeadingStyle::SetextWithAtxClosed => {
235                        if level <= 2 {
236                            // Use Setext for h1/h2
237                            if level == 1 {
238                                HeadingStyle::Setext1
239                            } else {
240                                HeadingStyle::Setext2
241                            }
242                        } else {
243                            // Use ATX closed for h3-h6
244                            HeadingStyle::AtxClosed
245                        }
246                    }
247                    _ => target_style,
248                };
249
250                // MDG only recognizes plain ATX headings: a setext heading never
251                // becomes a Gherkin node and a closing sequence leaks into the
252                // node's name (`# Feature: F #` is named "F #"). Steering every
253                // heading to plain ATX keeps MD003 enforcing a style while never
254                // emitting a form Gherkin cannot parse.
255                let expected_style = if ctx.flavor == crate::config::MarkdownFlavor::MDG {
256                    HeadingStyle::Atx
257                } else {
258                    expected_style
259                };
260
261                if current_style != expected_style {
262                    // The heading is recorded on the last line of its text, and a
263                    // setext heading's text is the paragraph its underline ends
264                    let first_line_num = line_num + 2 - heading.text_lines;
265
266                    // Generate fix for this heading
267                    let fix = if Self::conversion_is_safe(ctx, line_num, heading) {
268                        use crate::rules::heading_utils::HeadingUtils;
269
270                        // Convert heading to target style, preserving inline attribute lists
271                        let converted_heading =
272                            HeadingUtils::convert_heading_style(&heading.raw_text, level as u32, expected_style);
273
274                        // Preserve original indentation (including tabs) of the
275                        // line the heading text starts on
276                        let first_line_info = &ctx.lines[first_line_num - 1];
277                        let first_line = first_line_info.content(ctx.content);
278                        let original_indent = &first_line[..first_line_info.indent];
279                        let final_heading = format!("{original_indent}{converted_heading}");
280
281                        // A setext heading is the whole paragraph its underline
282                        // ends, so converting away from it replaces every line of
283                        // that paragraph. The underline goes with them, otherwise
284                        // it survives as a thematic break.
285                        let converting_from_setext =
286                            matches!(
287                                heading.style,
288                                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
289                            ) && !matches!(expected_style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
290                        let last_line = if converting_from_setext {
291                            line_num + 2
292                        } else {
293                            line_num + 1
294                        };
295
296                        let start = ctx.line_content_byte_range(first_line_num).start;
297                        let end = ctx.line_content_byte_range(last_line).end;
298
299                        Some(crate::rule::Fix::new(start..end, final_heading))
300                    } else {
301                        None
302                    };
303
304                    // Calculate precise character range for the heading marker
305                    let (start_line, start_col, end_line, end_col) =
306                        calculate_heading_range(first_line_num, line_num + 1, line_info.content(ctx.content));
307
308                    result.push(LintWarning {
309                        rule_name: Some(self.name().to_string()),
310                        line: start_line,
311                        column: start_col,
312                        end_line,
313                        end_column: end_col,
314                        message: format!(
315                            "Heading style should be {}, found {}",
316                            match expected_style {
317                                HeadingStyle::Atx => "# Heading",
318                                HeadingStyle::AtxClosed => "# Heading #",
319                                HeadingStyle::Setext1 => "Heading\n=======",
320                                HeadingStyle::Setext2 => "Heading\n-------",
321                                HeadingStyle::Consistent => "consistent with the first heading",
322                                HeadingStyle::SetextWithAtx => "setext-with-atx style",
323                                HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
324                            },
325                            match current_style {
326                                HeadingStyle::Atx => "# Heading",
327                                HeadingStyle::AtxClosed => "# Heading #",
328                                HeadingStyle::Setext1 => "Heading (underlined with =)",
329                                HeadingStyle::Setext2 => "Heading (underlined with -)",
330                                HeadingStyle::Consistent => "consistent style",
331                                HeadingStyle::SetextWithAtx => "setext-with-atx style",
332                                HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
333                            }
334                        ),
335                        severity: Severity::Warning,
336                        fix,
337                    });
338                }
339            }
340        }
341
342        Ok(result)
343    }
344
345    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
346        // Get all warnings with their fixes
347        let warnings = self.check(ctx)?;
348        let warnings =
349            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
350
351        // If no warnings, return original content
352        if warnings.is_empty() {
353            return Ok(ctx.content.to_string());
354        }
355
356        // Collect all fixes and sort by range start (descending) to apply from end to beginning
357        let mut fixes: Vec<_> = warnings
358            .iter()
359            .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
360            .collect();
361        fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
362
363        // Apply fixes from end to beginning to preserve byte offsets
364        let mut result = ctx.content.to_string();
365        for (start, end, replacement) in fixes {
366            if start < result.len() && end <= result.len() && start <= end {
367                result.replace_range(start..end, replacement);
368            }
369        }
370
371        Ok(result)
372    }
373
374    fn category(&self) -> RuleCategory {
375        RuleCategory::Heading
376    }
377
378    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
379        // Fast path: check if document likely has headings using character frequency
380        if ctx.content.is_empty() || !ctx.likely_has_headings() {
381            return true;
382        }
383        // Verify headings actually exist (handles false positives from character frequency)
384        !ctx.lines.iter().any(|line| line.heading.is_some())
385    }
386
387    fn as_any(&self) -> &dyn std::any::Any {
388        self
389    }
390
391    crate::impl_rule_config_sections!(MD003Config);
392
393    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
394    where
395        Self: Sized,
396    {
397        let rule_config = crate::rule_config_serde::load_rule_config::<MD003Config>(config);
398        let style_explicit = option_is_explicit(config, "MD003", "style");
399
400        Box::new(Self {
401            config: rule_config,
402            style_explicit,
403        })
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use crate::lint_context::LintContext;
411
412    #[test]
413    fn test_atx_heading_style() {
414        let rule = MD003HeadingStyle::default();
415        let content = "# Heading 1\n## Heading 2\n### Heading 3";
416        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
417        let result = rule.check(&ctx).unwrap();
418        assert!(result.is_empty());
419    }
420
421    #[test]
422    fn test_setext_heading_style() {
423        let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
424        let content = "Heading 1\n=========\n\nHeading 2\n---------";
425        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
426        let result = rule.check(&ctx).unwrap();
427        assert!(result.is_empty());
428    }
429
430    #[test]
431    fn test_front_matter() {
432        let rule = MD003HeadingStyle::default();
433        let content = "---\ntitle: Test\n---\n\n# Heading 1\n## Heading 2";
434
435        // Test should detect headings and apply consistent style
436        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
437        let result = rule.check(&ctx).unwrap();
438        assert!(
439            result.is_empty(),
440            "No warnings expected for content with front matter, found: {result:?}"
441        );
442    }
443
444    #[test]
445    fn test_consistent_heading_style() {
446        // Default rule uses Atx which serves as our "consistent" mode
447        let rule = MD003HeadingStyle::default();
448        let content = "# Heading 1\n## Heading 2\n### Heading 3";
449        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
450        let result = rule.check(&ctx).unwrap();
451        assert!(result.is_empty());
452    }
453
454    #[test]
455    fn test_with_different_styles() {
456        // Test with consistent style (ATX)
457        let rule = MD003HeadingStyle::new(HeadingStyle::Consistent);
458        let content = "# Heading 1\n## Heading 2\n### Heading 3";
459        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
460        let result = rule.check(&ctx).unwrap();
461
462        // Make test more resilient
463        assert!(
464            result.is_empty(),
465            "No warnings expected for consistent ATX style, found: {result:?}"
466        );
467
468        // Test with incorrect style
469        let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
470        let content = "# Heading 1 #\nHeading 2\n-----\n### Heading 3";
471        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
472        let result = rule.check(&ctx).unwrap();
473        assert!(
474            !result.is_empty(),
475            "Should have warnings for inconsistent heading styles"
476        );
477
478        // Test with setext style
479        let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
480        let content = "Heading 1\n=========\nHeading 2\n---------\n### Heading 3";
481        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
482        let result = rule.check(&ctx).unwrap();
483        // The level 3 heading can't be setext, so it's valid as ATX
484        assert!(
485            result.is_empty(),
486            "No warnings expected for setext style with ATX for level 3, found: {result:?}"
487        );
488    }
489
490    #[test]
491    fn test_setext_with_atx_style() {
492        let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtx);
493        // Setext for h1/h2, ATX for h3-h6
494        let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3\n\n#### Heading 4";
495        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
496        let result = rule.check(&ctx).unwrap();
497        assert!(
498            result.is_empty(),
499            "SesetxtWithAtx style should accept setext for h1/h2 and ATX for h3+"
500        );
501
502        // Test incorrect usage - ATX for h1/h2
503        let content_wrong = "# Heading 1\n## Heading 2\n### Heading 3";
504        let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
505        let result_wrong = rule.check(&ctx_wrong).unwrap();
506        assert_eq!(
507            result_wrong.len(),
508            2,
509            "Should flag ATX headings for h1/h2 with setext_with_atx style"
510        );
511    }
512
513    #[test]
514    fn test_fix_preserves_attribute_lists() {
515        // ATX closed heading with attribute list, converted to ATX
516        let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
517        let content = "# Heading { #custom-id .class } #";
518        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
519
520        // Should flag: found ATX closed, expected ATX
521        let warnings = rule.check(&ctx).unwrap();
522        assert_eq!(warnings.len(), 1);
523        let fix = warnings[0].fix.as_ref().expect("Should have a fix");
524        assert!(
525            fix.replacement.contains("{ #custom-id .class }"),
526            "check() fix should preserve attribute list, got: {}",
527            fix.replacement
528        );
529
530        // Verify fix() also preserves attribute list
531        let fixed = rule.fix(&ctx).unwrap();
532        assert!(
533            fixed.contains("{ #custom-id .class }"),
534            "fix() should preserve attribute list, got: {fixed}"
535        );
536        assert!(
537            !fixed.contains(" #\n") && !fixed.ends_with(" #"),
538            "fix() should remove ATX closed trailing hashes, got: {fixed}"
539        );
540    }
541
542    #[test]
543    fn test_setext_with_atx_closed_style() {
544        let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtxClosed);
545        // Setext for h1/h2, ATX closed for h3-h6
546        let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3 ###\n\n#### Heading 4 ####";
547        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
548        let result = rule.check(&ctx).unwrap();
549        assert!(
550            result.is_empty(),
551            "SetextWithAtxClosed style should accept setext for h1/h2 and ATX closed for h3+"
552        );
553
554        // Test incorrect usage - regular ATX for h3+
555        let content_wrong = "Heading 1\n=========\n\n### Heading 3\n\n#### Heading 4";
556        let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
557        let result_wrong = rule.check(&ctx_wrong).unwrap();
558        assert_eq!(
559            result_wrong.len(),
560            2,
561            "Should flag non-closed ATX headings for h3+ with setext_with_atx_closed style"
562        );
563    }
564
565    #[test]
566    fn test_mdg_steers_every_heading_to_plain_atx() {
567        // MDG parses `#{1,6} ` headings only, so setext headings are corrected
568        // into ATX and closing sequences are dropped rather than preserved.
569        let cases = [
570            (
571                MD003HeadingStyle::new(HeadingStyle::Atx),
572                "Checkout\n========\n\n## Scenario: Buy an item\n",
573                "# Checkout\n\n## Scenario: Buy an item\n",
574            ),
575            (
576                MD003HeadingStyle::new(HeadingStyle::AtxClosed),
577                "# Feature: Checkout\n\n## Scenario: Buy an item ##\n",
578                "# Feature: Checkout\n\n## Scenario: Buy an item\n",
579            ),
580            (
581                MD003HeadingStyle::new(HeadingStyle::Setext1),
582                "# Feature: Checkout\n\nScenario: Documentation\n-----------------------\n",
583                "# Feature: Checkout\n\n## Scenario: Documentation\n",
584            ),
585            (
586                MD003HeadingStyle::default(),
587                "Checkout\n========\n\nGuide\n-----\n\n## Scenario: Buy an item\n",
588                "# Checkout\n\n## Guide\n\n## Scenario: Buy an item\n",
589            ),
590        ];
591
592        for (rule, content, expected) in cases {
593            let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
594
595            assert!(!rule.should_skip(&mdg_ctx));
596            assert!(
597                !rule.check(&mdg_ctx).unwrap().is_empty(),
598                "MDG must still report non-ATX headings in {content:?}"
599            );
600            let fixed = rule.fix(&mdg_ctx).unwrap();
601            assert_eq!(fixed, expected, "MDG must steer {content:?} to plain ATX");
602
603            let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
604            assert!(rule.check(&fixed_ctx).unwrap().is_empty());
605            assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
606        }
607    }
608
609    #[test]
610    fn test_mdg_tracks_only_an_explicit_style_for_override_notices() {
611        let direct = MD003HeadingStyle::new(HeadingStyle::Setext1);
612        assert!(direct.style_explicit);
613
614        let defaulted = MD003HeadingStyle::from_config_struct(MD003Config {
615            style: HeadingStyle::Setext1,
616        });
617        assert!(!defaulted.style_explicit);
618
619        let mut config = crate::config::Config::default();
620        let mut rule_config = crate::config::RuleConfig::default();
621        rule_config
622            .values
623            .insert("style".to_string(), toml::Value::String("setext".to_string()));
624        config.rules.insert("MD003".to_string(), rule_config);
625        let configured = MD003HeadingStyle::from_config(&config);
626        let configured = configured
627            .as_any()
628            .downcast_ref::<MD003HeadingStyle>()
629            .expect("MD003::from_config builds MD003HeadingStyle");
630        assert!(configured.style_explicit);
631
632        let default_configured = MD003HeadingStyle::from_config(&crate::config::Config::default());
633        let default_configured = default_configured
634            .as_any()
635            .downcast_ref::<MD003HeadingStyle>()
636            .expect("MD003::from_config builds MD003HeadingStyle");
637        assert!(!default_configured.style_explicit);
638    }
639    #[test]
640    fn test_literal_headings_are_preserved_beside_visible_style_fixes() {
641        for (style, literal, visible, target) in [
642            (HeadingStyle::Atx, "\nFake\n---\n", "Visible\n---", "## Visible"),
643            (HeadingStyle::Atx, "\n# Fake #\n", "# Visible #", "# Visible"),
644            (HeadingStyle::AtxClosed, "\n# Fake\n", "# Visible", "# Visible #"),
645            (HeadingStyle::Setext1, "\n# Fake\n", "# Visible", "Visible\n======="),
646        ] {
647            let rule = MD003HeadingStyle::new(style);
648            for (flavor, code) in [
649                (
650                    crate::config::MarkdownFlavor::Standard,
651                    format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
652                ),
653                (
654                    crate::config::MarkdownFlavor::Hugo,
655                    format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
656                ),
657            ] {
658                for ending in ["\n", "\r\n"] {
659                    let source = format!("{code}\n\n{visible}\n").replace('\n', ending);
660                    let expected = format!("{code}\n\n{target}\n").replace('\n', ending);
661                    let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
662                    let warnings = rule.check(&ctx).unwrap();
663                    assert_eq!(warnings.len(), 1, "{source}");
664                    assert_eq!(
665                        crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
666                        expected,
667                        "{source}"
668                    );
669                    // The direct fixer inserts LF in a newly generated Setext heading.
670                    let direct_expected = format!("{}{ending}{ending}{target}{ending}", code.replace('\n', ending));
671                    assert_eq!(rule.fix(&ctx).unwrap(), direct_expected, "{source}");
672                }
673            }
674        }
675    }
676
677    #[test]
678    fn test_literal_heading_styles_do_not_choose_visible_style() {
679        let rule = MD003HeadingStyle::default();
680        for literal in ["# Fake #\n\n# Other #", "\nFake\n===\n\nOther\n===\n"] {
681            for (flavor, code) in [
682                (
683                    crate::config::MarkdownFlavor::Standard,
684                    format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
685                ),
686                (
687                    crate::config::MarkdownFlavor::Hugo,
688                    format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
689                ),
690            ] {
691                let source = format!("{code}\n\n# Visible\n");
692                let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
693                assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
694                assert_eq!(rule.fix(&ctx).unwrap(), source);
695            }
696        }
697    }
698
699    #[test]
700    fn test_dynamic_and_mdx_body_heading_styles_remain_fixable() {
701        let rule = MD003HeadingStyle::new(HeadingStyle::AtxClosed);
702        for (flavor, source, target) in [
703            (
704                crate::config::MarkdownFlavor::Standard,
705                "# {{ title }}\n",
706                "# {{ title }} #\n",
707            ),
708            (
709                crate::config::MarkdownFlavor::MDX,
710                "<div>\n\n# Visible\n\n</div>\n",
711                "<div>\n\n# Visible #\n\n</div>\n",
712            ),
713        ] {
714            let ctx = crate::lint_context::LintContext::new(source, flavor, None);
715            assert_eq!(rule.check(&ctx).unwrap().len(), 1);
716            assert_eq!(rule.fix(&ctx).unwrap(), target);
717        }
718    }
719
720    #[test]
721    fn test_unsafe_title_conversions_leave_safe_neighbors_fixable() {
722        for (style, source_title, adjacent, fixed_adjacent) in [
723            (
724                HeadingStyle::Setext1,
725                "# {% set unused=\"first  \nlast\" %}{{ unused|length }}",
726                "# Adjacent",
727                "Adjacent\n========",
728            ),
729            (
730                HeadingStyle::Setext1,
731                "# {{< note title=`first  \nlast` >}}",
732                "# Adjacent",
733                "Adjacent\n========",
734            ),
735            (
736                HeadingStyle::Atx,
737                "{% set unused=\"first  \nlast\" %}{{ unused|length }}\n===",
738                "Adjacent\n===",
739                "# Adjacent",
740            ),
741            (
742                HeadingStyle::Atx,
743                "{{< note title=`first  \nlast` >}}\n===",
744                "Adjacent\n===",
745                "# Adjacent",
746            ),
747            (
748                HeadingStyle::AtxClosed,
749                "# {% set unused=\"first  \nlast\" %}{{ unused|length }}",
750                "# Adjacent",
751                "# Adjacent #",
752            ),
753        ] {
754            let rule = MD003HeadingStyle::new(style);
755            let flavor = if source_title.contains("{{<") {
756                crate::config::MarkdownFlavor::Hugo
757            } else {
758                crate::config::MarkdownFlavor::Standard
759            };
760            for ending in ["\n", "\r\n"] {
761                let source = format!("{source_title}\n\n{adjacent}\n").replace('\n', ending);
762                let expected = format!("{source_title}\n\n{fixed_adjacent}\n").replace('\n', ending);
763                let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
764                let warnings = rule.check(&ctx).unwrap();
765                assert_eq!(warnings.len(), 2, "{source}");
766                assert!(warnings[0].fix.is_none(), "{source}");
767                assert!(warnings[1].fix.is_some());
768                assert_eq!(
769                    crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
770                    expected
771                );
772                let direct_expected = format!(
773                    "{}{ending}{ending}{fixed_adjacent}{ending}",
774                    source_title.replace('\n', ending)
775                );
776                assert_eq!(rule.fix(&ctx).unwrap(), direct_expected);
777            }
778        }
779    }
780
781    #[test]
782    fn test_setext_style_conversion_preserves_literal_markup_and_hard_breaks() {
783        for title in [
784            "First\\\nSecond",
785            "First  \nSecond",
786            "`first  \nlast`",
787            "`first\n  last`",
788            "<span title=\"first\nlast\">Visible</span>",
789        ] {
790            for style in [HeadingStyle::Atx, HeadingStyle::AtxClosed] {
791                let rule = MD003HeadingStyle::new(style);
792                let source = format!("{title}\n===\n\nAdjacent\n===\n");
793                let adjacent = if style == HeadingStyle::Atx {
794                    "# Adjacent"
795                } else {
796                    "# Adjacent #"
797                };
798                let expected = format!("{title}\n===\n\n{adjacent}\n");
799                let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
800                let warnings = rule.check(&ctx).unwrap();
801                assert_eq!(warnings.len(), 2);
802                assert!(warnings[0].fix.is_none(), "{source}");
803                assert!(warnings[1].fix.is_some());
804                assert_eq!(rule.fix(&ctx).unwrap(), expected);
805            }
806        }
807    }
808
809    #[test]
810    fn test_safe_dynamic_and_setext_style_conversions_remain_fixable() {
811        for (style, source, expected) in [
812            (HeadingStyle::Setext1, "# {{ title }}\n", "{{ title }}\n===========\n"),
813            (HeadingStyle::AtxClosed, "# {{ title }}\n", "# {{ title }} #\n"),
814            (HeadingStyle::Atx, "`first\nlast`\n===\n", "# `first last`\n"),
815        ] {
816            let rule = MD003HeadingStyle::new(style);
817            let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
818            assert_eq!(rule.check(&ctx).unwrap().len(), 1);
819            assert!(rule.check(&ctx).unwrap()[0].fix.is_some());
820            assert_eq!(rule.fix(&ctx).unwrap(), expected);
821        }
822    }
823
824    #[test]
825    fn test_literal_closing_hashes_do_not_choose_heading_style() {
826        for (flavor, prefix, suffix) in [
827            (
828                crate::config::MarkdownFlavor::Standard,
829                "{% set unused=\"",
830                "\" %}{{ unused|length }}",
831            ),
832            (crate::config::MarkdownFlavor::Hugo, "{{< note title=`", "` >}}"),
833        ] {
834            for hashes in ["#", "###", "########"] {
835                for pad in ["", "  ", "\t"] {
836                    for ending in ["\n", "\r\n"] {
837                        let title = format!("# {prefix}naïve {hashes}{pad}\nlast{suffix}");
838                        let source = format!("{title}\n\n{title}\n\n# Adjacent\n").replace('\n', ending);
839                        for style in [HeadingStyle::Atx, HeadingStyle::Consistent] {
840                            let rule = MD003HeadingStyle::new(style);
841                            let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
842                            assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
843                            assert_eq!(rule.fix(&ctx).unwrap(), source);
844                        }
845                    }
846                }
847            }
848        }
849    }
850
851    #[test]
852    fn test_literal_closing_hashes_still_require_a_real_closed_marker() {
853        let rule = MD003HeadingStyle::new(HeadingStyle::AtxClosed);
854        let source = "# {% set unused=\"first ###\nlast\" %}{{ unused|length }}\n\n# Adjacent\n";
855        let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
856        let warnings = rule.check(&ctx).unwrap();
857        assert_eq!(warnings.len(), 2);
858        assert!(warnings[0].fix.is_none());
859        assert!(warnings[1].fix.is_some());
860        assert_eq!(
861            rule.fix(&ctx).unwrap(),
862            "# {% set unused=\"first ###\nlast\" %}{{ unused|length }}\n\n# Adjacent #\n"
863        );
864    }
865
866    #[test]
867    fn test_real_closing_hashes_after_dynamic_titles_remain_closed() {
868        let rule = MD003HeadingStyle::new(HeadingStyle::AtxClosed);
869        for (flavor, source) in [
870            (crate::config::MarkdownFlavor::Standard, "# {{ title }} ###\n"),
871            (crate::config::MarkdownFlavor::Standard, "# {{ title }} ### {#custom}\n"),
872            (crate::config::MarkdownFlavor::MDX, "<div>\n\n# Visible ###\n\n</div>\n"),
873        ] {
874            let ctx = crate::lint_context::LintContext::new(source, flavor, None);
875            assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
876            assert_eq!(rule.fix(&ctx).unwrap(), source);
877        }
878    }
879
880    #[test]
881    fn test_multiline_inline_titles_survive_heading_style_conversion() {
882        for node in ["[Link]", "![Image]"] {
883            for (open, close) in [('"', '"'), ('\'', '\''), ('(', ')')] {
884                for style in [HeadingStyle::Atx, HeadingStyle::AtxClosed] {
885                    let rule = MD003HeadingStyle::new(style);
886                    let title = format!("{node}(https://example.com {open}first\nlast{close})\n===");
887                    let adjacent = if style == HeadingStyle::Atx {
888                        "# Adjacent"
889                    } else {
890                        "# Adjacent #"
891                    };
892                    for ending in ["\n", "\r\n"] {
893                        let source = format!("{title}\n\nAdjacent\n===\n").replace('\n', ending);
894                        let expected = format!("{title}\n\n{adjacent}\n").replace('\n', ending);
895                        let ctx = crate::lint_context::LintContext::new(
896                            &source,
897                            crate::config::MarkdownFlavor::Standard,
898                            None,
899                        );
900                        let warnings = rule.check(&ctx).unwrap();
901                        assert_eq!(warnings.len(), 2, "{source}");
902                        assert!(warnings[0].fix.is_none());
903                        assert!(warnings[1].fix.is_some());
904                        assert_eq!(
905                            crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
906                            expected
907                        );
908                        assert_eq!(rule.fix(&ctx).unwrap(), expected);
909                    }
910                }
911            }
912        }
913    }
914
915    #[test]
916    fn test_link_labels_and_external_titles_remain_style_fixable() {
917        let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
918        for (source, expected) in [
919            (
920                "[first\nlast](https://example.com \"single title\")\n===\n",
921                "# [first last](https://example.com \"single title\")\n",
922            ),
923            (
924                "[first\nlast][ref]\n===\n\n[ref]: https://example.com \"first\nlast\"\n",
925                "# [first last][ref]\n\n[ref]: https://example.com \"first\nlast\"\n",
926            ),
927            (
928                "![Image][ref]\n===\n\n[ref]: https://example.com/i.png \"first\nlast\"\n",
929                "# ![Image][ref]\n\n[ref]: https://example.com/i.png \"first\nlast\"\n",
930            ),
931        ] {
932            let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
933            let warnings = rule.check(&ctx).unwrap();
934            assert_eq!(warnings.len(), 1);
935            assert!(warnings[0].fix.is_some(), "{source}");
936            assert_eq!(rule.fix(&ctx).unwrap(), expected);
937        }
938    }
939
940    #[test]
941    fn test_entity_newlines_do_not_block_safe_heading_style_joins() {
942        let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
943        for entity in ["&NewLine;", "&#10;", "&#xA;"] {
944            for (title, joined) in [
945                (
946                    format!("[first\nlast](https://example.com \"title{entity}line\")"),
947                    format!("[first last](https://example.com \"title{entity}line\")"),
948                ),
949                (
950                    format!("![Image](https://example.com\n\"title{entity}line\")"),
951                    format!("![Image](https://example.com \"title{entity}line\")"),
952                ),
953                (
954                    format!(
955                        "[![Image](https://example.com/i.png \"image{entity}title\")\nLabel](https://example.com \"link{entity}title\")"
956                    ),
957                    format!(
958                        "[![Image](https://example.com/i.png \"image{entity}title\") Label](https://example.com \"link{entity}title\")"
959                    ),
960                ),
961            ] {
962                for flavor in [
963                    crate::config::MarkdownFlavor::Standard,
964                    crate::config::MarkdownFlavor::MDX,
965                ] {
966                    for ending in ["\n", "\r\n"] {
967                        let source = format!("{title}\n===\n").replace('\n', ending);
968                        let expected = format!("# {joined}\n").replace('\n', ending);
969                        let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
970                        let warnings = rule.check(&ctx).unwrap();
971                        assert_eq!(warnings.len(), 1, "{source}");
972                        assert!(warnings[0].fix.is_some(), "{source}");
973                        assert_eq!(rule.fix(&ctx).unwrap(), expected);
974                    }
975                }
976            }
977        }
978    }
979
980    #[test]
981    fn test_mixed_inline_title_data_still_withholds_unsafe_heading_joins() {
982        let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
983        let source = "[Entity](https://example.com \"title&NewLine;line\") [Raw](https://example.com \"first\nlast\")\n===\n\nAdjacent\n===\n";
984        let expected = "[Entity](https://example.com \"title&NewLine;line\") [Raw](https://example.com \"first\nlast\")\n===\n\n# Adjacent\n";
985        let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
986        let warnings = rule.check(&ctx).unwrap();
987        assert_eq!(warnings.len(), 2);
988        assert!(warnings[0].fix.is_none());
989        assert!(warnings[1].fix.is_some());
990        assert_eq!(rule.fix(&ctx).unwrap(), expected);
991    }
992    #[test]
993    fn plain_atx_fixes_preserve_literal_terminal_hashes() {
994        let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
995        for title in ["Title #", "标题 ###", "#######", "Title\t####", "**Title** ###"] {
996            let first_hash = title.len() - title.bytes().rev().take_while(|&byte| byte == b'#').count();
997            let escaped = format!("{}\\{}", &title[..first_hash], &title[first_hash..]);
998            for ending in ["\n", "\r\n"] {
999                for body in [format!("{title}\n===\n"), format!("# {title} #\n")] {
1000                    let source = body.replace('\n', ending);
1001                    let expected = format!("# {escaped}{ending}");
1002                    let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1003                    let warnings = rule.check(&ctx).unwrap();
1004                    assert_eq!(warnings.len(), 1, "{source}");
1005                    assert_eq!(
1006                        crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
1007                        expected
1008                    );
1009                    assert_eq!(rule.fix(&ctx).unwrap(), expected);
1010                    let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
1011                    assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1012                    assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1013                }
1014            }
1015        }
1016    }
1017}