1use toml;
2
3use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
4use crate::rule_config_serde::RuleConfig;
5use crate::utils::range_utils::calculate_match_range;
6use std::collections::{HashMap, HashSet};
7
8mod md024_config;
9use md024_config::MD024Config;
10
11#[derive(Clone, Debug, Default)]
12pub struct MD024NoDuplicateHeading {
13 config: MD024Config,
14}
15
16impl MD024NoDuplicateHeading {
17 pub fn new(allow_different_nesting: bool, siblings_only: bool) -> Self {
18 Self {
19 config: MD024Config {
20 allow_different_nesting,
21 siblings_only,
22 },
23 }
24 }
25
26 pub fn from_config_struct(config: MD024Config) -> Self {
27 Self { config }
28 }
29}
30
31impl Rule for MD024NoDuplicateHeading {
32 fn name(&self) -> &'static str {
33 "MD024"
34 }
35
36 fn description(&self) -> &'static str {
37 "Multiple headings with the same content"
38 }
39
40 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
41 if ctx.lines.is_empty() {
43 return Ok(Vec::new());
44 }
45
46 let mut warnings = Vec::new();
47 let mut seen_headings: HashSet<String> = HashSet::new();
48 let mut seen_headings_per_level: HashMap<u8, HashSet<String>> = HashMap::new();
49
50 let mut current_section_path: Vec<(u8, String)> = Vec::new(); let mut seen_siblings: HashMap<String, HashSet<String>> = HashMap::new(); for (line_num, line_info) in ctx.lines.iter().enumerate() {
56 if let Some(heading) = &line_info.heading {
57 if heading.text.is_empty() {
59 continue;
60 }
61
62 let heading_key = heading.text.clone();
63 let level = heading.level;
64
65 let text_start_in_line = if let Some(pos) = line_info.content.find(&heading.text) {
67 pos
68 } else {
69 let trimmed = line_info.content.trim_start();
71 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
72 let after_hashes = &trimmed[hash_count..];
73 let text_start_in_trimmed = after_hashes.find(&heading.text).unwrap_or(0);
74 (line_info.content.len() - trimmed.len()) + hash_count + text_start_in_trimmed
75 };
76
77 let (start_line, start_col, end_line, end_col) =
78 calculate_match_range(line_num + 1, &line_info.content, text_start_in_line, heading.text.len());
79
80 if self.config.siblings_only {
81 while !current_section_path.is_empty() && current_section_path.last().unwrap().0 >= level {
83 current_section_path.pop();
84 }
85
86 let parent_path = current_section_path
88 .iter()
89 .map(|(_, text)| text.as_str())
90 .collect::<Vec<_>>()
91 .join("/");
92
93 let siblings = seen_siblings.entry(parent_path.clone()).or_default();
95 if siblings.contains(&heading_key) {
96 warnings.push(LintWarning {
97 rule_name: Some(self.name()),
98 message: format!("Duplicate heading: '{}'.", heading.text),
99 line: start_line,
100 column: start_col,
101 end_line,
102 end_column: end_col,
103 severity: Severity::Warning,
104 fix: None,
105 });
106 } else {
107 siblings.insert(heading_key.clone());
108 }
109
110 current_section_path.push((level, heading_key.clone()));
112 } else if self.config.allow_different_nesting {
113 let seen = seen_headings_per_level.entry(level).or_default();
115 if seen.contains(&heading_key) {
116 warnings.push(LintWarning {
117 rule_name: Some(self.name()),
118 message: format!("Duplicate heading: '{}'.", heading.text),
119 line: start_line,
120 column: start_col,
121 end_line,
122 end_column: end_col,
123 severity: Severity::Warning,
124 fix: None,
125 });
126 } else {
127 seen.insert(heading_key.clone());
128 }
129 } else {
130 if seen_headings.contains(&heading_key) {
132 warnings.push(LintWarning {
133 rule_name: Some(self.name()),
134 message: format!("Duplicate heading: '{}'.", heading.text),
135 line: start_line,
136 column: start_col,
137 end_line,
138 end_column: end_col,
139 severity: Severity::Warning,
140 fix: None,
141 });
142 } else {
143 seen_headings.insert(heading_key.clone());
144 }
145 }
146 }
147 }
148
149 Ok(warnings)
150 }
151
152 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
153 Ok(ctx.content.to_string())
155 }
156
157 fn category(&self) -> RuleCategory {
159 RuleCategory::Heading
160 }
161
162 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
164 ctx.lines.iter().all(|line| line.heading.is_none())
165 }
166
167 fn as_any(&self) -> &dyn std::any::Any {
168 self
169 }
170
171 fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
172 None
173 }
174
175 fn default_config_section(&self) -> Option<(String, toml::Value)> {
176 let default_config = MD024Config::default();
177 let json_value = serde_json::to_value(&default_config).ok()?;
178 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
179
180 if let toml::Value::Table(table) = toml_value {
181 if !table.is_empty() {
182 Some((MD024Config::RULE_NAME.to_string(), toml::Value::Table(table)))
183 } else {
184 None
185 }
186 } else {
187 None
188 }
189 }
190
191 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
192 where
193 Self: Sized,
194 {
195 let rule_config = crate::rule_config_serde::load_rule_config::<MD024Config>(config);
196 Box::new(Self::from_config_struct(rule_config))
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use crate::lint_context::LintContext;
204
205 fn run_test(content: &str, config: MD024Config) -> LintResult {
206 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
207 let rule = MD024NoDuplicateHeading::from_config_struct(config);
208 rule.check(&ctx)
209 }
210
211 fn run_fix_test(content: &str, config: MD024Config) -> Result<String, LintError> {
212 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
213 let rule = MD024NoDuplicateHeading::from_config_struct(config);
214 rule.fix(&ctx)
215 }
216
217 #[test]
218 fn test_no_duplicate_headings() {
219 let content = r#"# First Heading
220
221Some content here.
222
223## Second Heading
224
225More content.
226
227### Third Heading
228
229Even more content.
230
231## Fourth Heading
232
233Final content."#;
234
235 let config = MD024Config::default();
236 let result = run_test(content, config);
237 assert!(result.is_ok());
238 let warnings = result.unwrap();
239 assert_eq!(warnings.len(), 0);
240 }
241
242 #[test]
243 fn test_duplicate_headings_same_level() {
244 let content = r#"# First Heading
245
246Some content here.
247
248## Second Heading
249
250More content.
251
252## Second Heading
253
254This is a duplicate."#;
255
256 let config = MD024Config::default();
257 let result = run_test(content, config);
258 assert!(result.is_ok());
259 let warnings = result.unwrap();
260 assert_eq!(warnings.len(), 1);
261 assert_eq!(warnings[0].message, "Duplicate heading: 'Second Heading'.");
262 assert_eq!(warnings[0].line, 9);
263 }
264
265 #[test]
266 fn test_duplicate_headings_different_levels_default() {
267 let content = r#"# Main Title
268
269Some content.
270
271## Main Title
272
273This has the same text but different level."#;
274
275 let config = MD024Config::default();
276 let result = run_test(content, config);
277 assert!(result.is_ok());
278 let warnings = result.unwrap();
279 assert_eq!(warnings.len(), 1);
280 assert_eq!(warnings[0].message, "Duplicate heading: 'Main Title'.");
281 assert_eq!(warnings[0].line, 5);
282 }
283
284 #[test]
285 fn test_duplicate_headings_different_levels_allow_different_nesting() {
286 let content = r#"# Main Title
287
288Some content.
289
290## Main Title
291
292This has the same text but different level."#;
293
294 let config = MD024Config {
295 allow_different_nesting: true,
296 siblings_only: false,
297 };
298 let result = run_test(content, config);
299 assert!(result.is_ok());
300 let warnings = result.unwrap();
301 assert_eq!(warnings.len(), 0);
302 }
303
304 #[test]
305 fn test_case_sensitivity() {
306 let content = r#"# First Heading
307
308Some content.
309
310## first heading
311
312Different case.
313
314### FIRST HEADING
315
316All caps."#;
317
318 let config = MD024Config::default();
319 let result = run_test(content, config);
320 assert!(result.is_ok());
321 let warnings = result.unwrap();
322 assert_eq!(warnings.len(), 0);
324 }
325
326 #[test]
327 fn test_headings_with_trailing_punctuation() {
328 let content = r#"# First Heading!
329
330Some content.
331
332## First Heading!
333
334Same with punctuation.
335
336### First Heading
337
338Without punctuation."#;
339
340 let config = MD024Config::default();
341 let result = run_test(content, config);
342 assert!(result.is_ok());
343 let warnings = result.unwrap();
344 assert_eq!(warnings.len(), 1);
345 assert_eq!(warnings[0].message, "Duplicate heading: 'First Heading!'.");
346 }
347
348 #[test]
349 fn test_headings_with_inline_formatting() {
350 let content = r#"# **Bold Heading**
351
352Some content.
353
354## *Italic Heading*
355
356More content.
357
358### **Bold Heading**
359
360Duplicate with same formatting.
361
362#### `Code Heading`
363
364Code formatted.
365
366##### `Code Heading`
367
368Duplicate code formatted."#;
369
370 let config = MD024Config::default();
371 let result = run_test(content, config);
372 assert!(result.is_ok());
373 let warnings = result.unwrap();
374 assert_eq!(warnings.len(), 2);
375 assert_eq!(warnings[0].message, "Duplicate heading: '**Bold Heading**'.");
376 assert_eq!(warnings[1].message, "Duplicate heading: '`Code Heading`'.");
377 }
378
379 #[test]
380 fn test_headings_in_different_sections() {
381 let content = r#"# Section One
382
383## Subsection
384
385Some content.
386
387# Section Two
388
389## Subsection
390
391Same subsection name in different section."#;
392
393 let config = MD024Config::default();
394 let result = run_test(content, config);
395 assert!(result.is_ok());
396 let warnings = result.unwrap();
397 assert_eq!(warnings.len(), 1);
398 assert_eq!(warnings[0].message, "Duplicate heading: 'Subsection'.");
399 assert_eq!(warnings[0].line, 9);
400 }
401
402 #[test]
403 fn test_multiple_duplicates() {
404 let content = r#"# Title
405
406## Subtitle
407
408### Title
409
410#### Subtitle
411
412## Title
413
414### Subtitle"#;
415
416 let config = MD024Config::default();
417 let result = run_test(content, config);
418 assert!(result.is_ok());
419 let warnings = result.unwrap();
420 assert_eq!(warnings.len(), 4);
421 assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
423 assert_eq!(warnings[0].line, 5);
424 assert_eq!(warnings[1].message, "Duplicate heading: 'Subtitle'.");
426 assert_eq!(warnings[1].line, 7);
427 assert_eq!(warnings[2].message, "Duplicate heading: 'Title'.");
429 assert_eq!(warnings[2].line, 9);
430 assert_eq!(warnings[3].message, "Duplicate heading: 'Subtitle'.");
432 assert_eq!(warnings[3].line, 11);
433 }
434
435 #[test]
436 fn test_empty_headings() {
437 let content = r#"#
438
439Some content.
440
441##
442
443More content.
444
445### Non-empty
446
447####
448
449Another empty."#;
450
451 let config = MD024Config::default();
452 let result = run_test(content, config);
453 assert!(result.is_ok());
454 let warnings = result.unwrap();
455 assert_eq!(warnings.len(), 0);
457 }
458
459 #[test]
460 fn test_unicode_and_special_characters() {
461 let content = r#"# 你好世界
462
463Some content.
464
465## Émojis 🎉🎊
466
467More content.
468
469### 你好世界
470
471Duplicate Chinese.
472
473#### Émojis 🎉🎊
474
475Duplicate emojis.
476
477##### Special <chars> & symbols!
478
479###### Special <chars> & symbols!
480
481Duplicate special chars."#;
482
483 let config = MD024Config::default();
484 let result = run_test(content, config);
485 assert!(result.is_ok());
486 let warnings = result.unwrap();
487 assert_eq!(warnings.len(), 3);
488 assert_eq!(warnings[0].message, "Duplicate heading: '你好世界'.");
489 assert_eq!(warnings[1].message, "Duplicate heading: 'Émojis 🎉🎊'.");
490 assert_eq!(warnings[2].message, "Duplicate heading: 'Special <chars> & symbols!'.");
491 }
492
493 #[test]
494 fn test_allow_different_nesting_with_same_level_duplicates() {
495 let content = r#"# Section One
496
497## Title
498
499### Subsection
500
501## Title
502
503This is a duplicate at the same level.
504
505# Section Two
506
507## Title
508
509Different section, but still a duplicate when allow_different_nesting is true."#;
510
511 let config = MD024Config {
512 allow_different_nesting: true,
513 siblings_only: false,
514 };
515 let result = run_test(content, config);
516 assert!(result.is_ok());
517 let warnings = result.unwrap();
518 assert_eq!(warnings.len(), 2);
519 assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
520 assert_eq!(warnings[0].line, 7);
521 assert_eq!(warnings[1].message, "Duplicate heading: 'Title'.");
522 assert_eq!(warnings[1].line, 13);
523 }
524
525 #[test]
526 fn test_atx_style_headings_with_closing_hashes() {
527 let content = r#"# Heading One #
528
529Some content.
530
531## Heading Two ##
532
533More content.
534
535### Heading One ###
536
537Duplicate with different style."#;
538
539 let config = MD024Config::default();
540 let result = run_test(content, config);
541 assert!(result.is_ok());
542 let warnings = result.unwrap();
543 assert_eq!(warnings.len(), 1);
545 assert_eq!(warnings[0].message, "Duplicate heading: 'Heading One'.");
546 assert_eq!(warnings[0].line, 9);
547 }
548
549 #[test]
550 fn test_fix_method_returns_unchanged() {
551 let content = r#"# Duplicate
552
553## Duplicate
554
555This has duplicates."#;
556
557 let config = MD024Config::default();
558 let result = run_fix_test(content, config);
559 assert!(result.is_ok());
560 assert_eq!(result.unwrap(), content);
561 }
562
563 #[test]
564 fn test_empty_content() {
565 let content = "";
566 let config = MD024Config::default();
567 let result = run_test(content, config);
568 assert!(result.is_ok());
569 let warnings = result.unwrap();
570 assert_eq!(warnings.len(), 0);
571 }
572
573 #[test]
574 fn test_no_headings() {
575 let content = r#"This is just regular text.
576
577No headings anywhere.
578
579Just paragraphs."#;
580
581 let config = MD024Config::default();
582 let result = run_test(content, config);
583 assert!(result.is_ok());
584 let warnings = result.unwrap();
585 assert_eq!(warnings.len(), 0);
586 }
587
588 #[test]
589 fn test_whitespace_differences() {
590 let content = r#"# Heading with spaces
591
592Some content.
593
594## Heading with spaces
595
596Different amount of spaces.
597
598### Heading with spaces
599
600Exact match."#;
601
602 let config = MD024Config::default();
603 let result = run_test(content, config);
604 assert!(result.is_ok());
605 let warnings = result.unwrap();
606 assert_eq!(warnings.len(), 2);
608 assert_eq!(warnings[0].message, "Duplicate heading: 'Heading with spaces'.");
609 assert_eq!(warnings[0].line, 5);
610 assert_eq!(warnings[1].message, "Duplicate heading: 'Heading with spaces'.");
611 assert_eq!(warnings[1].line, 9);
612 }
613
614 #[test]
615 fn test_column_positions() {
616 let content = r#"# First
617
618## Second
619
620### First"#;
621
622 let config = MD024Config::default();
623 let result = run_test(content, config);
624 assert!(result.is_ok());
625 let warnings = result.unwrap();
626 assert_eq!(warnings.len(), 1);
627 assert_eq!(warnings[0].line, 5);
628 assert_eq!(warnings[0].column, 5); assert_eq!(warnings[0].end_line, 5);
630 assert_eq!(warnings[0].end_column, 10); }
632
633 #[test]
634 fn test_complex_nesting_scenario() {
635 let content = r#"# Main Document
636
637## Introduction
638
639### Overview
640
641## Implementation
642
643### Overview
644
645This Overview is in a different section.
646
647## Conclusion
648
649### Overview
650
651Another Overview in yet another section."#;
652
653 let config = MD024Config {
654 allow_different_nesting: true,
655 siblings_only: false,
656 };
657 let result = run_test(content, config);
658 assert!(result.is_ok());
659 let warnings = result.unwrap();
660 assert_eq!(warnings.len(), 2);
662 assert_eq!(warnings[0].message, "Duplicate heading: 'Overview'.");
663 assert_eq!(warnings[0].line, 9);
664 assert_eq!(warnings[1].message, "Duplicate heading: 'Overview'.");
665 assert_eq!(warnings[1].line, 15);
666 }
667
668 #[test]
669 fn test_setext_style_headings() {
670 let content = r#"Main Title
671==========
672
673Some content.
674
675Second Title
676------------
677
678More content.
679
680Main Title
681==========
682
683Duplicate setext."#;
684
685 let config = MD024Config::default();
686 let result = run_test(content, config);
687 assert!(result.is_ok());
688 let warnings = result.unwrap();
689 assert_eq!(warnings.len(), 1);
690 assert_eq!(warnings[0].message, "Duplicate heading: 'Main Title'.");
691 assert_eq!(warnings[0].line, 11);
692 }
693
694 #[test]
695 fn test_mixed_heading_styles() {
696 let content = r#"# ATX Title
697
698Some content.
699
700ATX Title
701=========
702
703Same text, different style."#;
704
705 let config = MD024Config::default();
706 let result = run_test(content, config);
707 assert!(result.is_ok());
708 let warnings = result.unwrap();
709 assert_eq!(warnings.len(), 1);
710 assert_eq!(warnings[0].message, "Duplicate heading: 'ATX Title'.");
711 assert_eq!(warnings[0].line, 5);
712 }
713
714 #[test]
715 fn test_heading_with_links() {
716 let content = r#"# [Link Text](http://example.com)
717
718Some content.
719
720## [Link Text](http://example.com)
721
722Duplicate heading with link.
723
724### [Different Link](http://example.com)
725
726Not a duplicate."#;
727
728 let config = MD024Config::default();
729 let result = run_test(content, config);
730 assert!(result.is_ok());
731 let warnings = result.unwrap();
732 assert_eq!(warnings.len(), 1);
733 assert_eq!(
734 warnings[0].message,
735 "Duplicate heading: '[Link Text](http://example.com)'."
736 );
737 assert_eq!(warnings[0].line, 5);
738 }
739
740 #[test]
741 fn test_consecutive_duplicates() {
742 let content = r#"# Title
743
744## Title
745
746### Title
747
748Three in a row."#;
749
750 let config = MD024Config::default();
751 let result = run_test(content, config);
752 assert!(result.is_ok());
753 let warnings = result.unwrap();
754 assert_eq!(warnings.len(), 2);
755 assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
756 assert_eq!(warnings[0].line, 3);
757 assert_eq!(warnings[1].message, "Duplicate heading: 'Title'.");
758 assert_eq!(warnings[1].line, 5);
759 }
760
761 #[test]
762 fn test_siblings_only_config() {
763 let content = r#"# Section One
764
765## Subsection
766
767### Details
768
769# Section Two
770
771## Subsection
772
773Different parent sections, so not siblings - no warning expected."#;
774
775 let config = MD024Config {
776 allow_different_nesting: false,
777 siblings_only: true,
778 };
779 let result = run_test(content, config);
780 assert!(result.is_ok());
781 let warnings = result.unwrap();
782 assert_eq!(warnings.len(), 0);
784 }
785
786 #[test]
787 fn test_siblings_only_with_actual_siblings() {
788 let content = r#"# Main Section
789
790## First Subsection
791
792### Details
793
794## Second Subsection
795
796### Details
797
798The two 'Details' headings are siblings under different subsections - no warning.
799
800## First Subsection
801
802This 'First Subsection' IS a sibling duplicate."#;
803
804 let config = MD024Config {
805 allow_different_nesting: false,
806 siblings_only: true,
807 };
808 let result = run_test(content, config);
809 assert!(result.is_ok());
810 let warnings = result.unwrap();
811 assert_eq!(warnings.len(), 1);
813 assert_eq!(warnings[0].message, "Duplicate heading: 'First Subsection'.");
814 assert_eq!(warnings[0].line, 13);
815 }
816
817 #[test]
818 fn test_code_spans_in_headings() {
819 let content = r#"# `code` in heading
820
821Some content.
822
823## `code` in heading
824
825Duplicate with code span."#;
826
827 let config = MD024Config::default();
828 let result = run_test(content, config);
829 assert!(result.is_ok());
830 let warnings = result.unwrap();
831 assert_eq!(warnings.len(), 1);
832 assert_eq!(warnings[0].message, "Duplicate heading: '`code` in heading'.");
833 assert_eq!(warnings[0].line, 5);
834 }
835
836 #[test]
837 fn test_very_long_heading() {
838 let long_text = "This is a very long heading that goes on and on and on and contains many words to test how the rule handles long headings";
839 let content = format!("# {long_text}\n\nSome content.\n\n## {long_text}\n\nDuplicate long heading.");
840
841 let config = MD024Config::default();
842 let result = run_test(&content, config);
843 assert!(result.is_ok());
844 let warnings = result.unwrap();
845 assert_eq!(warnings.len(), 1);
846 assert_eq!(warnings[0].message, format!("Duplicate heading: '{long_text}'."));
847 assert_eq!(warnings[0].line, 5);
848 }
849
850 #[test]
851 fn test_heading_with_html_entities() {
852 let content = r#"# Title & More
853
854Some content.
855
856## Title & More
857
858Duplicate with HTML entity."#;
859
860 let config = MD024Config::default();
861 let result = run_test(content, config);
862 assert!(result.is_ok());
863 let warnings = result.unwrap();
864 assert_eq!(warnings.len(), 1);
865 assert_eq!(warnings[0].message, "Duplicate heading: 'Title & More'.");
866 assert_eq!(warnings[0].line, 5);
867 }
868
869 #[test]
870 fn test_three_duplicates_different_nesting() {
871 let content = r#"# Main
872
873## Main
874
875### Main
876
877#### Main
878
879All same text, different levels."#;
880
881 let config = MD024Config {
882 allow_different_nesting: true,
883 siblings_only: false,
884 };
885 let result = run_test(content, config);
886 assert!(result.is_ok());
887 let warnings = result.unwrap();
888 assert_eq!(warnings.len(), 0);
890 }
891}