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        // Headings inside blockquotes vote like any other heading.
125        for parsed in ctx.headings() {
126            let line_num = parsed.line_num - 1;
127            if Self::heading_is_in_literal_code(ctx, line_num, parsed.heading) {
128                continue;
129            }
130            // Map from LintContext heading style to rules heading style and count
131            let style = Self::heading_style(ctx, line_num, parsed.heading);
132            *style_counts.entry(style).or_insert(0) += 1;
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 native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
179        (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(3))
180    }
181
182    fn name(&self) -> &'static str {
183        "MD003"
184    }
185
186    fn description(&self) -> &'static str {
187        "Heading style"
188    }
189
190    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
191        let mut result = Vec::new();
192
193        // Get the target style using cached heading information
194        let target_style = self.get_target_style(ctx);
195
196        // Process headings using cached heading information
197        for parsed in ctx.headings() {
198            let line_num = parsed.line_num - 1;
199            let line_info = parsed.line_info;
200            let heading = parsed.heading;
201            if Self::heading_is_in_literal_code(ctx, line_num, heading) {
202                continue;
203            }
204            let level = heading.level;
205
206            // Map the cached heading style to the rule's HeadingStyle
207            let current_style = Self::heading_style(ctx, line_num, heading);
208
209            // Determine expected style based on level and target
210            let expected_style = match target_style {
211                HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
212                    if level > 2 {
213                        // Setext only supports levels 1-2. The heading cannot
214                        // comply at all, so keep the ATX flavor it already has
215                        // instead of restyling it to one the config never asked
216                        // for. That also keeps the fix idempotent under
217                        // `consistent`: rewriting only ever moves headings into
218                        // the target style, so it can never flip the prevalence
219                        // count that chose the target.
220                        current_style
221                    } else if level == 1 {
222                        HeadingStyle::Setext1
223                    } else {
224                        HeadingStyle::Setext2
225                    }
226                }
227                HeadingStyle::SetextWithAtx => {
228                    if level <= 2 {
229                        // Use Setext for h1/h2
230                        if level == 1 {
231                            HeadingStyle::Setext1
232                        } else {
233                            HeadingStyle::Setext2
234                        }
235                    } else {
236                        // Use ATX for h3-h6
237                        HeadingStyle::Atx
238                    }
239                }
240                HeadingStyle::SetextWithAtxClosed => {
241                    if level <= 2 {
242                        // Use Setext for h1/h2
243                        if level == 1 {
244                            HeadingStyle::Setext1
245                        } else {
246                            HeadingStyle::Setext2
247                        }
248                    } else {
249                        // Use ATX closed for h3-h6
250                        HeadingStyle::AtxClosed
251                    }
252                }
253                _ => target_style,
254            };
255
256            // MDG only recognizes plain ATX headings: a setext heading never
257            // becomes a Gherkin node and a closing sequence leaks into the
258            // node's name (`# Feature: F #` is named "F #"). Steering every
259            // heading to plain ATX keeps MD003 enforcing a style while never
260            // emitting a form Gherkin cannot parse.
261            let expected_style = if ctx.flavor == crate::config::MarkdownFlavor::MDG {
262                HeadingStyle::Atx
263            } else {
264                expected_style
265            };
266
267            if current_style != expected_style {
268                // The heading is recorded on the last line of its text, and a
269                // setext heading's text is the paragraph its underline ends
270                let first_line_num = line_num + 2 - heading.text_lines;
271
272                // Generate fix for this heading
273                // A quoted heading keeps its warning without a fix: the
274                // conversions here do not rebuild the container prefix of
275                // the heading and of the underline a setext heading needs.
276                let fix = if !parsed.is_blockquote() && Self::conversion_is_safe(ctx, line_num, heading) {
277                    use crate::rules::heading_utils::HeadingUtils;
278
279                    // Convert heading to target style, preserving inline attribute lists
280                    let converted_heading =
281                        HeadingUtils::convert_heading_style(&heading.raw_text, level as u32, expected_style);
282
283                    // Preserve everything before the heading on the line its text
284                    // starts on: the indentation (including tabs) and, for a
285                    // heading that opens a list item, the list marker
286                    let first_line_info = &ctx.lines[first_line_num - 1];
287                    let first_line = first_line_info.content(ctx.content);
288                    let original_indent = &first_line[..heading.first_line_prefix_len()];
289
290                    // An ATX heading becoming setext gains an underline line. It
291                    // must sit in the same container as the text line, so it
292                    // carries the same indentation.
293                    let atx_to_setext = heading.style == crate::lint_context::HeadingStyle::ATX
294                        && level <= 2
295                        && matches!(expected_style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
296                    let converted_heading = if atx_to_setext {
297                        converted_heading.replacen('\n', &format!("\n{original_indent}"), 1)
298                    } else {
299                        converted_heading
300                    };
301                    let final_heading = format!("{original_indent}{converted_heading}");
302
303                    // A setext heading is the whole paragraph its underline
304                    // ends, so converting away from it replaces every line of
305                    // that paragraph. The underline goes with them, otherwise
306                    // it survives as a thematic break.
307                    let converting_from_setext =
308                        matches!(
309                            heading.style,
310                            crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
311                        ) && !matches!(expected_style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
312                    let last_line = if converting_from_setext {
313                        line_num + 2
314                    } else {
315                        line_num + 1
316                    };
317
318                    let start = ctx.line_content_byte_range(first_line_num).start;
319                    let end = ctx.line_content_byte_range(last_line).end;
320
321                    // Declined conversions keep the warning without a fix: the
322                    // heading stays a valid ATX heading instead of being merged
323                    // into the block above or turned into another construct.
324                    if atx_to_setext
325                        && !crate::rules::heading_utils::atx_to_setext_is_safe(ctx, line_num, heading, &final_heading)
326                    {
327                        None
328                    } else {
329                        Some(crate::rule::Fix::new(start..end, final_heading))
330                    }
331                } else {
332                    None
333                };
334
335                // Calculate precise character range for the heading marker
336                let (start_line, start_col, end_line, end_col) =
337                    calculate_heading_range(first_line_num, line_num + 1, line_info.content(ctx.content));
338
339                result.push(LintWarning {
340                    rule_name: Some(self.name().to_string()),
341                    line: start_line,
342                    column: start_col,
343                    end_line,
344                    end_column: end_col,
345                    message: format!(
346                        "Heading style should be {}, found {}",
347                        match expected_style {
348                            HeadingStyle::Atx => "# Heading",
349                            HeadingStyle::AtxClosed => "# Heading #",
350                            HeadingStyle::Setext1 => "Heading\n=======",
351                            HeadingStyle::Setext2 => "Heading\n-------",
352                            HeadingStyle::Consistent => "consistent with the first heading",
353                            HeadingStyle::SetextWithAtx => "setext-with-atx style",
354                            HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
355                        },
356                        match current_style {
357                            HeadingStyle::Atx => "# Heading",
358                            HeadingStyle::AtxClosed => "# Heading #",
359                            HeadingStyle::Setext1 => "Heading (underlined with =)",
360                            HeadingStyle::Setext2 => "Heading (underlined with -)",
361                            HeadingStyle::Consistent => "consistent style",
362                            HeadingStyle::SetextWithAtx => "setext-with-atx style",
363                            HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
364                        }
365                    ),
366                    severity: Severity::Warning,
367                    fix,
368                });
369            }
370        }
371
372        Ok(result)
373    }
374
375    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
376        // Get all warnings with their fixes
377        let warnings = self.check(ctx)?;
378        let warnings =
379            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
380
381        // If no warnings, return original content
382        if warnings.is_empty() {
383            return Ok(ctx.content.to_string());
384        }
385
386        // Collect all fixes and sort by range start (descending) to apply from end to beginning
387        let mut fixes: Vec<_> = warnings
388            .iter()
389            .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
390            .collect();
391        fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
392
393        // Apply fixes from end to beginning to preserve byte offsets
394        let mut result = ctx.content.to_string();
395        for (start, end, replacement) in fixes {
396            if start < result.len() && end <= result.len() && start <= end {
397                result.replace_range(start..end, replacement);
398            }
399        }
400
401        Ok(result)
402    }
403
404    fn category(&self) -> RuleCategory {
405        RuleCategory::Heading
406    }
407
408    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
409        // Fast path: check if document likely has headings using character frequency
410        if ctx.content.is_empty() || !ctx.likely_has_headings() {
411            return true;
412        }
413        // Verify headings actually exist (handles false positives from character frequency)
414        ctx.headings().next().is_none()
415    }
416
417    fn as_any(&self) -> &dyn std::any::Any {
418        self
419    }
420
421    crate::impl_rule_config_sections!(MD003Config);
422
423    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
424    where
425        Self: Sized,
426    {
427        let rule_config = crate::rule_config_serde::load_rule_config::<MD003Config>(config);
428        let style_explicit = option_is_explicit(config, "MD003", "style");
429
430        Box::new(Self {
431            config: rule_config,
432            style_explicit,
433        })
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use crate::lint_context::LintContext;
441
442    #[test]
443    fn test_atx_heading_style() {
444        let rule = MD003HeadingStyle::default();
445        let content = "# Heading 1\n## Heading 2\n### Heading 3";
446        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
447        let result = rule.check(&ctx).unwrap();
448        assert!(result.is_empty());
449    }
450
451    #[test]
452    fn test_setext_heading_style() {
453        let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
454        let content = "Heading 1\n=========\n\nHeading 2\n---------";
455        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
456        let result = rule.check(&ctx).unwrap();
457        assert!(result.is_empty());
458    }
459
460    #[test]
461    fn test_front_matter() {
462        let rule = MD003HeadingStyle::default();
463        let content = "---\ntitle: Test\n---\n\n# Heading 1\n## Heading 2";
464
465        // Test should detect headings and apply consistent style
466        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
467        let result = rule.check(&ctx).unwrap();
468        assert!(
469            result.is_empty(),
470            "No warnings expected for content with front matter, found: {result:?}"
471        );
472    }
473
474    #[test]
475    fn test_consistent_heading_style() {
476        // Default rule uses Atx which serves as our "consistent" mode
477        let rule = MD003HeadingStyle::default();
478        let content = "# Heading 1\n## Heading 2\n### Heading 3";
479        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
480        let result = rule.check(&ctx).unwrap();
481        assert!(result.is_empty());
482    }
483
484    #[test]
485    fn test_with_different_styles() {
486        // Test with consistent style (ATX)
487        let rule = MD003HeadingStyle::new(HeadingStyle::Consistent);
488        let content = "# Heading 1\n## Heading 2\n### Heading 3";
489        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
490        let result = rule.check(&ctx).unwrap();
491
492        // Make test more resilient
493        assert!(
494            result.is_empty(),
495            "No warnings expected for consistent ATX style, found: {result:?}"
496        );
497
498        // Test with incorrect style
499        let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
500        let content = "# Heading 1 #\nHeading 2\n-----\n### Heading 3";
501        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
502        let result = rule.check(&ctx).unwrap();
503        assert!(
504            !result.is_empty(),
505            "Should have warnings for inconsistent heading styles"
506        );
507
508        // Test with setext style
509        let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
510        let content = "Heading 1\n=========\nHeading 2\n---------\n### Heading 3";
511        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
512        let result = rule.check(&ctx).unwrap();
513        // The level 3 heading can't be setext, so it's valid as ATX
514        assert!(
515            result.is_empty(),
516            "No warnings expected for setext style with ATX for level 3, found: {result:?}"
517        );
518    }
519
520    #[test]
521    fn test_setext_with_atx_style() {
522        let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtx);
523        // Setext for h1/h2, ATX for h3-h6
524        let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3\n\n#### Heading 4";
525        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
526        let result = rule.check(&ctx).unwrap();
527        assert!(
528            result.is_empty(),
529            "SesetxtWithAtx style should accept setext for h1/h2 and ATX for h3+"
530        );
531
532        // Test incorrect usage - ATX for h1/h2
533        let content_wrong = "# Heading 1\n## Heading 2\n### Heading 3";
534        let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
535        let result_wrong = rule.check(&ctx_wrong).unwrap();
536        assert_eq!(
537            result_wrong.len(),
538            2,
539            "Should flag ATX headings for h1/h2 with setext_with_atx style"
540        );
541    }
542
543    #[test]
544    fn test_fix_preserves_attribute_lists() {
545        // ATX closed heading with attribute list, converted to ATX
546        let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
547        let content = "# Heading { #custom-id .class } #";
548        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
549
550        // Should flag: found ATX closed, expected ATX
551        let warnings = rule.check(&ctx).unwrap();
552        assert_eq!(warnings.len(), 1);
553        let fix = warnings[0].fix.as_ref().expect("Should have a fix");
554        assert!(
555            fix.replacement.contains("{ #custom-id .class }"),
556            "check() fix should preserve attribute list, got: {}",
557            fix.replacement
558        );
559
560        // Verify fix() also preserves attribute list
561        let fixed = rule.fix(&ctx).unwrap();
562        assert!(
563            fixed.contains("{ #custom-id .class }"),
564            "fix() should preserve attribute list, got: {fixed}"
565        );
566        assert!(
567            !fixed.contains(" #\n") && !fixed.ends_with(" #"),
568            "fix() should remove ATX closed trailing hashes, got: {fixed}"
569        );
570    }
571
572    #[test]
573    fn test_setext_with_atx_closed_style() {
574        let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtxClosed);
575        // Setext for h1/h2, ATX closed for h3-h6
576        let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3 ###\n\n#### Heading 4 ####";
577        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
578        let result = rule.check(&ctx).unwrap();
579        assert!(
580            result.is_empty(),
581            "SetextWithAtxClosed style should accept setext for h1/h2 and ATX closed for h3+"
582        );
583
584        // Test incorrect usage - regular ATX for h3+
585        let content_wrong = "Heading 1\n=========\n\n### Heading 3\n\n#### Heading 4";
586        let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
587        let result_wrong = rule.check(&ctx_wrong).unwrap();
588        assert_eq!(
589            result_wrong.len(),
590            2,
591            "Should flag non-closed ATX headings for h3+ with setext_with_atx_closed style"
592        );
593    }
594
595    #[test]
596    fn test_mdg_steers_every_heading_to_plain_atx() {
597        // MDG parses `#{1,6} ` headings only, so setext headings are corrected
598        // into ATX and closing sequences are dropped rather than preserved.
599        let cases = [
600            (
601                MD003HeadingStyle::new(HeadingStyle::Atx),
602                "Checkout\n========\n\n## Scenario: Buy an item\n",
603                "# Checkout\n\n## Scenario: Buy an item\n",
604            ),
605            (
606                MD003HeadingStyle::new(HeadingStyle::AtxClosed),
607                "# Feature: Checkout\n\n## Scenario: Buy an item ##\n",
608                "# Feature: Checkout\n\n## Scenario: Buy an item\n",
609            ),
610            (
611                MD003HeadingStyle::new(HeadingStyle::Setext1),
612                "# Feature: Checkout\n\nScenario: Documentation\n-----------------------\n",
613                "# Feature: Checkout\n\n## Scenario: Documentation\n",
614            ),
615            (
616                MD003HeadingStyle::default(),
617                "Checkout\n========\n\nGuide\n-----\n\n## Scenario: Buy an item\n",
618                "# Checkout\n\n## Guide\n\n## Scenario: Buy an item\n",
619            ),
620        ];
621
622        for (rule, content, expected) in cases {
623            let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
624
625            assert!(!rule.should_skip(&mdg_ctx));
626            assert!(
627                !rule.check(&mdg_ctx).unwrap().is_empty(),
628                "MDG must still report non-ATX headings in {content:?}"
629            );
630            let fixed = rule.fix(&mdg_ctx).unwrap();
631            assert_eq!(fixed, expected, "MDG must steer {content:?} to plain ATX");
632
633            let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
634            assert!(rule.check(&fixed_ctx).unwrap().is_empty());
635            assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
636        }
637    }
638
639    #[test]
640    fn test_mdg_tracks_only_an_explicit_style_for_override_notices() {
641        let direct = MD003HeadingStyle::new(HeadingStyle::Setext1);
642        assert!(direct.style_explicit);
643
644        let defaulted = MD003HeadingStyle::from_config_struct(MD003Config {
645            style: HeadingStyle::Setext1,
646        });
647        assert!(!defaulted.style_explicit);
648
649        let mut config = crate::config::Config::default();
650        let mut rule_config = crate::config::RuleConfig::default();
651        rule_config
652            .values
653            .insert("style".to_string(), toml::Value::String("setext".to_string()));
654        config.rules.insert("MD003".to_string(), rule_config);
655        let configured = MD003HeadingStyle::from_config(&config);
656        let configured = configured
657            .as_any()
658            .downcast_ref::<MD003HeadingStyle>()
659            .expect("MD003::from_config builds MD003HeadingStyle");
660        assert!(configured.style_explicit);
661
662        let default_configured = MD003HeadingStyle::from_config(&crate::config::Config::default());
663        let default_configured = default_configured
664            .as_any()
665            .downcast_ref::<MD003HeadingStyle>()
666            .expect("MD003::from_config builds MD003HeadingStyle");
667        assert!(!default_configured.style_explicit);
668    }
669    #[test]
670    fn test_literal_headings_are_preserved_beside_visible_style_fixes() {
671        for (style, literal, visible, target) in [
672            (HeadingStyle::Atx, "\nFake\n---\n", "Visible\n---", "## Visible"),
673            (HeadingStyle::Atx, "\n# Fake #\n", "# Visible #", "# Visible"),
674            (HeadingStyle::AtxClosed, "\n# Fake\n", "# Visible", "# Visible #"),
675            (HeadingStyle::Setext1, "\n# Fake\n", "# Visible", "Visible\n======="),
676        ] {
677            let rule = MD003HeadingStyle::new(style);
678            for (flavor, code) in [
679                (
680                    crate::config::MarkdownFlavor::Standard,
681                    format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
682                ),
683                (
684                    crate::config::MarkdownFlavor::Hugo,
685                    format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
686                ),
687            ] {
688                for ending in ["\n", "\r\n"] {
689                    let source = format!("{code}\n\n{visible}\n").replace('\n', ending);
690                    let expected = format!("{code}\n\n{target}\n").replace('\n', ending);
691                    let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
692                    let warnings = rule.check(&ctx).unwrap();
693                    assert_eq!(warnings.len(), 1, "{source}");
694                    assert_eq!(
695                        crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
696                        expected,
697                        "{source}"
698                    );
699                    // The direct fixer inserts LF in a newly generated Setext heading.
700                    let direct_expected = format!("{}{ending}{ending}{target}{ending}", code.replace('\n', ending));
701                    assert_eq!(rule.fix(&ctx).unwrap(), direct_expected, "{source}");
702                }
703            }
704        }
705    }
706
707    #[test]
708    fn test_literal_heading_styles_do_not_choose_visible_style() {
709        let rule = MD003HeadingStyle::default();
710        for literal in ["# Fake #\n\n# Other #", "\nFake\n===\n\nOther\n===\n"] {
711            for (flavor, code) in [
712                (
713                    crate::config::MarkdownFlavor::Standard,
714                    format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
715                ),
716                (
717                    crate::config::MarkdownFlavor::Hugo,
718                    format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
719                ),
720            ] {
721                let source = format!("{code}\n\n# Visible\n");
722                let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
723                assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
724                assert_eq!(rule.fix(&ctx).unwrap(), source);
725            }
726        }
727    }
728
729    #[test]
730    fn test_dynamic_and_mdx_body_heading_styles_remain_fixable() {
731        let rule = MD003HeadingStyle::new(HeadingStyle::AtxClosed);
732        for (flavor, source, target) in [
733            (
734                crate::config::MarkdownFlavor::Standard,
735                "# {{ title }}\n",
736                "# {{ title }} #\n",
737            ),
738            (
739                crate::config::MarkdownFlavor::MDX,
740                "<div>\n\n# Visible\n\n</div>\n",
741                "<div>\n\n# Visible #\n\n</div>\n",
742            ),
743        ] {
744            let ctx = crate::lint_context::LintContext::new(source, flavor, None);
745            assert_eq!(rule.check(&ctx).unwrap().len(), 1);
746            assert_eq!(rule.fix(&ctx).unwrap(), target);
747        }
748    }
749
750    #[test]
751    fn test_unsafe_title_conversions_leave_safe_neighbors_fixable() {
752        for (style, source_title, adjacent, fixed_adjacent) in [
753            (
754                HeadingStyle::Setext1,
755                "# {% set unused=\"first  \nlast\" %}{{ unused|length }}",
756                "# Adjacent",
757                "Adjacent\n========",
758            ),
759            (
760                HeadingStyle::Setext1,
761                "# {{< note title=`first  \nlast` >}}",
762                "# Adjacent",
763                "Adjacent\n========",
764            ),
765            (
766                HeadingStyle::Atx,
767                "{% set unused=\"first  \nlast\" %}{{ unused|length }}\n===",
768                "Adjacent\n===",
769                "# Adjacent",
770            ),
771            (
772                HeadingStyle::Atx,
773                "{{< note title=`first  \nlast` >}}\n===",
774                "Adjacent\n===",
775                "# Adjacent",
776            ),
777            (
778                HeadingStyle::AtxClosed,
779                "# {% set unused=\"first  \nlast\" %}{{ unused|length }}",
780                "# Adjacent",
781                "# Adjacent #",
782            ),
783        ] {
784            let rule = MD003HeadingStyle::new(style);
785            let flavor = if source_title.contains("{{<") {
786                crate::config::MarkdownFlavor::Hugo
787            } else {
788                crate::config::MarkdownFlavor::Standard
789            };
790            for ending in ["\n", "\r\n"] {
791                let source = format!("{source_title}\n\n{adjacent}\n").replace('\n', ending);
792                let expected = format!("{source_title}\n\n{fixed_adjacent}\n").replace('\n', ending);
793                let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
794                let warnings = rule.check(&ctx).unwrap();
795                assert_eq!(warnings.len(), 2, "{source}");
796                assert!(warnings[0].fix.is_none(), "{source}");
797                assert!(warnings[1].fix.is_some());
798                assert_eq!(
799                    crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
800                    expected
801                );
802                let direct_expected = format!(
803                    "{}{ending}{ending}{fixed_adjacent}{ending}",
804                    source_title.replace('\n', ending)
805                );
806                assert_eq!(rule.fix(&ctx).unwrap(), direct_expected);
807            }
808        }
809    }
810
811    #[test]
812    fn test_setext_style_conversion_preserves_literal_markup_and_hard_breaks() {
813        for title in [
814            "First\\\nSecond",
815            "First  \nSecond",
816            "`first  \nlast`",
817            "`first\n  last`",
818            "<span title=\"first\nlast\">Visible</span>",
819        ] {
820            for style in [HeadingStyle::Atx, HeadingStyle::AtxClosed] {
821                let rule = MD003HeadingStyle::new(style);
822                let source = format!("{title}\n===\n\nAdjacent\n===\n");
823                let adjacent = if style == HeadingStyle::Atx {
824                    "# Adjacent"
825                } else {
826                    "# Adjacent #"
827                };
828                let expected = format!("{title}\n===\n\n{adjacent}\n");
829                let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
830                let warnings = rule.check(&ctx).unwrap();
831                assert_eq!(warnings.len(), 2);
832                assert!(warnings[0].fix.is_none(), "{source}");
833                assert!(warnings[1].fix.is_some());
834                assert_eq!(rule.fix(&ctx).unwrap(), expected);
835            }
836        }
837    }
838
839    #[test]
840    fn test_safe_dynamic_and_setext_style_conversions_remain_fixable() {
841        for (style, source, expected) in [
842            (HeadingStyle::Setext1, "# {{ title }}\n", "{{ title }}\n===========\n"),
843            (HeadingStyle::AtxClosed, "# {{ title }}\n", "# {{ title }} #\n"),
844            (HeadingStyle::Atx, "`first\nlast`\n===\n", "# `first last`\n"),
845        ] {
846            let rule = MD003HeadingStyle::new(style);
847            let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
848            assert_eq!(rule.check(&ctx).unwrap().len(), 1);
849            assert!(rule.check(&ctx).unwrap()[0].fix.is_some());
850            assert_eq!(rule.fix(&ctx).unwrap(), expected);
851        }
852    }
853
854    #[test]
855    fn test_literal_closing_hashes_do_not_choose_heading_style() {
856        for (flavor, prefix, suffix) in [
857            (
858                crate::config::MarkdownFlavor::Standard,
859                "{% set unused=\"",
860                "\" %}{{ unused|length }}",
861            ),
862            (crate::config::MarkdownFlavor::Hugo, "{{< note title=`", "` >}}"),
863        ] {
864            for hashes in ["#", "###", "########"] {
865                for pad in ["", "  ", "\t"] {
866                    for ending in ["\n", "\r\n"] {
867                        let title = format!("# {prefix}naïve {hashes}{pad}\nlast{suffix}");
868                        let source = format!("{title}\n\n{title}\n\n# Adjacent\n").replace('\n', ending);
869                        for style in [HeadingStyle::Atx, HeadingStyle::Consistent] {
870                            let rule = MD003HeadingStyle::new(style);
871                            let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
872                            assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
873                            assert_eq!(rule.fix(&ctx).unwrap(), source);
874                        }
875                    }
876                }
877            }
878        }
879    }
880
881    #[test]
882    fn test_literal_closing_hashes_still_require_a_real_closed_marker() {
883        let rule = MD003HeadingStyle::new(HeadingStyle::AtxClosed);
884        let source = "# {% set unused=\"first ###\nlast\" %}{{ unused|length }}\n\n# Adjacent\n";
885        let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
886        let warnings = rule.check(&ctx).unwrap();
887        assert_eq!(warnings.len(), 2);
888        assert!(warnings[0].fix.is_none());
889        assert!(warnings[1].fix.is_some());
890        assert_eq!(
891            rule.fix(&ctx).unwrap(),
892            "# {% set unused=\"first ###\nlast\" %}{{ unused|length }}\n\n# Adjacent #\n"
893        );
894    }
895
896    #[test]
897    fn test_real_closing_hashes_after_dynamic_titles_remain_closed() {
898        let rule = MD003HeadingStyle::new(HeadingStyle::AtxClosed);
899        for (flavor, source) in [
900            (crate::config::MarkdownFlavor::Standard, "# {{ title }} ###\n"),
901            (crate::config::MarkdownFlavor::Standard, "# {{ title }} ### {#custom}\n"),
902            (crate::config::MarkdownFlavor::MDX, "<div>\n\n# Visible ###\n\n</div>\n"),
903        ] {
904            let ctx = crate::lint_context::LintContext::new(source, flavor, None);
905            assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
906            assert_eq!(rule.fix(&ctx).unwrap(), source);
907        }
908    }
909
910    #[test]
911    fn test_multiline_inline_titles_survive_heading_style_conversion() {
912        for node in ["[Link]", "![Image]"] {
913            for (open, close) in [('"', '"'), ('\'', '\''), ('(', ')')] {
914                for style in [HeadingStyle::Atx, HeadingStyle::AtxClosed] {
915                    let rule = MD003HeadingStyle::new(style);
916                    let title = format!("{node}(https://example.com {open}first\nlast{close})\n===");
917                    let adjacent = if style == HeadingStyle::Atx {
918                        "# Adjacent"
919                    } else {
920                        "# Adjacent #"
921                    };
922                    for ending in ["\n", "\r\n"] {
923                        let source = format!("{title}\n\nAdjacent\n===\n").replace('\n', ending);
924                        let expected = format!("{title}\n\n{adjacent}\n").replace('\n', ending);
925                        let ctx = crate::lint_context::LintContext::new(
926                            &source,
927                            crate::config::MarkdownFlavor::Standard,
928                            None,
929                        );
930                        let warnings = rule.check(&ctx).unwrap();
931                        assert_eq!(warnings.len(), 2, "{source}");
932                        assert!(warnings[0].fix.is_none());
933                        assert!(warnings[1].fix.is_some());
934                        assert_eq!(
935                            crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
936                            expected
937                        );
938                        assert_eq!(rule.fix(&ctx).unwrap(), expected);
939                    }
940                }
941            }
942        }
943    }
944
945    #[test]
946    fn test_link_labels_and_external_titles_remain_style_fixable() {
947        let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
948        for (source, expected) in [
949            (
950                "[first\nlast](https://example.com \"single title\")\n===\n",
951                "# [first last](https://example.com \"single title\")\n",
952            ),
953            (
954                "[first\nlast][ref]\n===\n\n[ref]: https://example.com \"first\nlast\"\n",
955                "# [first last][ref]\n\n[ref]: https://example.com \"first\nlast\"\n",
956            ),
957            (
958                "![Image][ref]\n===\n\n[ref]: https://example.com/i.png \"first\nlast\"\n",
959                "# ![Image][ref]\n\n[ref]: https://example.com/i.png \"first\nlast\"\n",
960            ),
961        ] {
962            let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
963            let warnings = rule.check(&ctx).unwrap();
964            assert_eq!(warnings.len(), 1);
965            assert!(warnings[0].fix.is_some(), "{source}");
966            assert_eq!(rule.fix(&ctx).unwrap(), expected);
967        }
968    }
969
970    #[test]
971    fn test_entity_newlines_do_not_block_safe_heading_style_joins() {
972        let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
973        for entity in ["&NewLine;", "&#10;", "&#xA;"] {
974            for (title, joined) in [
975                (
976                    format!("[first\nlast](https://example.com \"title{entity}line\")"),
977                    format!("[first last](https://example.com \"title{entity}line\")"),
978                ),
979                (
980                    format!("![Image](https://example.com\n\"title{entity}line\")"),
981                    format!("![Image](https://example.com \"title{entity}line\")"),
982                ),
983                (
984                    format!(
985                        "[![Image](https://example.com/i.png \"image{entity}title\")\nLabel](https://example.com \"link{entity}title\")"
986                    ),
987                    format!(
988                        "[![Image](https://example.com/i.png \"image{entity}title\") Label](https://example.com \"link{entity}title\")"
989                    ),
990                ),
991            ] {
992                for flavor in [
993                    crate::config::MarkdownFlavor::Standard,
994                    crate::config::MarkdownFlavor::MDX,
995                ] {
996                    for ending in ["\n", "\r\n"] {
997                        let source = format!("{title}\n===\n").replace('\n', ending);
998                        let expected = format!("# {joined}\n").replace('\n', ending);
999                        let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
1000                        let warnings = rule.check(&ctx).unwrap();
1001                        assert_eq!(warnings.len(), 1, "{source}");
1002                        assert!(warnings[0].fix.is_some(), "{source}");
1003                        assert_eq!(rule.fix(&ctx).unwrap(), expected);
1004                    }
1005                }
1006            }
1007        }
1008    }
1009
1010    #[test]
1011    fn test_mixed_inline_title_data_still_withholds_unsafe_heading_joins() {
1012        let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
1013        let source = "[Entity](https://example.com \"title&NewLine;line\") [Raw](https://example.com \"first\nlast\")\n===\n\nAdjacent\n===\n";
1014        let expected = "[Entity](https://example.com \"title&NewLine;line\") [Raw](https://example.com \"first\nlast\")\n===\n\n# Adjacent\n";
1015        let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1016        let warnings = rule.check(&ctx).unwrap();
1017        assert_eq!(warnings.len(), 2);
1018        assert!(warnings[0].fix.is_none());
1019        assert!(warnings[1].fix.is_some());
1020        assert_eq!(rule.fix(&ctx).unwrap(), expected);
1021    }
1022    #[test]
1023    fn plain_atx_fixes_preserve_literal_terminal_hashes() {
1024        let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
1025        for title in ["Title #", "标题 ###", "#######", "Title\t####", "**Title** ###"] {
1026            let first_hash = title.len() - title.bytes().rev().take_while(|&byte| byte == b'#').count();
1027            let escaped = format!("{}\\{}", &title[..first_hash], &title[first_hash..]);
1028            for ending in ["\n", "\r\n"] {
1029                for body in [format!("{title}\n===\n"), format!("# {title} #\n")] {
1030                    let source = body.replace('\n', ending);
1031                    let expected = format!("# {escaped}{ending}");
1032                    let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1033                    let warnings = rule.check(&ctx).unwrap();
1034                    assert_eq!(warnings.len(), 1, "{source}");
1035                    assert_eq!(
1036                        crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
1037                        expected
1038                    );
1039                    assert_eq!(rule.fix(&ctx).unwrap(), expected);
1040                    let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
1041                    assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1042                    assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1043                }
1044            }
1045        }
1046    }
1047
1048    mod atx_to_setext_safety {
1049        use super::*;
1050        use crate::config::MarkdownFlavor;
1051        use crate::utils::fix_utils::apply_warning_fixes;
1052
1053        /// Level and plain text of every heading as pulldown-cmark renders it.
1054        fn headings(content: &str) -> Vec<(u8, String)> {
1055            use pulldown_cmark::{Event, Parser, Tag, TagEnd};
1056            let mut out = Vec::new();
1057            let mut current: Option<(u8, String)> = None;
1058            for event in Parser::new_ext(content, crate::utils::parser_options::rumdl_parser_options_for(content)) {
1059                match event {
1060                    Event::Start(Tag::Heading { level, .. }) => current = Some((level as u8, String::new())),
1061                    Event::End(TagEnd::Heading(_)) => out.extend(current.take()),
1062                    Event::Text(t) | Event::Code(t) => {
1063                        if let Some((_, text)) = current.as_mut() {
1064                            text.push_str(&t);
1065                        }
1066                    }
1067                    _ => {}
1068                }
1069            }
1070            out
1071        }
1072
1073        fn run(style: HeadingStyle, flavor: MarkdownFlavor, src: &str) -> (Vec<LintWarning>, String, String) {
1074            let rule = MD003HeadingStyle::new(style);
1075            let ctx = LintContext::new(src, flavor, None);
1076            let warnings = rule.check(&ctx).unwrap();
1077            let by_warning = apply_warning_fixes(src, &warnings).unwrap();
1078            let by_fix = rule.fix(&ctx).unwrap();
1079            (warnings, by_warning, by_fix)
1080        }
1081
1082        fn assert_declined_with(style: HeadingStyle, flavor: MarkdownFlavor, src: &str) {
1083            let (warnings, by_warning, by_fix) = run(style, flavor, src);
1084            assert!(!warnings.is_empty(), "{src:?}: expected a warning to remain");
1085            assert!(
1086                warnings.iter().all(|w| w.fix.is_none()),
1087                "{src:?}: expected no fix, got {warnings:?}"
1088            );
1089            assert_eq!(by_warning, src, "{src:?}: warning fixes changed the document");
1090            assert_eq!(by_fix, src, "{src:?}: fix() changed the document");
1091        }
1092
1093        fn assert_declined(src: &str) {
1094            assert_declined_with(HeadingStyle::Setext1, MarkdownFlavor::Standard, src);
1095        }
1096
1097        fn assert_converted_with(style: HeadingStyle, flavor: MarkdownFlavor, src: &str, expected: &str) {
1098            let (warnings, by_warning, by_fix) = run(style, flavor, src);
1099            assert!(!warnings.is_empty(), "{src:?}: expected a warning");
1100            assert_eq!(by_warning, expected, "{src:?}: warning fixes");
1101            assert_eq!(by_fix, expected, "{src:?}: fix()");
1102            assert_eq!(
1103                headings(src),
1104                headings(expected),
1105                "{src:?}: converting changed the heading set"
1106            );
1107        }
1108
1109        fn assert_converted(src: &str, expected: &str) {
1110            assert_converted_with(HeadingStyle::Setext1, MarkdownFlavor::Standard, src, expected);
1111        }
1112
1113        #[test]
1114        fn paragraph_above_is_not_absorbed() {
1115            assert_declined("para\n# H\n");
1116            assert_declined("para\n## H\n");
1117            assert_declined("para\n# H #\n");
1118            assert_declined("a\n# H\nb\n");
1119            assert_declined("para\r\n# H\r\n");
1120        }
1121
1122        #[test]
1123        fn list_and_table_above_decline() {
1124            assert_declined("- item\n# H\n");
1125            assert_declined("- item\n  # H\n");
1126            assert_declined("| a | b |\n|---|---|\n| 1 | 2 |\n# H\n");
1127        }
1128
1129        #[test]
1130        fn meta_styles_decline_with_paragraph_above() {
1131            assert_declined_with(HeadingStyle::SetextWithAtx, MarkdownFlavor::Standard, "para\n# H\n");
1132            assert_declined_with(
1133                HeadingStyle::SetextWithAtxClosed,
1134                MarkdownFlavor::Standard,
1135                "para\n# H\n",
1136            );
1137        }
1138
1139        #[test]
1140        fn empty_heading_is_not_deleted() {
1141            assert_declined("para\n#\n");
1142            assert_declined("\n#\n");
1143            assert_declined("\n##\n");
1144            assert_declined("# \n");
1145        }
1146
1147        #[test]
1148        fn heading_text_that_is_block_syntax_declines() {
1149            for text in [
1150                "- a",
1151                "+ x",
1152                "* x",
1153                "1.",
1154                "1) x",
1155                ">",
1156                "> q",
1157                "---",
1158                "***",
1159                "```js",
1160                "~~~",
1161                "<div>",
1162                "<!-- c -->",
1163                "[x]: /u",
1164            ] {
1165                assert_declined(&format!("\n# {text}\n"));
1166            }
1167            assert_declined("\n## -\n");
1168        }
1169
1170        #[test]
1171        fn list_continuation_underline_keeps_the_container_indent() {
1172            assert_converted("- a\n\n  # H\n", "- a\n\n  H\n  =\n");
1173            assert_converted("1. a\n\n   ## H\n", "1. a\n\n   H\n   -\n");
1174            assert_converted("\n   # H\n", "\n   H\n   =\n");
1175        }
1176
1177        #[test]
1178        fn blockquote_heading_is_left_alone() {
1179            assert_declined("> # H\n");
1180        }
1181
1182        #[test]
1183        fn safe_positions_still_convert() {
1184            assert_converted(
1185                "# Heading 1\n## Heading 2\n",
1186                "Heading 1\n=========\nHeading 2\n---------\n",
1187            );
1188            assert_converted("# A\n# B\n", "A\n=\nB\n=\n");
1189            assert_converted("# H\n", "H\n=\n");
1190            assert_converted("para\n\n# H\n", "para\n\nH\n=\n");
1191            assert_converted("---\n\n# H\n", "---\n\nH\n=\n");
1192            assert_converted("***\n# H\n", "***\nH\n=\n");
1193            assert_converted("```\ncode\n```\n# H\n", "```\ncode\n```\nH\n=\n");
1194            assert_converted("Foo\n===\n# H\n", "Foo\n===\nH\n=\n");
1195            assert_converted("---\ntitle: x\n---\n# H\n", "---\ntitle: x\n---\nH\n=\n");
1196            assert_converted("# H #\n", "H\n=\n");
1197            assert_converted("para\n\n## H\n", "para\n\nH\n-\n");
1198        }
1199
1200        #[test]
1201        fn setext_with_atx_converts_only_levels_one_and_two() {
1202            assert_converted_with(
1203                HeadingStyle::SetextWithAtx,
1204                MarkdownFlavor::Standard,
1205                "# A\n## B\n### C\n",
1206                "A\n=\nB\n-\n### C\n",
1207            );
1208            assert_converted_with(
1209                HeadingStyle::SetextWithAtx,
1210                MarkdownFlavor::Standard,
1211                "\n# A #\n",
1212                "\nA\n=\n",
1213            );
1214        }
1215
1216        #[test]
1217        fn levels_three_and_up_stay_atx() {
1218            let (warnings, by_warning, _) = run(HeadingStyle::Setext1, MarkdownFlavor::Standard, "para\n### H\n");
1219            assert!(warnings.is_empty());
1220            assert_eq!(by_warning, "para\n### H\n");
1221        }
1222
1223        #[test]
1224        fn text_that_only_looks_like_syntax_converts() {
1225            assert_converted("\n# ===\n", "\n===\n===\n");
1226            assert_converted("\n# \\-\n", "\n\\-\n==\n");
1227            assert_converted("\n# foo\\\n", "\nfoo\\\n====\n");
1228            assert_converted("\n# ::: note\n", "\n::: note\n========\n");
1229            assert_converted("\n# Normal title\n", "\nNormal title\n============\n");
1230        }
1231
1232        #[test]
1233        fn flavor_block_syntax_declines() {
1234            for (flavor, text) in [
1235                (MarkdownFlavor::Pandoc, "::: note"),
1236                (MarkdownFlavor::Quarto, "::: note"),
1237                (MarkdownFlavor::MkDocs, "!!! note"),
1238                (MarkdownFlavor::MkDocs, "??? note"),
1239                (MarkdownFlavor::MkDocs, "=== \"Tab\""),
1240                (MarkdownFlavor::MDX, "<Foo />"),
1241                (MarkdownFlavor::MDX, "import x from 'y'"),
1242                (MarkdownFlavor::MDX, "{expr}"),
1243                (MarkdownFlavor::Quarto, "$$"),
1244                (MarkdownFlavor::Pandoc, "$$ x"),
1245                (MarkdownFlavor::Standard, "+++"),
1246            ] {
1247                let src = format!("\n# {text}\n");
1248                let (_, by_warning, by_fix) = run(HeadingStyle::Setext1, flavor, &src);
1249                assert_eq!(
1250                    by_warning, src,
1251                    "{flavor:?} {text:?}: warning fixes changed the document"
1252                );
1253                assert_eq!(by_fix, src, "{flavor:?} {text:?}: fix() changed the document");
1254            }
1255        }
1256
1257        #[test]
1258        fn fixed_output_keeps_headings_for_every_flavor() {
1259            for flavor in [
1260                MarkdownFlavor::Standard,
1261                MarkdownFlavor::Pandoc,
1262                MarkdownFlavor::Quarto,
1263                MarkdownFlavor::MkDocs,
1264                MarkdownFlavor::MDX,
1265            ] {
1266                let src = "\n# Plain title\n\n## Second\n";
1267                let (_, by_warning, _) = run(HeadingStyle::Setext1, flavor, src);
1268                assert_eq!(headings(src), headings(&by_warning), "{flavor:?}");
1269                assert!(by_warning.contains("Plain title\n===="), "{flavor:?}: {by_warning:?}");
1270            }
1271        }
1272    }
1273    mod quoted_headings {
1274        use super::*;
1275        use crate::config::MarkdownFlavor;
1276
1277        fn check_with(style: HeadingStyle, src: &str) -> Vec<LintWarning> {
1278            let ctx = LintContext::new(src, MarkdownFlavor::Standard, None);
1279            MD003HeadingStyle::new(style).check(&ctx).unwrap()
1280        }
1281
1282        fn lines_of(warnings: &[LintWarning]) -> Vec<usize> {
1283            warnings.iter().map(|w| w.line).collect()
1284        }
1285
1286        fn assert_unchanged_by_fix(style: HeadingStyle, src: &str) {
1287            let ctx = LintContext::new(src, MarkdownFlavor::Standard, None);
1288            let rule = MD003HeadingStyle::new(style);
1289            assert_eq!(rule.fix(&ctx).unwrap(), src, "{src:?}: fix() changed the document");
1290        }
1291
1292        #[test]
1293        fn quoted_headings_are_judged_against_the_consistent_style() {
1294            // Setext1, Setext2 and ATX each have one heading, and a tie goes to
1295            // ATX: the unquoted setext heading is fixable, the quoted one is not.
1296            let src = "Top\n===\n\n> ## Quote atx\n>\n> Setext in quote\n> ---\n";
1297            let warnings = check_with(HeadingStyle::Consistent, src);
1298            assert_eq!(lines_of(&warnings), vec![1, 6], "{warnings:?}");
1299            assert!(warnings[0].fix.is_some());
1300            assert!(warnings[1].fix.is_none());
1301
1302            // The quoted ATX heading is the odd one out when setext prevails.
1303            let src = "Top\n===\n\nNext\n====\n\n> ## Quote atx\n";
1304            let warnings = check_with(HeadingStyle::Consistent, src);
1305            assert_eq!(lines_of(&warnings), vec![7], "{warnings:?}");
1306            assert!(warnings[0].fix.is_none());
1307            assert_unchanged_by_fix(HeadingStyle::Consistent, "> ## Quote atx\n\nTop\n===\n\nNext\n====\n");
1308        }
1309
1310        #[test]
1311        fn quoted_setext_heading_is_flagged_in_an_atx_document() {
1312            let src = "# Top\n\n> Setext in quote\n> ---\n";
1313            let warnings = check_with(HeadingStyle::Consistent, src);
1314            // One ATX and one setext heading tie, ATX wins.
1315            assert_eq!(lines_of(&warnings), vec![3], "{warnings:?}");
1316            assert!(warnings[0].message.contains("# Heading"), "{}", warnings[0].message);
1317            assert!(warnings.iter().all(|w| w.fix.is_none()));
1318            assert_unchanged_by_fix(HeadingStyle::Consistent, src);
1319        }
1320
1321        #[test]
1322        fn unquoted_setext_in_the_same_shape_is_flagged_too() {
1323            let warnings = check_with(HeadingStyle::Consistent, "# Top\n\nPlain\n---\n");
1324            assert_eq!(lines_of(&warnings), vec![3]);
1325        }
1326
1327        #[test]
1328        fn nested_quotes_are_checked() {
1329            let src = "# Top\n\n> > Inner\n> > ---\n";
1330            let warnings = check_with(HeadingStyle::Consistent, src);
1331            assert_eq!(lines_of(&warnings), vec![3], "{warnings:?}");
1332            assert!(warnings[0].fix.is_none());
1333        }
1334
1335        #[test]
1336        fn quote_inside_a_list_item_is_checked() {
1337            let src = "# Top\n\n- item\n\n  > Quoted\n  > ---\n";
1338            let warnings = check_with(HeadingStyle::Consistent, src);
1339            assert_eq!(lines_of(&warnings), vec![5], "{warnings:?}");
1340            assert!(warnings[0].fix.is_none());
1341            let src = "- > Quoted\n  > ---\n\n# Top\n";
1342            let warnings = check_with(HeadingStyle::Atx, src);
1343            assert_eq!(lines_of(&warnings), vec![1], "{warnings:?}");
1344        }
1345
1346        #[test]
1347        fn list_item_inside_a_quote_is_checked() {
1348            let src = "# Top\n\n> - # Item heading #\n";
1349            let warnings = check_with(HeadingStyle::Atx, src);
1350            assert_eq!(lines_of(&warnings), vec![3], "{warnings:?}");
1351            assert!(warnings[0].fix.is_none());
1352        }
1353
1354        #[test]
1355        fn fixed_style_flags_quoted_headings_without_a_fix() {
1356            for (style, src) in [
1357                (HeadingStyle::Setext1, "> # H\n"),
1358                (HeadingStyle::Atx, "> H\n> =\n"),
1359                (HeadingStyle::AtxClosed, "> ## H\n"),
1360                (HeadingStyle::Atx, "> > ## H ##\n"),
1361            ] {
1362                let warnings = check_with(style, src);
1363                assert_eq!(warnings.len(), 1, "{style:?} {src:?}: {warnings:?}");
1364                assert!(warnings[0].fix.is_none(), "{style:?} {src:?}");
1365                assert_unchanged_by_fix(style, src);
1366            }
1367        }
1368
1369        #[test]
1370        fn quote_only_document_is_not_skipped() {
1371            let ctx = LintContext::new("> # H\n", MarkdownFlavor::Standard, None);
1372            let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
1373            assert!(!rule.should_skip(&ctx));
1374        }
1375
1376        #[test]
1377        fn quoted_headings_vote_in_the_consistent_style() {
1378            // Two quoted setext headings outvote one unquoted ATX heading, so the
1379            // ATX heading is the one reported (and it is fixable).
1380            let src = "# Top\n\n> A\n> -\n\n> B\n> -\n";
1381            let warnings = check_with(HeadingStyle::Consistent, src);
1382            assert_eq!(lines_of(&warnings), vec![1], "{warnings:?}");
1383            assert!(warnings[0].fix.is_some(), "the unquoted heading stays fixable");
1384        }
1385
1386        #[test]
1387        fn quoted_headings_alone_can_set_the_style() {
1388            let src = "> A\n> -\n\n> B\n> -\n";
1389            assert!(check_with(HeadingStyle::Consistent, src).is_empty());
1390        }
1391
1392        #[test]
1393        fn consistent_quoted_and_unquoted_headings_are_clean() {
1394            assert!(check_with(HeadingStyle::Consistent, "# A\n\n> ## B\n\n> > ### C\n").is_empty());
1395            assert!(check_with(HeadingStyle::Consistent, "A\n=\n\n> B\n> -\n").is_empty());
1396        }
1397
1398        #[test]
1399        fn setext_levels_above_two_stay_atx_in_quotes() {
1400            assert!(check_with(HeadingStyle::Setext1, "> ### H\n").is_empty());
1401            assert!(check_with(HeadingStyle::SetextWithAtx, "> ### H\n").is_empty());
1402        }
1403
1404        #[test]
1405        fn code_and_html_look_alikes_are_not_headings() {
1406            assert!(check_with(HeadingStyle::Setext1, "> ```\n> # H\n> ```\n").is_empty());
1407            assert!(check_with(HeadingStyle::Setext1, ">     # H\n").is_empty());
1408            assert!(check_with(HeadingStyle::Setext1, "> text # not a heading\n").is_empty());
1409        }
1410    }
1411}