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 is_heading(&self, line_index: usize, ctx: &crate::lint_context::LintContext) -> bool {
86 if line_index < ctx.lines.len() {
87 ctx.lines[line_index].heading.is_some()
88 } else {
89 false
90 }
91 }
92}
93
94impl Rule for MD043RequiredHeadings {
95 fn name(&self) -> &'static str {
96 "MD043"
97 }
98
99 fn description(&self) -> &'static str {
100 "Required heading structure"
101 }
102
103 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
104 let mut warnings = Vec::new();
105 let actual_headings = self.extract_headings(ctx);
106
107 if self.config.headings.is_empty() {
109 return Ok(warnings);
110 }
111
112 let headings_match = if actual_headings.len() != self.config.headings.len() {
114 false
115 } else {
116 actual_headings
117 .iter()
118 .zip(self.config.headings.iter())
119 .all(|(actual, expected)| self.headings_match(expected, actual))
120 };
121
122 if !headings_match {
123 if actual_headings.is_empty() && !self.config.headings.is_empty() {
125 warnings.push(LintWarning {
126 rule_name: Some(self.name().to_string()),
127 line: 1,
128 column: 1,
129 end_line: 1,
130 end_column: 2,
131 message: format!("Required headings not found: {:?}", self.config.headings),
132 severity: Severity::Warning,
133 fix: None, });
135 return Ok(warnings);
136 }
137
138 for (i, line_info) in ctx.lines.iter().enumerate() {
140 if self.is_heading(i, ctx) {
141 let (start_line, start_col, end_line, end_col) = calculate_heading_range(i + 1, &line_info.content);
143
144 warnings.push(LintWarning {
145 rule_name: Some(self.name().to_string()),
146 line: start_line,
147 column: start_col,
148 end_line,
149 end_column: end_col,
150 message: "Heading structure does not match the required structure".to_string(),
151 severity: Severity::Warning,
152 fix: None, });
154 }
155 }
156
157 if warnings.is_empty() {
160 warnings.push(LintWarning {
161 rule_name: Some(self.name().to_string()),
162 line: 1,
163 column: 1,
164 end_line: 1,
165 end_column: 2,
166 message: format!(
167 "Heading structure does not match required structure. Expected: {:?}, Found: {:?}",
168 self.config.headings, actual_headings
169 ),
170 severity: Severity::Warning,
171 fix: None, });
173 }
174 }
175
176 Ok(warnings)
177 }
178
179 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
180 let content = ctx.content;
181 if self.config.headings.is_empty() {
183 return Ok(content.to_string());
184 }
185
186 let actual_headings = self.extract_headings(ctx);
187
188 if actual_headings.len() == self.config.headings.len()
190 && actual_headings
191 .iter()
192 .zip(self.config.headings.iter())
193 .all(|(actual, expected)| self.headings_match(expected, actual))
194 {
195 return Ok(content.to_string());
196 }
197
198 Ok(content.to_string())
212 }
213
214 fn category(&self) -> RuleCategory {
216 RuleCategory::Heading
217 }
218
219 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
221 if self.config.headings.is_empty() || ctx.content.is_empty() {
223 return true;
224 }
225
226 let has_heading = ctx.lines.iter().any(|line| line.heading.is_some());
228
229 !has_heading
230 }
231
232 fn as_any(&self) -> &dyn std::any::Any {
233 self
234 }
235
236 fn default_config_section(&self) -> Option<(String, toml::Value)> {
237 let default_config = MD043Config::default();
238 let json_value = serde_json::to_value(&default_config).ok()?;
239 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
240 if let toml::Value::Table(table) = toml_value {
241 if !table.is_empty() {
242 Some((MD043Config::RULE_NAME.to_string(), toml::Value::Table(table)))
243 } else {
244 None
245 }
246 } else {
247 None
248 }
249 }
250
251 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
252 where
253 Self: Sized,
254 {
255 let rule_config = crate::rule_config_serde::load_rule_config::<MD043Config>(config);
256 Box::new(MD043RequiredHeadings::from_config_struct(rule_config))
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use crate::lint_context::LintContext;
264
265 #[test]
266 fn test_extract_headings_code_blocks() {
267 let required = vec!["# Test Document".to_string(), "## Real heading 2".to_string()];
269 let rule = MD043RequiredHeadings::new(required);
270
271 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.";
273 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
274 let actual_headings = rule.extract_headings(&ctx);
275 assert_eq!(
276 actual_headings,
277 vec!["# Test Document".to_string(), "## Real heading 2".to_string()],
278 "Should extract correct headings and ignore code blocks"
279 );
280
281 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.";
283 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
284 let actual_headings = rule.extract_headings(&ctx);
285 assert_eq!(
286 actual_headings,
287 vec!["# Test Document".to_string(), "## Not Real heading 2".to_string()],
288 "Should extract actual headings including mismatched ones"
289 );
290 }
291
292 #[test]
293 fn test_with_document_structure() {
294 let required = vec![
296 "# Introduction".to_string(),
297 "# Method".to_string(),
298 "# Results".to_string(),
299 ];
300 let rule = MD043RequiredHeadings::new(required);
301
302 let content = "# Introduction\n\nContent\n\n# Method\n\nMore content\n\n# Results\n\nFinal content";
304 let warnings = rule
305 .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
306 .unwrap();
307 assert!(warnings.is_empty(), "Expected no warnings for matching headings");
308
309 let content = "# Introduction\n\nContent\n\n# Results\n\nSkipped method";
311 let warnings = rule
312 .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
313 .unwrap();
314 assert!(!warnings.is_empty(), "Expected warnings for mismatched headings");
315
316 let content = "No headings here, just plain text";
318 let warnings = rule
319 .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
320 .unwrap();
321 assert!(!warnings.is_empty(), "Expected warnings when headings are missing");
322
323 let required_setext = vec![
325 "=========== Introduction".to_string(),
326 "------ Method".to_string(),
327 "======= Results".to_string(),
328 ];
329 let rule_setext = MD043RequiredHeadings::new(required_setext);
330 let content = "Introduction\n===========\n\nContent\n\nMethod\n------\n\nMore content\n\nResults\n=======\n\nFinal content";
331 let warnings = rule_setext
332 .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
333 .unwrap();
334 assert!(warnings.is_empty(), "Expected no warnings for matching setext headings");
335 }
336
337 #[test]
338 fn test_should_skip_no_false_positives() {
339 let required = vec!["Test".to_string()];
341 let rule = MD043RequiredHeadings::new(required);
342
343 let content = "This paragraph contains a # character but is not a heading";
345 assert!(
346 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
347 "Should skip content with # in normal text"
348 );
349
350 let content = "Regular paragraph\n\n```markdown\n# This is not a real heading\n```\n\nMore text";
352 assert!(
353 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
354 "Should skip content with heading-like syntax in code blocks"
355 );
356
357 let content = "Some text\n\n- List item 1\n- List item 2\n\nMore text";
359 assert!(
360 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
361 "Should skip content with list items using dash"
362 );
363
364 let content = "Some text\n\n---\n\nMore text below the horizontal rule";
366 assert!(
367 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
368 "Should skip content with horizontal rule"
369 );
370
371 let content = "This is a normal paragraph with equals sign x = y + z";
373 assert!(
374 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
375 "Should skip content with equals sign in normal text"
376 );
377
378 let content = "This is a normal paragraph with minus sign x - y = z";
380 assert!(
381 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
382 "Should skip content with minus sign in normal text"
383 );
384 }
385
386 #[test]
387 fn test_should_skip_heading_detection() {
388 let required = vec!["Test".to_string()];
390 let rule = MD043RequiredHeadings::new(required);
391
392 let content = "# This is a heading\n\nAnd some content";
394 assert!(
395 !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
396 "Should not skip content with ATX heading"
397 );
398
399 let content = "This is a heading\n================\n\nAnd some content";
401 assert!(
402 !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
403 "Should not skip content with Setext heading (=)"
404 );
405
406 let content = "This is a subheading\n------------------\n\nAnd some content";
408 assert!(
409 !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
410 "Should not skip content with Setext heading (-)"
411 );
412
413 let content = "## This is a heading ##\n\nAnd some content";
415 assert!(
416 !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
417 "Should not skip content with ATX heading with closing hashes"
418 );
419 }
420
421 #[test]
422 fn test_config_match_case_sensitive() {
423 let config = MD043Config {
424 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
425 match_case: true,
426 };
427 let rule = MD043RequiredHeadings::from_config_struct(config);
428
429 let content = "# introduction\n\n# method";
431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
432 let result = rule.check(&ctx).unwrap();
433
434 assert!(
435 !result.is_empty(),
436 "Should detect case mismatch when match_case is true"
437 );
438 }
439
440 #[test]
441 fn test_config_match_case_insensitive() {
442 let config = MD043Config {
443 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
444 match_case: false,
445 };
446 let rule = MD043RequiredHeadings::from_config_struct(config);
447
448 let content = "# introduction\n\n# method";
450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
451 let result = rule.check(&ctx).unwrap();
452
453 assert!(result.is_empty(), "Should allow case mismatch when match_case is false");
454 }
455
456 #[test]
457 fn test_config_case_insensitive_mixed() {
458 let config = MD043Config {
459 headings: vec!["# Introduction".to_string(), "# METHOD".to_string()],
460 match_case: false,
461 };
462 let rule = MD043RequiredHeadings::from_config_struct(config);
463
464 let content = "# INTRODUCTION\n\n# method";
466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
467 let result = rule.check(&ctx).unwrap();
468
469 assert!(
470 result.is_empty(),
471 "Should allow mixed case variations when match_case is false"
472 );
473 }
474
475 #[test]
476 fn test_config_case_sensitive_exact_match() {
477 let config = MD043Config {
478 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
479 match_case: true,
480 };
481 let rule = MD043RequiredHeadings::from_config_struct(config);
482
483 let content = "# Introduction\n\n# Method";
485 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
486 let result = rule.check(&ctx).unwrap();
487
488 assert!(
489 result.is_empty(),
490 "Should pass with exact case match when match_case is true"
491 );
492 }
493
494 #[test]
495 fn test_default_config() {
496 let rule = MD043RequiredHeadings::default();
497
498 let content = "# Any heading\n\n# Another heading";
500 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
501 let result = rule.check(&ctx).unwrap();
502
503 assert!(result.is_empty(), "Should be disabled with default empty headings");
504 }
505
506 #[test]
507 fn test_default_config_section() {
508 let rule = MD043RequiredHeadings::default();
509 let config_section = rule.default_config_section();
510
511 assert!(config_section.is_some());
512 let (name, value) = config_section.unwrap();
513 assert_eq!(name, "MD043");
514
515 if let toml::Value::Table(table) = value {
517 assert!(table.contains_key("headings"));
518 assert!(table.contains_key("match-case"));
519 assert_eq!(table["headings"], toml::Value::Array(vec![]));
520 assert_eq!(table["match-case"], toml::Value::Boolean(false));
521 } else {
522 panic!("Expected TOML table");
523 }
524 }
525
526 #[test]
527 fn test_headings_match_case_sensitive() {
528 let config = MD043Config {
529 headings: vec![],
530 match_case: true,
531 };
532 let rule = MD043RequiredHeadings::from_config_struct(config);
533
534 assert!(rule.headings_match("Test", "Test"));
535 assert!(!rule.headings_match("Test", "test"));
536 assert!(!rule.headings_match("test", "Test"));
537 }
538
539 #[test]
540 fn test_headings_match_case_insensitive() {
541 let config = MD043Config {
542 headings: vec![],
543 match_case: false,
544 };
545 let rule = MD043RequiredHeadings::from_config_struct(config);
546
547 assert!(rule.headings_match("Test", "Test"));
548 assert!(rule.headings_match("Test", "test"));
549 assert!(rule.headings_match("test", "Test"));
550 assert!(rule.headings_match("TEST", "test"));
551 }
552
553 #[test]
554 fn test_config_empty_headings() {
555 let config = MD043Config {
556 headings: vec![],
557 match_case: true,
558 };
559 let rule = MD043RequiredHeadings::from_config_struct(config);
560
561 let content = "# Any heading\n\n# Another heading";
563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
564 let result = rule.check(&ctx).unwrap();
565
566 assert!(result.is_empty(), "Should be disabled with empty headings list");
567 }
568
569 #[test]
570 fn test_fix_respects_configuration() {
571 let config = MD043Config {
572 headings: vec!["# Title".to_string(), "# Content".to_string()],
573 match_case: false,
574 };
575 let rule = MD043RequiredHeadings::from_config_struct(config);
576
577 let content = "Wrong content";
578 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
579 let fixed = rule.fix(&ctx).unwrap();
580
581 let expected = "Wrong content";
583 assert_eq!(fixed, expected);
584 }
585}