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