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