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