1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, 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
47impl MD043RequiredHeadings {
48 pub fn new(headings: Vec<String>) -> Self {
49 Self {
50 config: MD043Config {
51 headings,
52 match_case: default_match_case(),
53 },
54 }
55 }
56
57 pub fn from_config_struct(config: MD043Config) -> Self {
59 Self { config }
60 }
61
62 fn headings_match(&self, expected: &str, actual: &str) -> bool {
64 if self.config.match_case {
65 expected == actual
66 } else {
67 expected.to_lowercase() == actual.to_lowercase()
68 }
69 }
70
71 fn extract_headings(&self, ctx: &crate::lint_context::LintContext) -> Vec<String> {
72 let mut result = Vec::new();
73
74 for line_info in &ctx.lines {
75 if let Some(heading) = &line_info.heading {
76 let full_heading = format!("{} {}", heading.marker, heading.text.trim());
78 result.push(full_heading);
79 }
80 }
81
82 result
83 }
84
85 fn match_headings_with_wildcards(
95 &self,
96 actual_headings: &[String],
97 expected_patterns: &[String],
98 ) -> (bool, usize, usize) {
99 let mut exp_idx = 0;
100 let mut act_idx = 0;
101 let mut match_any = false; while exp_idx < expected_patterns.len() && act_idx < actual_headings.len() {
104 let pattern = &expected_patterns[exp_idx];
105
106 if pattern == "*" {
107 exp_idx += 1;
109 if exp_idx >= expected_patterns.len() {
110 return (true, exp_idx, actual_headings.len());
112 }
113 match_any = true;
115 continue;
116 } else if pattern == "+" {
117 if act_idx >= actual_headings.len() {
119 return (false, exp_idx, act_idx); }
121 act_idx += 1;
122 exp_idx += 1;
123 match_any = true;
125 if exp_idx >= expected_patterns.len() {
127 return (true, exp_idx, actual_headings.len());
128 }
129 continue;
130 } else if pattern == "?" {
131 act_idx += 1;
133 exp_idx += 1;
134 match_any = false;
135 continue;
136 }
137
138 let actual = &actual_headings[act_idx];
140 if self.headings_match(pattern, actual) {
141 act_idx += 1;
143 exp_idx += 1;
144 match_any = false;
145 } else if match_any {
146 act_idx += 1;
148 } else {
149 return (false, exp_idx, act_idx);
151 }
152 }
153
154 while exp_idx < expected_patterns.len() {
156 let pattern = &expected_patterns[exp_idx];
157 if pattern == "*" {
158 exp_idx += 1;
160 } else if pattern == "+" {
161 return (false, exp_idx, act_idx);
163 } else if pattern == "?" {
164 return (false, exp_idx, act_idx);
166 } else {
167 return (false, exp_idx, act_idx);
169 }
170 }
171
172 let all_matched = act_idx == actual_headings.len() && exp_idx == expected_patterns.len();
174 (all_matched, exp_idx, act_idx)
175 }
176
177 fn is_heading(&self, line_index: usize, ctx: &crate::lint_context::LintContext) -> bool {
178 if line_index < ctx.lines.len() {
179 ctx.lines[line_index].heading.is_some()
180 } else {
181 false
182 }
183 }
184}
185
186impl Rule for MD043RequiredHeadings {
187 fn name(&self) -> &'static str {
188 "MD043"
189 }
190
191 fn description(&self) -> &'static str {
192 "Required heading structure"
193 }
194
195 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
196 let mut warnings = Vec::new();
197 let actual_headings = self.extract_headings(ctx);
198
199 if self.config.headings.is_empty() {
201 return Ok(warnings);
202 }
203
204 let all_optional_wildcards = self.config.headings.iter().all(|p| p == "*");
206 if actual_headings.is_empty() && all_optional_wildcards {
207 return Ok(warnings);
210 }
211
212 let (headings_match, _exp_idx, _act_idx) =
214 self.match_headings_with_wildcards(&actual_headings, &self.config.headings);
215
216 if !headings_match {
217 if actual_headings.is_empty() && !self.config.headings.is_empty() {
219 warnings.push(LintWarning {
220 rule_name: Some(self.name().to_string()),
221 line: 1,
222 column: 1,
223 end_line: 1,
224 end_column: 2,
225 message: format!("Required headings not found: {:?}", self.config.headings),
226 severity: Severity::Warning,
227 fix: None,
228 });
229 return Ok(warnings);
230 }
231
232 for (i, line_info) in ctx.lines.iter().enumerate() {
234 if self.is_heading(i, ctx) {
235 let (start_line, start_col, end_line, end_col) =
237 calculate_heading_range(i + 1, line_info.content(ctx.content));
238
239 warnings.push(LintWarning {
240 rule_name: Some(self.name().to_string()),
241 line: start_line,
242 column: start_col,
243 end_line,
244 end_column: end_col,
245 message: "Heading structure does not match the required structure".to_string(),
246 severity: Severity::Warning,
247 fix: None,
248 });
249 }
250 }
251
252 if warnings.is_empty() {
255 warnings.push(LintWarning {
256 rule_name: Some(self.name().to_string()),
257 line: 1,
258 column: 1,
259 end_line: 1,
260 end_column: 2,
261 message: format!(
262 "Heading structure does not match required structure. Expected: {:?}, Found: {:?}",
263 self.config.headings, actual_headings
264 ),
265 severity: Severity::Warning,
266 fix: None,
267 });
268 }
269 }
270
271 Ok(warnings)
272 }
273
274 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
275 let content = ctx.content;
276 if self.config.headings.is_empty() {
278 return Ok(content.to_string());
279 }
280
281 let actual_headings = self.extract_headings(ctx);
282
283 let (headings_match, _, _) = self.match_headings_with_wildcards(&actual_headings, &self.config.headings);
285 if headings_match {
286 return Ok(content.to_string());
287 }
288
289 Ok(content.to_string())
303 }
304
305 fn category(&self) -> RuleCategory {
307 RuleCategory::Heading
308 }
309
310 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
312 if self.config.headings.is_empty() || ctx.content.is_empty() {
314 return true;
315 }
316
317 let has_heading = ctx.lines.iter().any(|line| line.heading.is_some());
319
320 if !has_heading {
323 let has_required_wildcards = self.config.headings.iter().any(|p| p == "?" || p == "+");
324 if has_required_wildcards {
325 return false; }
327 }
328
329 !has_heading
330 }
331
332 fn as_any(&self) -> &dyn std::any::Any {
333 self
334 }
335
336 fn default_config_section(&self) -> Option<(String, toml::Value)> {
337 let default_config = MD043Config::default();
338 let json_value = serde_json::to_value(&default_config).ok()?;
339 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
340 if let toml::Value::Table(table) = toml_value {
341 if !table.is_empty() {
342 Some((MD043Config::RULE_NAME.to_string(), toml::Value::Table(table)))
343 } else {
344 None
345 }
346 } else {
347 None
348 }
349 }
350
351 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
352 where
353 Self: Sized,
354 {
355 let rule_config = crate::rule_config_serde::load_rule_config::<MD043Config>(config);
356 Box::new(MD043RequiredHeadings::from_config_struct(rule_config))
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use crate::lint_context::LintContext;
364
365 #[test]
366 fn test_extract_headings_code_blocks() {
367 let required = vec!["# Test Document".to_string(), "## Real heading 2".to_string()];
369 let rule = MD043RequiredHeadings::new(required);
370
371 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.";
373 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
374 let actual_headings = rule.extract_headings(&ctx);
375 assert_eq!(
376 actual_headings,
377 vec!["# Test Document".to_string(), "## Real heading 2".to_string()],
378 "Should extract correct headings and ignore code blocks"
379 );
380
381 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.";
383 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
384 let actual_headings = rule.extract_headings(&ctx);
385 assert_eq!(
386 actual_headings,
387 vec!["# Test Document".to_string(), "## Not Real heading 2".to_string()],
388 "Should extract actual headings including mismatched ones"
389 );
390 }
391
392 #[test]
393 fn test_with_document_structure() {
394 let required = vec![
396 "# Introduction".to_string(),
397 "# Method".to_string(),
398 "# Results".to_string(),
399 ];
400 let rule = MD043RequiredHeadings::new(required);
401
402 let content = "# Introduction\n\nContent\n\n# Method\n\nMore content\n\n# Results\n\nFinal content";
404 let warnings = rule
405 .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
406 .unwrap();
407 assert!(warnings.is_empty(), "Expected no warnings for matching headings");
408
409 let content = "# Introduction\n\nContent\n\n# Results\n\nSkipped method";
411 let warnings = rule
412 .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
413 .unwrap();
414 assert!(!warnings.is_empty(), "Expected warnings for mismatched headings");
415
416 let content = "No headings here, just plain text";
418 let warnings = rule
419 .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
420 .unwrap();
421 assert!(!warnings.is_empty(), "Expected warnings when headings are missing");
422
423 let required_setext = vec![
425 "=========== Introduction".to_string(),
426 "------ Method".to_string(),
427 "======= Results".to_string(),
428 ];
429 let rule_setext = MD043RequiredHeadings::new(required_setext);
430 let content = "Introduction\n===========\n\nContent\n\nMethod\n------\n\nMore content\n\nResults\n=======\n\nFinal content";
431 let warnings = rule_setext
432 .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
433 .unwrap();
434 assert!(warnings.is_empty(), "Expected no warnings for matching setext headings");
435 }
436
437 #[test]
438 fn test_should_skip_no_false_positives() {
439 let required = vec!["Test".to_string()];
441 let rule = MD043RequiredHeadings::new(required);
442
443 let content = "This paragraph contains a # character but is not a heading";
445 assert!(
446 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
447 "Should skip content with # in normal text"
448 );
449
450 let content = "Regular paragraph\n\n```markdown\n# This is not a real heading\n```\n\nMore text";
452 assert!(
453 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
454 "Should skip content with heading-like syntax in code blocks"
455 );
456
457 let content = "Some text\n\n- List item 1\n- List item 2\n\nMore text";
459 assert!(
460 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
461 "Should skip content with list items using dash"
462 );
463
464 let content = "Some text\n\n---\n\nMore text below the horizontal rule";
466 assert!(
467 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
468 "Should skip content with horizontal rule"
469 );
470
471 let content = "This is a normal paragraph with equals sign x = y + z";
473 assert!(
474 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
475 "Should skip content with equals sign in normal text"
476 );
477
478 let content = "This is a normal paragraph with minus sign x - y = z";
480 assert!(
481 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
482 "Should skip content with minus sign in normal text"
483 );
484 }
485
486 #[test]
487 fn test_should_skip_heading_detection() {
488 let required = vec!["Test".to_string()];
490 let rule = MD043RequiredHeadings::new(required);
491
492 let content = "# This is a heading\n\nAnd some content";
494 assert!(
495 !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
496 "Should not skip content with ATX heading"
497 );
498
499 let content = "This is a heading\n================\n\nAnd some content";
501 assert!(
502 !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
503 "Should not skip content with Setext heading (=)"
504 );
505
506 let content = "This is a subheading\n------------------\n\nAnd some content";
508 assert!(
509 !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
510 "Should not skip content with Setext heading (-)"
511 );
512
513 let content = "## This is a heading ##\n\nAnd some content";
515 assert!(
516 !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
517 "Should not skip content with ATX heading with closing hashes"
518 );
519 }
520
521 #[test]
522 fn test_config_match_case_sensitive() {
523 let config = MD043Config {
524 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
525 match_case: true,
526 };
527 let rule = MD043RequiredHeadings::from_config_struct(config);
528
529 let content = "# introduction\n\n# method";
531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
532 let result = rule.check(&ctx).unwrap();
533
534 assert!(
535 !result.is_empty(),
536 "Should detect case mismatch when match_case is true"
537 );
538 }
539
540 #[test]
541 fn test_config_match_case_insensitive() {
542 let config = MD043Config {
543 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
544 match_case: false,
545 };
546 let rule = MD043RequiredHeadings::from_config_struct(config);
547
548 let content = "# introduction\n\n# method";
550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
551 let result = rule.check(&ctx).unwrap();
552
553 assert!(result.is_empty(), "Should allow case mismatch when match_case is false");
554 }
555
556 #[test]
557 fn test_config_case_insensitive_mixed() {
558 let config = MD043Config {
559 headings: vec!["# Introduction".to_string(), "# METHOD".to_string()],
560 match_case: false,
561 };
562 let rule = MD043RequiredHeadings::from_config_struct(config);
563
564 let content = "# INTRODUCTION\n\n# method";
566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
567 let result = rule.check(&ctx).unwrap();
568
569 assert!(
570 result.is_empty(),
571 "Should allow mixed case variations when match_case is false"
572 );
573 }
574
575 #[test]
576 fn test_config_case_sensitive_exact_match() {
577 let config = MD043Config {
578 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
579 match_case: true,
580 };
581 let rule = MD043RequiredHeadings::from_config_struct(config);
582
583 let content = "# Introduction\n\n# Method";
585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
586 let result = rule.check(&ctx).unwrap();
587
588 assert!(
589 result.is_empty(),
590 "Should pass with exact case match when match_case is true"
591 );
592 }
593
594 #[test]
595 fn test_default_config() {
596 let rule = MD043RequiredHeadings::default();
597
598 let content = "# Any heading\n\n# Another heading";
600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
601 let result = rule.check(&ctx).unwrap();
602
603 assert!(result.is_empty(), "Should be disabled with default empty headings");
604 }
605
606 #[test]
607 fn test_default_config_section() {
608 let rule = MD043RequiredHeadings::default();
609 let config_section = rule.default_config_section();
610
611 assert!(config_section.is_some());
612 let (name, value) = config_section.unwrap();
613 assert_eq!(name, "MD043");
614
615 if let toml::Value::Table(table) = value {
617 assert!(table.contains_key("headings"));
618 assert!(table.contains_key("match-case"));
619 assert_eq!(table["headings"], toml::Value::Array(vec![]));
620 assert_eq!(table["match-case"], toml::Value::Boolean(false));
621 } else {
622 panic!("Expected TOML table");
623 }
624 }
625
626 #[test]
627 fn test_headings_match_case_sensitive() {
628 let config = MD043Config {
629 headings: vec![],
630 match_case: true,
631 };
632 let rule = MD043RequiredHeadings::from_config_struct(config);
633
634 assert!(rule.headings_match("Test", "Test"));
635 assert!(!rule.headings_match("Test", "test"));
636 assert!(!rule.headings_match("test", "Test"));
637 }
638
639 #[test]
640 fn test_headings_match_case_insensitive() {
641 let config = MD043Config {
642 headings: vec![],
643 match_case: false,
644 };
645 let rule = MD043RequiredHeadings::from_config_struct(config);
646
647 assert!(rule.headings_match("Test", "Test"));
648 assert!(rule.headings_match("Test", "test"));
649 assert!(rule.headings_match("test", "Test"));
650 assert!(rule.headings_match("TEST", "test"));
651 }
652
653 #[test]
654 fn test_config_empty_headings() {
655 let config = MD043Config {
656 headings: vec![],
657 match_case: true,
658 };
659 let rule = MD043RequiredHeadings::from_config_struct(config);
660
661 let content = "# Any heading\n\n# Another heading";
663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
664 let result = rule.check(&ctx).unwrap();
665
666 assert!(result.is_empty(), "Should be disabled with empty headings list");
667 }
668
669 #[test]
670 fn test_fix_respects_configuration() {
671 let config = MD043Config {
672 headings: vec!["# Title".to_string(), "# Content".to_string()],
673 match_case: false,
674 };
675 let rule = MD043RequiredHeadings::from_config_struct(config);
676
677 let content = "Wrong content";
678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
679 let fixed = rule.fix(&ctx).unwrap();
680
681 let expected = "Wrong content";
683 assert_eq!(fixed, expected);
684 }
685
686 #[test]
689 fn test_asterisk_wildcard_zero_headings() {
690 let config = MD043Config {
692 headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
693 match_case: false,
694 };
695 let rule = MD043RequiredHeadings::from_config_struct(config);
696
697 let content = "# Start\n\n# End";
698 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
699 let result = rule.check(&ctx).unwrap();
700
701 assert!(result.is_empty(), "* should allow zero headings between Start and End");
702 }
703
704 #[test]
705 fn test_asterisk_wildcard_multiple_headings() {
706 let config = MD043Config {
708 headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
709 match_case: false,
710 };
711 let rule = MD043RequiredHeadings::from_config_struct(config);
712
713 let content = "# Start\n\n## Section 1\n\n## Section 2\n\n## Section 3\n\n# End";
714 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
715 let result = rule.check(&ctx).unwrap();
716
717 assert!(
718 result.is_empty(),
719 "* should allow multiple headings between Start and End"
720 );
721 }
722
723 #[test]
724 fn test_asterisk_wildcard_at_end() {
725 let config = MD043Config {
727 headings: vec!["# Introduction".to_string(), "*".to_string()],
728 match_case: false,
729 };
730 let rule = MD043RequiredHeadings::from_config_struct(config);
731
732 let content = "# Introduction\n\n## Details\n\n### Subsection\n\n## More";
733 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
734 let result = rule.check(&ctx).unwrap();
735
736 assert!(result.is_empty(), "* at end should allow any trailing headings");
737 }
738
739 #[test]
740 fn test_plus_wildcard_requires_at_least_one() {
741 let config = MD043Config {
743 headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
744 match_case: false,
745 };
746 let rule = MD043RequiredHeadings::from_config_struct(config);
747
748 let content = "# Start\n\n# End";
750 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
751 let result = rule.check(&ctx).unwrap();
752
753 assert!(!result.is_empty(), "+ should require at least one heading");
754 }
755
756 #[test]
757 fn test_plus_wildcard_allows_multiple() {
758 let config = MD043Config {
760 headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
761 match_case: false,
762 };
763 let rule = MD043RequiredHeadings::from_config_struct(config);
764
765 let content = "# Start\n\n## Middle\n\n# End";
767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
768 let result = rule.check(&ctx).unwrap();
769
770 assert!(result.is_empty(), "+ should allow one heading");
771
772 let content = "# Start\n\n## Middle 1\n\n## Middle 2\n\n## Middle 3\n\n# End";
774 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
775 let result = rule.check(&ctx).unwrap();
776
777 assert!(result.is_empty(), "+ should allow multiple headings");
778 }
779
780 #[test]
781 fn test_question_wildcard_exactly_one() {
782 let config = MD043Config {
784 headings: vec!["?".to_string(), "## Description".to_string()],
785 match_case: false,
786 };
787 let rule = MD043RequiredHeadings::from_config_struct(config);
788
789 let content = "# Project Name\n\n## Description";
791 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
792 let result = rule.check(&ctx).unwrap();
793
794 assert!(result.is_empty(), "? should allow exactly one heading");
795 }
796
797 #[test]
798 fn test_question_wildcard_fails_with_zero() {
799 let config = MD043Config {
801 headings: vec!["?".to_string(), "## Description".to_string()],
802 match_case: false,
803 };
804 let rule = MD043RequiredHeadings::from_config_struct(config);
805
806 let content = "## Description";
807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
808 let result = rule.check(&ctx).unwrap();
809
810 assert!(!result.is_empty(), "? should require exactly one heading");
811 }
812
813 #[test]
814 fn test_complex_wildcard_pattern() {
815 let config = MD043Config {
817 headings: vec![
818 "?".to_string(), "## Overview".to_string(), "*".to_string(), "## License".to_string(), ],
823 match_case: false,
824 };
825 let rule = MD043RequiredHeadings::from_config_struct(config);
826
827 let content = "# My Project\n\n## Overview\n\n## License";
829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
830 let result = rule.check(&ctx).unwrap();
831
832 assert!(result.is_empty(), "Complex pattern should match minimal structure");
833
834 let content = "# My Project\n\n## Overview\n\n## Installation\n\n## Usage\n\n## License";
836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
837 let result = rule.check(&ctx).unwrap();
838
839 assert!(result.is_empty(), "Complex pattern should match with optional sections");
840 }
841
842 #[test]
843 fn test_multiple_asterisks() {
844 let config = MD043Config {
846 headings: vec![
847 "# Title".to_string(),
848 "*".to_string(),
849 "## Middle".to_string(),
850 "*".to_string(),
851 "# End".to_string(),
852 ],
853 match_case: false,
854 };
855 let rule = MD043RequiredHeadings::from_config_struct(config);
856
857 let content = "# Title\n\n## Middle\n\n# End";
858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
859 let result = rule.check(&ctx).unwrap();
860
861 assert!(result.is_empty(), "Multiple * wildcards should work");
862
863 let content = "# Title\n\n### Details\n\n## Middle\n\n### More Details\n\n# End";
864 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
865 let result = rule.check(&ctx).unwrap();
866
867 assert!(
868 result.is_empty(),
869 "Multiple * wildcards should allow flexible structure"
870 );
871 }
872
873 #[test]
874 fn test_wildcard_with_case_sensitivity() {
875 let config = MD043Config {
877 headings: vec![
878 "?".to_string(),
879 "## Description".to_string(), ],
881 match_case: true,
882 };
883 let rule = MD043RequiredHeadings::from_config_struct(config);
884
885 let content = "# Title\n\n## Description";
887 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
888 let result = rule.check(&ctx).unwrap();
889
890 assert!(result.is_empty(), "Wildcard should work with case-sensitive matching");
891
892 let content = "# Title\n\n## description";
894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
895 let result = rule.check(&ctx).unwrap();
896
897 assert!(
898 !result.is_empty(),
899 "Case-sensitive matching should detect case mismatch"
900 );
901 }
902
903 #[test]
904 fn test_all_wildcards_pattern() {
905 let config = MD043Config {
907 headings: vec!["*".to_string()],
908 match_case: false,
909 };
910 let rule = MD043RequiredHeadings::from_config_struct(config);
911
912 let content = "# Any\n\n## Headings\n\n### Work";
914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
915 let result = rule.check(&ctx).unwrap();
916
917 assert!(result.is_empty(), "* alone should allow any heading structure");
918
919 let content = "No headings here";
921 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
922 let result = rule.check(&ctx).unwrap();
923
924 assert!(result.is_empty(), "* alone should allow no headings");
925 }
926
927 #[test]
928 fn test_wildcard_edge_cases() {
929 let config = MD043Config {
931 headings: vec!["# Start".to_string(), "+".to_string()],
932 match_case: false,
933 };
934 let rule = MD043RequiredHeadings::from_config_struct(config);
935
936 let content = "# Start";
938 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
939 let result = rule.check(&ctx).unwrap();
940
941 assert!(!result.is_empty(), "+ at end should require at least one more heading");
942
943 let content = "# Start\n\n## More";
945 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
946 let result = rule.check(&ctx).unwrap();
947
948 assert!(result.is_empty(), "+ at end should allow additional headings");
949 }
950
951 #[test]
952 fn test_fix_with_wildcards() {
953 let config = MD043Config {
955 headings: vec!["?".to_string(), "## Description".to_string()],
956 match_case: false,
957 };
958 let rule = MD043RequiredHeadings::from_config_struct(config);
959
960 let content = "# Project\n\n## Description";
962 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
963 let fixed = rule.fix(&ctx).unwrap();
964
965 assert_eq!(fixed, content, "Fix should preserve matching wildcard content");
966
967 let content = "# Project\n\n## Other";
969 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
970 let fixed = rule.fix(&ctx).unwrap();
971
972 assert_eq!(
973 fixed, content,
974 "Fix should preserve non-matching content to prevent data loss"
975 );
976 }
977
978 #[test]
981 fn test_consecutive_wildcards() {
982 let config = MD043Config {
984 headings: vec![
985 "# Start".to_string(),
986 "*".to_string(),
987 "+".to_string(),
988 "# End".to_string(),
989 ],
990 match_case: false,
991 };
992 let rule = MD043RequiredHeadings::from_config_struct(config);
993
994 let content = "# Start\n\n## Middle\n\n# End";
996 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
997 let result = rule.check(&ctx).unwrap();
998
999 assert!(result.is_empty(), "Consecutive * and + should work together");
1000
1001 let content = "# Start\n\n# End";
1003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1004 let result = rule.check(&ctx).unwrap();
1005
1006 assert!(!result.is_empty(), "Should fail when + is not satisfied");
1007 }
1008
1009 #[test]
1010 fn test_question_mark_doesnt_consume_literal_match() {
1011 let config = MD043Config {
1013 headings: vec!["?".to_string(), "## Description".to_string(), "## License".to_string()],
1014 match_case: false,
1015 };
1016 let rule = MD043RequiredHeadings::from_config_struct(config);
1017
1018 let content = "# Title\n\n## Description\n\n## License";
1020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1021 let result = rule.check(&ctx).unwrap();
1022
1023 assert!(result.is_empty(), "? should consume exactly one heading");
1024
1025 let content = "## Description\n\n## License";
1027 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1028 let result = rule.check(&ctx).unwrap();
1029
1030 assert!(!result.is_empty(), "? requires exactly one heading to match");
1031 }
1032
1033 #[test]
1034 fn test_asterisk_between_literals_complex() {
1035 let config = MD043Config {
1037 headings: vec![
1038 "# Title".to_string(),
1039 "## Section A".to_string(),
1040 "*".to_string(),
1041 "## Section B".to_string(),
1042 ],
1043 match_case: false,
1044 };
1045 let rule = MD043RequiredHeadings::from_config_struct(config);
1046
1047 let content = "# Title\n\n## Section A\n\n## Section B";
1049 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1050 let result = rule.check(&ctx).unwrap();
1051
1052 assert!(result.is_empty(), "* should allow zero headings");
1053
1054 let content = "# Title\n\n## Section A\n\n### Sub1\n\n### Sub2\n\n### Sub3\n\n## Section B";
1056 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1057 let result = rule.check(&ctx).unwrap();
1058
1059 assert!(result.is_empty(), "* should allow multiple headings");
1060
1061 let content = "# Title\n\n## Section A\n\n### Sub1";
1063 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1064 let result = rule.check(&ctx).unwrap();
1065
1066 assert!(
1067 !result.is_empty(),
1068 "Should fail when required heading after * is missing"
1069 );
1070 }
1071
1072 #[test]
1073 fn test_plus_requires_consumption() {
1074 let config = MD043Config {
1076 headings: vec!["+".to_string()],
1077 match_case: false,
1078 };
1079 let rule = MD043RequiredHeadings::from_config_struct(config);
1080
1081 let content = "No headings here";
1083 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1084 let result = rule.check(&ctx).unwrap();
1085
1086 assert!(!result.is_empty(), "+ should fail with zero headings");
1087
1088 let content = "# Any heading";
1090 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1091 let result = rule.check(&ctx).unwrap();
1092
1093 assert!(result.is_empty(), "+ should pass with one heading");
1094
1095 let content = "# First\n\n## Second\n\n### Third";
1097 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1098 let result = rule.check(&ctx).unwrap();
1099
1100 assert!(result.is_empty(), "+ should pass with multiple headings");
1101 }
1102
1103 #[test]
1104 fn test_mixed_wildcard_and_literal_ordering() {
1105 let config = MD043Config {
1107 headings: vec![
1108 "# A".to_string(),
1109 "*".to_string(),
1110 "# B".to_string(),
1111 "*".to_string(),
1112 "# C".to_string(),
1113 ],
1114 match_case: false,
1115 };
1116 let rule = MD043RequiredHeadings::from_config_struct(config);
1117
1118 let content = "# A\n\n# B\n\n# C";
1120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1121 let result = rule.check(&ctx).unwrap();
1122
1123 assert!(result.is_empty(), "Should match literals in correct order");
1124
1125 let content = "# A\n\n# C\n\n# B";
1127 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1128 let result = rule.check(&ctx).unwrap();
1129
1130 assert!(!result.is_empty(), "Should fail when literals are out of order");
1131
1132 let content = "# A\n\n# C";
1134 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1135 let result = rule.check(&ctx).unwrap();
1136
1137 assert!(!result.is_empty(), "Should fail when required literal is missing");
1138 }
1139
1140 #[test]
1141 fn test_only_wildcards_with_headings() {
1142 let config = MD043Config {
1144 headings: vec!["?".to_string(), "+".to_string()],
1145 match_case: false,
1146 };
1147 let rule = MD043RequiredHeadings::from_config_struct(config);
1148
1149 let content = "# First\n\n## Second";
1151 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1152 let result = rule.check(&ctx).unwrap();
1153
1154 assert!(result.is_empty(), "? followed by + should require at least 2 headings");
1155
1156 let content = "# First";
1158 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1159 let result = rule.check(&ctx).unwrap();
1160
1161 assert!(
1162 !result.is_empty(),
1163 "Should fail with only 1 heading when ? + is required"
1164 );
1165 }
1166
1167 #[test]
1168 fn test_asterisk_matching_algorithm_greedy_vs_lazy() {
1169 let config = MD043Config {
1171 headings: vec![
1172 "# Start".to_string(),
1173 "*".to_string(),
1174 "## Target".to_string(),
1175 "# End".to_string(),
1176 ],
1177 match_case: false,
1178 };
1179 let rule = MD043RequiredHeadings::from_config_struct(config);
1180
1181 let content = "# Start\n\n## Other\n\n## Target\n\n# End";
1183 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1184 let result = rule.check(&ctx).unwrap();
1185
1186 assert!(result.is_empty(), "* should correctly skip to next literal match");
1187
1188 let content = "# Start\n\n## Target\n\n## Target\n\n# End";
1191 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1192 let result = rule.check(&ctx).unwrap();
1193
1194 assert!(
1195 !result.is_empty(),
1196 "Should fail with extra headings that don't match pattern"
1197 );
1198 }
1199
1200 #[test]
1201 fn test_wildcard_at_start() {
1202 let config = MD043Config {
1204 headings: vec!["*".to_string(), "## End".to_string()],
1205 match_case: false,
1206 };
1207 let rule = MD043RequiredHeadings::from_config_struct(config);
1208
1209 let content = "# Random\n\n## Stuff\n\n## End";
1211 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1212 let result = rule.check(&ctx).unwrap();
1213
1214 assert!(result.is_empty(), "* at start should allow any preceding headings");
1215
1216 let config = MD043Config {
1218 headings: vec!["+".to_string(), "## End".to_string()],
1219 match_case: false,
1220 };
1221 let rule = MD043RequiredHeadings::from_config_struct(config);
1222
1223 let content = "## End";
1225 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1226 let result = rule.check(&ctx).unwrap();
1227
1228 assert!(!result.is_empty(), "+ at start should require at least one heading");
1229
1230 let content = "# First\n\n## End";
1231 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1232 let result = rule.check(&ctx).unwrap();
1233
1234 assert!(result.is_empty(), "+ at start should allow headings before End");
1235 }
1236
1237 #[test]
1238 fn test_wildcard_with_setext_headings() {
1239 let config = MD043Config {
1241 headings: vec!["?".to_string(), "====== Section".to_string(), "*".to_string()],
1242 match_case: false,
1243 };
1244 let rule = MD043RequiredHeadings::from_config_struct(config);
1245
1246 let content = "Title\n=====\n\nSection\n======\n\nOptional\n--------";
1247 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1248 let result = rule.check(&ctx).unwrap();
1249
1250 assert!(result.is_empty(), "Wildcards should work with setext headings");
1251 }
1252
1253 #[test]
1254 fn test_empty_document_with_required_wildcards() {
1255 let config = MD043Config {
1257 headings: vec!["?".to_string()],
1258 match_case: false,
1259 };
1260 let rule = MD043RequiredHeadings::from_config_struct(config);
1261
1262 let content = "No headings";
1263 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1264 let result = rule.check(&ctx).unwrap();
1265
1266 assert!(!result.is_empty(), "Empty document should fail with ? requirement");
1267
1268 let config = MD043Config {
1270 headings: vec!["+".to_string()],
1271 match_case: false,
1272 };
1273 let rule = MD043RequiredHeadings::from_config_struct(config);
1274
1275 let content = "No headings";
1276 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1277 let result = rule.check(&ctx).unwrap();
1278
1279 assert!(!result.is_empty(), "Empty document should fail with + requirement");
1280 }
1281
1282 #[test]
1283 fn test_trailing_headings_after_pattern_completion() {
1284 let config = MD043Config {
1286 headings: vec!["# Title".to_string(), "## Section".to_string()],
1287 match_case: false,
1288 };
1289 let rule = MD043RequiredHeadings::from_config_struct(config);
1290
1291 let content = "# Title\n\n## Section\n\n### Extra";
1293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1294 let result = rule.check(&ctx).unwrap();
1295
1296 assert!(!result.is_empty(), "Should fail with trailing headings beyond pattern");
1297
1298 let config = MD043Config {
1300 headings: vec!["# Title".to_string(), "## Section".to_string(), "*".to_string()],
1301 match_case: false,
1302 };
1303 let rule = MD043RequiredHeadings::from_config_struct(config);
1304
1305 let content = "# Title\n\n## Section\n\n### Extra";
1306 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1307 let result = rule.check(&ctx).unwrap();
1308
1309 assert!(result.is_empty(), "* at end should allow trailing headings");
1310 }
1311}