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 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::default();
297 let result = run_test(content, config);
298 assert!(result.is_ok());
299 let warnings = result.unwrap();
300 assert_eq!(warnings.len(), 1);
301 assert_eq!(warnings[0].message, "Duplicate heading: 'Main Title'.");
302 assert_eq!(warnings[0].line, 5);
303 }
304
305 #[test]
306 fn test_duplicate_headings_different_levels_allow_different_nesting() {
307 let content = r#"# Main Title
308
309Some content.
310
311## Main Title
312
313This has the same text but different level."#;
314
315 let config = MD024Config {
316 allow_different_nesting: true,
317 siblings_only: false,
318 };
319 let result = run_test(content, config);
320 assert!(result.is_ok());
321 let warnings = result.unwrap();
322 assert_eq!(warnings.len(), 0);
323 }
324
325 #[test]
326 fn test_case_sensitivity() {
327 let content = r#"# First Heading
328
329Some content.
330
331## first heading
332
333Different case.
334
335### FIRST HEADING
336
337All caps."#;
338
339 let config = MD024Config::default();
340 let result = run_test(content, config);
341 assert!(result.is_ok());
342 let warnings = result.unwrap();
343 assert_eq!(warnings.len(), 0);
345 }
346
347 #[test]
348 fn test_headings_with_trailing_punctuation() {
349 let content = r#"# First Heading!
350
351Some content.
352
353## First Heading!
354
355Same with punctuation.
356
357### First Heading
358
359Without punctuation."#;
360
361 let config = MD024Config::default();
362 let result = run_test(content, config);
363 assert!(result.is_ok());
364 let warnings = result.unwrap();
365 assert_eq!(warnings.len(), 1);
366 assert_eq!(warnings[0].message, "Duplicate heading: 'First Heading!'.");
367 }
368
369 #[test]
370 fn test_headings_with_inline_formatting() {
371 let content = r#"# **Bold Heading**
372
373Some content.
374
375## *Italic Heading*
376
377More content.
378
379### **Bold Heading**
380
381Duplicate with same formatting.
382
383#### `Code Heading`
384
385Code formatted.
386
387##### `Code Heading`
388
389Duplicate code formatted."#;
390
391 let config = MD024Config::default();
392 let result = run_test(content, config);
393 assert!(result.is_ok());
394 let warnings = result.unwrap();
395 assert_eq!(warnings.len(), 2);
396 assert_eq!(warnings[0].message, "Duplicate heading: '**Bold Heading**'.");
397 assert_eq!(warnings[1].message, "Duplicate heading: '`Code Heading`'.");
398 }
399
400 #[test]
401 fn test_headings_in_different_sections() {
402 let content = r#"# Section One
403
404## Subsection
405
406Some content.
407
408# Section Two
409
410## Subsection
411
412Same subsection name in different section."#;
413
414 let config = MD024Config::default();
415 let result = run_test(content, config);
416 assert!(result.is_ok());
417 let warnings = result.unwrap();
418 assert_eq!(warnings.len(), 1);
419 assert_eq!(warnings[0].message, "Duplicate heading: 'Subsection'.");
420 assert_eq!(warnings[0].line, 9);
421 }
422
423 #[test]
424 fn test_multiple_duplicates() {
425 let content = r#"# Title
426
427## Subtitle
428
429### Title
430
431#### Subtitle
432
433## Title
434
435### Subtitle"#;
436
437 let config = MD024Config::default();
438 let result = run_test(content, config);
439 assert!(result.is_ok());
440 let warnings = result.unwrap();
441 assert_eq!(warnings.len(), 4);
442 assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
444 assert_eq!(warnings[0].line, 5);
445 assert_eq!(warnings[1].message, "Duplicate heading: 'Subtitle'.");
447 assert_eq!(warnings[1].line, 7);
448 assert_eq!(warnings[2].message, "Duplicate heading: 'Title'.");
450 assert_eq!(warnings[2].line, 9);
451 assert_eq!(warnings[3].message, "Duplicate heading: 'Subtitle'.");
453 assert_eq!(warnings[3].line, 11);
454 }
455
456 #[test]
457 fn test_empty_headings() {
458 let content = r#"#
459
460Some content.
461
462##
463
464More content.
465
466### Non-empty
467
468####
469
470Another empty."#;
471
472 let config = MD024Config::default();
473 let result = run_test(content, config);
474 assert!(result.is_ok());
475 let warnings = result.unwrap();
476 assert_eq!(warnings.len(), 0);
478 }
479
480 #[test]
481 fn test_unicode_and_special_characters() {
482 let content = r#"# 你好世界
483
484Some content.
485
486## Émojis 🎉🎊
487
488More content.
489
490### 你好世界
491
492Duplicate Chinese.
493
494#### Émojis 🎉🎊
495
496Duplicate emojis.
497
498##### Special <chars> & symbols!
499
500###### Special <chars> & symbols!
501
502Duplicate special chars."#;
503
504 let config = MD024Config::default();
505 let result = run_test(content, config);
506 assert!(result.is_ok());
507 let warnings = result.unwrap();
508 assert_eq!(warnings.len(), 3);
509 assert_eq!(warnings[0].message, "Duplicate heading: '你好世界'.");
510 assert_eq!(warnings[1].message, "Duplicate heading: 'Émojis 🎉🎊'.");
511 assert_eq!(warnings[2].message, "Duplicate heading: 'Special <chars> & symbols!'.");
512 }
513
514 #[test]
515 fn test_allow_different_nesting_with_same_level_duplicates() {
516 let content = r#"# Section One
517
518## Title
519
520### Subsection
521
522## Title
523
524This is a duplicate at the same level.
525
526# Section Two
527
528## Title
529
530Different section, but still a duplicate when allow_different_nesting is true."#;
531
532 let config = MD024Config {
533 allow_different_nesting: true,
534 siblings_only: false,
535 };
536 let result = run_test(content, config);
537 assert!(result.is_ok());
538 let warnings = result.unwrap();
539 assert_eq!(warnings.len(), 2);
540 assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
541 assert_eq!(warnings[0].line, 7);
542 assert_eq!(warnings[1].message, "Duplicate heading: 'Title'.");
543 assert_eq!(warnings[1].line, 13);
544 }
545
546 #[test]
547 fn test_atx_style_headings_with_closing_hashes() {
548 let content = r#"# Heading One #
549
550Some content.
551
552## Heading Two ##
553
554More content.
555
556### Heading One ###
557
558Duplicate with different style."#;
559
560 let config = MD024Config::default();
561 let result = run_test(content, config);
562 assert!(result.is_ok());
563 let warnings = result.unwrap();
564 assert_eq!(warnings.len(), 1);
566 assert_eq!(warnings[0].message, "Duplicate heading: 'Heading One'.");
567 assert_eq!(warnings[0].line, 9);
568 }
569
570 #[test]
571 fn test_fix_method_returns_unchanged() {
572 let content = r#"# Duplicate
573
574## Duplicate
575
576This has duplicates."#;
577
578 let config = MD024Config::default();
579 let result = run_fix_test(content, config);
580 assert!(result.is_ok());
581 assert_eq!(result.unwrap(), content);
582 }
583
584 #[test]
585 fn test_empty_content() {
586 let content = "";
587 let config = MD024Config::default();
588 let result = run_test(content, config);
589 assert!(result.is_ok());
590 let warnings = result.unwrap();
591 assert_eq!(warnings.len(), 0);
592 }
593
594 #[test]
595 fn test_no_headings() {
596 let content = r#"This is just regular text.
597
598No headings anywhere.
599
600Just paragraphs."#;
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(), 0);
607 }
608
609 #[test]
610 fn test_whitespace_differences() {
611 let content = r#"# Heading with spaces
612
613Some content.
614
615## Heading with spaces
616
617Different amount of spaces.
618
619### Heading with spaces
620
621Exact match."#;
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(), 2);
629 assert_eq!(warnings[0].message, "Duplicate heading: 'Heading with spaces'.");
630 assert_eq!(warnings[0].line, 5);
631 assert_eq!(warnings[1].message, "Duplicate heading: 'Heading with spaces'.");
632 assert_eq!(warnings[1].line, 9);
633 }
634
635 #[test]
636 fn test_column_positions() {
637 let content = r#"# First
638
639## Second
640
641### First"#;
642
643 let config = MD024Config::default();
644 let result = run_test(content, config);
645 assert!(result.is_ok());
646 let warnings = result.unwrap();
647 assert_eq!(warnings.len(), 1);
648 assert_eq!(warnings[0].line, 5);
649 assert_eq!(warnings[0].column, 5); assert_eq!(warnings[0].end_line, 5);
651 assert_eq!(warnings[0].end_column, 10); }
653
654 #[test]
655 fn test_complex_nesting_scenario() {
656 let content = r#"# Main Document
657
658## Introduction
659
660### Overview
661
662## Implementation
663
664### Overview
665
666This Overview is in a different section.
667
668## Conclusion
669
670### Overview
671
672Another Overview in yet another section."#;
673
674 let config = MD024Config {
675 allow_different_nesting: true,
676 siblings_only: false,
677 };
678 let result = run_test(content, config);
679 assert!(result.is_ok());
680 let warnings = result.unwrap();
681 assert_eq!(warnings.len(), 2);
683 assert_eq!(warnings[0].message, "Duplicate heading: 'Overview'.");
684 assert_eq!(warnings[0].line, 9);
685 assert_eq!(warnings[1].message, "Duplicate heading: 'Overview'.");
686 assert_eq!(warnings[1].line, 15);
687 }
688
689 #[test]
690 fn test_setext_style_headings() {
691 let content = r#"Main Title
692==========
693
694Some content.
695
696Second Title
697------------
698
699More content.
700
701Main Title
702==========
703
704Duplicate setext."#;
705
706 let config = MD024Config::default();
707 let result = run_test(content, config);
708 assert!(result.is_ok());
709 let warnings = result.unwrap();
710 assert_eq!(warnings.len(), 1);
711 assert_eq!(warnings[0].message, "Duplicate heading: 'Main Title'.");
712 assert_eq!(warnings[0].line, 11);
713 }
714
715 #[test]
716 fn test_mixed_heading_styles() {
717 let content = r#"# ATX Title
718
719Some content.
720
721ATX Title
722=========
723
724Same text, different style."#;
725
726 let config = MD024Config::default();
727 let result = run_test(content, config);
728 assert!(result.is_ok());
729 let warnings = result.unwrap();
730 assert_eq!(warnings.len(), 1);
731 assert_eq!(warnings[0].message, "Duplicate heading: 'ATX Title'.");
732 assert_eq!(warnings[0].line, 5);
733 }
734
735 #[test]
736 fn test_heading_with_links() {
737 let content = r#"# [Link Text](http://example.com)
738
739Some content.
740
741## [Link Text](http://example.com)
742
743Duplicate heading with link.
744
745### [Different Link](http://example.com)
746
747Not a duplicate."#;
748
749 let config = MD024Config::default();
750 let result = run_test(content, config);
751 assert!(result.is_ok());
752 let warnings = result.unwrap();
753 assert_eq!(warnings.len(), 1);
754 assert_eq!(
755 warnings[0].message,
756 "Duplicate heading: '[Link Text](http://example.com)'."
757 );
758 assert_eq!(warnings[0].line, 5);
759 }
760
761 #[test]
762 fn test_consecutive_duplicates() {
763 let content = r#"# Title
764
765## Title
766
767### Title
768
769Three in a row."#;
770
771 let config = MD024Config::default();
772 let result = run_test(content, config);
773 assert!(result.is_ok());
774 let warnings = result.unwrap();
775 assert_eq!(warnings.len(), 2);
776 assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
777 assert_eq!(warnings[0].line, 3);
778 assert_eq!(warnings[1].message, "Duplicate heading: 'Title'.");
779 assert_eq!(warnings[1].line, 5);
780 }
781
782 #[test]
783 fn test_siblings_only_config() {
784 let content = r#"# Section One
785
786## Subsection
787
788### Details
789
790# Section Two
791
792## Subsection
793
794Different parent sections, so not siblings - no warning expected."#;
795
796 let config = MD024Config {
797 allow_different_nesting: false,
798 siblings_only: true,
799 };
800 let result = run_test(content, config);
801 assert!(result.is_ok());
802 let warnings = result.unwrap();
803 assert_eq!(warnings.len(), 0);
805 }
806
807 #[test]
808 fn test_siblings_only_with_actual_siblings() {
809 let content = r#"# Main Section
810
811## First Subsection
812
813### Details
814
815## Second Subsection
816
817### Details
818
819The two 'Details' headings are siblings under different subsections - no warning.
820
821## First Subsection
822
823This 'First Subsection' IS a sibling duplicate."#;
824
825 let config = MD024Config {
826 allow_different_nesting: false,
827 siblings_only: true,
828 };
829 let result = run_test(content, config);
830 assert!(result.is_ok());
831 let warnings = result.unwrap();
832 assert_eq!(warnings.len(), 1);
834 assert_eq!(warnings[0].message, "Duplicate heading: 'First Subsection'.");
835 assert_eq!(warnings[0].line, 13);
836 }
837
838 #[test]
839 fn test_code_spans_in_headings() {
840 let content = r#"# `code` in heading
841
842Some content.
843
844## `code` in heading
845
846Duplicate with code span."#;
847
848 let config = MD024Config::default();
849 let result = run_test(content, config);
850 assert!(result.is_ok());
851 let warnings = result.unwrap();
852 assert_eq!(warnings.len(), 1);
853 assert_eq!(warnings[0].message, "Duplicate heading: '`code` in heading'.");
854 assert_eq!(warnings[0].line, 5);
855 }
856
857 #[test]
858 fn test_very_long_heading() {
859 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";
860 let content = format!("# {long_text}\n\nSome content.\n\n## {long_text}\n\nDuplicate long heading.");
861
862 let config = MD024Config::default();
863 let result = run_test(&content, config);
864 assert!(result.is_ok());
865 let warnings = result.unwrap();
866 assert_eq!(warnings.len(), 1);
867 assert_eq!(warnings[0].message, format!("Duplicate heading: '{long_text}'."));
868 assert_eq!(warnings[0].line, 5);
869 }
870
871 #[test]
872 fn test_heading_with_html_entities() {
873 let content = r#"# Title & More
874
875Some content.
876
877## Title & More
878
879Duplicate with HTML entity."#;
880
881 let config = MD024Config::default();
882 let result = run_test(content, config);
883 assert!(result.is_ok());
884 let warnings = result.unwrap();
885 assert_eq!(warnings.len(), 1);
886 assert_eq!(warnings[0].message, "Duplicate heading: 'Title & More'.");
887 assert_eq!(warnings[0].line, 5);
888 }
889
890 #[test]
891 fn test_three_duplicates_different_nesting() {
892 let content = r#"# Main
893
894## Main
895
896### Main
897
898#### Main
899
900All same text, different levels."#;
901
902 let config = MD024Config {
903 allow_different_nesting: true,
904 siblings_only: false,
905 };
906 let result = run_test(content, config);
907 assert!(result.is_ok());
908 let warnings = result.unwrap();
909 assert_eq!(warnings.len(), 0);
911 }
912}