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