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