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