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()),
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()),
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()),
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 ctx.lines.iter().all(|line| line.heading.is_none())
185 }
186
187 fn as_any(&self) -> &dyn std::any::Any {
188 self
189 }
190
191 fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
192 None
193 }
194
195 fn default_config_section(&self) -> Option<(String, toml::Value)> {
196 let default_config = MD024Config::default();
197 let json_value = serde_json::to_value(&default_config).ok()?;
198 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
199
200 if let toml::Value::Table(table) = toml_value {
201 if !table.is_empty() {
202 Some((MD024Config::RULE_NAME.to_string(), toml::Value::Table(table)))
203 } else {
204 None
205 }
206 } else {
207 None
208 }
209 }
210
211 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
212 where
213 Self: Sized,
214 {
215 let rule_config = crate::rule_config_serde::load_rule_config::<MD024Config>(config);
216 Box::new(Self::from_config_struct(rule_config))
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use crate::lint_context::LintContext;
224
225 fn run_test(content: &str, config: MD024Config) -> LintResult {
226 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
227 let rule = MD024NoDuplicateHeading::from_config_struct(config);
228 rule.check(&ctx)
229 }
230
231 fn run_fix_test(content: &str, config: MD024Config) -> Result<String, LintError> {
232 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
233 let rule = MD024NoDuplicateHeading::from_config_struct(config);
234 rule.fix(&ctx)
235 }
236
237 #[test]
238 fn test_no_duplicate_headings() {
239 let content = r#"# First Heading
240
241Some content here.
242
243## Second Heading
244
245More content.
246
247### Third Heading
248
249Even more content.
250
251## Fourth Heading
252
253Final content."#;
254
255 let config = MD024Config::default();
256 let result = run_test(content, config);
257 assert!(result.is_ok());
258 let warnings = result.unwrap();
259 assert_eq!(warnings.len(), 0);
260 }
261
262 #[test]
263 fn test_duplicate_headings_same_level() {
264 let content = r#"# First Heading
265
266Some content here.
267
268## Second Heading
269
270More content.
271
272## Second Heading
273
274This is a duplicate."#;
275
276 let config = MD024Config::default();
277 let result = run_test(content, config);
278 assert!(result.is_ok());
279 let warnings = result.unwrap();
280 assert_eq!(warnings.len(), 1);
281 assert_eq!(warnings[0].message, "Duplicate heading: 'Second Heading'.");
282 assert_eq!(warnings[0].line, 9);
283 }
284
285 #[test]
286 fn test_duplicate_headings_different_levels_default() {
287 let content = r#"# Main Title
288
289Some content.
290
291## Main Title
292
293This has the same text but different level."#;
294
295 let config = MD024Config::default();
296 let result = run_test(content, config);
297 assert!(result.is_ok());
298 let warnings = result.unwrap();
299 assert_eq!(warnings.len(), 1);
300 assert_eq!(warnings[0].message, "Duplicate heading: 'Main Title'.");
301 assert_eq!(warnings[0].line, 5);
302 }
303
304 #[test]
305 fn test_duplicate_headings_different_levels_allow_different_nesting() {
306 let content = r#"# Main Title
307
308Some content.
309
310## Main Title
311
312This has the same text but different level."#;
313
314 let config = MD024Config {
315 allow_different_nesting: true,
316 siblings_only: false,
317 };
318 let result = run_test(content, config);
319 assert!(result.is_ok());
320 let warnings = result.unwrap();
321 assert_eq!(warnings.len(), 0);
322 }
323
324 #[test]
325 fn test_case_sensitivity() {
326 let content = r#"# First Heading
327
328Some content.
329
330## first heading
331
332Different case.
333
334### FIRST HEADING
335
336All caps."#;
337
338 let config = MD024Config::default();
339 let result = run_test(content, config);
340 assert!(result.is_ok());
341 let warnings = result.unwrap();
342 assert_eq!(warnings.len(), 0);
344 }
345
346 #[test]
347 fn test_headings_with_trailing_punctuation() {
348 let content = r#"# First Heading!
349
350Some content.
351
352## First Heading!
353
354Same with punctuation.
355
356### First Heading
357
358Without punctuation."#;
359
360 let config = MD024Config::default();
361 let result = run_test(content, config);
362 assert!(result.is_ok());
363 let warnings = result.unwrap();
364 assert_eq!(warnings.len(), 1);
365 assert_eq!(warnings[0].message, "Duplicate heading: 'First Heading!'.");
366 }
367
368 #[test]
369 fn test_headings_with_inline_formatting() {
370 let content = r#"# **Bold Heading**
371
372Some content.
373
374## *Italic Heading*
375
376More content.
377
378### **Bold Heading**
379
380Duplicate with same formatting.
381
382#### `Code Heading`
383
384Code formatted.
385
386##### `Code Heading`
387
388Duplicate code formatted."#;
389
390 let config = MD024Config::default();
391 let result = run_test(content, config);
392 assert!(result.is_ok());
393 let warnings = result.unwrap();
394 assert_eq!(warnings.len(), 2);
395 assert_eq!(warnings[0].message, "Duplicate heading: '**Bold Heading**'.");
396 assert_eq!(warnings[1].message, "Duplicate heading: '`Code Heading`'.");
397 }
398
399 #[test]
400 fn test_headings_in_different_sections() {
401 let content = r#"# Section One
402
403## Subsection
404
405Some content.
406
407# Section Two
408
409## Subsection
410
411Same subsection name in different section."#;
412
413 let config = MD024Config::default();
414 let result = run_test(content, config);
415 assert!(result.is_ok());
416 let warnings = result.unwrap();
417 assert_eq!(warnings.len(), 1);
418 assert_eq!(warnings[0].message, "Duplicate heading: 'Subsection'.");
419 assert_eq!(warnings[0].line, 9);
420 }
421
422 #[test]
423 fn test_multiple_duplicates() {
424 let content = r#"# Title
425
426## Subtitle
427
428### Title
429
430#### Subtitle
431
432## Title
433
434### Subtitle"#;
435
436 let config = MD024Config::default();
437 let result = run_test(content, config);
438 assert!(result.is_ok());
439 let warnings = result.unwrap();
440 assert_eq!(warnings.len(), 4);
441 assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
443 assert_eq!(warnings[0].line, 5);
444 assert_eq!(warnings[1].message, "Duplicate heading: 'Subtitle'.");
446 assert_eq!(warnings[1].line, 7);
447 assert_eq!(warnings[2].message, "Duplicate heading: 'Title'.");
449 assert_eq!(warnings[2].line, 9);
450 assert_eq!(warnings[3].message, "Duplicate heading: 'Subtitle'.");
452 assert_eq!(warnings[3].line, 11);
453 }
454
455 #[test]
456 fn test_empty_headings() {
457 let content = r#"#
458
459Some content.
460
461##
462
463More content.
464
465### Non-empty
466
467####
468
469Another empty."#;
470
471 let config = MD024Config::default();
472 let result = run_test(content, config);
473 assert!(result.is_ok());
474 let warnings = result.unwrap();
475 assert_eq!(warnings.len(), 0);
477 }
478
479 #[test]
480 fn test_unicode_and_special_characters() {
481 let content = r#"# 你好世界
482
483Some content.
484
485## Émojis 🎉🎊
486
487More content.
488
489### 你好世界
490
491Duplicate Chinese.
492
493#### Émojis 🎉🎊
494
495Duplicate emojis.
496
497##### Special <chars> & symbols!
498
499###### Special <chars> & symbols!
500
501Duplicate special chars."#;
502
503 let config = MD024Config::default();
504 let result = run_test(content, config);
505 assert!(result.is_ok());
506 let warnings = result.unwrap();
507 assert_eq!(warnings.len(), 3);
508 assert_eq!(warnings[0].message, "Duplicate heading: '你好世界'.");
509 assert_eq!(warnings[1].message, "Duplicate heading: 'Émojis 🎉🎊'.");
510 assert_eq!(warnings[2].message, "Duplicate heading: 'Special <chars> & symbols!'.");
511 }
512
513 #[test]
514 fn test_allow_different_nesting_with_same_level_duplicates() {
515 let content = r#"# Section One
516
517## Title
518
519### Subsection
520
521## Title
522
523This is a duplicate at the same level.
524
525# Section Two
526
527## Title
528
529Different section, but still a duplicate when allow_different_nesting is true."#;
530
531 let config = MD024Config {
532 allow_different_nesting: true,
533 siblings_only: false,
534 };
535 let result = run_test(content, config);
536 assert!(result.is_ok());
537 let warnings = result.unwrap();
538 assert_eq!(warnings.len(), 2);
539 assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
540 assert_eq!(warnings[0].line, 7);
541 assert_eq!(warnings[1].message, "Duplicate heading: 'Title'.");
542 assert_eq!(warnings[1].line, 13);
543 }
544
545 #[test]
546 fn test_atx_style_headings_with_closing_hashes() {
547 let content = r#"# Heading One #
548
549Some content.
550
551## Heading Two ##
552
553More content.
554
555### Heading One ###
556
557Duplicate with different style."#;
558
559 let config = MD024Config::default();
560 let result = run_test(content, config);
561 assert!(result.is_ok());
562 let warnings = result.unwrap();
563 assert_eq!(warnings.len(), 1);
565 assert_eq!(warnings[0].message, "Duplicate heading: 'Heading One'.");
566 assert_eq!(warnings[0].line, 9);
567 }
568
569 #[test]
570 fn test_fix_method_returns_unchanged() {
571 let content = r#"# Duplicate
572
573## Duplicate
574
575This has duplicates."#;
576
577 let config = MD024Config::default();
578 let result = run_fix_test(content, config);
579 assert!(result.is_ok());
580 assert_eq!(result.unwrap(), content);
581 }
582
583 #[test]
584 fn test_empty_content() {
585 let content = "";
586 let config = MD024Config::default();
587 let result = run_test(content, config);
588 assert!(result.is_ok());
589 let warnings = result.unwrap();
590 assert_eq!(warnings.len(), 0);
591 }
592
593 #[test]
594 fn test_no_headings() {
595 let content = r#"This is just regular text.
596
597No headings anywhere.
598
599Just paragraphs."#;
600
601 let config = MD024Config::default();
602 let result = run_test(content, config);
603 assert!(result.is_ok());
604 let warnings = result.unwrap();
605 assert_eq!(warnings.len(), 0);
606 }
607
608 #[test]
609 fn test_whitespace_differences() {
610 let content = r#"# Heading with spaces
611
612Some content.
613
614## Heading with spaces
615
616Different amount of spaces.
617
618### Heading with spaces
619
620Exact match."#;
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(), 2);
628 assert_eq!(warnings[0].message, "Duplicate heading: 'Heading with spaces'.");
629 assert_eq!(warnings[0].line, 5);
630 assert_eq!(warnings[1].message, "Duplicate heading: 'Heading with spaces'.");
631 assert_eq!(warnings[1].line, 9);
632 }
633
634 #[test]
635 fn test_column_positions() {
636 let content = r#"# First
637
638## Second
639
640### First"#;
641
642 let config = MD024Config::default();
643 let result = run_test(content, config);
644 assert!(result.is_ok());
645 let warnings = result.unwrap();
646 assert_eq!(warnings.len(), 1);
647 assert_eq!(warnings[0].line, 5);
648 assert_eq!(warnings[0].column, 5); assert_eq!(warnings[0].end_line, 5);
650 assert_eq!(warnings[0].end_column, 10); }
652
653 #[test]
654 fn test_complex_nesting_scenario() {
655 let content = r#"# Main Document
656
657## Introduction
658
659### Overview
660
661## Implementation
662
663### Overview
664
665This Overview is in a different section.
666
667## Conclusion
668
669### Overview
670
671Another Overview in yet another section."#;
672
673 let config = MD024Config {
674 allow_different_nesting: true,
675 siblings_only: false,
676 };
677 let result = run_test(content, config);
678 assert!(result.is_ok());
679 let warnings = result.unwrap();
680 assert_eq!(warnings.len(), 2);
682 assert_eq!(warnings[0].message, "Duplicate heading: 'Overview'.");
683 assert_eq!(warnings[0].line, 9);
684 assert_eq!(warnings[1].message, "Duplicate heading: 'Overview'.");
685 assert_eq!(warnings[1].line, 15);
686 }
687
688 #[test]
689 fn test_setext_style_headings() {
690 let content = r#"Main Title
691==========
692
693Some content.
694
695Second Title
696------------
697
698More content.
699
700Main Title
701==========
702
703Duplicate setext."#;
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: 'Main Title'.");
711 assert_eq!(warnings[0].line, 11);
712 }
713
714 #[test]
715 fn test_mixed_heading_styles() {
716 let content = r#"# ATX Title
717
718Some content.
719
720ATX Title
721=========
722
723Same text, different style."#;
724
725 let config = MD024Config::default();
726 let result = run_test(content, config);
727 assert!(result.is_ok());
728 let warnings = result.unwrap();
729 assert_eq!(warnings.len(), 1);
730 assert_eq!(warnings[0].message, "Duplicate heading: 'ATX Title'.");
731 assert_eq!(warnings[0].line, 5);
732 }
733
734 #[test]
735 fn test_heading_with_links() {
736 let content = r#"# [Link Text](http://example.com)
737
738Some content.
739
740## [Link Text](http://example.com)
741
742Duplicate heading with link.
743
744### [Different Link](http://example.com)
745
746Not a duplicate."#;
747
748 let config = MD024Config::default();
749 let result = run_test(content, config);
750 assert!(result.is_ok());
751 let warnings = result.unwrap();
752 assert_eq!(warnings.len(), 1);
753 assert_eq!(
754 warnings[0].message,
755 "Duplicate heading: '[Link Text](http://example.com)'."
756 );
757 assert_eq!(warnings[0].line, 5);
758 }
759
760 #[test]
761 fn test_consecutive_duplicates() {
762 let content = r#"# Title
763
764## Title
765
766### Title
767
768Three in a row."#;
769
770 let config = MD024Config::default();
771 let result = run_test(content, config);
772 assert!(result.is_ok());
773 let warnings = result.unwrap();
774 assert_eq!(warnings.len(), 2);
775 assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
776 assert_eq!(warnings[0].line, 3);
777 assert_eq!(warnings[1].message, "Duplicate heading: 'Title'.");
778 assert_eq!(warnings[1].line, 5);
779 }
780
781 #[test]
782 fn test_siblings_only_config() {
783 let content = r#"# Section One
784
785## Subsection
786
787### Details
788
789# Section Two
790
791## Subsection
792
793Different parent sections, so not siblings - no warning expected."#;
794
795 let config = MD024Config {
796 allow_different_nesting: false,
797 siblings_only: true,
798 };
799 let result = run_test(content, config);
800 assert!(result.is_ok());
801 let warnings = result.unwrap();
802 assert_eq!(warnings.len(), 0);
804 }
805
806 #[test]
807 fn test_siblings_only_with_actual_siblings() {
808 let content = r#"# Main Section
809
810## First Subsection
811
812### Details
813
814## Second Subsection
815
816### Details
817
818The two 'Details' headings are siblings under different subsections - no warning.
819
820## First Subsection
821
822This 'First Subsection' IS a sibling duplicate."#;
823
824 let config = MD024Config {
825 allow_different_nesting: false,
826 siblings_only: true,
827 };
828 let result = run_test(content, config);
829 assert!(result.is_ok());
830 let warnings = result.unwrap();
831 assert_eq!(warnings.len(), 1);
833 assert_eq!(warnings[0].message, "Duplicate heading: 'First Subsection'.");
834 assert_eq!(warnings[0].line, 13);
835 }
836
837 #[test]
838 fn test_code_spans_in_headings() {
839 let content = r#"# `code` in heading
840
841Some content.
842
843## `code` in heading
844
845Duplicate with code span."#;
846
847 let config = MD024Config::default();
848 let result = run_test(content, config);
849 assert!(result.is_ok());
850 let warnings = result.unwrap();
851 assert_eq!(warnings.len(), 1);
852 assert_eq!(warnings[0].message, "Duplicate heading: '`code` in heading'.");
853 assert_eq!(warnings[0].line, 5);
854 }
855
856 #[test]
857 fn test_very_long_heading() {
858 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";
859 let content = format!("# {long_text}\n\nSome content.\n\n## {long_text}\n\nDuplicate long heading.");
860
861 let config = MD024Config::default();
862 let result = run_test(&content, config);
863 assert!(result.is_ok());
864 let warnings = result.unwrap();
865 assert_eq!(warnings.len(), 1);
866 assert_eq!(warnings[0].message, format!("Duplicate heading: '{long_text}'."));
867 assert_eq!(warnings[0].line, 5);
868 }
869
870 #[test]
871 fn test_heading_with_html_entities() {
872 let content = r#"# Title & More
873
874Some content.
875
876## Title & More
877
878Duplicate with HTML entity."#;
879
880 let config = MD024Config::default();
881 let result = run_test(content, config);
882 assert!(result.is_ok());
883 let warnings = result.unwrap();
884 assert_eq!(warnings.len(), 1);
885 assert_eq!(warnings[0].message, "Duplicate heading: 'Title & More'.");
886 assert_eq!(warnings[0].line, 5);
887 }
888
889 #[test]
890 fn test_three_duplicates_different_nesting() {
891 let content = r#"# Main
892
893## Main
894
895### Main
896
897#### Main
898
899All same text, different levels."#;
900
901 let config = MD024Config {
902 allow_different_nesting: true,
903 siblings_only: false,
904 };
905 let result = run_test(content, config);
906 assert!(result.is_ok());
907 let warnings = result.unwrap();
908 assert_eq!(warnings.len(), 0);
910 }
911}