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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8#[serde(rename_all = "kebab-case")]
9pub struct MD043Config {
10 #[serde(default = "default_headings")]
12 pub headings: Vec<String>,
13 #[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#[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#[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 pub fn from_config_struct(config: MD043Config) -> Self {
240 Self { config }
241 }
242
243 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 if !heading.is_valid {
263 continue;
264 }
265
266 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 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 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 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 Ok(ctx.content.to_string())
782 }
783
784 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 let required = vec!["# Test Document".to_string(), "## Real heading 2".to_string()];
813 let rule = MD043RequiredHeadings::new(required);
814
815 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 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 let required = vec![
846 "# Introduction".to_string(),
847 "# Method".to_string(),
848 "# Results".to_string(),
849 ];
850 let rule = MD043RequiredHeadings::new(required);
851
852 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 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 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 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 let required = vec!["Test".to_string()];
907 let rule = MD043RequiredHeadings::new(required);
908
909 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 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 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 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 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 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 let required = vec!["Test".to_string()];
987 let rule = MD043RequiredHeadings::new(required);
988
989 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 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 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 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 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 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 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 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 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 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 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 let expected = "Wrong content";
1196 assert_eq!(fixed, expected);
1197 }
1198
1199 #[test]
1202 fn test_asterisk_wildcard_zero_headings() {
1203 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 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 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 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 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 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 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 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 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 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 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 let config = MD043Config {
1330 headings: vec![
1331 "?".to_string(), "## Overview".to_string(), "*".to_string(), "## License".to_string(), ],
1336 match_case: false,
1337 };
1338 let rule = MD043RequiredHeadings::from_config_struct(config);
1339
1340 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 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 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 let config = MD043Config {
1390 headings: vec![
1391 "?".to_string(),
1392 "## Description".to_string(), ],
1394 match_case: true,
1395 };
1396 let rule = MD043RequiredHeadings::from_config_struct(config);
1397
1398 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 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 let config = MD043Config {
1420 headings: vec!["*".to_string()],
1421 match_case: false,
1422 };
1423 let rule = MD043RequiredHeadings::from_config_struct(config);
1424
1425 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 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 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 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 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 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 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 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 #[test]
1494 fn test_consecutive_wildcards() {
1495 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 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 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 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 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 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 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 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 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 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 let config = MD043Config {
1589 headings: vec!["+".to_string()],
1590 match_case: false,
1591 };
1592 let rule = MD043RequiredHeadings::from_config_struct(config);
1593
1594 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let pattern_values = ["# A", "# B", "*", "+", "?"];
1989 let actual_values = ["# A", "# B", "# X"];
1990
1991 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 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}