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