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