1mod md041_config;
2
3pub use md041_config::MD041Config;
4
5use crate::rule::{LintError, LintResult, LintWarning, Rule, Severity};
6use crate::rules::front_matter_utils::FrontMatterUtils;
7use crate::utils::range_utils::calculate_line_range;
8use crate::utils::regex_cache::HTML_HEADING_PATTERN;
9use regex::Regex;
10
11#[derive(Clone)]
16pub struct MD041FirstLineHeading {
17 pub level: usize,
18 pub front_matter_title: bool,
19 pub front_matter_title_pattern: Option<Regex>,
20}
21
22impl Default for MD041FirstLineHeading {
23 fn default() -> Self {
24 Self {
25 level: 1,
26 front_matter_title: true,
27 front_matter_title_pattern: None,
28 }
29 }
30}
31
32impl MD041FirstLineHeading {
33 pub fn new(level: usize, front_matter_title: bool) -> Self {
34 Self {
35 level,
36 front_matter_title,
37 front_matter_title_pattern: None,
38 }
39 }
40
41 pub fn with_pattern(level: usize, front_matter_title: bool, pattern: Option<String>) -> Self {
42 let front_matter_title_pattern = pattern.and_then(|p| match Regex::new(&p) {
43 Ok(regex) => Some(regex),
44 Err(e) => {
45 log::warn!("Invalid front_matter_title_pattern regex: {e}");
46 None
47 }
48 });
49
50 Self {
51 level,
52 front_matter_title,
53 front_matter_title_pattern,
54 }
55 }
56
57 fn has_front_matter_title(&self, content: &str) -> bool {
58 if !self.front_matter_title {
59 return false;
60 }
61
62 if let Some(ref pattern) = self.front_matter_title_pattern {
64 let front_matter_lines = FrontMatterUtils::extract_front_matter(content);
65 for line in front_matter_lines {
66 if pattern.is_match(line) {
67 return true;
68 }
69 }
70 return false;
71 }
72
73 FrontMatterUtils::has_front_matter_field(content, "title:")
75 }
76
77 fn is_non_content_line(line: &str) -> bool {
79 let trimmed = line.trim();
80
81 if trimmed.starts_with('[') && trimmed.contains("]: ") {
83 return true;
84 }
85
86 if trimmed.starts_with('*') && trimmed.contains("]: ") {
88 return true;
89 }
90
91 false
92 }
93
94 fn is_html_heading(ctx: &crate::lint_context::LintContext, first_line_idx: usize, level: usize) -> bool {
96 let first_line_content = ctx.lines[first_line_idx].content(ctx.content);
98 if let Ok(Some(captures)) = HTML_HEADING_PATTERN.captures(first_line_content.trim())
99 && let Some(h_level) = captures.get(1)
100 && h_level.as_str().parse::<usize>().unwrap_or(0) == level
101 {
102 return true;
103 }
104
105 let html_tags = ctx.html_tags();
107 let target_tag = format!("h{level}");
108
109 let opening_index = html_tags.iter().position(|tag| {
111 tag.line == first_line_idx + 1 && tag.tag_name == target_tag
113 && !tag.is_closing
114 });
115
116 let Some(open_idx) = opening_index else {
117 return false;
118 };
119
120 let mut depth = 1usize;
123 for tag in html_tags.iter().skip(open_idx + 1) {
124 if tag.line <= first_line_idx + 1 {
126 continue;
127 }
128
129 if tag.tag_name == target_tag {
130 if tag.is_closing {
131 depth -= 1;
132 if depth == 0 {
133 return true;
134 }
135 } else if !tag.is_self_closing {
136 depth += 1;
137 }
138 }
139 }
140
141 false
142 }
143}
144
145impl Rule for MD041FirstLineHeading {
146 fn name(&self) -> &'static str {
147 "MD041"
148 }
149
150 fn description(&self) -> &'static str {
151 "First line in file should be a top level heading"
152 }
153
154 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
155 let mut warnings = Vec::new();
156
157 if self.should_skip(ctx) {
159 return Ok(warnings);
160 }
161
162 let mut first_content_line_num = None;
164 let mut skip_lines = 0;
165
166 if ctx.lines.first().map(|l| l.content(ctx.content).trim()) == Some("---") {
168 for (idx, line_info) in ctx.lines.iter().enumerate().skip(1) {
170 if line_info.content(ctx.content).trim() == "---" {
171 skip_lines = idx + 1;
172 break;
173 }
174 }
175 }
176
177 for (line_num, line_info) in ctx.lines.iter().enumerate().skip(skip_lines) {
178 let line_content = line_info.content(ctx.content).trim();
179 if line_info.in_esm_block {
181 continue;
182 }
183 if line_info.in_html_comment {
185 continue;
186 }
187 if !line_content.is_empty() && !Self::is_non_content_line(line_info.content(ctx.content)) {
188 first_content_line_num = Some(line_num);
189 break;
190 }
191 }
192
193 if first_content_line_num.is_none() {
194 return Ok(warnings);
196 }
197
198 let first_line_idx = first_content_line_num.unwrap();
199
200 let first_line_info = &ctx.lines[first_line_idx];
202 let is_correct_heading = if let Some(heading) = &first_line_info.heading {
203 heading.level as usize == self.level
204 } else {
205 Self::is_html_heading(ctx, first_line_idx, self.level)
207 };
208
209 if !is_correct_heading {
210 let first_line = first_line_idx + 1; let first_line_content = first_line_info.content(ctx.content);
213 let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
214
215 warnings.push(LintWarning {
216 rule_name: Some(self.name().to_string()),
217 line: start_line,
218 column: start_col,
219 end_line,
220 end_column: end_col,
221 message: format!("First line in file should be a level {} heading", self.level),
222 severity: Severity::Warning,
223 fix: None, });
225 }
226 Ok(warnings)
227 }
228
229 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
230 Ok(ctx.content.to_string())
233 }
234
235 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
237 let only_directives = !ctx.content.is_empty()
242 && ctx.content.lines().filter(|l| !l.trim().is_empty()).all(|l| {
243 let t = l.trim();
244 (t.starts_with("{{#") && t.ends_with("}}"))
246 || (t.starts_with("<!--") && t.ends_with("-->"))
248 });
249
250 ctx.content.is_empty()
251 || (self.front_matter_title && self.has_front_matter_title(ctx.content))
252 || only_directives
253 }
254
255 fn as_any(&self) -> &dyn std::any::Any {
256 self
257 }
258
259 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
260 where
261 Self: Sized,
262 {
263 let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
265
266 let use_front_matter = !md041_config.front_matter_title.is_empty();
267
268 Box::new(MD041FirstLineHeading::with_pattern(
269 md041_config.level.as_usize(),
270 use_front_matter,
271 md041_config.front_matter_title_pattern,
272 ))
273 }
274
275 fn default_config_section(&self) -> Option<(String, toml::Value)> {
276 Some((
277 "MD041".to_string(),
278 toml::toml! {
279 level = 1
280 front-matter-title = "title"
281 front-matter-title-pattern = ""
282 }
283 .into(),
284 ))
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use crate::lint_context::LintContext;
292
293 #[test]
294 fn test_first_line_is_heading_correct_level() {
295 let rule = MD041FirstLineHeading::default();
296
297 let content = "# My Document\n\nSome content here.";
299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
300 let result = rule.check(&ctx).unwrap();
301 assert!(
302 result.is_empty(),
303 "Expected no warnings when first line is a level 1 heading"
304 );
305 }
306
307 #[test]
308 fn test_first_line_is_heading_wrong_level() {
309 let rule = MD041FirstLineHeading::default();
310
311 let content = "## My Document\n\nSome content here.";
313 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
314 let result = rule.check(&ctx).unwrap();
315 assert_eq!(result.len(), 1);
316 assert_eq!(result[0].line, 1);
317 assert!(result[0].message.contains("level 1 heading"));
318 }
319
320 #[test]
321 fn test_first_line_not_heading() {
322 let rule = MD041FirstLineHeading::default();
323
324 let content = "This is not a heading\n\n# This is a heading";
326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
327 let result = rule.check(&ctx).unwrap();
328 assert_eq!(result.len(), 1);
329 assert_eq!(result[0].line, 1);
330 assert!(result[0].message.contains("level 1 heading"));
331 }
332
333 #[test]
334 fn test_empty_lines_before_heading() {
335 let rule = MD041FirstLineHeading::default();
336
337 let content = "\n\n# My Document\n\nSome content.";
339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
340 let result = rule.check(&ctx).unwrap();
341 assert!(
342 result.is_empty(),
343 "Expected no warnings when empty lines precede a valid heading"
344 );
345
346 let content = "\n\nNot a heading\n\nSome content.";
348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
349 let result = rule.check(&ctx).unwrap();
350 assert_eq!(result.len(), 1);
351 assert_eq!(result[0].line, 3); assert!(result[0].message.contains("level 1 heading"));
353 }
354
355 #[test]
356 fn test_front_matter_with_title() {
357 let rule = MD041FirstLineHeading::new(1, true);
358
359 let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
362 let result = rule.check(&ctx).unwrap();
363 assert!(
364 result.is_empty(),
365 "Expected no warnings when front matter has title field"
366 );
367 }
368
369 #[test]
370 fn test_front_matter_without_title() {
371 let rule = MD041FirstLineHeading::new(1, true);
372
373 let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
375 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
376 let result = rule.check(&ctx).unwrap();
377 assert_eq!(result.len(), 1);
378 assert_eq!(result[0].line, 6); }
380
381 #[test]
382 fn test_front_matter_disabled() {
383 let rule = MD041FirstLineHeading::new(1, false);
384
385 let content = "---\ntitle: My Document\n---\n\nSome content here.";
387 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
388 let result = rule.check(&ctx).unwrap();
389 assert_eq!(result.len(), 1);
390 assert_eq!(result[0].line, 5); }
392
393 #[test]
394 fn test_html_comments_before_heading() {
395 let rule = MD041FirstLineHeading::default();
396
397 let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
400 let result = rule.check(&ctx).unwrap();
401 assert!(
402 result.is_empty(),
403 "HTML comments should be skipped when checking for first heading"
404 );
405 }
406
407 #[test]
408 fn test_multiline_html_comment_before_heading() {
409 let rule = MD041FirstLineHeading::default();
410
411 let content = "<!--\nThis is a multi-line\nHTML comment\n-->\n# My Document\n\nContent.";
413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
414 let result = rule.check(&ctx).unwrap();
415 assert!(
416 result.is_empty(),
417 "Multi-line HTML comments should be skipped when checking for first heading"
418 );
419 }
420
421 #[test]
422 fn test_html_comment_with_blank_lines_before_heading() {
423 let rule = MD041FirstLineHeading::default();
424
425 let content = "<!-- This is a comment -->\n\n# My Document\n\nContent.";
427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
428 let result = rule.check(&ctx).unwrap();
429 assert!(
430 result.is_empty(),
431 "HTML comments with blank lines should be skipped when checking for first heading"
432 );
433 }
434
435 #[test]
436 fn test_html_comment_before_html_heading() {
437 let rule = MD041FirstLineHeading::default();
438
439 let content = "<!-- This is a comment -->\n<h1>My Document</h1>\n\nContent.";
441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
442 let result = rule.check(&ctx).unwrap();
443 assert!(
444 result.is_empty(),
445 "HTML comments should be skipped before HTML headings"
446 );
447 }
448
449 #[test]
450 fn test_document_with_only_html_comments() {
451 let rule = MD041FirstLineHeading::default();
452
453 let content = "<!-- This is a comment -->\n<!-- Another comment -->";
455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
456 let result = rule.check(&ctx).unwrap();
457 assert!(
458 result.is_empty(),
459 "Documents with only HTML comments should not trigger MD041"
460 );
461 }
462
463 #[test]
464 fn test_html_comment_followed_by_non_heading() {
465 let rule = MD041FirstLineHeading::default();
466
467 let content = "<!-- This is a comment -->\nThis is not a heading\n\nSome content.";
469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
470 let result = rule.check(&ctx).unwrap();
471 assert_eq!(
472 result.len(),
473 1,
474 "HTML comment followed by non-heading should still trigger MD041"
475 );
476 assert_eq!(
477 result[0].line, 2,
478 "Warning should be on the first non-comment, non-heading line"
479 );
480 }
481
482 #[test]
483 fn test_multiple_html_comments_before_heading() {
484 let rule = MD041FirstLineHeading::default();
485
486 let content = "<!-- First comment -->\n<!-- Second comment -->\n# My Document\n\nContent.";
488 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
489 let result = rule.check(&ctx).unwrap();
490 assert!(
491 result.is_empty(),
492 "Multiple HTML comments should all be skipped before heading"
493 );
494 }
495
496 #[test]
497 fn test_html_comment_with_wrong_level_heading() {
498 let rule = MD041FirstLineHeading::default();
499
500 let content = "<!-- This is a comment -->\n## Wrong Level Heading\n\nContent.";
502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
503 let result = rule.check(&ctx).unwrap();
504 assert_eq!(
505 result.len(),
506 1,
507 "HTML comment followed by wrong-level heading should still trigger MD041"
508 );
509 assert!(
510 result[0].message.contains("level 1 heading"),
511 "Should require level 1 heading"
512 );
513 }
514
515 #[test]
516 fn test_html_comment_mixed_with_reference_definitions() {
517 let rule = MD041FirstLineHeading::default();
518
519 let content = "<!-- Comment -->\n[ref]: https://example.com\n# My Document\n\nContent.";
521 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
522 let result = rule.check(&ctx).unwrap();
523 assert!(
524 result.is_empty(),
525 "HTML comments and reference definitions should both be skipped before heading"
526 );
527 }
528
529 #[test]
530 fn test_html_comment_after_front_matter() {
531 let rule = MD041FirstLineHeading::default();
532
533 let content = "---\nauthor: John\n---\n<!-- Comment -->\n# My Document\n\nContent.";
535 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
536 let result = rule.check(&ctx).unwrap();
537 assert!(
538 result.is_empty(),
539 "HTML comments after front matter should be skipped before heading"
540 );
541 }
542
543 #[test]
544 fn test_html_comment_not_at_start_should_not_affect_rule() {
545 let rule = MD041FirstLineHeading::default();
546
547 let content = "# Valid Heading\n\nSome content.\n\n<!-- Comment in middle -->\n\nMore content.";
549 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
550 let result = rule.check(&ctx).unwrap();
551 assert!(
552 result.is_empty(),
553 "HTML comments in middle of document should not affect MD041 (only first content matters)"
554 );
555 }
556
557 #[test]
558 fn test_multiline_html_comment_followed_by_non_heading() {
559 let rule = MD041FirstLineHeading::default();
560
561 let content = "<!--\nMulti-line\ncomment\n-->\nThis is not a heading\n\nContent.";
563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
564 let result = rule.check(&ctx).unwrap();
565 assert_eq!(
566 result.len(),
567 1,
568 "Multi-line HTML comment followed by non-heading should still trigger MD041"
569 );
570 assert_eq!(
571 result[0].line, 5,
572 "Warning should be on the first non-comment, non-heading line"
573 );
574 }
575
576 #[test]
577 fn test_different_heading_levels() {
578 let rule = MD041FirstLineHeading::new(2, false);
580
581 let content = "## Second Level Heading\n\nContent.";
582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
583 let result = rule.check(&ctx).unwrap();
584 assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
585
586 let content = "# First Level Heading\n\nContent.";
588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
589 let result = rule.check(&ctx).unwrap();
590 assert_eq!(result.len(), 1);
591 assert!(result[0].message.contains("level 2 heading"));
592 }
593
594 #[test]
595 fn test_setext_headings() {
596 let rule = MD041FirstLineHeading::default();
597
598 let content = "My Document\n===========\n\nContent.";
600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
601 let result = rule.check(&ctx).unwrap();
602 assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
603
604 let content = "My Document\n-----------\n\nContent.";
606 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
607 let result = rule.check(&ctx).unwrap();
608 assert_eq!(result.len(), 1);
609 assert!(result[0].message.contains("level 1 heading"));
610 }
611
612 #[test]
613 fn test_empty_document() {
614 let rule = MD041FirstLineHeading::default();
615
616 let content = "";
618 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
619 let result = rule.check(&ctx).unwrap();
620 assert!(result.is_empty(), "Expected no warnings for empty document");
621 }
622
623 #[test]
624 fn test_whitespace_only_document() {
625 let rule = MD041FirstLineHeading::default();
626
627 let content = " \n\n \t\n";
629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
630 let result = rule.check(&ctx).unwrap();
631 assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
632 }
633
634 #[test]
635 fn test_front_matter_then_whitespace() {
636 let rule = MD041FirstLineHeading::default();
637
638 let content = "---\ntitle: Test\n---\n\n \n\n";
640 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
641 let result = rule.check(&ctx).unwrap();
642 assert!(
643 result.is_empty(),
644 "Expected no warnings when no content after front matter"
645 );
646 }
647
648 #[test]
649 fn test_multiple_front_matter_types() {
650 let rule = MD041FirstLineHeading::new(1, true);
651
652 let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
654 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
655 let result = rule.check(&ctx).unwrap();
656 assert_eq!(result.len(), 1);
657 assert!(result[0].message.contains("level 1 heading"));
658
659 let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
661 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
662 let result = rule.check(&ctx).unwrap();
663 assert_eq!(result.len(), 1);
664 assert!(result[0].message.contains("level 1 heading"));
665
666 let content = "---\ntitle: My Document\n---\n\nContent.";
668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
669 let result = rule.check(&ctx).unwrap();
670 assert!(
671 result.is_empty(),
672 "Expected no warnings for YAML front matter with title"
673 );
674
675 let content = "+++\ntitle: My Document\n+++\n\nContent.";
677 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
678 let result = rule.check(&ctx).unwrap();
679 assert!(result.is_empty(), "Expected no warnings when title: pattern is found");
680 }
681
682 #[test]
683 fn test_malformed_front_matter() {
684 let rule = MD041FirstLineHeading::new(1, true);
685
686 let content = "- --\ntitle: My Document\n- --\n\nContent.";
688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
689 let result = rule.check(&ctx).unwrap();
690 assert!(
691 result.is_empty(),
692 "Expected no warnings for malformed front matter with title"
693 );
694 }
695
696 #[test]
697 fn test_front_matter_with_heading() {
698 let rule = MD041FirstLineHeading::default();
699
700 let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
703 let result = rule.check(&ctx).unwrap();
704 assert!(
705 result.is_empty(),
706 "Expected no warnings when first line after front matter is correct heading"
707 );
708 }
709
710 #[test]
711 fn test_no_fix_suggestion() {
712 let rule = MD041FirstLineHeading::default();
713
714 let content = "Not a heading\n\nContent.";
716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
717 let result = rule.check(&ctx).unwrap();
718 assert_eq!(result.len(), 1);
719 assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
720 }
721
722 #[test]
723 fn test_complex_document_structure() {
724 let rule = MD041FirstLineHeading::default();
725
726 let content =
728 "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
730 let result = rule.check(&ctx).unwrap();
731 assert!(
732 result.is_empty(),
733 "HTML comments should be skipped, so first heading after comment should be valid"
734 );
735 }
736
737 #[test]
738 fn test_heading_with_special_characters() {
739 let rule = MD041FirstLineHeading::default();
740
741 let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
744 let result = rule.check(&ctx).unwrap();
745 assert!(
746 result.is_empty(),
747 "Expected no warnings for heading with inline formatting"
748 );
749 }
750
751 #[test]
752 fn test_level_configuration() {
753 for level in 1..=6 {
755 let rule = MD041FirstLineHeading::new(level, false);
756
757 let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
759 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
760 let result = rule.check(&ctx).unwrap();
761 assert!(
762 result.is_empty(),
763 "Expected no warnings for correct level {level} heading"
764 );
765
766 let wrong_level = if level == 1 { 2 } else { 1 };
768 let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
769 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
770 let result = rule.check(&ctx).unwrap();
771 assert_eq!(result.len(), 1);
772 assert!(result[0].message.contains(&format!("level {level} heading")));
773 }
774 }
775
776 #[test]
777 fn test_issue_152_multiline_html_heading() {
778 let rule = MD041FirstLineHeading::default();
779
780 let content = "<h1>\nSome text\n</h1>";
782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
783 let result = rule.check(&ctx).unwrap();
784 assert!(
785 result.is_empty(),
786 "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
787 );
788 }
789
790 #[test]
791 fn test_multiline_html_heading_with_attributes() {
792 let rule = MD041FirstLineHeading::default();
793
794 let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
796 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
797 let result = rule.check(&ctx).unwrap();
798 assert!(
799 result.is_empty(),
800 "Multi-line HTML heading with attributes should be recognized"
801 );
802 }
803
804 #[test]
805 fn test_multiline_html_heading_wrong_level() {
806 let rule = MD041FirstLineHeading::default();
807
808 let content = "<h2>\nSome text\n</h2>";
810 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
811 let result = rule.check(&ctx).unwrap();
812 assert_eq!(result.len(), 1);
813 assert!(result[0].message.contains("level 1 heading"));
814 }
815
816 #[test]
817 fn test_multiline_html_heading_with_content_after() {
818 let rule = MD041FirstLineHeading::default();
819
820 let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
822 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
823 let result = rule.check(&ctx).unwrap();
824 assert!(
825 result.is_empty(),
826 "Multi-line HTML heading followed by content should be valid"
827 );
828 }
829
830 #[test]
831 fn test_multiline_html_heading_incomplete() {
832 let rule = MD041FirstLineHeading::default();
833
834 let content = "<h1>\nSome text\n\nMore content without closing tag";
836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
837 let result = rule.check(&ctx).unwrap();
838 assert_eq!(result.len(), 1);
839 assert!(result[0].message.contains("level 1 heading"));
840 }
841
842 #[test]
843 fn test_singleline_html_heading_still_works() {
844 let rule = MD041FirstLineHeading::default();
845
846 let content = "<h1>My Document</h1>\n\nContent.";
848 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
849 let result = rule.check(&ctx).unwrap();
850 assert!(
851 result.is_empty(),
852 "Single-line HTML headings should still be recognized"
853 );
854 }
855
856 #[test]
857 fn test_multiline_html_heading_with_nested_tags() {
858 let rule = MD041FirstLineHeading::default();
859
860 let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
862 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
863 let result = rule.check(&ctx).unwrap();
864 assert!(
865 result.is_empty(),
866 "Multi-line HTML heading with nested tags should be recognized"
867 );
868 }
869
870 #[test]
871 fn test_multiline_html_heading_various_levels() {
872 for level in 1..=6 {
874 let rule = MD041FirstLineHeading::new(level, false);
875
876 let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
878 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
879 let result = rule.check(&ctx).unwrap();
880 assert!(
881 result.is_empty(),
882 "Multi-line HTML heading at level {level} should be recognized"
883 );
884
885 let wrong_level = if level == 1 { 2 } else { 1 };
887 let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
888 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
889 let result = rule.check(&ctx).unwrap();
890 assert_eq!(result.len(), 1);
891 assert!(result[0].message.contains(&format!("level {level} heading")));
892 }
893 }
894
895 #[test]
896 fn test_issue_152_nested_heading_spans_many_lines() {
897 let rule = MD041FirstLineHeading::default();
898
899 let content = "<h1>\n <div>\n <img\n href=\"https://example.com/image.png\"\n alt=\"Example Image\"\n />\n <a\n href=\"https://example.com\"\n >Example Project</a>\n <span>Documentation</span>\n </div>\n</h1>";
900 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
901 let result = rule.check(&ctx).unwrap();
902 assert!(result.is_empty(), "Nested multi-line HTML heading should be recognized");
903 }
904
905 #[test]
906 fn test_issue_152_picture_tag_heading() {
907 let rule = MD041FirstLineHeading::default();
908
909 let content = "<h1>\n <picture>\n <source\n srcset=\"https://example.com/light.png\"\n media=\"(prefers-color-scheme: light)\"\n />\n <source\n srcset=\"https://example.com/dark.png\"\n media=\"(prefers-color-scheme: dark)\"\n />\n <img src=\"https://example.com/default.png\" />\n </picture>\n</h1>";
910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
911 let result = rule.check(&ctx).unwrap();
912 assert!(
913 result.is_empty(),
914 "Picture tag inside multi-line HTML heading should be recognized"
915 );
916 }
917}