1use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use std::collections::{HashMap, HashSet};
3
4mod md024_config;
5use md024_config::MD024Config;
6
7#[derive(Clone, Debug, Default)]
8pub struct MD024NoDuplicateHeading {
9 config: MD024Config,
10}
11
12impl MD024NoDuplicateHeading {
13 pub fn new(allow_different_nesting: bool, siblings_only: bool) -> Self {
14 Self {
15 config: MD024Config {
16 allow_different_nesting,
17 siblings_only,
18 allow_different_link_anchors: true,
19 },
20 }
21 }
22
23 pub fn from_config_struct(config: MD024Config) -> Self {
24 Self { config }
25 }
26}
27
28impl Rule for MD024NoDuplicateHeading {
29 fn name(&self) -> &'static str {
30 "MD024"
31 }
32
33 fn description(&self) -> &'static str {
34 "Multiple headings with the same content"
35 }
36
37 fn fix_capability(&self) -> FixCapability {
38 FixCapability::Unfixable
39 }
40
41 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
42 if ctx.lines.is_empty() {
44 return Ok(Vec::new());
45 }
46
47 type HeadingKey<'a> = (&'a str, Option<&'a str>);
50
51 let mut warnings = Vec::new();
52 let mut seen_headings: HashSet<HeadingKey<'_>> = HashSet::new();
53 let mut seen_headings_per_level: HashMap<u8, HashSet<HeadingKey<'_>>> = HashMap::new();
54
55 let mut current_section_path: Vec<(u8, HeadingKey<'_>)> = Vec::new();
57 let mut seen_siblings: HashMap<Vec<HeadingKey<'_>>, HashSet<HeadingKey<'_>>> = HashMap::new();
58
59 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
61 let mut in_snippet_section = false;
62
63 for (line_num, line_info) in ctx.lines.iter().enumerate() {
65 if is_mkdocs {
67 if crate::utils::mkdocs_snippets::is_snippet_section_start(line_info.content(ctx.content)) {
68 in_snippet_section = true;
69 continue; } else if crate::utils::mkdocs_snippets::is_snippet_section_end(line_info.content(ctx.content)) {
71 in_snippet_section = false;
72 continue; }
74 }
75
76 if is_mkdocs && in_snippet_section {
78 continue;
79 }
80
81 if let Some(parsed) = ctx.heading_on_line(line_num + 1) {
85 let heading = parsed.heading;
86 let marker_idx = line_num + usize::from(parsed.is_setext());
90 if ctx.lines.get(marker_idx).is_some_and(|line| {
91 let marker_offset = line.byte_offset + line.indent;
92 ctx.is_inside_template_code(marker_offset) || ctx.is_inside_mdx_code(marker_offset)
93 }) {
94 continue;
95 }
96 if heading.text.is_empty() {
98 continue;
99 }
100
101 let heading_key: HeadingKey<'_> = if self.config.allow_different_link_anchors {
102 (heading.text.as_str(), heading.custom_id.as_deref())
103 } else {
104 (heading.text.as_str(), None)
105 };
106 let level = heading.level;
107
108 let is_duplicate = if self.config.siblings_only {
109 while current_section_path
111 .last()
112 .is_some_and(|(parent_level, _)| *parent_level >= level)
113 {
114 current_section_path.pop();
115 }
116 let parent_path = current_section_path.iter().map(|(_, key)| *key).collect();
117 let is_duplicate = !seen_siblings.entry(parent_path).or_default().insert(heading_key);
118 current_section_path.push((level, heading_key));
119 is_duplicate
120 } else if self.config.allow_different_nesting {
121 !seen_headings_per_level.entry(level).or_default().insert(heading_key)
122 } else {
123 !seen_headings.insert(heading_key)
124 };
125
126 if !is_duplicate {
127 continue;
128 }
129
130 let (start_line, start_col, end_line, end_col) = parsed.text_position_range(ctx);
133
134 warnings.push(LintWarning {
135 rule_name: Some(self.name().to_string()),
136 message: format!("Duplicate heading: '{}'.", heading.text),
137 line: start_line,
138 column: start_col,
139 end_line,
140 end_column: end_col,
141 severity: Severity::Error,
142 fix: None,
143 });
144 }
145 }
146
147 Ok(warnings)
148 }
149
150 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
151 Ok(ctx.content.to_string())
153 }
154
155 fn category(&self) -> RuleCategory {
157 RuleCategory::Heading
158 }
159
160 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
162 if !ctx.likely_has_headings() {
164 return true;
165 }
166 ctx.headings().next().is_none()
168 }
169
170 fn as_any(&self) -> &dyn std::any::Any {
171 self
172 }
173
174 crate::impl_rule_config_methods!(MD024Config);
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use crate::lint_context::LintContext;
181
182 fn run_test(content: &str, config: MD024Config) -> LintResult {
183 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
184 let rule = MD024NoDuplicateHeading::from_config_struct(config);
185 rule.check(&ctx)
186 }
187
188 fn run_fix_test(content: &str, config: MD024Config) -> Result<String, LintError> {
189 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
190 let rule = MD024NoDuplicateHeading::from_config_struct(config);
191 rule.fix(&ctx)
192 }
193
194 #[test]
195 fn test_no_duplicate_headings() {
196 let content = r#"# First Heading
197
198Some content here.
199
200## Second Heading
201
202More content.
203
204### Third Heading
205
206Even more content.
207
208## Fourth Heading
209
210Final content."#;
211
212 let config = MD024Config::default();
213 let result = run_test(content, config);
214 assert!(result.is_ok());
215 let warnings = result.unwrap();
216 assert_eq!(warnings.len(), 0);
217 }
218
219 #[test]
220 fn test_duplicate_headings_same_level() {
221 let content = r#"# First Heading
222
223Some content here.
224
225## Second Heading
226
227More content.
228
229## Second Heading
230
231This is a duplicate."#;
232
233 let config = MD024Config::default();
234 let result = run_test(content, config);
235 assert!(result.is_ok());
236 let warnings = result.unwrap();
237 assert_eq!(warnings.len(), 1);
238 assert_eq!(warnings[0].message, "Duplicate heading: 'Second Heading'.");
239 assert_eq!(warnings[0].line, 9);
240 }
241
242 #[test]
243 fn test_duplicate_headings_different_levels_default() {
244 let content = r#"# Main Title
245
246Some content.
247
248## Main Title
249
250This has the same text but different level."#;
251
252 let config = MD024Config {
253 allow_different_nesting: false,
254 siblings_only: false,
255 ..MD024Config::default()
256 };
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: 'Main Title'.");
262 assert_eq!(warnings[0].line, 5);
263 }
264
265 #[test]
266 fn test_duplicate_headings_different_levels_allow_different_nesting() {
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 {
276 allow_different_nesting: true,
277 siblings_only: false,
278 ..MD024Config::default()
279 };
280 let result = run_test(content, config);
281 assert!(result.is_ok());
282 let warnings = result.unwrap();
283 assert_eq!(warnings.len(), 0);
284 }
285
286 #[test]
287 fn test_case_sensitivity() {
288 let content = r#"# First Heading
289
290Some content.
291
292## first heading
293
294Different case.
295
296### FIRST HEADING
297
298All caps."#;
299
300 let config = MD024Config::default();
301 let result = run_test(content, config);
302 assert!(result.is_ok());
303 let warnings = result.unwrap();
304 assert_eq!(warnings.len(), 0);
306 }
307
308 #[test]
309 fn test_headings_with_trailing_punctuation() {
310 let content = r#"# First Heading!
311
312Some content.
313
314## First Heading!
315
316Same with punctuation.
317
318### First Heading
319
320Without punctuation."#;
321
322 let config = MD024Config {
323 allow_different_nesting: false,
324 siblings_only: false,
325 ..MD024Config::default()
326 };
327 let result = run_test(content, config);
328 assert!(result.is_ok());
329 let warnings = result.unwrap();
330 assert_eq!(warnings.len(), 1);
331 assert_eq!(warnings[0].message, "Duplicate heading: 'First Heading!'.");
332 }
333
334 #[test]
335 fn test_headings_with_inline_formatting() {
336 let content = r#"# **Bold Heading**
337
338Some content.
339
340## *Italic Heading*
341
342More content.
343
344### **Bold Heading**
345
346Duplicate with same formatting.
347
348#### `Code Heading`
349
350Code formatted.
351
352##### `Code Heading`
353
354Duplicate code formatted."#;
355
356 let config = MD024Config {
357 allow_different_nesting: false,
358 siblings_only: false,
359 ..MD024Config::default()
360 };
361 let result = run_test(content, config);
362 assert!(result.is_ok());
363 let warnings = result.unwrap();
364 assert_eq!(warnings.len(), 2);
365 assert_eq!(warnings[0].message, "Duplicate heading: '**Bold Heading**'.");
366 assert_eq!(warnings[1].message, "Duplicate heading: '`Code Heading`'.");
367 }
368
369 #[test]
370 fn test_headings_in_different_sections() {
371 let content = r#"# Section One
372
373## Subsection
374
375Some content.
376
377# Section Two
378
379## Subsection
380
381Same subsection name in different section."#;
382
383 let config = MD024Config {
384 allow_different_nesting: false,
385 siblings_only: false,
386 ..MD024Config::default()
387 };
388 let result = run_test(content, config);
389 assert!(result.is_ok());
390 let warnings = result.unwrap();
391 assert_eq!(warnings.len(), 1);
392 assert_eq!(warnings[0].message, "Duplicate heading: 'Subsection'.");
393 assert_eq!(warnings[0].line, 9);
394 }
395
396 #[test]
397 fn test_multiple_duplicates() {
398 let content = r#"# Title
399
400## Subtitle
401
402### Title
403
404#### Subtitle
405
406## Title
407
408### Subtitle"#;
409
410 let config = MD024Config {
411 allow_different_nesting: false,
412 siblings_only: false,
413 ..MD024Config::default()
414 };
415 let result = run_test(content, config);
416 assert!(result.is_ok());
417 let warnings = result.unwrap();
418 assert_eq!(warnings.len(), 4);
419 assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
421 assert_eq!(warnings[0].line, 5);
422 assert_eq!(warnings[1].message, "Duplicate heading: 'Subtitle'.");
424 assert_eq!(warnings[1].line, 7);
425 assert_eq!(warnings[2].message, "Duplicate heading: 'Title'.");
427 assert_eq!(warnings[2].line, 9);
428 assert_eq!(warnings[3].message, "Duplicate heading: 'Subtitle'.");
430 assert_eq!(warnings[3].line, 11);
431 }
432
433 #[test]
434 fn test_empty_headings() {
435 let content = r#"#
436
437Some content.
438
439##
440
441More content.
442
443### Non-empty
444
445####
446
447Another empty."#;
448
449 let config = MD024Config::default();
450 let result = run_test(content, config);
451 assert!(result.is_ok());
452 let warnings = result.unwrap();
453 assert_eq!(warnings.len(), 0);
455 }
456
457 #[test]
458 fn test_unicode_and_special_characters() {
459 let content = r#"# 你好世界
460
461Some content.
462
463## Émojis 🎉🎊
464
465More content.
466
467### 你好世界
468
469Duplicate Chinese.
470
471#### Émojis 🎉🎊
472
473Duplicate emojis.
474
475##### Special <chars> & symbols!
476
477###### Special <chars> & symbols!
478
479Duplicate special chars."#;
480
481 let config = MD024Config {
482 allow_different_nesting: false,
483 siblings_only: false,
484 ..MD024Config::default()
485 };
486 let result = run_test(content, config);
487 assert!(result.is_ok());
488 let warnings = result.unwrap();
489 assert_eq!(warnings.len(), 3);
490 assert_eq!(warnings[0].message, "Duplicate heading: '你好世界'.");
491 assert_eq!(warnings[1].message, "Duplicate heading: 'Émojis 🎉🎊'.");
492 assert_eq!(warnings[2].message, "Duplicate heading: 'Special <chars> & symbols!'.");
493 }
494
495 #[test]
496 fn test_allow_different_nesting_with_same_level_duplicates() {
497 let content = r#"# Section One
498
499## Title
500
501### Subsection
502
503## Title
504
505This is a duplicate at the same level.
506
507# Section Two
508
509## Title
510
511Different section, but still a duplicate when allow_different_nesting is true."#;
512
513 let config = MD024Config {
514 allow_different_nesting: true,
515 siblings_only: false,
516 ..MD024Config::default()
517 };
518 let result = run_test(content, config);
519 assert!(result.is_ok());
520 let warnings = result.unwrap();
521 assert_eq!(warnings.len(), 2);
522 assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
523 assert_eq!(warnings[0].line, 7);
524 assert_eq!(warnings[1].message, "Duplicate heading: 'Title'.");
525 assert_eq!(warnings[1].line, 13);
526 }
527
528 #[test]
529 fn test_atx_style_headings_with_closing_hashes() {
530 let content = r#"# Heading One #
531
532Some content.
533
534## Heading Two ##
535
536More content.
537
538### Heading One ###
539
540Duplicate with different style."#;
541
542 let config = MD024Config {
543 allow_different_nesting: false,
544 siblings_only: false,
545 ..MD024Config::default()
546 };
547 let result = run_test(content, config);
548 assert!(result.is_ok());
549 let warnings = result.unwrap();
550 assert_eq!(warnings.len(), 1);
552 assert_eq!(warnings[0].message, "Duplicate heading: 'Heading One'.");
553 assert_eq!(warnings[0].line, 9);
554 }
555
556 #[test]
557 fn test_fix_method_returns_unchanged() {
558 let content = r#"# Duplicate
559
560## Duplicate
561
562This has duplicates."#;
563
564 let config = MD024Config::default();
565 let result = run_fix_test(content, config);
566 assert!(result.is_ok());
567 assert_eq!(result.unwrap(), content);
568 }
569
570 #[test]
571 fn test_empty_content() {
572 let content = "";
573 let config = MD024Config::default();
574 let result = run_test(content, config);
575 assert!(result.is_ok());
576 let warnings = result.unwrap();
577 assert_eq!(warnings.len(), 0);
578 }
579
580 #[test]
581 fn test_no_headings() {
582 let content = r#"This is just regular text.
583
584No headings anywhere.
585
586Just paragraphs."#;
587
588 let config = MD024Config::default();
589 let result = run_test(content, config);
590 assert!(result.is_ok());
591 let warnings = result.unwrap();
592 assert_eq!(warnings.len(), 0);
593 }
594
595 #[test]
596 fn test_whitespace_differences() {
597 let content = r#"# Heading with spaces
598
599Some content.
600
601## Heading with spaces
602
603Different amount of spaces.
604
605### Heading with spaces
606
607Exact match."#;
608
609 let config = MD024Config {
610 allow_different_nesting: false,
611 siblings_only: false,
612 ..MD024Config::default()
613 };
614 let result = run_test(content, config);
615 assert!(result.is_ok());
616 let warnings = result.unwrap();
617 assert_eq!(warnings.len(), 2);
619 assert_eq!(warnings[0].message, "Duplicate heading: 'Heading with spaces'.");
620 assert_eq!(warnings[0].line, 5);
621 assert_eq!(warnings[1].message, "Duplicate heading: 'Heading with spaces'.");
622 assert_eq!(warnings[1].line, 9);
623 }
624
625 #[test]
626 fn test_column_positions() {
627 let content = r#"# First
628
629## Second
630
631### First"#;
632
633 let config = MD024Config {
634 allow_different_nesting: false,
635 siblings_only: false,
636 ..MD024Config::default()
637 };
638 let result = run_test(content, config);
639 assert!(result.is_ok());
640 let warnings = result.unwrap();
641 assert_eq!(warnings.len(), 1);
642 assert_eq!(warnings[0].line, 5);
643 assert_eq!(warnings[0].column, 5); assert_eq!(warnings[0].end_line, 5);
645 assert_eq!(warnings[0].end_column, 10); }
647
648 #[test]
649 fn test_complex_nesting_scenario() {
650 let content = r#"# Main Document
651
652## Introduction
653
654### Overview
655
656## Implementation
657
658### Overview
659
660This Overview is in a different section.
661
662## Conclusion
663
664### Overview
665
666Another Overview in yet another section."#;
667
668 let config = MD024Config {
669 allow_different_nesting: true,
670 siblings_only: false,
671 ..MD024Config::default()
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 {
745 allow_different_nesting: false,
746 siblings_only: false,
747 ..MD024Config::default()
748 };
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 {
771 allow_different_nesting: false,
772 siblings_only: false,
773 ..MD024Config::default()
774 };
775 let result = run_test(content, config);
776 assert!(result.is_ok());
777 let warnings = result.unwrap();
778 assert_eq!(warnings.len(), 2);
779 assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
780 assert_eq!(warnings[0].line, 3);
781 assert_eq!(warnings[1].message, "Duplicate heading: 'Title'.");
782 assert_eq!(warnings[1].line, 5);
783 }
784
785 #[test]
786 fn test_siblings_only_config() {
787 let content = r#"# Section One
788
789## Subsection
790
791### Details
792
793# Section Two
794
795## Subsection
796
797Different parent sections, so not siblings - no warning expected."#;
798
799 let config = MD024Config {
800 allow_different_nesting: false,
801 siblings_only: true,
802 ..MD024Config::default()
803 };
804 let result = run_test(content, config);
805 assert!(result.is_ok());
806 let warnings = result.unwrap();
807 assert_eq!(warnings.len(), 0);
809 }
810
811 #[test]
812 fn test_siblings_only_with_actual_siblings() {
813 let content = r#"# Main Section
814
815## First Subsection
816
817### Details
818
819## Second Subsection
820
821### Details
822
823The two 'Details' headings are siblings under different subsections - no warning.
824
825## First Subsection
826
827This 'First Subsection' IS a sibling duplicate."#;
828
829 let config = MD024Config {
830 allow_different_nesting: false,
831 siblings_only: true,
832 ..MD024Config::default()
833 };
834 let result = run_test(content, config);
835 assert!(result.is_ok());
836 let warnings = result.unwrap();
837 assert_eq!(warnings.len(), 1);
839 assert_eq!(warnings[0].message, "Duplicate heading: 'First Subsection'.");
840 assert_eq!(warnings[0].line, 13);
841 }
842
843 #[test]
844 fn test_code_spans_in_headings() {
845 let content = r#"# `code` in heading
846
847Some content.
848
849## `code` in heading
850
851Duplicate with code span."#;
852
853 let config = MD024Config {
854 allow_different_nesting: false,
855 siblings_only: false,
856 ..MD024Config::default()
857 };
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, "Duplicate heading: '`code` in heading'.");
863 assert_eq!(warnings[0].line, 5);
864 }
865
866 #[test]
867 fn test_very_long_heading() {
868 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";
869 let content = format!("# {long_text}\n\nSome content.\n\n## {long_text}\n\nDuplicate long heading.");
870
871 let config = MD024Config {
872 allow_different_nesting: false,
873 siblings_only: false,
874 ..MD024Config::default()
875 };
876 let result = run_test(&content, config);
877 assert!(result.is_ok());
878 let warnings = result.unwrap();
879 assert_eq!(warnings.len(), 1);
880 assert_eq!(warnings[0].message, format!("Duplicate heading: '{long_text}'."));
881 assert_eq!(warnings[0].line, 5);
882 }
883
884 #[test]
885 fn test_heading_with_html_entities() {
886 let content = r#"# Title & More
887
888Some content.
889
890## Title & More
891
892Duplicate with HTML entity."#;
893
894 let config = MD024Config {
895 allow_different_nesting: false,
896 siblings_only: false,
897 ..MD024Config::default()
898 };
899 let result = run_test(content, config);
900 assert!(result.is_ok());
901 let warnings = result.unwrap();
902 assert_eq!(warnings.len(), 1);
903 assert_eq!(warnings[0].message, "Duplicate heading: 'Title & More'.");
904 assert_eq!(warnings[0].line, 5);
905 }
906
907 #[test]
908 fn test_three_duplicates_different_nesting() {
909 let content = r#"# Main
910
911## Main
912
913### Main
914
915#### Main
916
917All same text, different levels."#;
918
919 let config = MD024Config {
920 allow_different_nesting: true,
921 siblings_only: false,
922 ..MD024Config::default()
923 };
924 let result = run_test(content, config);
925 assert!(result.is_ok());
926 let warnings = result.unwrap();
927 assert_eq!(warnings.len(), 0);
929 }
930
931 #[test]
934 fn test_custom_anchor_different_ids_no_warning_default() {
935 let content = "#### Unit testing\n\n#### Unit testing {#custom-anchor}\n";
937 let config = MD024Config::default();
938 let result = run_test(content, config);
939 assert!(result.is_ok());
940 let warnings = result.unwrap();
941 assert_eq!(
942 warnings.len(),
943 0,
944 "headings with different custom anchors must not be flagged"
945 );
946 }
947
948 #[test]
949 fn test_custom_anchor_same_id_flagged() {
950 let content = "## Overview {#overview}\n\n## Overview {#overview}\n";
952 let config = MD024Config::default();
953 let result = run_test(content, config);
954 assert!(result.is_ok());
955 let warnings = result.unwrap();
956 assert_eq!(
957 warnings.len(),
958 1,
959 "headings with identical custom anchors must be flagged"
960 );
961 assert_eq!(warnings[0].message, "Duplicate heading: 'Overview'.");
962 }
963
964 #[test]
965 fn test_custom_anchor_one_with_id_one_without_no_warning() {
966 let content = "## Setup\n\n## Setup {#alt-setup}\n";
968 let config = MD024Config::default();
969 let result = run_test(content, config);
970 assert!(result.is_ok());
971 let warnings = result.unwrap();
972 assert_eq!(
973 warnings.len(),
974 0,
975 "a plain heading and one with a custom anchor must not collide"
976 );
977 }
978
979 #[test]
980 fn test_allow_different_link_anchors_false_restores_original_behavior() {
981 let content = "#### Unit testing\n\n#### Unit testing {#custom-anchor}\n";
984 let config = MD024Config {
985 allow_different_link_anchors: false,
986 siblings_only: false,
987 ..MD024Config::default()
988 };
989 let result = run_test(content, config);
990 assert!(result.is_ok());
991 let warnings = result.unwrap();
992 assert_eq!(
993 warnings.len(),
994 1,
995 "with allow_different_link_anchors=false the duplicate must be flagged"
996 );
997 assert_eq!(warnings[0].message, "Duplicate heading: 'Unit testing'.");
998 }
999
1000 #[test]
1001 fn test_custom_anchor_with_siblings_only() {
1002 let content = concat!(
1005 "# Parent\n\n",
1006 "## Section {#section-a}\n\n",
1007 "## Section {#section-b}\n\n",
1008 "## Section {#section-a}\n",
1009 );
1010 let config = MD024Config {
1011 siblings_only: true,
1012 allow_different_link_anchors: true,
1013 ..MD024Config::default()
1014 };
1015 let result = run_test(content, config);
1016 assert!(result.is_ok());
1017 let warnings = result.unwrap();
1018 assert_eq!(
1020 warnings.len(),
1021 1,
1022 "only exact key collision under same parent must be flagged"
1023 );
1024 assert_eq!(warnings[0].message, "Duplicate heading: 'Section'.");
1025 }
1026
1027 #[test]
1028 fn test_custom_anchor_with_allow_different_nesting() {
1029 let content = concat!(
1032 "## Topic {#topic-1}\n\n",
1033 "### Topic {#topic-2}\n\n",
1034 "## Topic {#topic-1}\n",
1035 );
1036 let config = MD024Config {
1037 allow_different_nesting: true,
1038 siblings_only: false,
1039 allow_different_link_anchors: true,
1040 };
1041 let result = run_test(content, config);
1042 assert!(result.is_ok());
1043 let warnings = result.unwrap();
1044 assert_eq!(
1047 warnings.len(),
1048 1,
1049 "same-level, same-anchor headings must still be flagged"
1050 );
1051 assert_eq!(warnings[0].message, "Duplicate heading: 'Topic'.");
1052 }
1053
1054 #[test]
1055 fn test_heading_text_containing_hash_no_false_collision() {
1056 let content = "## Foo#bar\n\n## Foo {#bar}\n";
1061 let config = MD024Config {
1062 allow_different_nesting: false,
1063 siblings_only: false,
1064 allow_different_link_anchors: true,
1065 };
1066 let result = run_test(content, config);
1067 assert!(result.is_ok());
1068 let warnings = result.unwrap();
1069 assert!(
1070 warnings.is_empty(),
1071 "heading text containing # must not collide with a different heading carrying {{#id}}; got: {warnings:#?}",
1072 );
1073 }
1074
1075 #[test]
1076 fn test_heading_text_containing_hash_real_duplicate_still_flagged() {
1077 let content = "## Foo#bar\n\n## Foo#bar\n";
1080 let config = MD024Config {
1081 allow_different_nesting: false,
1082 siblings_only: false,
1083 allow_different_link_anchors: true,
1084 };
1085 let result = run_test(content, config);
1086 assert!(result.is_ok());
1087 let warnings = result.unwrap();
1088 assert_eq!(warnings.len(), 1);
1089 assert_eq!(warnings[0].message, "Duplicate heading: 'Foo#bar'.");
1090 }
1091
1092 #[test]
1093 fn test_mdg_flags_repeated_structural_headings() {
1094 let rule = MD024NoDuplicateHeading::default();
1098 let content = "# Feature: Checkout\n\n## Scenario: Purchase\n\n## Scenario: Purchase\n\n#### Examples:\n\n#### Examples:\n";
1099
1100 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1101 let warnings = rule.check(&mdg_ctx).unwrap();
1102 assert_eq!(warnings.len(), 2, "MDG duplicates must be reported: {warnings:?}");
1103 assert_eq!(warnings[0].message, "Duplicate heading: 'Scenario: Purchase'.");
1104 assert_eq!(warnings[1].message, "Duplicate heading: 'Examples:'.");
1105
1106 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1107 assert_eq!(
1108 rule.check(&standard_ctx).unwrap().len(),
1109 warnings.len(),
1110 "MDG must not differ from Standard"
1111 );
1112 }
1113
1114 #[test]
1115 fn test_mdg_accepts_uniquely_named_structural_headings() {
1116 let rule = MD024NoDuplicateHeading::default();
1119 let content = "# Feature: Checkout\n\n## Scenario Outline: Purchase\n\n#### Examples: valid cards\n\n#### Examples: expired cards\n";
1120
1121 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1122 assert!(rule.check(&mdg_ctx).unwrap().is_empty());
1123 }
1124 #[test]
1125 fn test_literal_headings_do_not_enter_duplicate_sets() {
1126 for rule in [
1127 MD024NoDuplicateHeading::new(false, false),
1128 MD024NoDuplicateHeading::new(true, false),
1129 MD024NoDuplicateHeading::new(false, true),
1130 MD024NoDuplicateHeading::new(true, true),
1131 ] {
1132 for literal in ["# Visible", "\nVisible\n===\n"] {
1133 for code in [
1134 format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
1135 format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
1136 ] {
1137 let flavor = if code.starts_with("{{<") {
1138 crate::config::MarkdownFlavor::Hugo
1139 } else {
1140 crate::config::MarkdownFlavor::Standard
1141 };
1142 for source in [format!("# Visible\n\n{code}\n"), format!("{code}\n\n# Visible\n")] {
1143 for ending in ["\n", "\r\n"] {
1144 let source = source.replace('\n', ending);
1145 let ctx = LintContext::new(&source, flavor, None);
1146 assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
1147 assert_eq!(rule.fix(&ctx).unwrap(), source);
1148 }
1149 }
1150 }
1151 }
1152 }
1153 }
1154
1155 #[test]
1156 fn test_literal_parent_headings_do_not_hide_visible_sibling_duplicates() {
1157 for rule in [
1158 MD024NoDuplicateHeading::new(false, true),
1159 MD024NoDuplicateHeading::new(true, true),
1160 ] {
1161 for literal in ["# Fake", "\nFake\n===\n"] {
1162 for code in [
1163 format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
1164 format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
1165 ] {
1166 let flavor = if code.starts_with("{{<") {
1167 crate::config::MarkdownFlavor::Hugo
1168 } else {
1169 crate::config::MarkdownFlavor::Standard
1170 };
1171 for ending in ["\n", "\r\n"] {
1172 let source = format!("# Parent\n\n## Repeat\n\n{code}\n\n## Repeat\n").replace('\n', ending);
1173 let ctx = LintContext::new(&source, flavor, None);
1174 let warnings = rule.check(&ctx).unwrap();
1175 assert_eq!(warnings.len(), 1, "{source}: {warnings:?}");
1176 assert_eq!(warnings[0].line, source.lines().count());
1177 assert_eq!(rule.fix(&ctx).unwrap(), source);
1178 }
1179 }
1180 }
1181 }
1182 }
1183
1184 #[test]
1185 fn test_duplicate_dynamic_heading_text_remains_visible() {
1186 let rule = MD024NoDuplicateHeading::default();
1187 for title in ["# {{ title }}", "Title {{ title }}\n==="] {
1188 let source = format!("{title}\n\n{title}\n");
1189 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1190 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1191 assert_eq!(rule.fix(&ctx).unwrap(), source);
1192 }
1193 }
1194 mod quoted_headings {
1195 use super::*;
1196
1197 fn lines(content: &str, config: MD024Config) -> Vec<usize> {
1198 run_test(content, config).unwrap().iter().map(|w| w.line).collect()
1199 }
1200
1201 fn flat() -> MD024Config {
1202 MD024Config {
1203 siblings_only: false,
1204 ..Default::default()
1205 }
1206 }
1207
1208 #[test]
1209 fn quoted_duplicate_of_an_unquoted_heading_is_flagged() {
1210 assert_eq!(lines("# Same\n\n> # Same\n", flat()), vec![3]);
1211 assert_eq!(lines("> # Same\n\n# Same\n", flat()), vec![3]);
1212 }
1213
1214 #[test]
1215 fn quoted_headings_duplicate_each_other() {
1216 assert_eq!(lines("> ## Same\n\n> ## Same\n", flat()), vec![3]);
1217 assert_eq!(lines("> Same\n> ---\n\n> Same\n> ---\n", flat()), vec![4]);
1218 }
1219
1220 #[test]
1221 fn nested_and_listed_quotes_are_checked() {
1222 assert_eq!(lines("# Same\n\n> > # Same\n", flat()), vec![3]);
1223 assert_eq!(lines("# Same\n\n- x\n\n > # Same\n", flat()), vec![5]);
1224 assert_eq!(lines("# Same\n\n> - # Same\n", flat()), vec![3]);
1225 assert_eq!(lines("# Same\n\n- > # Same\n", flat()), vec![3]);
1226 }
1227
1228 #[test]
1229 fn quoted_headings_follow_the_nesting_options() {
1230 let nesting = MD024Config {
1231 allow_different_nesting: true,
1232 siblings_only: false,
1233 ..Default::default()
1234 };
1235 assert!(lines("# Same\n\n> ## Same\n", nesting.clone()).is_empty());
1236 assert_eq!(lines("## Same\n\n> ## Same\n", nesting), vec![3]);
1237 }
1238
1239 #[test]
1240 fn quoted_headings_are_scoped_by_siblings_only() {
1241 let siblings = MD024Config {
1242 siblings_only: true,
1243 ..Default::default()
1244 };
1245 assert_eq!(lines("# A\n\n## Note\n\n> ## Note\n", siblings.clone()), vec![5]);
1247 assert!(lines("# A\n\n## Note\n\n# B\n\n> ## Note\n", siblings).is_empty());
1249 }
1250
1251 #[test]
1252 fn quote_only_document_is_not_skipped() {
1253 let ctx = LintContext::new("> # Same\n\n> # Same\n", crate::config::MarkdownFlavor::Standard, None);
1254 assert!(!MD024NoDuplicateHeading::default().should_skip(&ctx));
1255 }
1256
1257 #[test]
1258 fn distinct_and_code_look_alikes_are_not_flagged() {
1259 assert!(lines("# A\n\n> # B\n", flat()).is_empty());
1260 assert!(lines("# Same\n\n> ```\n> # Same\n> ```\n", flat()).is_empty());
1261 }
1262 }
1263}