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