1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::utils::range_utils::calculate_heading_range;
4use lazy_static::lazy_static;
5use regex::Regex;
6use serde::{Deserialize, Serialize};
7
8lazy_static! {
9 static ref ATX_HEADING: Regex = Regex::new(r"^(#+)\s+(.+)$").unwrap();
11 static ref SETEXT_UNDERLINE: Regex = Regex::new(r"^([=-]+)$").unwrap();
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17#[serde(rename_all = "kebab-case")]
18pub struct MD043Config {
19 #[serde(default = "default_headings")]
21 pub headings: Vec<String>,
22 #[serde(default = "default_match_case")]
24 pub match_case: bool,
25}
26
27impl Default for MD043Config {
28 fn default() -> Self {
29 Self {
30 headings: default_headings(),
31 match_case: default_match_case(),
32 }
33 }
34}
35
36fn default_headings() -> Vec<String> {
37 Vec::new()
38}
39
40fn default_match_case() -> bool {
41 false
42}
43
44impl RuleConfig for MD043Config {
45 const RULE_NAME: &'static str = "MD043";
46}
47
48#[derive(Clone, Default)]
52pub struct MD043RequiredHeadings {
53 config: MD043Config,
54}
55
56impl MD043RequiredHeadings {
57 pub fn new(headings: Vec<String>) -> Self {
58 Self {
59 config: MD043Config {
60 headings,
61 match_case: default_match_case(),
62 },
63 }
64 }
65
66 pub fn from_config_struct(config: MD043Config) -> Self {
68 Self { config }
69 }
70
71 fn headings_match(&self, expected: &str, actual: &str) -> bool {
73 if self.config.match_case {
74 expected == actual
75 } else {
76 expected.to_lowercase() == actual.to_lowercase()
77 }
78 }
79
80 fn extract_headings(&self, ctx: &crate::lint_context::LintContext) -> Vec<String> {
81 let mut result = Vec::new();
82
83 for line_info in &ctx.lines {
84 if let Some(heading) = &line_info.heading {
85 let full_heading = format!("{} {}", heading.marker, heading.text.trim());
87 result.push(full_heading);
88 }
89 }
90
91 result
92 }
93
94 fn is_heading(&self, line_index: usize, ctx: &crate::lint_context::LintContext) -> bool {
95 if line_index < ctx.lines.len() {
96 ctx.lines[line_index].heading.is_some()
97 } else {
98 false
99 }
100 }
101}
102
103impl Rule for MD043RequiredHeadings {
104 fn name(&self) -> &'static str {
105 "MD043"
106 }
107
108 fn description(&self) -> &'static str {
109 "Required heading structure"
110 }
111
112 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
113 let mut warnings = Vec::new();
114 let actual_headings = self.extract_headings(ctx);
115
116 if self.config.headings.is_empty() {
118 return Ok(warnings);
119 }
120
121 let headings_match = if actual_headings.len() != self.config.headings.len() {
123 false
124 } else {
125 actual_headings
126 .iter()
127 .zip(self.config.headings.iter())
128 .all(|(actual, expected)| self.headings_match(expected, actual))
129 };
130
131 if !headings_match {
132 if actual_headings.is_empty() && !self.config.headings.is_empty() {
134 warnings.push(LintWarning {
135 rule_name: Some(self.name()),
136 line: 1,
137 column: 1,
138 end_line: 1,
139 end_column: 2,
140 message: format!("Required headings not found: {:?}", self.config.headings),
141 severity: Severity::Warning,
142 fix: None, });
144 return Ok(warnings);
145 }
146
147 for (i, line_info) in ctx.lines.iter().enumerate() {
149 if self.is_heading(i, ctx) {
150 let (start_line, start_col, end_line, end_col) = calculate_heading_range(i + 1, &line_info.content);
152
153 warnings.push(LintWarning {
154 rule_name: Some(self.name()),
155 line: start_line,
156 column: start_col,
157 end_line,
158 end_column: end_col,
159 message: "Heading structure does not match the required structure".to_string(),
160 severity: Severity::Warning,
161 fix: None, });
163 }
164 }
165
166 if warnings.is_empty() {
169 warnings.push(LintWarning {
170 rule_name: Some(self.name()),
171 line: 1,
172 column: 1,
173 end_line: 1,
174 end_column: 2,
175 message: format!(
176 "Heading structure does not match required structure. Expected: {:?}, Found: {:?}",
177 self.config.headings, actual_headings
178 ),
179 severity: Severity::Warning,
180 fix: None, });
182 }
183 }
184
185 Ok(warnings)
186 }
187
188 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
189 let content = ctx.content;
190 if self.config.headings.is_empty() {
192 return Ok(content.to_string());
193 }
194
195 let actual_headings = self.extract_headings(ctx);
196
197 if actual_headings.len() == self.config.headings.len()
199 && actual_headings
200 .iter()
201 .zip(self.config.headings.iter())
202 .all(|(actual, expected)| self.headings_match(expected, actual))
203 {
204 return Ok(content.to_string());
205 }
206
207 Ok(content.to_string())
221 }
222
223 fn category(&self) -> RuleCategory {
225 RuleCategory::Heading
226 }
227
228 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
230 if self.config.headings.is_empty() || ctx.content.is_empty() {
232 return true;
233 }
234
235 let has_heading = ctx.lines.iter().any(|line| line.heading.is_some());
237
238 !has_heading
239 }
240
241 fn as_any(&self) -> &dyn std::any::Any {
242 self
243 }
244
245 fn default_config_section(&self) -> Option<(String, toml::Value)> {
246 let default_config = MD043Config::default();
247 let json_value = serde_json::to_value(&default_config).ok()?;
248 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
249 if let toml::Value::Table(table) = toml_value {
250 if !table.is_empty() {
251 Some((MD043Config::RULE_NAME.to_string(), toml::Value::Table(table)))
252 } else {
253 None
254 }
255 } else {
256 None
257 }
258 }
259
260 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
261 where
262 Self: Sized,
263 {
264 let rule_config = crate::rule_config_serde::load_rule_config::<MD043Config>(config);
265 Box::new(MD043RequiredHeadings::from_config_struct(rule_config))
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use crate::lint_context::LintContext;
273
274 #[test]
275 fn test_extract_headings_code_blocks() {
276 let required = vec!["# Test Document".to_string(), "## Real heading 2".to_string()];
278 let rule = MD043RequiredHeadings::new(required);
279
280 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.";
282 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
283 let actual_headings = rule.extract_headings(&ctx);
284 assert_eq!(
285 actual_headings,
286 vec!["# Test Document".to_string(), "## Real heading 2".to_string()],
287 "Should extract correct headings and ignore code blocks"
288 );
289
290 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.";
292 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
293 let actual_headings = rule.extract_headings(&ctx);
294 assert_eq!(
295 actual_headings,
296 vec!["# Test Document".to_string(), "## Not Real heading 2".to_string()],
297 "Should extract actual headings including mismatched ones"
298 );
299 }
300
301 #[test]
302 fn test_with_document_structure() {
303 let required = vec![
305 "# Introduction".to_string(),
306 "# Method".to_string(),
307 "# Results".to_string(),
308 ];
309 let rule = MD043RequiredHeadings::new(required);
310
311 let content = "# Introduction\n\nContent\n\n# Method\n\nMore content\n\n# Results\n\nFinal content";
313 let warnings = rule
314 .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
315 .unwrap();
316 assert!(warnings.is_empty(), "Expected no warnings for matching headings");
317
318 let content = "# Introduction\n\nContent\n\n# Results\n\nSkipped method";
320 let warnings = rule
321 .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
322 .unwrap();
323 assert!(!warnings.is_empty(), "Expected warnings for mismatched headings");
324
325 let content = "No headings here, just plain text";
327 let warnings = rule
328 .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
329 .unwrap();
330 assert!(!warnings.is_empty(), "Expected warnings when headings are missing");
331
332 let required_setext = vec![
334 "=========== Introduction".to_string(),
335 "------ Method".to_string(),
336 "======= Results".to_string(),
337 ];
338 let rule_setext = MD043RequiredHeadings::new(required_setext);
339 let content = "Introduction\n===========\n\nContent\n\nMethod\n------\n\nMore content\n\nResults\n=======\n\nFinal content";
340 let warnings = rule_setext
341 .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
342 .unwrap();
343 assert!(warnings.is_empty(), "Expected no warnings for matching setext headings");
344 }
345
346 #[test]
347 fn test_should_skip_no_false_positives() {
348 let required = vec!["Test".to_string()];
350 let rule = MD043RequiredHeadings::new(required);
351
352 let content = "This paragraph contains a # character but is not a heading";
354 assert!(
355 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
356 "Should skip content with # in normal text"
357 );
358
359 let content = "Regular paragraph\n\n```markdown\n# This is not a real heading\n```\n\nMore text";
361 assert!(
362 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
363 "Should skip content with heading-like syntax in code blocks"
364 );
365
366 let content = "Some text\n\n- List item 1\n- List item 2\n\nMore text";
368 assert!(
369 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
370 "Should skip content with list items using dash"
371 );
372
373 let content = "Some text\n\n---\n\nMore text below the horizontal rule";
375 assert!(
376 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
377 "Should skip content with horizontal rule"
378 );
379
380 let content = "This is a normal paragraph with equals sign x = y + z";
382 assert!(
383 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
384 "Should skip content with equals sign in normal text"
385 );
386
387 let content = "This is a normal paragraph with minus sign x - y = z";
389 assert!(
390 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
391 "Should skip content with minus sign in normal text"
392 );
393 }
394
395 #[test]
396 fn test_should_skip_heading_detection() {
397 let required = vec!["Test".to_string()];
399 let rule = MD043RequiredHeadings::new(required);
400
401 let content = "# This is a heading\n\nAnd some content";
403 assert!(
404 !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
405 "Should not skip content with ATX heading"
406 );
407
408 let content = "This is a heading\n================\n\nAnd some content";
410 assert!(
411 !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
412 "Should not skip content with Setext heading (=)"
413 );
414
415 let content = "This is a subheading\n------------------\n\nAnd some content";
417 assert!(
418 !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
419 "Should not skip content with Setext heading (-)"
420 );
421
422 let content = "## This is a heading ##\n\nAnd some content";
424 assert!(
425 !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
426 "Should not skip content with ATX heading with closing hashes"
427 );
428 }
429
430 #[test]
431 fn test_config_match_case_sensitive() {
432 let config = MD043Config {
433 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
434 match_case: true,
435 };
436 let rule = MD043RequiredHeadings::from_config_struct(config);
437
438 let content = "# introduction\n\n# method";
440 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
441 let result = rule.check(&ctx).unwrap();
442
443 assert!(
444 !result.is_empty(),
445 "Should detect case mismatch when match_case is true"
446 );
447 }
448
449 #[test]
450 fn test_config_match_case_insensitive() {
451 let config = MD043Config {
452 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
453 match_case: false,
454 };
455 let rule = MD043RequiredHeadings::from_config_struct(config);
456
457 let content = "# introduction\n\n# method";
459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
460 let result = rule.check(&ctx).unwrap();
461
462 assert!(result.is_empty(), "Should allow case mismatch when match_case is false");
463 }
464
465 #[test]
466 fn test_config_case_insensitive_mixed() {
467 let config = MD043Config {
468 headings: vec!["# Introduction".to_string(), "# METHOD".to_string()],
469 match_case: false,
470 };
471 let rule = MD043RequiredHeadings::from_config_struct(config);
472
473 let content = "# INTRODUCTION\n\n# method";
475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
476 let result = rule.check(&ctx).unwrap();
477
478 assert!(
479 result.is_empty(),
480 "Should allow mixed case variations when match_case is false"
481 );
482 }
483
484 #[test]
485 fn test_config_case_sensitive_exact_match() {
486 let config = MD043Config {
487 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
488 match_case: true,
489 };
490 let rule = MD043RequiredHeadings::from_config_struct(config);
491
492 let content = "# Introduction\n\n# Method";
494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
495 let result = rule.check(&ctx).unwrap();
496
497 assert!(
498 result.is_empty(),
499 "Should pass with exact case match when match_case is true"
500 );
501 }
502
503 #[test]
504 fn test_default_config() {
505 let rule = MD043RequiredHeadings::default();
506
507 let content = "# Any heading\n\n# Another heading";
509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
510 let result = rule.check(&ctx).unwrap();
511
512 assert!(result.is_empty(), "Should be disabled with default empty headings");
513 }
514
515 #[test]
516 fn test_default_config_section() {
517 let rule = MD043RequiredHeadings::default();
518 let config_section = rule.default_config_section();
519
520 assert!(config_section.is_some());
521 let (name, value) = config_section.unwrap();
522 assert_eq!(name, "MD043");
523
524 if let toml::Value::Table(table) = value {
526 assert!(table.contains_key("headings"));
527 assert!(table.contains_key("match-case"));
528 assert_eq!(table["headings"], toml::Value::Array(vec![]));
529 assert_eq!(table["match-case"], toml::Value::Boolean(false));
530 } else {
531 panic!("Expected TOML table");
532 }
533 }
534
535 #[test]
536 fn test_headings_match_case_sensitive() {
537 let config = MD043Config {
538 headings: vec![],
539 match_case: true,
540 };
541 let rule = MD043RequiredHeadings::from_config_struct(config);
542
543 assert!(rule.headings_match("Test", "Test"));
544 assert!(!rule.headings_match("Test", "test"));
545 assert!(!rule.headings_match("test", "Test"));
546 }
547
548 #[test]
549 fn test_headings_match_case_insensitive() {
550 let config = MD043Config {
551 headings: vec![],
552 match_case: false,
553 };
554 let rule = MD043RequiredHeadings::from_config_struct(config);
555
556 assert!(rule.headings_match("Test", "Test"));
557 assert!(rule.headings_match("Test", "test"));
558 assert!(rule.headings_match("test", "Test"));
559 assert!(rule.headings_match("TEST", "test"));
560 }
561
562 #[test]
563 fn test_config_empty_headings() {
564 let config = MD043Config {
565 headings: vec![],
566 match_case: true,
567 };
568 let rule = MD043RequiredHeadings::from_config_struct(config);
569
570 let content = "# Any heading\n\n# Another heading";
572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
573 let result = rule.check(&ctx).unwrap();
574
575 assert!(result.is_empty(), "Should be disabled with empty headings list");
576 }
577
578 #[test]
579 fn test_fix_respects_configuration() {
580 let config = MD043Config {
581 headings: vec!["# Title".to_string(), "# Content".to_string()],
582 match_case: false,
583 };
584 let rule = MD043RequiredHeadings::from_config_struct(config);
585
586 let content = "Wrong content";
587 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
588 let fixed = rule.fix(&ctx).unwrap();
589
590 let expected = "Wrong content";
592 assert_eq!(fixed, expected);
593 }
594}