1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::types::HeadingLevel;
6use crate::utils::range_utils::calculate_match_range;
7use crate::utils::thematic_break;
8use toml;
9
10mod md025_config;
11use md025_config::MD025Config;
12
13#[derive(Clone, Default)]
14pub struct MD025SingleTitle {
15 config: MD025Config,
16}
17
18impl MD025SingleTitle {
19 pub fn new(level: usize, front_matter_title: &str) -> Self {
20 Self {
21 config: MD025Config {
22 level: HeadingLevel::new(level as u8).expect("Level must be 1-6"),
23 front_matter_title: front_matter_title.to_string(),
24 allow_document_sections: true,
25 allow_with_separators: true,
26 },
27 }
28 }
29
30 pub fn strict() -> Self {
31 Self {
32 config: MD025Config {
33 level: HeadingLevel::new(1).unwrap(),
34 front_matter_title: "title".to_string(),
35 allow_document_sections: false,
36 allow_with_separators: false,
37 },
38 }
39 }
40
41 pub fn from_config_struct(config: MD025Config) -> Self {
42 Self { config }
43 }
44
45 fn has_front_matter_title(&self, ctx: &crate::lint_context::LintContext) -> bool {
47 self.front_matter_lines_have_title(ctx.raw_lines())
48 }
49
50 pub(crate) fn default_front_matter_lines_have_title(lines: &[&str]) -> bool {
51 Self::default().front_matter_lines_have_title(lines)
52 }
53
54 fn front_matter_lines_have_title(&self, content_lines: &[&str]) -> bool {
55 if self.config.front_matter_title.is_empty() {
56 return false;
57 }
58
59 if content_lines.first().map(|l| l.trim()) != Some("---") {
60 return false;
61 }
62
63 for (idx, line) in content_lines.iter().enumerate().skip(1) {
64 if line.trim() == "---" {
65 let front_matter_content = content_lines[1..idx].join("\n");
66 return front_matter_content
67 .lines()
68 .any(|l| l.trim().starts_with(&format!("{}:", self.config.front_matter_title)));
69 }
70 }
71
72 false
73 }
74
75 fn is_document_section_heading(&self, heading_text: &str) -> bool {
77 if !self.config.allow_document_sections {
78 return false;
79 }
80
81 let lower_text = heading_text.to_lowercase();
82
83 let section_indicators = [
85 "appendix",
86 "appendices",
87 "reference",
88 "references",
89 "bibliography",
90 "index",
91 "indices",
92 "glossary",
93 "glossaries",
94 "conclusion",
95 "conclusions",
96 "summary",
97 "executive summary",
98 "acknowledgment",
99 "acknowledgments",
100 "acknowledgement",
101 "acknowledgements",
102 "about",
103 "contact",
104 "license",
105 "legal",
106 "changelog",
107 "change log",
108 "history",
109 "faq",
110 "frequently asked questions",
111 "troubleshooting",
112 "support",
113 "installation",
114 "setup",
115 "getting started",
116 "api reference",
117 "api documentation",
118 "examples",
119 "tutorials",
120 "guides",
121 ];
122
123 let words: Vec<&str> = lower_text.split_whitespace().collect();
125 section_indicators.iter().any(|&indicator| {
126 let indicator_words: Vec<&str> = indicator.split_whitespace().collect();
128 let starts_with_indicator = if indicator_words.len() == 1 {
129 words.first() == Some(&indicator)
130 } else {
131 words.len() >= indicator_words.len()
132 && words[..indicator_words.len()] == indicator_words[..]
133 };
134
135 starts_with_indicator ||
136 lower_text.starts_with(&format!("{indicator}:")) ||
137 words.contains(&indicator) ||
139 (indicator_words.len() > 1 && words.windows(indicator_words.len()).any(|w| w == indicator_words.as_slice())) ||
141 (indicator == "appendix" && words.contains(&"appendix") && words.len() >= 2 && {
143 let after_appendix = words.iter().skip_while(|&&w| w != "appendix").nth(1);
144 matches!(after_appendix, Some(&"a" | &"b" | &"c" | &"d" | &"1" | &"2" | &"3" | &"i" | &"ii" | &"iii" | &"iv"))
145 })
146 })
147 }
148
149 fn is_horizontal_rule(line: &str) -> bool {
150 thematic_break::is_thematic_break(line)
151 }
152
153 fn is_potential_setext_heading(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
155 if line_num == 0 || line_num >= ctx.lines.len() {
156 return false;
157 }
158
159 let line = ctx.lines[line_num].content(ctx.content).trim();
160 let prev_line = if line_num > 0 {
161 ctx.lines[line_num - 1].content(ctx.content).trim()
162 } else {
163 ""
164 };
165
166 let is_dash_line = !line.is_empty() && line.chars().all(|c| c == '-');
167 let is_equals_line = !line.is_empty() && line.chars().all(|c| c == '=');
168 let prev_line_has_content = !prev_line.is_empty() && !Self::is_horizontal_rule(prev_line);
169 (is_dash_line || is_equals_line) && prev_line_has_content
170 }
171
172 fn demotion_span(
179 ctx: &crate::lint_context::LintContext,
180 line_num: usize,
181 heading: &crate::lint_context::HeadingInfo,
182 ) -> (std::ops::Range<usize>, String) {
183 let first_idx = line_num + 1 - heading.text_lines;
184 let is_setext = matches!(
185 heading.style,
186 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
187 );
188 let range = if is_setext && line_num + 2 <= ctx.lines.len() {
189 ctx.line_content_byte_range(first_idx + 1).start..ctx.line_content_byte_range(line_num + 2).end
190 } else {
191 ctx.line_content_byte_range(first_idx + 1)
192 };
193 let first_content = ctx.lines[first_idx].content(ctx.content);
196 (range, first_content[..heading.first_line_prefix_len()].to_string())
197 }
198
199 fn heading_is_in_literal_code(
202 ctx: &crate::lint_context::LintContext,
203 heading_idx: usize,
204 heading: &crate::lint_context::HeadingInfo,
205 ) -> bool {
206 let marker_idx = if matches!(
207 heading.style,
208 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
209 ) {
210 heading_idx + 1
211 } else {
212 heading_idx
213 };
214 ctx.lines.get(marker_idx).is_some_and(|line| {
215 let marker_offset = line.byte_offset + line.indent;
216 ctx.is_inside_template_code(marker_offset) || ctx.is_inside_mdx_code(marker_offset)
217 })
218 }
219
220 fn demotion_is_safe(
222 ctx: &crate::lint_context::LintContext,
223 heading_idx: usize,
224 heading: &crate::lint_context::HeadingInfo,
225 ) -> bool {
226 crate::rules::heading_utils::setext_to_atx_is_safe(ctx, heading_idx, heading)
227 }
228
229 fn section_end(ctx: &crate::lint_context::LintContext, heading_idx: usize, target_level: usize) -> usize {
230 ((heading_idx + 1)..ctx.lines.len())
231 .find(|&idx| {
232 ctx.heading_on_line(idx + 1).is_some_and(|parsed| {
233 parsed.heading.level as usize <= target_level
234 && !parsed.line_info.in_code_block
235 && !Self::heading_is_in_literal_code(ctx, idx, parsed.heading)
236 })
237 })
238 .unwrap_or(ctx.lines.len())
239 }
240
241 fn section_demotion_is_safe(
244 &self,
245 ctx: &crate::lint_context::LintContext,
246 heading_idx: usize,
247 heading: &crate::lint_context::HeadingInfo,
248 ) -> bool {
249 if !Self::demotion_is_safe(ctx, heading_idx, heading) {
250 return false;
251 }
252 let end = Self::section_end(ctx, heading_idx, self.config.level.as_usize());
253 ((heading_idx + 1)..end).all(|idx| {
254 let Some(parsed) = ctx.heading_on_line(idx + 1) else {
255 return true;
256 };
257 let child = parsed.heading;
258 parsed.line_info.in_code_block
259 || child.level >= 6
260 || Self::heading_is_in_literal_code(ctx, idx, child)
261 || ctx.inline_config().is_rule_disabled("MD025", parsed.first_line_num())
262 || if parsed.is_blockquote() {
263 !parsed.is_setext()
267 } else {
268 Self::demotion_is_safe(ctx, idx, child)
269 }
270 })
271 }
272
273 fn has_separator_before_heading(&self, ctx: &crate::lint_context::LintContext, heading_line: usize) -> bool {
275 if !self.config.allow_with_separators || heading_line == 0 {
276 return false;
277 }
278
279 let search_start = heading_line.saturating_sub(5);
282
283 for line_num in search_start..heading_line {
284 if line_num >= ctx.lines.len() {
285 continue;
286 }
287
288 let info = &ctx.lines[line_num];
289 let marker_offset = info.byte_offset + info.indent;
290 if info.in_code_block
291 || info.in_front_matter
292 || info.in_html_comment
293 || ctx.is_inside_template_code(marker_offset)
294 || ctx.is_inside_mdx_code(marker_offset)
295 {
296 continue;
297 }
298 let line = &info.content(ctx.content);
299 if Self::is_horizontal_rule(line) && !Self::is_potential_setext_heading(ctx, line_num) {
300 let has_intermediate_heading = ((line_num + 1)..heading_line).any(|idx| {
303 let mut recorded_idx = idx;
307 while recorded_idx < heading_line
308 && ctx.heading_on_line(recorded_idx + 1).is_none()
309 && ctx.lines[recorded_idx].is_setext_heading_text
310 {
311 recorded_idx += 1;
312 }
313 recorded_idx < heading_line
314 && ctx.heading_on_line(recorded_idx + 1).is_some_and(|parsed| {
315 !parsed.line_info.in_code_block
316 && !Self::heading_is_in_literal_code(ctx, recorded_idx, parsed.heading)
317 })
318 });
319
320 if !has_intermediate_heading {
321 return true;
322 }
323 }
324 }
325
326 false
327 }
328}
329
330impl Rule for MD025SingleTitle {
331 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
332 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(25))
333 }
334
335 fn name(&self) -> &'static str {
336 "MD025"
337 }
338
339 fn description(&self) -> &'static str {
340 "Multiple top-level headings in the same document"
341 }
342
343 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
344 if ctx.lines.is_empty() {
346 return Ok(Vec::new());
347 }
348
349 let mut warnings = Vec::new();
350
351 let found_title_in_front_matter = self.has_front_matter_title(ctx);
352
353 let mut target_level_headings = Vec::new();
355 for parsed in ctx.headings() {
356 let line_num = parsed.line_num - 1;
357 if parsed.heading.level as usize == self.config.level.as_usize() {
358 if parsed.line_info.in_code_block || Self::heading_is_in_literal_code(ctx, line_num, parsed.heading) {
362 continue;
363 }
364 target_level_headings.push(line_num);
365 }
366 }
367
368 let headings_to_flag: &[usize] = if found_title_in_front_matter {
373 &target_level_headings
374 } else if target_level_headings.len() > 1 {
375 &target_level_headings[1..]
376 } else {
377 &[]
378 };
379
380 if !headings_to_flag.is_empty() {
381 for &line_num in headings_to_flag {
382 if let Some(parsed) = ctx.heading_on_line(line_num + 1) {
383 let heading = parsed.heading;
384 let heading_text = &heading.text;
385 let first_idx = line_num + 1 - heading.text_lines;
388
389 let should_allow = self.is_document_section_heading(heading_text)
391 || self.has_separator_before_heading(ctx, first_idx);
392
393 if should_allow {
394 continue; }
396
397 let line_content = &ctx.lines[line_num].content(ctx.content);
399 let (start_line, start_col, end_line, end_col) = if heading.text_lines > 1 {
400 let first_content = ctx.lines[first_idx].content(ctx.content);
403 let indent_chars = first_content.len() - first_content.trim_start().len();
404 (
405 first_idx + 1,
406 first_content[..indent_chars].chars().count() + 1,
407 line_num + 1,
408 line_content.trim_end().chars().count() + 1,
409 )
410 } else {
411 let text_start_in_line = if let Some(pos) = line_content.find(heading_text) {
412 pos
413 } else {
414 if line_content.trim_start().starts_with('#') {
416 let trimmed = line_content.trim_start();
417 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
418 let after_hashes = &trimmed[hash_count..];
419 let text_start_in_trimmed = after_hashes.find(heading_text).unwrap_or(0);
420 (line_content.len() - trimmed.len()) + hash_count + text_start_in_trimmed
421 } else {
422 0 }
424 };
425 calculate_match_range(
426 line_num + 1, line_content,
428 text_start_in_line,
429 heading_text.len(),
430 )
431 };
432
433 let (fix_range, indentation) = Self::demotion_span(ctx, line_num, heading);
434
435 let demoted_level = self.config.level.as_usize() + 1;
439 let fix = if demoted_level > 6
442 || parsed.is_blockquote()
443 || !self.section_demotion_is_safe(ctx, line_num, heading)
444 {
445 None
446 } else {
447 let raw = if heading.has_closing_sequence {
448 std::borrow::Cow::Borrowed(heading.raw_text.as_str())
449 } else {
450 crate::rules::heading_utils::escape_atx_closing_hashes(&heading.raw_text)
451 };
452 let hashes = "#".repeat(demoted_level);
453 let closing = if heading.has_closing_sequence {
454 format!(" {}", "#".repeat(demoted_level))
455 } else {
456 String::new()
457 };
458 let replacement = if raw.is_empty() {
459 format!("{indentation}{hashes}{closing}")
460 } else {
461 format!("{indentation}{hashes} {raw}{closing}")
462 };
463 Some(Fix::new(fix_range, replacement))
464 };
465
466 warnings.push(LintWarning {
467 rule_name: Some(self.name().to_string()),
468 message: format!(
469 "Multiple top-level headings (level {}) in the same document",
470 self.config.level.as_usize()
471 ),
472 line: start_line,
473 column: start_col,
474 end_line,
475 end_column: end_col,
476 severity: Severity::Error,
477 fix,
478 });
479 }
480 }
481 }
482
483 Ok(warnings)
484 }
485
486 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
487 let warnings = self.check(ctx)?;
488 if warnings.is_empty() {
489 return Ok(ctx.content.to_string());
490 }
491 let warnings =
492 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
493
494 let mut all_warnings = warnings.clone();
499
500 let target_level = self.config.level.as_usize();
501
502 for warning in &warnings {
503 if warning.fix.is_none() {
504 continue;
505 }
506 let mut heading_line = warning.line - 1;
510 while heading_line + 1 < ctx.lines.len()
511 && ctx.lines[heading_line].heading.is_none()
512 && ctx.lines[heading_line].is_setext_heading_text
513 {
514 heading_line += 1;
515 }
516
517 let section_end = Self::section_end(ctx, heading_line, target_level);
519
520 for line_num in (heading_line + 1)..section_end {
522 let Some(parsed) = ctx.heading_on_line(line_num + 1) else {
523 continue;
524 };
525 let (line_info, heading) = (parsed.line_info, parsed.heading);
526 if line_info.in_code_block || Self::heading_is_in_literal_code(ctx, line_num, heading) {
527 continue;
528 }
529
530 let new_level = heading.level as usize + 1;
531 if new_level > 6 {
532 continue;
534 }
535
536 let line_content = line_info.content(ctx.content);
537
538 let (fix_range, indentation) = Self::demotion_span(ctx, line_num, heading);
541 let first_line = line_num + 2 - heading.text_lines;
542
543 let hashes = "#".repeat(new_level);
544 let raw = if heading.has_closing_sequence {
545 std::borrow::Cow::Borrowed(heading.raw_text.as_str())
546 } else {
547 crate::rules::heading_utils::escape_atx_closing_hashes(&heading.raw_text)
548 };
549 let closing = if heading.has_closing_sequence {
550 format!(" {}", "#".repeat(new_level))
551 } else {
552 String::new()
553 };
554 let replacement = if raw.is_empty() {
555 format!("{indentation}{hashes}{closing}")
556 } else {
557 format!("{indentation}{hashes} {raw}{closing}")
558 };
559
560 all_warnings.push(crate::rule::LintWarning {
561 rule_name: Some(self.name().to_string()),
562 message: String::new(),
563 line: first_line,
564 column: 1,
565 end_line: line_num + 1,
566 end_column: line_content.chars().count(),
567 severity: crate::rule::Severity::Error,
568 fix: Some(Fix::new(fix_range, replacement)),
569 });
570 }
571 }
572
573 let all_warnings =
577 crate::utils::fix_utils::filter_warnings_by_inline_config(all_warnings, ctx.inline_config(), self.name());
578
579 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &all_warnings)
580 .map_err(crate::rule::LintError::InvalidInput)
581 }
582
583 fn category(&self) -> RuleCategory {
585 RuleCategory::Heading
586 }
587
588 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
590 if ctx.content.is_empty() {
592 return true;
593 }
594
595 if !ctx.likely_has_headings() {
597 return true;
598 }
599
600 let has_fm_title = self.has_front_matter_title(ctx);
601
602 let mut target_level_count = 0;
604 for parsed in ctx.headings() {
605 let line_info = parsed.line_info;
606 if parsed.heading.level as usize == self.config.level.as_usize() {
607 if line_info.in_code_block
611 || line_info.in_pymdown_block
612 || Self::heading_is_in_literal_code(ctx, parsed.line_num - 1, parsed.heading)
613 {
614 continue;
615 }
616 target_level_count += 1;
617
618 if has_fm_title {
620 return false;
621 }
622
623 if target_level_count > 1 {
625 return false;
626 }
627 }
628 }
629
630 target_level_count <= 1
632 }
633
634 fn as_any(&self) -> &dyn std::any::Any {
635 self
636 }
637
638 crate::impl_rule_config_methods!(MD025Config);
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644 use crate::lint_context::LintContext;
645
646 #[test]
647 fn test_with_cached_headings() {
648 let rule = MD025SingleTitle::default();
649
650 let content = "# Title\n\n## Section 1\n\n## Section 2";
652 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
653 let result = rule.check(&ctx).unwrap();
654 assert!(result.is_empty());
655
656 let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
658 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
659 let result = rule.check(&ctx).unwrap();
660 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 5);
662
663 let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
665 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
666 let result = rule.check(&ctx).unwrap();
667 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
668 assert_eq!(result[0].line, 5);
669 }
670
671 #[test]
672 fn test_allow_document_sections() {
673 let config = md025_config::MD025Config {
675 allow_document_sections: true,
676 ..Default::default()
677 };
678 let rule = MD025SingleTitle::from_config_struct(config);
679
680 let valid_cases = vec![
682 "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
683 "# Introduction\n\nContent here\n\n# References\n\nRef content",
684 "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
685 "# Manual\n\nContent\n\n# Index\n\nIndex content",
686 "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
687 "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
688 "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
689 ];
690
691 for case in valid_cases {
692 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
693 let result = rule.check(&ctx).unwrap();
694 assert!(result.is_empty(), "Should not flag document sections in: {case}");
695 }
696
697 let invalid_cases = vec![
699 "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
700 "# First\n\nContent\n\n# Second Title\n\nMore content",
701 ];
702
703 for case in invalid_cases {
704 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
705 let result = rule.check(&ctx).unwrap();
706 assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
707 }
708 }
709
710 #[test]
711 fn test_strict_mode() {
712 let rule = MD025SingleTitle::strict(); let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
716 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717 let result = rule.check(&ctx).unwrap();
718 assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
719 }
720
721 #[test]
722 fn test_bounds_checking_bug() {
723 let rule = MD025SingleTitle::default();
726
727 let content = "# First\n#";
729 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730
731 let result = rule.check(&ctx);
733 assert!(result.is_ok());
734
735 let fix_result = rule.fix(&ctx);
737 assert!(fix_result.is_ok());
738 }
739
740 #[test]
741 fn test_bounds_checking_edge_case() {
742 let rule = MD025SingleTitle::default();
745
746 let content = "# First Title\n#";
750 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
751
752 let result = rule.check(&ctx);
754 assert!(result.is_ok());
755
756 if let Ok(warnings) = result
757 && !warnings.is_empty()
758 {
759 let fix_result = rule.fix(&ctx);
761 assert!(fix_result.is_ok());
762
763 if let Ok(fixed_content) = fix_result {
765 assert!(!fixed_content.is_empty());
766 assert!(fixed_content.contains("##"));
768 }
769 }
770 }
771
772 #[test]
773 fn test_horizontal_rule_separators() {
774 let config = md025_config::MD025Config {
776 allow_with_separators: true,
777 ..Default::default()
778 };
779 let rule = MD025SingleTitle::from_config_struct(config);
780
781 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.\n\n***\n\n# Third Title\n\nFinal content.";
783 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
784 let result = rule.check(&ctx).unwrap();
785 assert!(
786 result.is_empty(),
787 "Should not flag headings separated by horizontal rules"
788 );
789
790 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.\n\n# Third Title\n\nNo separator before this one.";
792 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
793 let result = rule.check(&ctx).unwrap();
794 assert_eq!(result.len(), 1, "Should flag the heading without separator");
795 assert_eq!(result[0].line, 11); let strict_rule = MD025SingleTitle::strict();
799 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
800 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801 let result = strict_rule.check(&ctx).unwrap();
802 assert_eq!(
803 result.len(),
804 1,
805 "Strict mode should flag all multiple H1s regardless of separators"
806 );
807 }
808
809 #[test]
810 fn test_python_comments_in_code_blocks() {
811 let rule = MD025SingleTitle::default();
812
813 let content = "# Main Title\n\n```python\n# This is a Python comment, not a heading\nprint('Hello')\n```\n\n## Section\n\nMore content.";
815 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816 let result = rule.check(&ctx).unwrap();
817 assert!(
818 result.is_empty(),
819 "Should not flag Python comments in code blocks as headings"
820 );
821
822 let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
824 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
825 let fixed = rule.fix(&ctx).unwrap();
826 assert!(
827 fixed.contains("# Python comment"),
828 "Fix should preserve Python comments in code blocks"
829 );
830 assert!(
831 fixed.contains("## Second Title"),
832 "Fix should demote the actual second heading"
833 );
834 }
835
836 #[test]
837 fn test_fix_preserves_attribute_lists() {
838 let rule = MD025SingleTitle::strict();
839
840 let content = "# First Title\n\n# Second Title { #custom-id .special }";
842 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
843
844 let warnings = rule.check(&ctx).unwrap();
846 assert_eq!(warnings.len(), 1);
847 assert!(warnings[0].fix.is_some());
849
850 let fixed = rule.fix(&ctx).unwrap();
852 assert!(
853 fixed.contains("## Second Title { #custom-id .special }"),
854 "fix() should demote to H2 while preserving attribute list, got: {fixed}"
855 );
856 }
857
858 #[test]
859 fn test_frontmatter_title_counts_as_h1() {
860 let rule = MD025SingleTitle::default();
861
862 let content = "---\ntitle: Heading in frontmatter\n---\n\n# Heading in document\n\nSome introductory text.";
864 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
865 let result = rule.check(&ctx).unwrap();
866 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
867 assert_eq!(result[0].line, 5);
868 }
869
870 #[test]
871 fn test_frontmatter_title_with_multiple_body_h1s() {
872 let config = md025_config::MD025Config {
873 front_matter_title: "title".to_string(),
874 ..Default::default()
875 };
876 let rule = MD025SingleTitle::from_config_struct(config);
877
878 let content = "---\ntitle: FM Title\n---\n\n# First Body H1\n\nContent\n\n# Second Body H1\n\nMore content";
880 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
881 let result = rule.check(&ctx).unwrap();
882 assert_eq!(result.len(), 2, "Should flag all body H1s when frontmatter has title");
883 assert_eq!(result[0].line, 5);
884 assert_eq!(result[1].line, 9);
885 }
886
887 #[test]
888 fn test_frontmatter_without_title_no_warning() {
889 let rule = MD025SingleTitle::default();
890
891 let content = "---\nauthor: Someone\ndate: 2024-01-01\n---\n\n# Only Heading\n\nContent here.";
893 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
894 let result = rule.check(&ctx).unwrap();
895 assert!(result.is_empty(), "Should not flag when frontmatter has no title");
896 }
897
898 #[test]
899 fn test_no_frontmatter_single_h1_no_warning() {
900 let rule = MD025SingleTitle::default();
901
902 let content = "# Only Heading\n\nSome content.";
904 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
905 let result = rule.check(&ctx).unwrap();
906 assert!(result.is_empty(), "Should not flag single H1 without frontmatter");
907 }
908
909 #[test]
910 fn test_frontmatter_custom_title_key() {
911 let config = md025_config::MD025Config {
913 front_matter_title: "heading".to_string(),
914 ..Default::default()
915 };
916 let rule = MD025SingleTitle::from_config_struct(config);
917
918 let content = "---\nheading: My Heading\n---\n\n# Body Heading\n\nContent.";
920 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
921 let result = rule.check(&ctx).unwrap();
922 assert_eq!(
923 result.len(),
924 1,
925 "Should flag body H1 when custom frontmatter key matches"
926 );
927 assert_eq!(result[0].line, 5);
928
929 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
931 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
932 let result = rule.check(&ctx).unwrap();
933 assert!(
934 result.is_empty(),
935 "Should not flag when frontmatter key doesn't match config"
936 );
937 }
938
939 #[test]
940 fn test_frontmatter_title_empty_config_disables() {
941 let rule = MD025SingleTitle::new(1, "");
943
944 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
945 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
946 let result = rule.check(&ctx).unwrap();
947 assert!(result.is_empty(), "Should not flag when front_matter_title is empty");
948 }
949
950 #[test]
951 fn test_frontmatter_title_with_level_config() {
952 let config = md025_config::MD025Config {
954 level: HeadingLevel::new(2).unwrap(),
955 front_matter_title: "title".to_string(),
956 ..Default::default()
957 };
958 let rule = MD025SingleTitle::from_config_struct(config);
959
960 let content = "---\ntitle: FM Title\n---\n\n# Body H1\n\n## Body H2\n\nContent.";
962 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
963 let result = rule.check(&ctx).unwrap();
964 assert_eq!(
965 result.len(),
966 1,
967 "Should flag body H2 when level=2 and frontmatter has title"
968 );
969 assert_eq!(result[0].line, 7);
970 }
971
972 #[test]
973 fn test_frontmatter_title_fix_demotes_body_heading() {
974 let config = md025_config::MD025Config {
975 front_matter_title: "title".to_string(),
976 ..Default::default()
977 };
978 let rule = MD025SingleTitle::from_config_struct(config);
979
980 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
981 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
982 let fixed = rule.fix(&ctx).unwrap();
983 assert!(
984 fixed.contains("## Body Heading"),
985 "Fix should demote body H1 to H2 when frontmatter has title, got: {fixed}"
986 );
987 assert!(fixed.contains("---\ntitle: FM Title\n---"));
989 }
990
991 #[test]
992 fn test_frontmatter_title_should_skip_respects_frontmatter() {
993 let rule = MD025SingleTitle::default();
994
995 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
997 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
998 assert!(
999 !rule.should_skip(&ctx),
1000 "should_skip must return false when frontmatter has title and body has H1"
1001 );
1002
1003 let content = "---\nauthor: Someone\n---\n\n# Body Heading\n\nContent.";
1005 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1006 assert!(
1007 rule.should_skip(&ctx),
1008 "should_skip should return true with no frontmatter title and single H1"
1009 );
1010 }
1011
1012 #[test]
1013 fn test_fix_cascades_subheadings_after_demoting_duplicate_h1() {
1014 let rule = MD025SingleTitle::default();
1015
1016 let content = "abcd\n\n# 1_1\n\n# 1_2\n\n## 1_2-2_1\n\n# 1_3\n\n## 1_3-2_1\n\n### 1_3-2_1-3_1\n";
1018 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1019 let fixed = rule.fix(&ctx).unwrap();
1020
1021 assert!(fixed.contains("# 1_1"), "First H1 must be preserved: {fixed}");
1022 assert!(
1023 fixed.contains("## 1_2\n"),
1024 "Duplicate H1 must be demoted to H2: {fixed}"
1025 );
1026 assert!(
1027 fixed.contains("### 1_2-2_1"),
1028 "H2 under demoted H1 must cascade to H3: {fixed}"
1029 );
1030 assert!(fixed.contains("## 1_3\n"), "Third H1 must be demoted to H2: {fixed}");
1031 assert!(
1032 fixed.contains("### 1_3-2_1"),
1033 "H2 under third demoted H1 must cascade to H3: {fixed}"
1034 );
1035 assert!(
1036 fixed.contains("#### 1_3-2_1-3_1"),
1037 "H3 under third demoted H1 must cascade to H4: {fixed}"
1038 );
1039 }
1040
1041 #[test]
1042 fn test_fix_cascades_single_section_only() {
1043 let rule = MD025SingleTitle::default();
1044
1045 let content = "# Main\n\n# Alpha\n\n## Alpha Sub\n\n# Beta\n\n## Beta Sub\n";
1047 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1048 let fixed = rule.fix(&ctx).unwrap();
1049
1050 assert!(fixed.contains("# Main\n"), "First H1 preserved: {fixed}");
1051 assert!(fixed.contains("## Alpha\n"), "Alpha H1 demoted to H2: {fixed}");
1052 assert!(fixed.contains("### Alpha Sub"), "Alpha Sub cascades to H3: {fixed}");
1053 assert!(fixed.contains("## Beta\n"), "Beta H1 demoted to H2: {fixed}");
1054 assert!(fixed.contains("### Beta Sub"), "Beta Sub cascades to H3: {fixed}");
1055 }
1056
1057 #[test]
1058 fn test_fix_cascade_stops_at_next_same_level() {
1059 let rule = MD025SingleTitle::default();
1060
1061 let content = "# Main\n\n# A\n\n## A1\n\n# B\n\n## B1\n\n### B1a\n";
1065 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1066 let fixed = rule.fix(&ctx).unwrap();
1067
1068 assert!(fixed.contains("## A\n"), "A demoted to H2: {fixed}");
1069 assert!(fixed.contains("### A1"), "A1 cascades to H3: {fixed}");
1070 assert!(fixed.contains("## B\n"), "B demoted to H2: {fixed}");
1071 assert!(fixed.contains("### B1"), "B1 cascades to H3: {fixed}");
1072 assert!(fixed.contains("#### B1a"), "B1a cascades to H4: {fixed}");
1073 assert!(fixed.contains("# Main"), "Main preserved at H1: {fixed}");
1075 }
1076
1077 #[test]
1078 fn test_fix_cascade_does_not_exceed_level_6() {
1079 let rule = MD025SingleTitle::default();
1081
1082 let content = "# Title\n\n# Section\n\n## L2\n\n### L3\n\n#### L4\n\n##### L5\n\n###### L6\n";
1084 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1085 let fixed = rule.fix(&ctx).unwrap();
1086
1087 assert!(fixed.contains("# Title"), "First H1 preserved: {fixed}");
1088 assert!(fixed.contains("## Section"), "Section demoted to H2: {fixed}");
1089 assert!(fixed.contains("### L2"), "L2 cascades to H3: {fixed}");
1090 assert!(fixed.contains("#### L3"), "L3 cascades to H4: {fixed}");
1091 assert!(fixed.contains("##### L4"), "L4 cascades to H5: {fixed}");
1092 assert!(fixed.contains("###### L5"), "L5 cascades to H6: {fixed}");
1093 assert!(fixed.contains("###### L6"), "L6 at max depth stays at H6: {fixed}");
1095 }
1096
1097 #[test]
1098 fn test_fix_cascade_respects_inline_disable_on_subordinate() {
1099 let rule = MD025SingleTitle::default();
1102
1103 let content = "# Title\n# Demote\n## Skip <!-- markdownlint-disable-line MD025 -->\n## Cascade\n";
1104 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1105 let fixed = rule.fix(&ctx).unwrap();
1106
1107 assert!(fixed.contains("## Demote"), "Duplicate H1 should be demoted: {fixed}");
1108 let skip_line = fixed.lines().find(|l| l.contains("Skip")).unwrap_or("");
1111 assert!(
1112 skip_line.starts_with("## Skip"),
1113 "Inline-disabled subordinate should stay at level 2, got line: {skip_line:?}"
1114 );
1115 assert!(
1117 fixed.contains("### Cascade"),
1118 "Non-disabled subordinate should cascade to level 3: {fixed}"
1119 );
1120 }
1121
1122 #[test]
1123 fn test_section_indicator_whole_word_matching() {
1124 let config = md025_config::MD025Config {
1126 allow_document_sections: true,
1127 ..Default::default()
1128 };
1129 let rule = MD025SingleTitle::from_config_struct(config);
1130
1131 let false_positive_cases = vec![
1133 "# Main Title\n\n# Understanding Reindex Operations",
1134 "# Main Title\n\n# The Summarization Pipeline",
1135 "# Main Title\n\n# Data Indexing Strategy",
1136 "# Main Title\n\n# Unsupported Browsers",
1137 ];
1138
1139 for case in false_positive_cases {
1140 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1141 let result = rule.check(&ctx).unwrap();
1142 assert_eq!(
1143 result.len(),
1144 1,
1145 "Should flag duplicate H1 (not a section indicator): {case}"
1146 );
1147 }
1148
1149 let true_positive_cases = vec![
1151 "# Main Title\n\n# Index",
1152 "# Main Title\n\n# Summary",
1153 "# Main Title\n\n# About",
1154 "# Main Title\n\n# References",
1155 ];
1156
1157 for case in true_positive_cases {
1158 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1159 let result = rule.check(&ctx).unwrap();
1160 assert!(result.is_empty(), "Should allow section indicator heading: {case}");
1161 }
1162 }
1163
1164 #[test]
1165 fn test_mdg_enforces_single_title() {
1166 let rule = MD025SingleTitle::strict();
1169 let content = "# Feature: Checkout\n\n# Rule: Registered customers\n\n# Scenario: Purchase\n";
1170
1171 let standard_ctx =
1172 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1173 let mdg_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1174
1175 assert_eq!(rule.check(&mdg_ctx).unwrap().len(), 2);
1176 assert_eq!(
1177 rule.check(&mdg_ctx).unwrap().len(),
1178 rule.check(&standard_ctx).unwrap().len(),
1179 "MDG must not differ from Standard"
1180 );
1181
1182 let fixed = rule.fix(&mdg_ctx).unwrap();
1183 assert_eq!(
1184 fixed, "# Feature: Checkout\n\n## Rule: Registered customers\n\n## Scenario: Purchase\n",
1185 "the Gherkin keywords must survive the demotion"
1186 );
1187
1188 let fixed_ctx = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
1189 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
1190 }
1191 #[test]
1192 fn test_literal_titles_do_not_count_as_visible_titles() {
1193 let rule = MD025SingleTitle::default();
1194 for literal in ["# Hidden", "Hidden\n==="] {
1195 for code in [
1196 format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
1197 format!("{{{{ \"first\n{literal}\nlast\" }}}}"),
1198 format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
1199 ] {
1200 let flavor = if code.starts_with("{{<") {
1201 crate::config::MarkdownFlavor::Hugo
1202 } else {
1203 crate::config::MarkdownFlavor::Standard
1204 };
1205 for source in [format!("# Visible\n\n{code}\n"), format!("{code}\n\n# Visible\n")] {
1206 for ending in ["\n", "\r\n"] {
1207 let source = source.replace('\n', ending);
1208 let ctx = LintContext::new(&source, flavor, None);
1209 assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
1210 assert!(rule.should_skip(&ctx), "{source}");
1211 assert_eq!(rule.fix(&ctx).unwrap(), source, "{source}");
1212 }
1213 }
1214 }
1215 }
1216 }
1217
1218 #[test]
1219 fn test_literal_headings_do_not_participate_in_demotion_cascades() {
1220 let rule = MD025SingleTitle::default();
1221 for literal in ["# Hidden", "## Hidden", "Hidden\n===", "Hidden\n---"] {
1222 for code in [
1223 format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
1224 format!("{{{{ \"first\n{literal}\nlast\" }}}}"),
1225 format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
1226 ] {
1227 let flavor = if code.starts_with("{{<") {
1228 crate::config::MarkdownFlavor::Hugo
1229 } else {
1230 crate::config::MarkdownFlavor::Standard
1231 };
1232 for ending in ["\n", "\r\n"] {
1233 let source = format!("# Visible\n\n# Duplicate\n\n{code}\n\n## Child\n").replace('\n', ending);
1234 let expected = format!("# Visible\n\n## Duplicate\n\n{code}\n\n### Child\n").replace('\n', ending);
1235 let ctx = LintContext::new(&source, flavor, None);
1236 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source}");
1237 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
1238 let fixed_ctx = LintContext::new(&expected, flavor, None);
1239 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1240 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1241 }
1242 }
1243 }
1244 }
1245
1246 #[test]
1247 fn test_dynamic_heading_text_remains_visible_to_title_demotion() {
1248 let rule = MD025SingleTitle::default();
1249 for (title, fixed) in [
1250 ("# {{ title }}", "## {{ title }}"),
1251 ("Title {{ title }}\n===", "## Title {{ title }}"),
1252 ] {
1253 let source = format!("# Visible\n\n{title}\n\n## Child\n");
1254 let expected = format!("# Visible\n\n{fixed}\n\n### Child\n");
1255 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1256 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1257 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1258 }
1259 }
1260 #[test]
1261 fn test_unsafe_setext_primary_withholds_its_entire_demotion_section() {
1262 let rule = MD025SingleTitle::default();
1263 for (flavor, text) in [
1264 (
1265 crate::config::MarkdownFlavor::Standard,
1266 "Title {% set unused=\"first\nmiddle\nlast\" %}{{ unused|length }}",
1267 ),
1268 (
1269 crate::config::MarkdownFlavor::Standard,
1270 "Title {{ \"first\nmiddle\nlast\" }}",
1271 ),
1272 (
1273 crate::config::MarkdownFlavor::Hugo,
1274 "Title {{< note title=`first\nmiddle\nlast` >}}",
1275 ),
1276 (
1277 crate::config::MarkdownFlavor::Standard,
1278 "Title <span title=\"first\nmiddle\nlast\">Text</span>",
1279 ),
1280 (crate::config::MarkdownFlavor::Standard, "Title\nline\\\nnext"),
1281 (crate::config::MarkdownFlavor::Standard, "Title\nline \nnext"),
1282 (
1283 crate::config::MarkdownFlavor::MDX,
1284 "Title <span title={`first\nmiddle\nlast`}>Text</span>",
1285 ),
1286 (crate::config::MarkdownFlavor::Standard, "Title `first \nmiddle`"),
1287 (crate::config::MarkdownFlavor::Standard, "Title `first \nmiddle`"),
1288 (crate::config::MarkdownFlavor::Standard, "Title `first\n middle`"),
1289 ] {
1290 for ending in ["\n", "\r\n"] {
1291 let first = format!("# Visible\n\n{text}\n===\n\n## Retained child\n\n");
1292 let source = format!("{first}# Other\n\n## Changed child\n").replace('\n', ending);
1293 let expected = format!("{first}## Other\n\n### Changed child\n").replace('\n', ending);
1294 let ctx = LintContext::new(&source, flavor, None);
1295 let warnings = rule.check(&ctx).unwrap();
1296 assert_eq!(warnings.len(), 2, "{source}: {warnings:?}");
1297 assert!(warnings[0].fix.is_none(), "{source}: {warnings:?}");
1298 assert!(warnings[1].fix.is_some());
1299 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
1300 assert_eq!(rule.fix(&LintContext::new(&expected, flavor, None)).unwrap(), expected);
1301 }
1302 }
1303 }
1304
1305 #[test]
1306 fn test_unsafe_setext_child_withholds_parent_and_siblings_together() {
1307 let rule = MD025SingleTitle::default();
1308 for (flavor, text) in [
1309 (
1310 crate::config::MarkdownFlavor::Standard,
1311 "Title {% set unused=\"first\nmiddle\nlast\" %}{{ unused|length }}",
1312 ),
1313 (
1314 crate::config::MarkdownFlavor::Standard,
1315 "Title {{ \"first\nmiddle\nlast\" }}",
1316 ),
1317 (
1318 crate::config::MarkdownFlavor::Standard,
1319 "Title <span title=\"first\nmiddle\nlast\">Text</span>",
1320 ),
1321 (crate::config::MarkdownFlavor::Standard, "Title\nline\\\nnext"),
1322 (crate::config::MarkdownFlavor::Standard, "Title\nline \nnext"),
1323 (crate::config::MarkdownFlavor::Standard, "Title `first \nmiddle`"),
1324 (crate::config::MarkdownFlavor::Standard, "Title `first \nmiddle`"),
1325 (crate::config::MarkdownFlavor::Standard, "Title `first\n middle`"),
1326 ] {
1327 for ending in ["\n", "\r\n"] {
1328 let first = format!("# Visible\n\n# Duplicate\n\n## Retained sibling\n\n{text}\n---\n\n");
1329 let source = format!("{first}# Other\n\n## Changed child\n").replace('\n', ending);
1330 let expected = format!("{first}## Other\n\n### Changed child\n").replace('\n', ending);
1331 let ctx = LintContext::new(&source, flavor, None);
1332 let warnings = rule.check(&ctx).unwrap();
1333 assert_eq!(warnings.len(), 2, "{source}: {warnings:?}");
1334 assert!(warnings[0].fix.is_none(), "{source}: {warnings:?}");
1335 assert!(warnings[1].fix.is_some());
1336 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
1337 assert_eq!(rule.fix(&LintContext::new(&expected, flavor, None)).unwrap(), expected);
1338 }
1339 }
1340 }
1341 #[test]
1342 fn test_setext_safety_preserves_safe_and_disabled_demotion_edits() {
1343 let rule = MD025SingleTitle::default();
1344 for (title, fixed) in [
1345 ("Title\nnext", "Title next"),
1346 ("Title {{ title }}", "Title {{ title }}"),
1347 ("Title `first\nmiddle`", "Title `first middle`"),
1348 ("Title `first\\\nmiddle`", "Title `first\\ middle`"),
1349 ("Title line\\\\\nnext", "Title line\\\\ next"),
1350 (
1351 "Title <span title=\"value\">Text</span>",
1352 "Title <span title=\"value\">Text</span>",
1353 ),
1354 ] {
1355 let source = format!("# Visible\n\n{title}\n===\n");
1356 let expected = format!("# Visible\n\n## {fixed}\n");
1357 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1358 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1359 assert!(rule.check(&ctx).unwrap()[0].fix.is_some(), "{source}");
1360 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1361 }
1362 let source = "# Visible\n\n# Duplicate\n\n<!-- rumdl-disable MD025 -->\nTitle <span title=\"first\nmiddle\nlast\">Text</span>\n---\n<!-- rumdl-enable MD025 -->\n";
1363 let expected = source.replace("# Duplicate\n", "## Duplicate\n");
1364 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1365 assert!(rule.check(&ctx).unwrap()[0].fix.is_some());
1366 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1367 }
1368 #[test]
1369 fn test_literal_separators_do_not_allow_multiple_titles() {
1370 let rule = MD025SingleTitle::from_config_struct(MD025Config {
1371 allow_with_separators: true,
1372 ..Default::default()
1373 });
1374 for (flavor, source) in [
1375 (
1376 crate::config::MarkdownFlavor::Standard,
1377 "# Visible\n\n{% set unused=\"first\n\n---\n\nlast\" %}{{ unused|length }}\n\n# Duplicate\n",
1378 ),
1379 (
1380 crate::config::MarkdownFlavor::Hugo,
1381 "# Visible\n\n{{< note title=`first\n\n---\n\nlast` >}}\n\n# Duplicate\n",
1382 ),
1383 (
1384 crate::config::MarkdownFlavor::Standard,
1385 "# Visible\n\n```text\n\n---\n\n```\n# Duplicate\n",
1386 ),
1387 (
1388 crate::config::MarkdownFlavor::Standard,
1389 "# Visible\n\n<!--\n\n---\n\n-->\n# Duplicate\n",
1390 ),
1391 (
1392 crate::config::MarkdownFlavor::Standard,
1393 "---\ntitle: Visible\n\n---\n# Duplicate\n",
1394 ),
1395 ] {
1396 for ending in ["\n", "\r\n"] {
1397 let source = source.replace('\n', ending);
1398 let expected = source.replace("# Duplicate", "## Duplicate");
1399 let ctx = LintContext::new(&source, flavor, None);
1400 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source}");
1401 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
1402 }
1403 }
1404 }
1405
1406 #[test]
1407 fn test_literal_intermediate_headings_do_not_block_real_separator_allowance() {
1408 let rule = MD025SingleTitle::from_config_struct(MD025Config {
1409 allow_with_separators: true,
1410 ..Default::default()
1411 });
1412 for literal in ["# Fake", "Fake\n==="] {
1413 for code in [
1414 format!("{{% set unused=\"\n{literal}\n\" %}}{{{{ unused|length }}}}"),
1415 format!("{{{{< note title=`\n{literal}\n` >}}}}"),
1416 ] {
1417 let flavor = if code.starts_with("{{<") {
1418 crate::config::MarkdownFlavor::Hugo
1419 } else {
1420 crate::config::MarkdownFlavor::Standard
1421 };
1422 for ending in ["\n", "\r\n"] {
1423 let source = format!("# Visible\n\n---\n{code}\n# Duplicate\n").replace('\n', ending);
1424 let ctx = LintContext::new(&source, flavor, None);
1425 assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
1426 assert_eq!(rule.fix(&ctx).unwrap(), source);
1427 }
1428 }
1429 }
1430 }
1431
1432 #[test]
1433 fn test_multiline_inline_titles_preserve_whole_demotion_bundles() {
1434 let rule = MD025SingleTitle::default();
1435 for node in ["[Link]", "![Image]"] {
1436 for (open, close) in [('"', '"'), ('\'', '\''), ('(', ')')] {
1437 let title = format!("{node}(https://example.com {open}first\nlast{close})\n===");
1438 for ending in ["\n", "\r\n"] {
1439 let source =
1440 format!("# First\n\n{title}\n\n### Child\n\nBody.\n\n# Adjacent\n").replace('\n', ending);
1441 let expected =
1442 format!("# First\n\n{title}\n\n### Child\n\nBody.\n\n## Adjacent\n").replace('\n', ending);
1443 let ctx =
1444 crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1445 let warnings = rule.check(&ctx).unwrap();
1446 assert_eq!(warnings.len(), 2, "{source}");
1447 assert!(warnings[0].fix.is_none());
1448 assert!(warnings[1].fix.is_some());
1449 assert_eq!(
1450 crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
1451 expected
1452 );
1453 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1454 }
1455 }
1456 }
1457 }
1458
1459 #[test]
1460 fn test_entity_newlines_do_not_block_safe_title_demotions() {
1461 let rule = MD025SingleTitle::default();
1462 let source = "# First\n\n[first\nlast](https://example.com \"title
line\")\n===\n\n# Adjacent\n";
1463 let expected = "# First\n\n## [first last](https://example.com \"title
line\")\n\n## Adjacent\n";
1464 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1465 let warnings = rule.check(&ctx).unwrap();
1466 assert_eq!(warnings.len(), 2);
1467 assert!(warnings.iter().all(|warning| warning.fix.is_some()));
1468 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1469 }
1470 #[test]
1471 fn setext_demotion_preserves_parent_and_child_literal_hashes() {
1472 let rule = MD025SingleTitle::new(1, "title");
1473 for ending in ["\n", "\r\n"] {
1474 let source = "# First\n\nTitle ###\n===\n\nChild ##\n---\n".replace('\n', ending);
1475 let expected = "# First\n\n## Title \\###\n\n### Child \\##\n".replace('\n', ending);
1476 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1477 let warnings = rule.check(&ctx).unwrap();
1478 assert_eq!(warnings.len(), 1);
1479 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1480 let parent_only = "# First\n\n## Title \\###\n\nChild ##\n---\n".replace('\n', ending);
1482 assert_eq!(
1483 crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
1484 parent_only
1485 );
1486 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
1487 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1488 }
1489 }
1490}