Skip to main content

rumdl_lib/rules/
md043_required_headings.rs

1use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::utils::range_utils::calculate_heading_range;
4use serde::{Deserialize, Serialize};
5
6/// Configuration for MD043 rule
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8#[serde(rename_all = "kebab-case")]
9pub struct MD043Config {
10    /// Required heading patterns
11    #[serde(default = "default_headings")]
12    pub headings: Vec<String>,
13    /// Case-sensitive matching (default: false)
14    #[serde(default = "default_match_case")]
15    pub match_case: bool,
16}
17
18impl Default for MD043Config {
19    fn default() -> Self {
20        Self {
21            headings: default_headings(),
22            match_case: default_match_case(),
23        }
24    }
25}
26
27fn default_headings() -> Vec<String> {
28    Vec::new()
29}
30
31fn default_match_case() -> bool {
32    false
33}
34
35impl RuleConfig for MD043Config {
36    const RULE_NAME: &'static str = "MD043";
37}
38
39/// Rule MD043: Required headings present
40///
41/// See [docs/md043.md](../../docs/md043.md) for full documentation, configuration, and examples.
42#[derive(Clone, Default)]
43pub struct MD043RequiredHeadings {
44    config: MD043Config,
45}
46
47#[derive(Debug, Clone)]
48struct DocumentHeading {
49    text: String,
50    match_key: String,
51    line_index: usize,
52}
53
54#[derive(Debug, Clone, Copy)]
55enum WildcardKind {
56    One,
57    OneOrMore,
58}
59
60impl WildcardKind {
61    fn pattern(self) -> &'static str {
62        match self {
63            Self::One => "?",
64            Self::OneOrMore => "+",
65        }
66    }
67
68    fn requirement(self) -> &'static str {
69        match self {
70            Self::One => "one heading",
71            Self::OneOrMore => "one or more headings",
72        }
73    }
74}
75
76#[derive(Debug, Clone, Copy)]
77enum PatternToken {
78    Literal { source_index: usize },
79    RequiredWildcard { source_index: usize, kind: WildcardKind },
80    RepeatingWildcard { anchor_index: Option<usize> },
81}
82
83/// Alignment score that minimizes edits, then favors exact matches, wildcard
84/// absorption, and substitutions in that order.
85#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
86struct AlignmentScore {
87    cost: usize,
88    exact_literals: usize,
89    wildcard_absorptions: usize,
90    substitutions: usize,
91}
92
93impl AlignmentScore {
94    fn with_cost(mut self) -> Self {
95        self.cost += 1;
96        self
97    }
98
99    fn with_exact(mut self) -> Self {
100        self.exact_literals += 1;
101        self
102    }
103
104    fn with_absorption(mut self) -> Self {
105        self.wildcard_absorptions += 1;
106        self
107    }
108
109    fn with_substitution(mut self) -> Self {
110        self.cost += 1;
111        self.substitutions += 1;
112        self
113    }
114
115    fn is_better_than(self, other: Self) -> bool {
116        self.cost < other.cost
117            || (self.cost == other.cost
118                && (self.exact_literals > other.exact_literals
119                    || (self.exact_literals == other.exact_literals
120                        && (self.wildcard_absorptions > other.wildcard_absorptions
121                            || (self.wildcard_absorptions == other.wildcard_absorptions
122                                && self.substitutions > other.substitutions)))))
123    }
124}
125
126#[repr(u8)]
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128enum AlignmentDecision {
129    Done,
130    MatchLiteral,
131    Substitute,
132    OmitLiteral,
133    ConsumeRequiredWildcard,
134    OmitRequiredWildcard,
135    ConsumeRepeatingWildcard,
136    SkipRepeatingWildcard,
137    Unexpected,
138}
139
140impl AlignmentDecision {
141    fn tie_break_priority(self) -> u8 {
142        match self {
143            Self::MatchLiteral | Self::Substitute | Self::ConsumeRequiredWildcard | Self::SkipRepeatingWildcard => 3,
144            Self::OmitLiteral | Self::OmitRequiredWildcard | Self::ConsumeRepeatingWildcard => 2,
145            Self::Unexpected => 1,
146            Self::Done => 0,
147        }
148    }
149}
150
151#[derive(Debug, Clone, Copy)]
152struct AlignmentCell {
153    score: AlignmentScore,
154    decision: AlignmentDecision,
155}
156
157#[derive(Debug, Clone, Copy)]
158enum AlignmentStep {
159    MatchLiteral {
160        expected_index: usize,
161        actual_index: usize,
162    },
163    Substitution {
164        expected_index: usize,
165        actual_index: usize,
166    },
167    MissingLiteral {
168        expected_index: usize,
169        next_actual_index: usize,
170    },
171    RequiredWildcardConsumed {
172        source_index: usize,
173        actual_index: usize,
174    },
175    RepeatingWildcardConsumed {
176        actual_index: usize,
177    },
178    UnsatisfiedWildcard {
179        source_index: usize,
180        kind: WildcardKind,
181        next_actual_index: usize,
182    },
183    Unexpected {
184        actual_index: usize,
185    },
186}
187
188#[derive(Debug, Clone, Copy)]
189enum AlignmentEvent {
190    LiteralMatch {
191        expected_index: usize,
192        actual_index: usize,
193    },
194    RequiredWildcardMatch {
195        source_index: usize,
196        actual_index: usize,
197    },
198    RepeatingWildcardMatch {
199        actual_index: usize,
200    },
201    Substitution {
202        expected_index: usize,
203        actual_index: usize,
204    },
205    MissingLiteral {
206        expected_index: usize,
207        next_actual_index: usize,
208    },
209    UnsatisfiedWildcard {
210        source_index: usize,
211        kind: WildcardKind,
212        next_actual_index: usize,
213    },
214    Unexpected {
215        actual_index: usize,
216    },
217    OutOfOrder {
218        expected_index: usize,
219        actual_index: usize,
220    },
221}
222
223#[derive(Debug)]
224struct AlignmentResult {
225    events: Vec<AlignmentEvent>,
226}
227
228impl MD043RequiredHeadings {
229    pub fn new(headings: Vec<String>) -> Self {
230        Self {
231            config: MD043Config {
232                headings,
233                match_case: default_match_case(),
234            },
235        }
236    }
237
238    /// Create a new instance with the given configuration
239    pub fn from_config_struct(config: MD043Config) -> Self {
240        Self { config }
241    }
242
243    /// Compare two headings based on the match_case configuration
244    fn headings_match(&self, expected: &str, actual: &str) -> bool {
245        self.match_key(expected) == self.match_key(actual)
246    }
247
248    fn match_key(&self, heading: &str) -> String {
249        if self.config.match_case {
250            heading.to_string()
251        } else {
252            heading.to_lowercase()
253        }
254    }
255
256    fn extract_headings(&self, ctx: &crate::lint_context::LintContext) -> Vec<DocumentHeading> {
257        let mut result = Vec::new();
258
259        // Headings inside blockquotes are part of the sequence like any other.
260        for parsed in ctx.headings() {
261            let line_index = parsed.line_num - 1;
262            let heading = parsed.heading;
263            let marker_idx = line_index
264                + usize::from(matches!(
265                    heading.style,
266                    crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
267                ));
268            if let Some(marker_line) = ctx.lines.get(marker_idx) {
269                let marker_offset = marker_line.byte_offset + marker_line.indent;
270                if ctx.is_inside_template_code(marker_offset) || ctx.is_inside_mdx_code(marker_offset) {
271                    continue;
272                }
273            }
274            // Reconstruct the full heading format with the hash symbols
275            let full_heading = format!("{} {}", heading.marker, heading.text.trim());
276            let match_key = self.match_key(&full_heading);
277            result.push(DocumentHeading {
278                text: full_heading,
279                match_key,
280                line_index,
281            });
282        }
283
284        result
285    }
286
287    fn compile_pattern(&self) -> Vec<PatternToken> {
288        let mut tokens = Vec::new();
289        let mut index = 0;
290
291        while index < self.config.headings.len() {
292            if !matches!(self.config.headings[index].as_str(), "*" | "+" | "?") {
293                tokens.push(PatternToken::Literal { source_index: index });
294                index += 1;
295                continue;
296            }
297
298            let run_start = index;
299            while index < self.config.headings.len() && matches!(self.config.headings[index].as_str(), "*" | "+" | "?")
300            {
301                index += 1;
302            }
303            let anchor_index = (index < self.config.headings.len()).then_some(index);
304            let mut has_repeating_slot = false;
305
306            for source_index in run_start..index {
307                match self.config.headings[source_index].as_str() {
308                    "?" => tokens.push(PatternToken::RequiredWildcard {
309                        source_index,
310                        kind: WildcardKind::One,
311                    }),
312                    "+" => {
313                        tokens.push(PatternToken::RequiredWildcard {
314                            source_index,
315                            kind: WildcardKind::OneOrMore,
316                        });
317                        has_repeating_slot = true;
318                    }
319                    "*" => has_repeating_slot = true,
320                    _ => unreachable!(),
321                }
322            }
323
324            if has_repeating_slot {
325                tokens.push(PatternToken::RepeatingWildcard { anchor_index });
326            }
327        }
328
329        tokens
330    }
331
332    fn is_anchor(actual: &DocumentHeading, anchor_index: Option<usize>, expected_keys: &[String]) -> bool {
333        anchor_index.is_some_and(|index| expected_keys[index] == actual.match_key)
334    }
335
336    /// Builds a suffix DP over compiled pattern tokens and actual headings.
337    /// Scores use two rolling rows; compact decisions are retained for backtracking.
338    fn align_steps(&self, actual: &[DocumentHeading], expected_keys: &[String]) -> Vec<AlignmentStep> {
339        let tokens = self.compile_pattern();
340        let columns = actual.len() + 1;
341        let cell_count = (tokens.len() + 1)
342            .checked_mul(columns)
343            .expect("MD043 alignment table dimensions overflow");
344        let mut decisions = vec![AlignmentDecision::Done; cell_count];
345        let table_index = |token_index: usize, actual_index: usize| token_index * columns + actual_index;
346        let mut next_scores = vec![AlignmentScore::default(); columns];
347        let mut current_scores = vec![AlignmentScore::default(); columns];
348
349        for actual_index in (0..actual.len()).rev() {
350            next_scores[actual_index] = next_scores[actual_index + 1].with_cost();
351            decisions[table_index(tokens.len(), actual_index)] = AlignmentDecision::Unexpected;
352        }
353
354        for token_index in (0..tokens.len()).rev() {
355            for actual_index in (0..=actual.len()).rev() {
356                let mut best = AlignmentCell {
357                    score: AlignmentScore {
358                        cost: usize::MAX,
359                        ..AlignmentScore::default()
360                    },
361                    decision: AlignmentDecision::Done,
362                };
363                let mut consider = |score: AlignmentScore, decision: AlignmentDecision| {
364                    if score.is_better_than(best.score)
365                        || (score == best.score && decision.tie_break_priority() > best.decision.tie_break_priority())
366                    {
367                        best = AlignmentCell { score, decision };
368                    }
369                };
370
371                match tokens[token_index] {
372                    PatternToken::Literal { source_index } => {
373                        if actual_index < actual.len() {
374                            let diagonal = next_scores[actual_index + 1];
375                            if expected_keys[source_index] == actual[actual_index].match_key {
376                                consider(diagonal.with_exact(), AlignmentDecision::MatchLiteral);
377                            } else {
378                                consider(diagonal.with_substitution(), AlignmentDecision::Substitute);
379                            }
380                        }
381                        consider(next_scores[actual_index].with_cost(), AlignmentDecision::OmitLiteral);
382                        if actual_index < actual.len() {
383                            consider(
384                                current_scores[actual_index + 1].with_cost(),
385                                AlignmentDecision::Unexpected,
386                            );
387                        }
388                    }
389                    PatternToken::RequiredWildcard { .. } => {
390                        if actual_index < actual.len() {
391                            consider(
392                                next_scores[actual_index + 1].with_absorption(),
393                                AlignmentDecision::ConsumeRequiredWildcard,
394                            );
395                        }
396                        consider(
397                            next_scores[actual_index].with_cost(),
398                            AlignmentDecision::OmitRequiredWildcard,
399                        );
400                    }
401                    PatternToken::RepeatingWildcard { anchor_index } => {
402                        consider(next_scores[actual_index], AlignmentDecision::SkipRepeatingWildcard);
403                        if actual_index < actual.len()
404                            && !Self::is_anchor(&actual[actual_index], anchor_index, expected_keys)
405                        {
406                            consider(
407                                current_scores[actual_index + 1].with_absorption(),
408                                AlignmentDecision::ConsumeRepeatingWildcard,
409                            );
410                        }
411                    }
412                }
413
414                current_scores[actual_index] = best.score;
415                decisions[table_index(token_index, actual_index)] = best.decision;
416            }
417
418            std::mem::swap(&mut current_scores, &mut next_scores);
419        }
420
421        let mut steps = Vec::new();
422        let mut token_index = 0;
423        let mut actual_index = 0;
424        while token_index < tokens.len() || actual_index < actual.len() {
425            let decision = decisions[table_index(token_index, actual_index)];
426            match decision {
427                AlignmentDecision::Done => break,
428                AlignmentDecision::MatchLiteral => {
429                    let PatternToken::Literal { source_index } = tokens[token_index] else {
430                        unreachable!()
431                    };
432                    steps.push(AlignmentStep::MatchLiteral {
433                        expected_index: source_index,
434                        actual_index,
435                    });
436                    token_index += 1;
437                    actual_index += 1;
438                }
439                AlignmentDecision::Substitute => {
440                    let PatternToken::Literal { source_index } = tokens[token_index] else {
441                        unreachable!()
442                    };
443                    steps.push(AlignmentStep::Substitution {
444                        expected_index: source_index,
445                        actual_index,
446                    });
447                    token_index += 1;
448                    actual_index += 1;
449                }
450                AlignmentDecision::OmitLiteral => {
451                    let PatternToken::Literal { source_index } = tokens[token_index] else {
452                        unreachable!()
453                    };
454                    steps.push(AlignmentStep::MissingLiteral {
455                        expected_index: source_index,
456                        next_actual_index: actual_index,
457                    });
458                    token_index += 1;
459                }
460                AlignmentDecision::ConsumeRequiredWildcard => {
461                    let PatternToken::RequiredWildcard { source_index, .. } = tokens[token_index] else {
462                        unreachable!()
463                    };
464                    steps.push(AlignmentStep::RequiredWildcardConsumed {
465                        source_index,
466                        actual_index,
467                    });
468                    token_index += 1;
469                    actual_index += 1;
470                }
471                AlignmentDecision::OmitRequiredWildcard => {
472                    let PatternToken::RequiredWildcard { source_index, kind, .. } = tokens[token_index] else {
473                        unreachable!()
474                    };
475                    steps.push(AlignmentStep::UnsatisfiedWildcard {
476                        source_index,
477                        kind,
478                        next_actual_index: actual_index,
479                    });
480                    token_index += 1;
481                }
482                AlignmentDecision::ConsumeRepeatingWildcard => {
483                    steps.push(AlignmentStep::RepeatingWildcardConsumed { actual_index });
484                    actual_index += 1;
485                }
486                AlignmentDecision::SkipRepeatingWildcard => token_index += 1,
487                AlignmentDecision::Unexpected => {
488                    steps.push(AlignmentStep::Unexpected { actual_index });
489                    actual_index += 1;
490                }
491            }
492        }
493
494        steps
495    }
496
497    /// Converts equivalent unmatched literal and actual occurrences into out-of-order
498    /// events. Required wildcard matches retain ownership of their consumed heading.
499    fn alignment(&self, actual: &[DocumentHeading]) -> AlignmentResult {
500        let expected_keys = self
501            .config
502            .headings
503            .iter()
504            .map(|heading| self.match_key(heading))
505            .collect::<Vec<_>>();
506        let steps = self.align_steps(actual, &expected_keys);
507        let mut paired_expected = vec![false; steps.len()];
508        let mut reordered = vec![None; steps.len()];
509        let unmatched_expected = steps
510            .iter()
511            .enumerate()
512            .filter_map(|(step_index, step)| match step {
513                AlignmentStep::MissingLiteral { expected_index, .. }
514                | AlignmentStep::Substitution { expected_index, .. } => Some((step_index, *expected_index)),
515                _ => None,
516            })
517            .collect::<Vec<_>>();
518        let candidates = steps
519            .iter()
520            .enumerate()
521            .filter_map(|(step_index, step)| match step {
522                AlignmentStep::Unexpected { actual_index }
523                | AlignmentStep::RepeatingWildcardConsumed { actual_index }
524                | AlignmentStep::Substitution { actual_index, .. } => Some((step_index, *actual_index)),
525                _ => None,
526            })
527            .collect::<Vec<_>>();
528        let mut used_candidates = vec![false; candidates.len()];
529
530        // Pair equal unmatched occurrences in sequence order. A substitution contributes both an
531        // unmatched expected side and an unmatched actual side, allowing crossed substitutions to
532        // become moves. Required wildcard slots retain ownership of their consumed heading.
533        for (expected_step, expected_index) in unmatched_expected {
534            if let Some((candidate_slot, (candidate_step, _actual_index))) =
535                candidates
536                    .iter()
537                    .enumerate()
538                    .find(|(candidate_slot, (_, actual_index))| {
539                        !used_candidates[*candidate_slot]
540                            && expected_keys[expected_index] == actual[*actual_index].match_key
541                    })
542            {
543                used_candidates[candidate_slot] = true;
544                paired_expected[expected_step] = true;
545                reordered[*candidate_step] = Some(expected_index);
546            }
547        }
548
549        let mut events = Vec::with_capacity(steps.len());
550        for (step_index, step) in steps.iter().enumerate() {
551            match *step {
552                AlignmentStep::MatchLiteral {
553                    expected_index,
554                    actual_index,
555                } => events.push(AlignmentEvent::LiteralMatch {
556                    expected_index,
557                    actual_index,
558                }),
559                AlignmentStep::RequiredWildcardConsumed {
560                    source_index,
561                    actual_index,
562                } => events.push(AlignmentEvent::RequiredWildcardMatch {
563                    source_index,
564                    actual_index,
565                }),
566                AlignmentStep::RepeatingWildcardConsumed { actual_index } => {
567                    if let Some(expected_index) = reordered[step_index] {
568                        events.push(AlignmentEvent::OutOfOrder {
569                            expected_index,
570                            actual_index,
571                        });
572                    } else {
573                        events.push(AlignmentEvent::RepeatingWildcardMatch { actual_index });
574                    }
575                }
576                AlignmentStep::Substitution {
577                    expected_index,
578                    actual_index,
579                } => match (paired_expected[step_index], reordered[step_index]) {
580                    (false, None) => events.push(AlignmentEvent::Substitution {
581                        expected_index,
582                        actual_index,
583                    }),
584                    (true, None) => events.push(AlignmentEvent::Unexpected { actual_index }),
585                    (true, Some(reordered_expected)) => events.push(AlignmentEvent::OutOfOrder {
586                        expected_index: reordered_expected,
587                        actual_index,
588                    }),
589                    (false, Some(reordered_expected)) => {
590                        events.push(AlignmentEvent::MissingLiteral {
591                            expected_index,
592                            next_actual_index: actual_index,
593                        });
594                        events.push(AlignmentEvent::OutOfOrder {
595                            expected_index: reordered_expected,
596                            actual_index,
597                        });
598                    }
599                },
600                AlignmentStep::MissingLiteral {
601                    expected_index,
602                    next_actual_index,
603                } => {
604                    if !paired_expected[step_index] {
605                        events.push(AlignmentEvent::MissingLiteral {
606                            expected_index,
607                            next_actual_index,
608                        });
609                    }
610                }
611                AlignmentStep::UnsatisfiedWildcard {
612                    source_index,
613                    kind,
614                    next_actual_index,
615                } => events.push(AlignmentEvent::UnsatisfiedWildcard {
616                    source_index,
617                    kind,
618                    next_actual_index,
619                }),
620                AlignmentStep::Unexpected { actual_index } => {
621                    if let Some(expected_index) = reordered[step_index] {
622                        events.push(AlignmentEvent::OutOfOrder {
623                            expected_index,
624                            actual_index,
625                        });
626                    } else {
627                        events.push(AlignmentEvent::Unexpected { actual_index });
628                    }
629                }
630            }
631        }
632
633        AlignmentResult { events }
634    }
635
636    fn heading_range(
637        &self,
638        heading: &DocumentHeading,
639        ctx: &crate::lint_context::LintContext,
640    ) -> (usize, usize, usize, usize) {
641        let line_info = &ctx.lines[heading.line_index];
642        let text_lines = ctx
643            .heading_on_line(heading.line_index + 1)
644            .map_or(1, |parsed| parsed.heading.text_lines);
645        calculate_heading_range(
646            heading.line_index + 2 - text_lines,
647            heading.line_index + 1,
648            line_info.content(ctx.content),
649        )
650    }
651
652    fn omission_range(
653        &self,
654        next_actual_index: usize,
655        actual: &[DocumentHeading],
656        ctx: &crate::lint_context::LintContext,
657    ) -> (usize, usize, usize, usize) {
658        actual
659            .get(next_actual_index)
660            .or_else(|| actual.last())
661            .map_or((1, 1, 1, 2), |heading| self.heading_range(heading, ctx))
662    }
663
664    fn reorder_message(&self, expected_index: usize, actual: &str) -> String {
665        let previous = self.config.headings[..expected_index]
666            .iter()
667            .rev()
668            .find(|heading| !matches!(heading.as_str(), "*" | "+" | "?"));
669        let next = self.config.headings[expected_index + 1..]
670            .iter()
671            .find(|heading| !matches!(heading.as_str(), "*" | "+" | "?"));
672        let location = match (previous, next) {
673            (Some(previous), Some(next)) => format!("expected between '{previous}' and '{next}'"),
674            (Some(previous), None) => format!("expected after '{previous}'"),
675            (None, Some(next)) => format!("expected before '{next}'"),
676            (None, None) => "expected at its configured position".to_string(),
677        };
678        format!("Heading structure does not match required structure. Heading '{actual}' is out of order; {location}")
679    }
680
681    fn warning(&self, range: (usize, usize, usize, usize), message: String) -> LintWarning {
682        LintWarning {
683            rule_name: Some(self.name().to_string()),
684            line: range.0,
685            column: range.1,
686            end_line: range.2,
687            end_column: range.3,
688            message,
689            severity: Severity::Warning,
690            fix: None,
691        }
692    }
693}
694
695impl Rule for MD043RequiredHeadings {
696    fn name(&self) -> &'static str {
697        "MD043"
698    }
699
700    fn description(&self) -> &'static str {
701        "Required heading structure"
702    }
703
704    fn fix_capability(&self) -> FixCapability {
705        FixCapability::Unfixable
706    }
707
708    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
709        if self.config.headings.is_empty() || ctx.content.is_empty() {
710            return Ok(Vec::new());
711        }
712
713        let actual = self.extract_headings(ctx);
714        let alignment = self.alignment(&actual);
715        let prefix = "Heading structure does not match required structure.";
716        let mut warnings = Vec::new();
717        for event in alignment.events {
718            match event {
719                AlignmentEvent::LiteralMatch {
720                    expected_index,
721                    actual_index,
722                } => debug_assert!(
723                    self.headings_match(&self.config.headings[expected_index], &actual[actual_index].text)
724                ),
725                AlignmentEvent::RequiredWildcardMatch {
726                    source_index,
727                    actual_index,
728                } => {
729                    debug_assert!(matches!(self.config.headings[source_index].as_str(), "+" | "?"));
730                    debug_assert!(actual_index < actual.len());
731                }
732                AlignmentEvent::RepeatingWildcardMatch { actual_index } => {
733                    debug_assert!(actual_index < actual.len());
734                }
735                AlignmentEvent::Substitution {
736                    expected_index,
737                    actual_index,
738                } => warnings.push(self.warning(
739                    self.heading_range(&actual[actual_index], ctx),
740                    format!(
741                        "{prefix} Expected heading '{}', but found '{}'",
742                        self.config.headings[expected_index], actual[actual_index].text
743                    ),
744                )),
745                AlignmentEvent::MissingLiteral {
746                    expected_index,
747                    next_actual_index,
748                } => warnings.push(self.warning(
749                    self.omission_range(next_actual_index, &actual, ctx),
750                    format!(
751                        "{prefix} Missing required heading '{}'",
752                        self.config.headings[expected_index]
753                    ),
754                )),
755                AlignmentEvent::UnsatisfiedWildcard {
756                    source_index,
757                    kind,
758                    next_actual_index,
759                } => warnings.push(self.warning(
760                    self.omission_range(next_actual_index, &actual, ctx),
761                    format!(
762                        "{prefix} Wildcard '{}' at pattern position {} requires {}, but none was available",
763                        kind.pattern(),
764                        source_index + 1,
765                        kind.requirement()
766                    ),
767                )),
768                AlignmentEvent::Unexpected { actual_index } => warnings.push(self.warning(
769                    self.heading_range(&actual[actual_index], ctx),
770                    format!(
771                        "{prefix} Unexpected heading '{}' at position {}",
772                        actual[actual_index].text,
773                        actual_index + 1
774                    ),
775                )),
776                AlignmentEvent::OutOfOrder {
777                    expected_index,
778                    actual_index,
779                } => warnings.push(self.warning(
780                    self.heading_range(&actual[actual_index], ctx),
781                    self.reorder_message(expected_index, &actual[actual_index].text),
782                )),
783            }
784        }
785
786        Ok(warnings)
787    }
788
789    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
790        // Auto-fixing MD043 would require restructuring the document (inserting,
791        // renaming, or reordering headings), which risks data loss. Return the
792        // content unchanged and let the user address the violation manually.
793        Ok(ctx.content.to_string())
794    }
795
796    /// Check if this rule should be skipped
797    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
798        if self.config.headings.is_empty() || ctx.content.is_empty() {
799            return true;
800        }
801
802        ctx.headings().next().is_none() && self.config.headings.iter().all(|pattern| pattern == "*")
803    }
804
805    fn as_any(&self) -> &dyn std::any::Any {
806        self
807    }
808
809    crate::impl_rule_config_methods!(MD043Config);
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815    use crate::lint_context::LintContext;
816
817    #[test]
818    fn test_extract_headings_code_blocks() {
819        // Create rule with required headings (now with hash symbols)
820        let required = vec!["# Test Document".to_string(), "## Real heading 2".to_string()];
821        let rule = MD043RequiredHeadings::new(required);
822
823        // Test 1: Basic content with code block
824        let content = "# Test Document\n\nThis is regular content.\n\n```markdown\n# This is a heading in a code block\n## Another heading in code block\n```\n\n## Real heading 2\n\nSome content.";
825        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
826        let actual_headings = rule.extract_headings(&ctx);
827        assert_eq!(
828            actual_headings
829                .iter()
830                .map(|heading| heading.text.clone())
831                .collect::<Vec<_>>(),
832            vec!["# Test Document".to_string(), "## Real heading 2".to_string()],
833            "Should extract correct headings and ignore code blocks"
834        );
835
836        // Test 2: Content with invalid headings
837        let content = "# Test Document\n\nThis is regular content.\n\n```markdown\n# This is a heading in a code block\n## This should be ignored\n```\n\n## Not Real heading 2\n\nSome content.";
838        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
839        let actual_headings = rule.extract_headings(&ctx);
840        assert_eq!(
841            actual_headings
842                .iter()
843                .map(|heading| heading.text.clone())
844                .collect::<Vec<_>>(),
845            vec!["# Test Document".to_string(), "## Not Real heading 2".to_string()],
846            "Should extract actual headings including mismatched ones"
847        );
848    }
849
850    #[test]
851    fn test_with_document_structure() {
852        // Test with required headings (now with hash symbols)
853        let required = vec![
854            "# Introduction".to_string(),
855            "# Method".to_string(),
856            "# Results".to_string(),
857        ];
858        let rule = MD043RequiredHeadings::new(required);
859
860        // Test with matching headings
861        let content = "# Introduction\n\nContent\n\n# Method\n\nMore content\n\n# Results\n\nFinal content";
862        let warnings = rule
863            .check(&LintContext::new(
864                content,
865                crate::config::MarkdownFlavor::Standard,
866                None,
867            ))
868            .unwrap();
869        assert!(warnings.is_empty(), "Expected no warnings for matching headings");
870
871        // Test with mismatched headings
872        let content = "# Introduction\n\nContent\n\n# Results\n\nSkipped method";
873        let warnings = rule
874            .check(&LintContext::new(
875                content,
876                crate::config::MarkdownFlavor::Standard,
877                None,
878            ))
879            .unwrap();
880        assert!(!warnings.is_empty(), "Expected warnings for mismatched headings");
881
882        // Test with no headings but requirements exist
883        let content = "No headings here, just plain text";
884        let warnings = rule
885            .check(&LintContext::new(
886                content,
887                crate::config::MarkdownFlavor::Standard,
888                None,
889            ))
890            .unwrap();
891        assert!(!warnings.is_empty(), "Expected warnings when headings are missing");
892
893        // Test with setext headings - use the correct format (marker text)
894        let required_setext = vec![
895            "=========== Introduction".to_string(),
896            "------ Method".to_string(),
897            "======= Results".to_string(),
898        ];
899        let rule_setext = MD043RequiredHeadings::new(required_setext);
900        let content = "Introduction\n===========\n\nContent\n\nMethod\n------\n\nMore content\n\nResults\n=======\n\nFinal content";
901        let warnings = rule_setext
902            .check(&LintContext::new(
903                content,
904                crate::config::MarkdownFlavor::Standard,
905                None,
906            ))
907            .unwrap();
908        assert!(warnings.is_empty(), "Expected no warnings for matching setext headings");
909    }
910
911    #[test]
912    fn test_should_not_skip_headingless_documents_with_literal_requirements() {
913        // Create rule with required headings
914        let required = vec!["Test".to_string()];
915        let rule = MD043RequiredHeadings::new(required);
916
917        // Test 1: Content with '#' character in normal text (not a heading)
918        let content = "This paragraph contains a # character but is not a heading";
919        assert!(
920            !rule.should_skip(&LintContext::new(
921                content,
922                crate::config::MarkdownFlavor::Standard,
923                None
924            )),
925            "Should check headingless content when a literal heading is required"
926        );
927
928        // Test 2: Content with code block containing heading-like syntax
929        let content = "Regular paragraph\n\n```markdown\n# This is not a real heading\n```\n\nMore text";
930        assert!(
931            !rule.should_skip(&LintContext::new(
932                content,
933                crate::config::MarkdownFlavor::Standard,
934                None
935            )),
936            "Should check content whose only heading syntax is in a code block"
937        );
938
939        // Test 3: Content with list items using '-' character
940        let content = "Some text\n\n- List item 1\n- List item 2\n\nMore text";
941        assert!(
942            !rule.should_skip(&LintContext::new(
943                content,
944                crate::config::MarkdownFlavor::Standard,
945                None
946            )),
947            "Should check headingless list content"
948        );
949
950        // Test 4: Content with horizontal rule that uses '---'
951        let content = "Some text\n\n---\n\nMore text below the horizontal rule";
952        assert!(
953            !rule.should_skip(&LintContext::new(
954                content,
955                crate::config::MarkdownFlavor::Standard,
956                None
957            )),
958            "Should check headingless content containing a horizontal rule"
959        );
960
961        // Test 5: Content with equals sign in normal text
962        let content = "This is a normal paragraph with equals sign x = y + z";
963        assert!(
964            !rule.should_skip(&LintContext::new(
965                content,
966                crate::config::MarkdownFlavor::Standard,
967                None
968            )),
969            "Should check headingless content containing an equals sign"
970        );
971
972        // Test 6: Content with dash/minus in normal text
973        let content = "This is a normal paragraph with minus sign x - y = z";
974        assert!(
975            !rule.should_skip(&LintContext::new(
976                content,
977                crate::config::MarkdownFlavor::Standard,
978                None
979            )),
980            "Should check headingless content containing a minus sign"
981        );
982
983        let optional = MD043RequiredHeadings::new(vec!["*".to_string()]);
984        assert!(optional.should_skip(&LintContext::new(
985            "No headings",
986            crate::config::MarkdownFlavor::Standard,
987            None
988        )));
989    }
990
991    #[test]
992    fn test_should_skip_heading_detection() {
993        // Create rule with required headings
994        let required = vec!["Test".to_string()];
995        let rule = MD043RequiredHeadings::new(required);
996
997        // Test 1: Content with ATX heading
998        let content = "# This is a heading\n\nAnd some content";
999        assert!(
1000            !rule.should_skip(&LintContext::new(
1001                content,
1002                crate::config::MarkdownFlavor::Standard,
1003                None
1004            )),
1005            "Should not skip content with ATX heading"
1006        );
1007
1008        // Test 2: Content with Setext heading (equals sign)
1009        let content = "This is a heading\n================\n\nAnd some content";
1010        assert!(
1011            !rule.should_skip(&LintContext::new(
1012                content,
1013                crate::config::MarkdownFlavor::Standard,
1014                None
1015            )),
1016            "Should not skip content with Setext heading (=)"
1017        );
1018
1019        // Test 3: Content with Setext heading (dash)
1020        let content = "This is a subheading\n------------------\n\nAnd some content";
1021        assert!(
1022            !rule.should_skip(&LintContext::new(
1023                content,
1024                crate::config::MarkdownFlavor::Standard,
1025                None
1026            )),
1027            "Should not skip content with Setext heading (-)"
1028        );
1029
1030        // Test 4: Content with ATX heading with closing hashes
1031        let content = "## This is a heading ##\n\nAnd some content";
1032        assert!(
1033            !rule.should_skip(&LintContext::new(
1034                content,
1035                crate::config::MarkdownFlavor::Standard,
1036                None
1037            )),
1038            "Should not skip content with ATX heading with closing hashes"
1039        );
1040    }
1041
1042    #[test]
1043    fn test_config_match_case_sensitive() {
1044        let config = MD043Config {
1045            headings: vec!["# Introduction".to_string(), "# Method".to_string()],
1046            match_case: true,
1047        };
1048        let rule = MD043RequiredHeadings::from_config_struct(config);
1049
1050        // Should fail with different case
1051        let content = "# introduction\n\n# method";
1052        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1053        let result = rule.check(&ctx).unwrap();
1054
1055        assert!(
1056            !result.is_empty(),
1057            "Should detect case mismatch when match_case is true"
1058        );
1059    }
1060
1061    #[test]
1062    fn test_config_match_case_insensitive() {
1063        let config = MD043Config {
1064            headings: vec!["# Introduction".to_string(), "# Method".to_string()],
1065            match_case: false,
1066        };
1067        let rule = MD043RequiredHeadings::from_config_struct(config);
1068
1069        // Should pass with different case
1070        let content = "# introduction\n\n# method";
1071        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1072        let result = rule.check(&ctx).unwrap();
1073
1074        assert!(result.is_empty(), "Should allow case mismatch when match_case is false");
1075    }
1076
1077    #[test]
1078    fn test_config_case_insensitive_mixed() {
1079        let config = MD043Config {
1080            headings: vec!["# Introduction".to_string(), "# METHOD".to_string()],
1081            match_case: false,
1082        };
1083        let rule = MD043RequiredHeadings::from_config_struct(config);
1084
1085        // Should pass with mixed case variations
1086        let content = "# INTRODUCTION\n\n# method";
1087        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1088        let result = rule.check(&ctx).unwrap();
1089
1090        assert!(
1091            result.is_empty(),
1092            "Should allow mixed case variations when match_case is false"
1093        );
1094    }
1095
1096    #[test]
1097    fn test_config_case_sensitive_exact_match() {
1098        let config = MD043Config {
1099            headings: vec!["# Introduction".to_string(), "# Method".to_string()],
1100            match_case: true,
1101        };
1102        let rule = MD043RequiredHeadings::from_config_struct(config);
1103
1104        // Should pass with exact case match
1105        let content = "# Introduction\n\n# Method";
1106        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1107        let result = rule.check(&ctx).unwrap();
1108
1109        assert!(
1110            result.is_empty(),
1111            "Should pass with exact case match when match_case is true"
1112        );
1113    }
1114
1115    #[test]
1116    fn test_default_config() {
1117        let rule = MD043RequiredHeadings::default();
1118
1119        // Should be disabled with empty headings
1120        let content = "# Any heading\n\n# Another heading";
1121        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1122        let result = rule.check(&ctx).unwrap();
1123
1124        assert!(result.is_empty(), "Should be disabled with default empty headings");
1125    }
1126
1127    #[test]
1128    fn test_default_config_section() {
1129        let rule = MD043RequiredHeadings::default();
1130        let config_section = rule.default_config_section();
1131
1132        assert!(config_section.is_some());
1133        let (name, value) = config_section.unwrap();
1134        assert_eq!(name, "MD043");
1135
1136        // Should contain both headings and match_case options with default values
1137        if let toml::Value::Table(table) = value {
1138            assert!(table.contains_key("headings"));
1139            assert!(table.contains_key("match-case"));
1140            assert_eq!(table["headings"], toml::Value::Array(vec![]));
1141            assert_eq!(table["match-case"], toml::Value::Boolean(false));
1142        } else {
1143            panic!("Expected TOML table");
1144        }
1145    }
1146
1147    #[test]
1148    fn test_headings_match_case_sensitive() {
1149        let config = MD043Config {
1150            headings: vec![],
1151            match_case: true,
1152        };
1153        let rule = MD043RequiredHeadings::from_config_struct(config);
1154
1155        assert!(rule.headings_match("Test", "Test"));
1156        assert!(!rule.headings_match("Test", "test"));
1157        assert!(!rule.headings_match("test", "Test"));
1158    }
1159
1160    #[test]
1161    fn test_headings_match_case_insensitive() {
1162        let config = MD043Config {
1163            headings: vec![],
1164            match_case: false,
1165        };
1166        let rule = MD043RequiredHeadings::from_config_struct(config);
1167
1168        assert!(rule.headings_match("Test", "Test"));
1169        assert!(rule.headings_match("Test", "test"));
1170        assert!(rule.headings_match("test", "Test"));
1171        assert!(rule.headings_match("TEST", "test"));
1172    }
1173
1174    #[test]
1175    fn test_config_empty_headings() {
1176        let config = MD043Config {
1177            headings: vec![],
1178            match_case: true,
1179        };
1180        let rule = MD043RequiredHeadings::from_config_struct(config);
1181
1182        // Should skip processing when no headings are required
1183        let content = "# Any heading\n\n# Another heading";
1184        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1185        let result = rule.check(&ctx).unwrap();
1186
1187        assert!(result.is_empty(), "Should be disabled with empty headings list");
1188    }
1189
1190    #[test]
1191    fn test_fix_respects_configuration() {
1192        let config = MD043Config {
1193            headings: vec!["# Title".to_string(), "# Content".to_string()],
1194            match_case: false,
1195        };
1196        let rule = MD043RequiredHeadings::from_config_struct(config);
1197
1198        let content = "Wrong content";
1199        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1200        let fixed = rule.fix(&ctx).unwrap();
1201
1202        // MD043 now preserves original content to prevent data loss
1203        let expected = "Wrong content";
1204        assert_eq!(fixed, expected);
1205    }
1206
1207    // Wildcard pattern tests
1208
1209    #[test]
1210    fn test_asterisk_wildcard_zero_headings() {
1211        // * allows zero headings
1212        let config = MD043Config {
1213            headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
1214            match_case: false,
1215        };
1216        let rule = MD043RequiredHeadings::from_config_struct(config);
1217
1218        let content = "# Start\n\n# End";
1219        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1220        let result = rule.check(&ctx).unwrap();
1221
1222        assert!(result.is_empty(), "* should allow zero headings between Start and End");
1223    }
1224
1225    #[test]
1226    fn test_asterisk_wildcard_multiple_headings() {
1227        // * allows multiple headings
1228        let config = MD043Config {
1229            headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
1230            match_case: false,
1231        };
1232        let rule = MD043RequiredHeadings::from_config_struct(config);
1233
1234        let content = "# Start\n\n## Section 1\n\n## Section 2\n\n## Section 3\n\n# End";
1235        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1236        let result = rule.check(&ctx).unwrap();
1237
1238        assert!(
1239            result.is_empty(),
1240            "* should allow multiple headings between Start and End"
1241        );
1242    }
1243
1244    #[test]
1245    fn test_asterisk_wildcard_at_end() {
1246        // * at end allows any remaining headings
1247        let config = MD043Config {
1248            headings: vec!["# Introduction".to_string(), "*".to_string()],
1249            match_case: false,
1250        };
1251        let rule = MD043RequiredHeadings::from_config_struct(config);
1252
1253        let content = "# Introduction\n\n## Details\n\n### Subsection\n\n## More";
1254        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1255        let result = rule.check(&ctx).unwrap();
1256
1257        assert!(result.is_empty(), "* at end should allow any trailing headings");
1258    }
1259
1260    #[test]
1261    fn test_plus_wildcard_requires_at_least_one() {
1262        // + requires at least one heading
1263        let config = MD043Config {
1264            headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
1265            match_case: false,
1266        };
1267        let rule = MD043RequiredHeadings::from_config_struct(config);
1268
1269        // Should fail with zero headings
1270        let content = "# Start\n\n# End";
1271        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1272        let result = rule.check(&ctx).unwrap();
1273
1274        assert!(!result.is_empty(), "+ should require at least one heading");
1275    }
1276
1277    #[test]
1278    fn test_plus_wildcard_allows_multiple() {
1279        // + allows multiple headings
1280        let config = MD043Config {
1281            headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
1282            match_case: false,
1283        };
1284        let rule = MD043RequiredHeadings::from_config_struct(config);
1285
1286        // Should pass with one heading
1287        let content = "# Start\n\n## Middle\n\n# End";
1288        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1289        let result = rule.check(&ctx).unwrap();
1290
1291        assert!(result.is_empty(), "+ should allow one heading");
1292
1293        // Should pass with multiple headings
1294        let content = "# Start\n\n## Middle 1\n\n## Middle 2\n\n## Middle 3\n\n# End";
1295        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1296        let result = rule.check(&ctx).unwrap();
1297
1298        assert!(result.is_empty(), "+ should allow multiple headings");
1299    }
1300
1301    #[test]
1302    fn test_question_wildcard_exactly_one() {
1303        // ? requires exactly one heading
1304        let config = MD043Config {
1305            headings: vec!["?".to_string(), "## Description".to_string()],
1306            match_case: false,
1307        };
1308        let rule = MD043RequiredHeadings::from_config_struct(config);
1309
1310        // Should pass with exactly one heading before Description
1311        let content = "# Project Name\n\n## Description";
1312        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1313        let result = rule.check(&ctx).unwrap();
1314
1315        assert!(result.is_empty(), "? should allow exactly one heading");
1316    }
1317
1318    #[test]
1319    fn test_question_wildcard_fails_with_zero() {
1320        // ? fails with zero headings
1321        let config = MD043Config {
1322            headings: vec!["?".to_string(), "## Description".to_string()],
1323            match_case: false,
1324        };
1325        let rule = MD043RequiredHeadings::from_config_struct(config);
1326
1327        let content = "## Description";
1328        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1329        let result = rule.check(&ctx).unwrap();
1330
1331        assert!(!result.is_empty(), "? should require exactly one heading");
1332    }
1333
1334    #[test]
1335    fn test_complex_wildcard_pattern() {
1336        // Complex pattern: variable title, required sections, optional details
1337        let config = MD043Config {
1338            headings: vec![
1339                "?".to_string(),           // Any project title
1340                "## Overview".to_string(), // Required
1341                "*".to_string(),           // Optional sections
1342                "## License".to_string(),  // Required
1343            ],
1344            match_case: false,
1345        };
1346        let rule = MD043RequiredHeadings::from_config_struct(config);
1347
1348        // Should pass with minimal structure
1349        let content = "# My Project\n\n## Overview\n\n## License";
1350        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1351        let result = rule.check(&ctx).unwrap();
1352
1353        assert!(result.is_empty(), "Complex pattern should match minimal structure");
1354
1355        // Should pass with additional sections
1356        let content = "# My Project\n\n## Overview\n\n## Installation\n\n## Usage\n\n## License";
1357        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1358        let result = rule.check(&ctx).unwrap();
1359
1360        assert!(result.is_empty(), "Complex pattern should match with optional sections");
1361    }
1362
1363    #[test]
1364    fn test_multiple_asterisks() {
1365        // Multiple * wildcards in pattern
1366        let config = MD043Config {
1367            headings: vec![
1368                "# Title".to_string(),
1369                "*".to_string(),
1370                "## Middle".to_string(),
1371                "*".to_string(),
1372                "# End".to_string(),
1373            ],
1374            match_case: false,
1375        };
1376        let rule = MD043RequiredHeadings::from_config_struct(config);
1377
1378        let content = "# Title\n\n## Middle\n\n# End";
1379        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1380        let result = rule.check(&ctx).unwrap();
1381
1382        assert!(result.is_empty(), "Multiple * wildcards should work");
1383
1384        let content = "# Title\n\n### Details\n\n## Middle\n\n### More Details\n\n# End";
1385        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1386        let result = rule.check(&ctx).unwrap();
1387
1388        assert!(
1389            result.is_empty(),
1390            "Multiple * wildcards should allow flexible structure"
1391        );
1392    }
1393
1394    #[test]
1395    fn test_wildcard_with_case_sensitivity() {
1396        // Wildcards work with case-sensitive matching
1397        let config = MD043Config {
1398            headings: vec![
1399                "?".to_string(),
1400                "## Description".to_string(), // Case-sensitive
1401            ],
1402            match_case: true,
1403        };
1404        let rule = MD043RequiredHeadings::from_config_struct(config);
1405
1406        // Should pass with correct case
1407        let content = "# Title\n\n## Description";
1408        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1409        let result = rule.check(&ctx).unwrap();
1410
1411        assert!(result.is_empty(), "Wildcard should work with case-sensitive matching");
1412
1413        // Should fail with wrong case
1414        let content = "# Title\n\n## description";
1415        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1416        let result = rule.check(&ctx).unwrap();
1417
1418        assert!(
1419            !result.is_empty(),
1420            "Case-sensitive matching should detect case mismatch"
1421        );
1422    }
1423
1424    #[test]
1425    fn test_all_wildcards_pattern() {
1426        // Pattern with only wildcards
1427        let config = MD043Config {
1428            headings: vec!["*".to_string()],
1429            match_case: false,
1430        };
1431        let rule = MD043RequiredHeadings::from_config_struct(config);
1432
1433        // Should pass with any headings
1434        let content = "# Any\n\n## Headings\n\n### Work";
1435        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1436        let result = rule.check(&ctx).unwrap();
1437
1438        assert!(result.is_empty(), "* alone should allow any heading structure");
1439
1440        // Should pass with no headings
1441        let content = "No headings here";
1442        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1443        let result = rule.check(&ctx).unwrap();
1444
1445        assert!(result.is_empty(), "* alone should allow no headings");
1446    }
1447
1448    #[test]
1449    fn test_wildcard_edge_cases() {
1450        // Edge case: + at end requires at least one more heading
1451        let config = MD043Config {
1452            headings: vec!["# Start".to_string(), "+".to_string()],
1453            match_case: false,
1454        };
1455        let rule = MD043RequiredHeadings::from_config_struct(config);
1456
1457        // Should fail with no additional headings
1458        let content = "# Start";
1459        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1460        let result = rule.check(&ctx).unwrap();
1461
1462        assert!(!result.is_empty(), "+ at end should require at least one more heading");
1463
1464        // Should pass with additional headings
1465        let content = "# Start\n\n## More";
1466        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1467        let result = rule.check(&ctx).unwrap();
1468
1469        assert!(result.is_empty(), "+ at end should allow additional headings");
1470    }
1471
1472    #[test]
1473    fn test_fix_with_wildcards() {
1474        // Fix should preserve content when wildcards are used
1475        let config = MD043Config {
1476            headings: vec!["?".to_string(), "## Description".to_string()],
1477            match_case: false,
1478        };
1479        let rule = MD043RequiredHeadings::from_config_struct(config);
1480
1481        // Matching content
1482        let content = "# Project\n\n## Description";
1483        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1484        let fixed = rule.fix(&ctx).unwrap();
1485
1486        assert_eq!(fixed, content, "Fix should preserve matching wildcard content");
1487
1488        // Non-matching content
1489        let content = "# Project\n\n## Other";
1490        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1491        let fixed = rule.fix(&ctx).unwrap();
1492
1493        assert_eq!(
1494            fixed, content,
1495            "Fix should preserve non-matching content to prevent data loss"
1496        );
1497    }
1498
1499    // Comprehensive edge case tests
1500
1501    #[test]
1502    fn test_consecutive_wildcards() {
1503        // Multiple wildcards in a row
1504        let config = MD043Config {
1505            headings: vec![
1506                "# Start".to_string(),
1507                "*".to_string(),
1508                "+".to_string(),
1509                "# End".to_string(),
1510            ],
1511            match_case: false,
1512        };
1513        let rule = MD043RequiredHeadings::from_config_struct(config);
1514
1515        // Should require at least one heading from +
1516        let content = "# Start\n\n## Middle\n\n# End";
1517        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1518        let result = rule.check(&ctx).unwrap();
1519
1520        assert!(result.is_empty(), "Consecutive * and + should work together");
1521
1522        // Should fail without the + requirement
1523        let content = "# Start\n\n# End";
1524        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1525        let result = rule.check(&ctx).unwrap();
1526
1527        assert!(!result.is_empty(), "Should fail when + is not satisfied");
1528    }
1529
1530    #[test]
1531    fn test_question_mark_doesnt_consume_literal_match() {
1532        // ? should match exactly one, not more
1533        let config = MD043Config {
1534            headings: vec!["?".to_string(), "## Description".to_string(), "## License".to_string()],
1535            match_case: false,
1536        };
1537        let rule = MD043RequiredHeadings::from_config_struct(config);
1538
1539        // Should match with exactly one before Description
1540        let content = "# Title\n\n## Description\n\n## License";
1541        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1542        let result = rule.check(&ctx).unwrap();
1543
1544        assert!(result.is_empty(), "? should consume exactly one heading");
1545
1546        // Should fail if Description comes first (? needs something to match)
1547        let content = "## Description\n\n## License";
1548        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1549        let result = rule.check(&ctx).unwrap();
1550
1551        assert!(!result.is_empty(), "? requires exactly one heading to match");
1552    }
1553
1554    #[test]
1555    fn test_asterisk_between_literals_complex() {
1556        // Test * matching when sandwiched between specific headings
1557        let config = MD043Config {
1558            headings: vec![
1559                "# Title".to_string(),
1560                "## Section A".to_string(),
1561                "*".to_string(),
1562                "## Section B".to_string(),
1563            ],
1564            match_case: false,
1565        };
1566        let rule = MD043RequiredHeadings::from_config_struct(config);
1567
1568        // Should work with zero headings between A and B
1569        let content = "# Title\n\n## Section A\n\n## Section B";
1570        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1571        let result = rule.check(&ctx).unwrap();
1572
1573        assert!(result.is_empty(), "* should allow zero headings");
1574
1575        // Should work with many headings between A and B
1576        let content = "# Title\n\n## Section A\n\n### Sub1\n\n### Sub2\n\n### Sub3\n\n## Section B";
1577        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1578        let result = rule.check(&ctx).unwrap();
1579
1580        assert!(result.is_empty(), "* should allow multiple headings");
1581
1582        // Should fail if Section B is missing
1583        let content = "# Title\n\n## Section A\n\n### Sub1";
1584        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1585        let result = rule.check(&ctx).unwrap();
1586
1587        assert!(
1588            !result.is_empty(),
1589            "Should fail when required heading after * is missing"
1590        );
1591    }
1592
1593    #[test]
1594    fn test_plus_requires_consumption() {
1595        // + must consume at least one heading
1596        let config = MD043Config {
1597            headings: vec!["+".to_string()],
1598            match_case: false,
1599        };
1600        let rule = MD043RequiredHeadings::from_config_struct(config);
1601
1602        // Should fail with no headings
1603        let content = "No headings here";
1604        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1605        let result = rule.check(&ctx).unwrap();
1606
1607        assert!(!result.is_empty(), "+ should fail with zero headings");
1608
1609        // Should pass with any heading
1610        let content = "# Any heading";
1611        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1612        let result = rule.check(&ctx).unwrap();
1613
1614        assert!(result.is_empty(), "+ should pass with one heading");
1615
1616        // Should pass with multiple headings
1617        let content = "# First\n\n## Second\n\n### Third";
1618        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1619        let result = rule.check(&ctx).unwrap();
1620
1621        assert!(result.is_empty(), "+ should pass with multiple headings");
1622    }
1623
1624    #[test]
1625    fn test_mixed_wildcard_and_literal_ordering() {
1626        // Ensure wildcards don't break literal matching order
1627        let config = MD043Config {
1628            headings: vec![
1629                "# A".to_string(),
1630                "*".to_string(),
1631                "# B".to_string(),
1632                "*".to_string(),
1633                "# C".to_string(),
1634            ],
1635            match_case: false,
1636        };
1637        let rule = MD043RequiredHeadings::from_config_struct(config);
1638
1639        // Should pass in correct order
1640        let content = "# A\n\n# B\n\n# C";
1641        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1642        let result = rule.check(&ctx).unwrap();
1643
1644        assert!(result.is_empty(), "Should match literals in correct order");
1645
1646        // Should fail in wrong order
1647        let content = "# A\n\n# C\n\n# B";
1648        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1649        let result = rule.check(&ctx).unwrap();
1650
1651        assert!(!result.is_empty(), "Should fail when literals are out of order");
1652
1653        // Should fail with missing required literal
1654        let content = "# A\n\n# C";
1655        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1656        let result = rule.check(&ctx).unwrap();
1657
1658        assert!(!result.is_empty(), "Should fail when required literal is missing");
1659    }
1660
1661    #[test]
1662    fn test_only_wildcards_with_headings() {
1663        // Pattern with only wildcards and content
1664        let config = MD043Config {
1665            headings: vec!["?".to_string(), "+".to_string()],
1666            match_case: false,
1667        };
1668        let rule = MD043RequiredHeadings::from_config_struct(config);
1669
1670        // Should require at least 2 headings (? = 1, + = 1+)
1671        let content = "# First\n\n## Second";
1672        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1673        let result = rule.check(&ctx).unwrap();
1674
1675        assert!(result.is_empty(), "? followed by + should require at least 2 headings");
1676
1677        // Should fail with only one heading
1678        let content = "# First";
1679        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1680        let result = rule.check(&ctx).unwrap();
1681
1682        assert!(
1683            !result.is_empty(),
1684            "Should fail with only 1 heading when ? + is required"
1685        );
1686    }
1687
1688    #[test]
1689    fn test_asterisk_matching_algorithm_greedy_vs_lazy() {
1690        // Test that * correctly finds the next literal match
1691        let config = MD043Config {
1692            headings: vec![
1693                "# Start".to_string(),
1694                "*".to_string(),
1695                "## Target".to_string(),
1696                "# End".to_string(),
1697            ],
1698            match_case: false,
1699        };
1700        let rule = MD043RequiredHeadings::from_config_struct(config);
1701
1702        // Should correctly skip to first "Target" match
1703        let content = "# Start\n\n## Other\n\n## Target\n\n# End";
1704        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1705        let result = rule.check(&ctx).unwrap();
1706
1707        assert!(result.is_empty(), "* should correctly skip to next literal match");
1708
1709        // Should handle case where there are extra headings after the match
1710        // (First Target matches, second Target is extra - should fail)
1711        let content = "# Start\n\n## Target\n\n## Target\n\n# End";
1712        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1713        let result = rule.check(&ctx).unwrap();
1714
1715        assert!(
1716            !result.is_empty(),
1717            "Should fail with extra headings that don't match pattern"
1718        );
1719    }
1720
1721    #[test]
1722    fn test_wildcard_at_start() {
1723        // Test wildcards at the beginning of pattern
1724        let config = MD043Config {
1725            headings: vec!["*".to_string(), "## End".to_string()],
1726            match_case: false,
1727        };
1728        let rule = MD043RequiredHeadings::from_config_struct(config);
1729
1730        // Should allow any headings before End
1731        let content = "# Random\n\n## Stuff\n\n## End";
1732        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1733        let result = rule.check(&ctx).unwrap();
1734
1735        assert!(result.is_empty(), "* at start should allow any preceding headings");
1736
1737        // Test + at start
1738        let config = MD043Config {
1739            headings: vec!["+".to_string(), "## End".to_string()],
1740            match_case: false,
1741        };
1742        let rule = MD043RequiredHeadings::from_config_struct(config);
1743
1744        // Should require at least one heading before End
1745        let content = "## End";
1746        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1747        let result = rule.check(&ctx).unwrap();
1748
1749        assert!(!result.is_empty(), "+ at start should require at least one heading");
1750
1751        let content = "# First\n\n## End";
1752        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1753        let result = rule.check(&ctx).unwrap();
1754
1755        assert!(result.is_empty(), "+ at start should allow headings before End");
1756    }
1757
1758    #[test]
1759    fn test_wildcard_with_setext_headings() {
1760        // Ensure wildcards work with setext headings too
1761        let config = MD043Config {
1762            headings: vec!["?".to_string(), "====== Section".to_string(), "*".to_string()],
1763            match_case: false,
1764        };
1765        let rule = MD043RequiredHeadings::from_config_struct(config);
1766
1767        let content = "Title\n=====\n\nSection\n======\n\nOptional\n--------";
1768        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1769        let result = rule.check(&ctx).unwrap();
1770
1771        assert!(result.is_empty(), "Wildcards should work with setext headings");
1772    }
1773
1774    #[test]
1775    fn test_empty_document_with_required_wildcards() {
1776        // Empty document should fail when + or ? are required
1777        let config = MD043Config {
1778            headings: vec!["?".to_string()],
1779            match_case: false,
1780        };
1781        let rule = MD043RequiredHeadings::from_config_struct(config);
1782
1783        let content = "No headings";
1784        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1785        let result = rule.check(&ctx).unwrap();
1786
1787        assert!(!result.is_empty(), "Empty document should fail with ? requirement");
1788
1789        // Test with +
1790        let config = MD043Config {
1791            headings: vec!["+".to_string()],
1792            match_case: false,
1793        };
1794        let rule = MD043RequiredHeadings::from_config_struct(config);
1795
1796        let content = "No headings";
1797        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1798        let result = rule.check(&ctx).unwrap();
1799
1800        assert!(!result.is_empty(), "Empty document should fail with + requirement");
1801    }
1802
1803    #[test]
1804    fn test_trailing_headings_after_pattern_completion() {
1805        // Extra headings after pattern is satisfied should fail
1806        let config = MD043Config {
1807            headings: vec!["# Title".to_string(), "## Section".to_string()],
1808            match_case: false,
1809        };
1810        let rule = MD043RequiredHeadings::from_config_struct(config);
1811
1812        // Should fail with extra headings
1813        let content = "# Title\n\n## Section\n\n### Extra";
1814        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1815        let result = rule.check(&ctx).unwrap();
1816
1817        assert!(!result.is_empty(), "Should fail with trailing headings beyond pattern");
1818
1819        // But * at end should allow them
1820        let config = MD043Config {
1821            headings: vec!["# Title".to_string(), "## Section".to_string(), "*".to_string()],
1822            match_case: false,
1823        };
1824        let rule = MD043RequiredHeadings::from_config_struct(config);
1825
1826        let content = "# Title\n\n## Section\n\n### Extra";
1827        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1828        let result = rule.check(&ctx).unwrap();
1829
1830        assert!(result.is_empty(), "* at end should allow trailing headings");
1831    }
1832
1833    #[test]
1834    fn test_reordering_respects_case_levels_and_duplicate_occurrences() {
1835        let insensitive = MD043RequiredHeadings::from_config_struct(MD043Config {
1836            headings: vec!["# A".into(), "# B".into(), "# A".into()],
1837            match_case: false,
1838        });
1839        let duplicate_move = LintContext::new("# A\n# a\n# B", crate::config::MarkdownFlavor::Standard, None);
1840        let result = insensitive.check(&duplicate_move).unwrap();
1841        assert_eq!(result.len(), 1);
1842        assert_eq!(
1843            result[0].message,
1844            "Heading structure does not match required structure. Heading '# B' is out of order; expected between '# A' and '# A'"
1845        );
1846        assert_eq!(result[0].line, 3);
1847
1848        let sensitive = MD043RequiredHeadings::from_config_struct(MD043Config {
1849            headings: vec!["# A".into(), "# B".into()],
1850            match_case: true,
1851        });
1852        let case_mismatch = LintContext::new("# B\n# a", crate::config::MarkdownFlavor::Standard, None);
1853        let result = sensitive.check(&case_mismatch).unwrap();
1854        assert!(result.iter().all(|warning| !warning.message.contains("out of order")));
1855
1856        let wrong_level = LintContext::new("# B\n## A", crate::config::MarkdownFlavor::Standard, None);
1857        let result = sensitive.check(&wrong_level).unwrap();
1858        assert!(result.iter().all(|warning| !warning.message.contains("out of order")));
1859    }
1860
1861    #[test]
1862    fn test_leading_and_trailing_moves_report_configured_neighbors() {
1863        let rule = MD043RequiredHeadings::new(vec!["# A".into(), "# B".into(), "# C".into()]);
1864        let leading = LintContext::new("# C\n# A\n# B", crate::config::MarkdownFlavor::Standard, None);
1865        let leading_result = rule.check(&leading).unwrap();
1866        assert_eq!(leading_result.len(), 1);
1867        assert_eq!(
1868            leading_result[0].message,
1869            "Heading structure does not match required structure. Heading '# C' is out of order; expected after '# B'"
1870        );
1871
1872        let trailing = LintContext::new("# B\n# C\n# A", crate::config::MarkdownFlavor::Standard, None);
1873        let trailing_result = rule.check(&trailing).unwrap();
1874        assert_eq!(trailing_result.len(), 1);
1875        assert_eq!(
1876            trailing_result[0].message,
1877            "Heading structure does not match required structure. Heading '# A' is out of order; expected before '# B'"
1878        );
1879    }
1880
1881    #[test]
1882    fn test_exhaustive_small_alignments_are_deterministic_and_owned() {
1883        let pattern_values = ["# A", "# B", "*", "+", "?"];
1884        let actual_values = ["# A", "# B", "# X"];
1885
1886        for pattern_len in 1..=3 {
1887            for pattern_number in 0..pattern_values.len().pow(pattern_len as u32) {
1888                let mut number = pattern_number;
1889                let mut headings = Vec::with_capacity(pattern_len);
1890                for _ in 0..pattern_len {
1891                    headings.push(pattern_values[number % pattern_values.len()].to_string());
1892                    number /= pattern_values.len();
1893                }
1894                let obligations = headings.iter().filter(|heading| heading.as_str() != "*").count();
1895
1896                for actual_len in 0..=3 {
1897                    for actual_number in 0..actual_values.len().pow(actual_len as u32) {
1898                        let mut number = actual_number;
1899                        let mut actual = Vec::with_capacity(actual_len);
1900                        for _ in 0..actual_len {
1901                            actual.push(actual_values[number % actual_values.len()]);
1902                            number /= actual_values.len();
1903                        }
1904                        let content = if actual.is_empty() {
1905                            "plain text".to_string()
1906                        } else {
1907                            actual.join("\n")
1908                        };
1909                        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1910
1911                        for match_case in [false, true] {
1912                            let rule = MD043RequiredHeadings::from_config_struct(MD043Config {
1913                                headings: headings.clone(),
1914                                match_case,
1915                            });
1916                            let first = rule.check(&ctx).unwrap();
1917                            let second = rule.check(&ctx).unwrap();
1918                            assert_eq!(first, second, "pattern={headings:?}, actual={actual:?}");
1919
1920                            let extracted = rule.extract_headings(&ctx);
1921                            let alignment = rule.alignment(&extracted);
1922                            let mut expected_uses = vec![0; headings.len()];
1923                            let mut actual_uses = vec![0; extracted.len()];
1924                            for event in &alignment.events {
1925                                match *event {
1926                                    AlignmentEvent::LiteralMatch {
1927                                        expected_index,
1928                                        actual_index,
1929                                    }
1930                                    | AlignmentEvent::Substitution {
1931                                        expected_index,
1932                                        actual_index,
1933                                    }
1934                                    | AlignmentEvent::OutOfOrder {
1935                                        expected_index,
1936                                        actual_index,
1937                                    } => {
1938                                        expected_uses[expected_index] += 1;
1939                                        actual_uses[actual_index] += 1;
1940                                    }
1941                                    AlignmentEvent::RequiredWildcardMatch {
1942                                        source_index,
1943                                        actual_index,
1944                                    } => {
1945                                        expected_uses[source_index] += 1;
1946                                        actual_uses[actual_index] += 1;
1947                                    }
1948                                    AlignmentEvent::RepeatingWildcardMatch { actual_index }
1949                                    | AlignmentEvent::Unexpected { actual_index } => {
1950                                        actual_uses[actual_index] += 1;
1951                                    }
1952                                    AlignmentEvent::MissingLiteral { expected_index, .. } => {
1953                                        expected_uses[expected_index] += 1;
1954                                    }
1955                                    AlignmentEvent::UnsatisfiedWildcard { source_index, .. } => {
1956                                        expected_uses[source_index] += 1;
1957                                    }
1958                                }
1959                            }
1960
1961                            for (index, pattern) in headings.iter().enumerate() {
1962                                let expected_count = usize::from(pattern != "*");
1963                                assert_eq!(
1964                                    expected_uses[index], expected_count,
1965                                    "pattern={headings:?}, actual={actual:?}, events={:?}",
1966                                    alignment.events
1967                                );
1968                            }
1969                            assert!(
1970                                actual_uses.iter().all(|uses| *uses == 1),
1971                                "pattern={headings:?}, actual={actual:?}, events={:?}",
1972                                alignment.events
1973                            );
1974                            assert_eq!(
1975                                first.is_empty(),
1976                                wildcard_language_accepts(&rule, &extracted),
1977                                "pattern={headings:?}, actual={actual:?}, events={:?}",
1978                                alignment.events
1979                            );
1980                            assert!(first.len() <= obligations + actual.len());
1981                        }
1982                    }
1983                }
1984            }
1985        }
1986    }
1987
1988    #[test]
1989    fn test_exhaustive_len4_alignment_matches_wildcard_language() {
1990        // Regression guard for the alignment DP. The size-3 exhaustive test above cannot
1991        // express interactions that need four tokens (two separate wildcard runs split by a
1992        // literal, a required + repeating wildcard followed by an anchor and a trailing
1993        // literal, etc.) -- exactly where an alignment regression would most plausibly hide.
1994        // This focuses on the core property only (accept/reject == the wildcard language) so
1995        // it stays cheap; determinism and ownership invariants are already covered at len 3.
1996        let pattern_values = ["# A", "# B", "*", "+", "?"];
1997        let actual_values = ["# A", "# B", "# X"];
1998
1999        // Parse each document once and reuse it across every pattern. Rebuilding the
2000        // LintContext per pattern dominates the runtime; precomputing keeps this in the
2001        // ~1s range so it can live in the default test run.
2002        let mut contents = Vec::new();
2003        for actual_len in 0..=4usize {
2004            for actual_number in 0..actual_values.len().pow(actual_len as u32) {
2005                let mut number = actual_number;
2006                let mut actual = Vec::with_capacity(actual_len);
2007                for _ in 0..actual_len {
2008                    actual.push(actual_values[number % actual_values.len()]);
2009                    number /= actual_values.len();
2010                }
2011                contents.push((actual.join("\n"), actual));
2012            }
2013        }
2014        let documents: Vec<(LintContext, &Vec<&str>)> = contents
2015            .iter()
2016            .map(|(content, actual)| {
2017                let text = if actual.is_empty() { "plain text" } else { content };
2018                (
2019                    LintContext::new(text, crate::config::MarkdownFlavor::Standard, None),
2020                    actual,
2021                )
2022            })
2023            .collect();
2024
2025        for pattern_len in 1..=4usize {
2026            for pattern_number in 0..pattern_values.len().pow(pattern_len as u32) {
2027                let mut number = pattern_number;
2028                let mut headings = Vec::with_capacity(pattern_len);
2029                for _ in 0..pattern_len {
2030                    headings.push(pattern_values[number % pattern_values.len()].to_string());
2031                    number /= pattern_values.len();
2032                }
2033                // Case sensitivity is orthogonal to the wildcard-run interactions this guards,
2034                // and is already exhaustively covered in both modes at len 3.
2035                let rule = MD043RequiredHeadings::from_config_struct(MD043Config {
2036                    headings: headings.clone(),
2037                    match_case: false,
2038                });
2039
2040                for (ctx, actual) in &documents {
2041                    let extracted = rule.extract_headings(ctx);
2042                    assert_eq!(
2043                        rule.check(ctx).unwrap().is_empty(),
2044                        wildcard_language_accepts(&rule, &extracted),
2045                        "pattern={headings:?}, actual={actual:?}"
2046                    );
2047                }
2048            }
2049        }
2050    }
2051
2052    #[test]
2053    fn test_fully_equal_scores_prefer_the_earliest_literal_occurrence() {
2054        let rule = MD043RequiredHeadings::new(vec!["# A".into(), "# A".into()]);
2055        let ctx = LintContext::new("# A", crate::config::MarkdownFlavor::Standard, None);
2056        let actual = rule.extract_headings(&ctx);
2057
2058        assert!(matches!(
2059            rule.alignment(&actual).events.as_slice(),
2060            [
2061                AlignmentEvent::LiteralMatch {
2062                    expected_index: 0,
2063                    actual_index: 0
2064                },
2065                AlignmentEvent::MissingLiteral { expected_index: 1, .. }
2066            ]
2067        ));
2068
2069        let rule = MD043RequiredHeadings::new(vec!["# A".into()]);
2070        let ctx = LintContext::new("# A\n# A", crate::config::MarkdownFlavor::Standard, None);
2071        let actual = rule.extract_headings(&ctx);
2072        assert!(matches!(
2073            rule.alignment(&actual).events.as_slice(),
2074            [
2075                AlignmentEvent::LiteralMatch {
2076                    expected_index: 0,
2077                    actual_index: 0
2078                },
2079                AlignmentEvent::Unexpected { actual_index: 1 }
2080            ]
2081        ));
2082    }
2083
2084    fn wildcard_language_accepts(rule: &MD043RequiredHeadings, actual: &[DocumentHeading]) -> bool {
2085        let mut pattern_index = 0;
2086        let mut actual_index = 0;
2087
2088        while pattern_index < rule.config.headings.len() {
2089            if !matches!(rule.config.headings[pattern_index].as_str(), "*" | "+" | "?") {
2090                if actual
2091                    .get(actual_index)
2092                    .is_none_or(|actual| !rule.headings_match(&rule.config.headings[pattern_index], &actual.text))
2093                {
2094                    return false;
2095                }
2096                pattern_index += 1;
2097                actual_index += 1;
2098                continue;
2099            }
2100
2101            let run_start = pattern_index;
2102            while pattern_index < rule.config.headings.len()
2103                && matches!(rule.config.headings[pattern_index].as_str(), "*" | "+" | "?")
2104            {
2105                pattern_index += 1;
2106            }
2107            let required = rule.config.headings[run_start..pattern_index]
2108                .iter()
2109                .filter(|pattern| matches!(pattern.as_str(), "+" | "?"))
2110                .count();
2111            if actual.len().saturating_sub(actual_index) < required {
2112                return false;
2113            }
2114            actual_index += required;
2115
2116            let repeats = rule.config.headings[run_start..pattern_index]
2117                .iter()
2118                .any(|pattern| matches!(pattern.as_str(), "*" | "+"));
2119            if repeats {
2120                if let Some(anchor) = rule.config.headings.get(pattern_index) {
2121                    while actual_index < actual.len() && !rule.headings_match(anchor, &actual[actual_index].text) {
2122                        actual_index += 1;
2123                    }
2124                } else {
2125                    actual_index = actual.len();
2126                }
2127            }
2128        }
2129
2130        actual_index == actual.len()
2131    }
2132
2133    #[test]
2134    fn test_literal_headings_do_not_satisfy_required_structure() {
2135        use crate::config::MarkdownFlavor;
2136        for (requirement, literal) in [("# Required", "# Required"), ("=== Required", "\nRequired\n===\n")] {
2137            let rule = MD043RequiredHeadings::new(vec![requirement.to_string()]);
2138            for (flavor, code) in [
2139                (
2140                    MarkdownFlavor::Standard,
2141                    format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
2142                ),
2143                (
2144                    MarkdownFlavor::Hugo,
2145                    format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
2146                ),
2147            ] {
2148                for ending in ["\n", "\r\n"] {
2149                    let source = code.replace('\n', ending);
2150                    let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
2151                    let warnings = rule.check(&ctx).unwrap();
2152                    assert_eq!(warnings.len(), 1, "{source}");
2153                    assert!(warnings[0].message.contains("Missing required heading"), "{warnings:?}");
2154                    assert!(warnings[0].fix.is_none());
2155                    assert_eq!(rule.fix(&ctx).unwrap(), source);
2156                }
2157            }
2158        }
2159    }
2160
2161    #[test]
2162    fn test_literal_headings_do_not_enter_required_alignment() {
2163        use crate::config::MarkdownFlavor;
2164        for (flavor, code) in [
2165            (
2166                MarkdownFlavor::Standard,
2167                "{% set unused=\"first\n# Extra\nlast\" %}{{ unused|length }}",
2168            ),
2169            (MarkdownFlavor::Hugo, "{{< note title=`first\n# Extra\nlast` >}}"),
2170        ] {
2171            for ending in ["\n", "\r\n"] {
2172                let source = format!("{code}\n\n# Required\n").replace('\n', ending);
2173                let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
2174                let exact = MD043RequiredHeadings::new(vec!["# Required".to_string()]);
2175                assert!(exact.check(&ctx).unwrap().is_empty(), "{source}");
2176                let wildcard = MD043RequiredHeadings::new(vec!["?".to_string(), "# Required".to_string()]);
2177                assert_eq!(wildcard.check(&ctx).unwrap().len(), 1, "{source}");
2178            }
2179        }
2180    }
2181
2182    #[test]
2183    fn test_dynamic_and_mdx_body_headings_remain_required_candidates() {
2184        for (flavor, source, required) in [
2185            (
2186                crate::config::MarkdownFlavor::Standard,
2187                "# {{ title }}\n",
2188                "# {{ title }}",
2189            ),
2190            (
2191                crate::config::MarkdownFlavor::MDX,
2192                "<div>\n\n# Required\n\n</div>\n",
2193                "# Required",
2194            ),
2195        ] {
2196            let rule = MD043RequiredHeadings::new(vec![required.to_string()]);
2197            let ctx = crate::lint_context::LintContext::new(source, flavor, None);
2198            assert!(rule.check(&ctx).unwrap().is_empty());
2199        }
2200    }
2201    mod quoted_headings {
2202        use super::*;
2203
2204        fn rule(headings: &[&str]) -> MD043RequiredHeadings {
2205            MD043RequiredHeadings::new(headings.iter().map(|h| (*h).to_string()).collect())
2206        }
2207
2208        fn warning_lines(rule: &MD043RequiredHeadings, content: &str) -> Vec<usize> {
2209            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2210            rule.check(&ctx).unwrap().iter().map(|w| w.line).collect()
2211        }
2212
2213        fn extracted(content: &str) -> Vec<String> {
2214            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2215            rule(&["# A"])
2216                .extract_headings(&ctx)
2217                .into_iter()
2218                .map(|heading| heading.text)
2219                .collect()
2220        }
2221
2222        #[test]
2223        fn quote_only_document_is_not_skipped_for_wildcard_patterns() {
2224            let quoted = LintContext::new("> ## B\n", crate::config::MarkdownFlavor::Standard, None);
2225            let plain = LintContext::new("text\n", crate::config::MarkdownFlavor::Standard, None);
2226            let wildcard = rule(&["*"]);
2227            assert!(!wildcard.should_skip(&quoted));
2228            assert!(wildcard.should_skip(&plain));
2229        }
2230
2231        #[test]
2232        fn quoted_headings_are_extracted_in_source_order() {
2233            assert_eq!(extracted("# A\n\n> ## B\n\n## C\n"), vec!["# A", "## B", "## C"]);
2234            assert_eq!(extracted("> > ## B\n"), vec!["## B"]);
2235            assert_eq!(extracted("- x\n\n  > ## B\n"), vec!["## B"]);
2236            assert_eq!(extracted("> - ## B\n"), vec!["## B"]);
2237            assert_eq!(extracted("> ```\n> # B\n> ```\n"), Vec::<String>::new());
2238        }
2239
2240        #[test]
2241        fn an_unexpected_quoted_heading_is_reported_on_its_line() {
2242            let r = rule(&["# A", "## B"]);
2243            assert!(warning_lines(&r, "# A\n\n## B\n").is_empty());
2244            assert_eq!(warning_lines(&r, "# A\n\n## B\n\n> ## Extra\n"), vec![5]);
2245        }
2246
2247        #[test]
2248        fn a_quoted_heading_satisfies_a_required_heading() {
2249            let r = rule(&["# A", "## B"]);
2250            assert!(warning_lines(&r, "# A\n\n> ## B\n").is_empty());
2251        }
2252
2253        #[test]
2254        fn a_quote_only_document_is_matched_not_skipped() {
2255            let r = rule(&["# A"]);
2256            let ctx = LintContext::new("> ## Wrong\n", crate::config::MarkdownFlavor::Standard, None);
2257            assert!(!r.should_skip(&ctx));
2258            assert!(!r.check(&ctx).unwrap().is_empty());
2259            let wildcard = rule(&["*"]);
2260            assert!(wildcard.should_skip(&LintContext::new(
2261                "text\n",
2262                crate::config::MarkdownFlavor::Standard,
2263                None
2264            )));
2265            assert!(
2266                warning_lines(&rule(&["# A"]), "> # A\n").is_empty(),
2267                "a matching quoted heading is accepted"
2268            );
2269        }
2270
2271        #[test]
2272        fn setext_quoted_heading_range_covers_its_text_lines() {
2273            let r = rule(&["# A"]);
2274            let ctx = LintContext::new(
2275                "# A\n\n> Long\n> text\n> ---\n",
2276                crate::config::MarkdownFlavor::Standard,
2277                None,
2278            );
2279            let warnings = r.check(&ctx).unwrap();
2280            assert_eq!(warnings.len(), 1, "{warnings:?}");
2281            assert_eq!((warnings[0].line, warnings[0].end_line), (3, 4), "{warnings:?}");
2282        }
2283    }
2284}