rumdl_lib/rules/md041_first_line_heading/
mod.rs1mod 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(line: &str, level: usize) -> bool {
96 if let Ok(Some(captures)) = HTML_HEADING_PATTERN.captures(line.trim())
97 && let Some(h_level) = captures.get(1)
98 {
99 return h_level.as_str().parse::<usize>().unwrap_or(0) == level;
100 }
101 false
102 }
103
104 fn is_multiline_html_heading(
116 lines: &[crate::lint_context::LineInfo],
117 start_idx: usize,
118 level: usize,
119 content: &str,
120 ) -> bool {
121 if start_idx >= lines.len() {
122 return false;
123 }
124
125 let first_line = lines[start_idx].content(content).trim();
126
127 let opening_pattern = format!(r"^<h{level}(?:\s|>)");
130 let Ok(opening_regex) = regex::Regex::new(&opening_pattern) else {
131 return false;
132 };
133
134 if !opening_regex.is_match(first_line) {
135 return false;
136 }
137
138 let has_opening_close = first_line.contains('>');
141 let closing_tag = format!("</h{level}>");
142
143 if !has_opening_close {
144 return false;
146 }
147
148 if first_line.contains(&closing_tag) {
150 return false; }
152
153 const MAX_LINES_TO_SCAN: usize = 10;
155 let end_idx = (start_idx + MAX_LINES_TO_SCAN).min(lines.len());
156
157 for line_info in lines.iter().take(end_idx).skip(start_idx + 1) {
158 let line_content = line_info.content(content);
159 if line_content.trim().contains(&closing_tag) {
160 return true;
161 }
162 }
163
164 false
165 }
166}
167
168impl Rule for MD041FirstLineHeading {
169 fn name(&self) -> &'static str {
170 "MD041"
171 }
172
173 fn description(&self) -> &'static str {
174 "First line in file should be a top level heading"
175 }
176
177 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
178 let mut warnings = Vec::new();
179
180 if self.should_skip(ctx) {
182 return Ok(warnings);
183 }
184
185 let mut first_content_line_num = None;
187 let mut skip_lines = 0;
188
189 if ctx.lines.first().map(|l| l.content(ctx.content).trim()) == Some("---") {
191 for (idx, line_info) in ctx.lines.iter().enumerate().skip(1) {
193 if line_info.content(ctx.content).trim() == "---" {
194 skip_lines = idx + 1;
195 break;
196 }
197 }
198 }
199
200 for (line_num, line_info) in ctx.lines.iter().enumerate().skip(skip_lines) {
201 let line_content = line_info.content(ctx.content).trim();
202 if line_info.in_esm_block {
204 continue;
205 }
206 if !line_content.is_empty() && !Self::is_non_content_line(line_info.content(ctx.content)) {
207 first_content_line_num = Some(line_num);
208 break;
209 }
210 }
211
212 if first_content_line_num.is_none() {
213 return Ok(warnings);
215 }
216
217 let first_line_idx = first_content_line_num.unwrap();
218
219 let first_line_info = &ctx.lines[first_line_idx];
221 let is_correct_heading = if let Some(heading) = &first_line_info.heading {
222 heading.level as usize == self.level
223 } else {
224 Self::is_html_heading(first_line_info.content(ctx.content), self.level)
226 || Self::is_multiline_html_heading(&ctx.lines, first_line_idx, self.level, ctx.content)
228 };
229
230 if !is_correct_heading {
231 let first_line = first_line_idx + 1; let first_line_content = first_line_info.content(ctx.content);
234 let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
235
236 warnings.push(LintWarning {
237 rule_name: Some(self.name().to_string()),
238 line: start_line,
239 column: start_col,
240 end_line,
241 end_column: end_col,
242 message: format!("First line in file should be a level {} heading", self.level),
243 severity: Severity::Warning,
244 fix: None, });
246 }
247 Ok(warnings)
248 }
249
250 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
251 Ok(ctx.content.to_string())
254 }
255
256 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
258 let only_directives = !ctx.content.is_empty()
263 && ctx.content.lines().filter(|l| !l.trim().is_empty()).all(|l| {
264 let t = l.trim();
265 (t.starts_with("{{#") && t.ends_with("}}"))
267 || (t.starts_with("<!--") && t.ends_with("-->"))
269 });
270
271 ctx.content.is_empty()
272 || (self.front_matter_title && self.has_front_matter_title(ctx.content))
273 || only_directives
274 }
275
276 fn as_any(&self) -> &dyn std::any::Any {
277 self
278 }
279
280 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
281 where
282 Self: Sized,
283 {
284 let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
286
287 let use_front_matter = !md041_config.front_matter_title.is_empty();
288
289 Box::new(MD041FirstLineHeading::with_pattern(
290 md041_config.level.as_usize(),
291 use_front_matter,
292 md041_config.front_matter_title_pattern,
293 ))
294 }
295
296 fn default_config_section(&self) -> Option<(String, toml::Value)> {
297 Some((
298 "MD041".to_string(),
299 toml::toml! {
300 level = 1
301 front-matter-title = "title"
302 front-matter-title-pattern = ""
303 }
304 .into(),
305 ))
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use crate::lint_context::LintContext;
313
314 #[test]
315 fn test_first_line_is_heading_correct_level() {
316 let rule = MD041FirstLineHeading::default();
317
318 let content = "# My Document\n\nSome content here.";
320 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
321 let result = rule.check(&ctx).unwrap();
322 assert!(
323 result.is_empty(),
324 "Expected no warnings when first line is a level 1 heading"
325 );
326 }
327
328 #[test]
329 fn test_first_line_is_heading_wrong_level() {
330 let rule = MD041FirstLineHeading::default();
331
332 let content = "## My Document\n\nSome content here.";
334 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
335 let result = rule.check(&ctx).unwrap();
336 assert_eq!(result.len(), 1);
337 assert_eq!(result[0].line, 1);
338 assert!(result[0].message.contains("level 1 heading"));
339 }
340
341 #[test]
342 fn test_first_line_not_heading() {
343 let rule = MD041FirstLineHeading::default();
344
345 let content = "This is not a heading\n\n# This is a heading";
347 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
348 let result = rule.check(&ctx).unwrap();
349 assert_eq!(result.len(), 1);
350 assert_eq!(result[0].line, 1);
351 assert!(result[0].message.contains("level 1 heading"));
352 }
353
354 #[test]
355 fn test_empty_lines_before_heading() {
356 let rule = MD041FirstLineHeading::default();
357
358 let content = "\n\n# My Document\n\nSome content.";
360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
361 let result = rule.check(&ctx).unwrap();
362 assert!(
363 result.is_empty(),
364 "Expected no warnings when empty lines precede a valid heading"
365 );
366
367 let content = "\n\nNot a heading\n\nSome content.";
369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
370 let result = rule.check(&ctx).unwrap();
371 assert_eq!(result.len(), 1);
372 assert_eq!(result[0].line, 3); assert!(result[0].message.contains("level 1 heading"));
374 }
375
376 #[test]
377 fn test_front_matter_with_title() {
378 let rule = MD041FirstLineHeading::new(1, true);
379
380 let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
382 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
383 let result = rule.check(&ctx).unwrap();
384 assert!(
385 result.is_empty(),
386 "Expected no warnings when front matter has title field"
387 );
388 }
389
390 #[test]
391 fn test_front_matter_without_title() {
392 let rule = MD041FirstLineHeading::new(1, true);
393
394 let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
397 let result = rule.check(&ctx).unwrap();
398 assert_eq!(result.len(), 1);
399 assert_eq!(result[0].line, 6); }
401
402 #[test]
403 fn test_front_matter_disabled() {
404 let rule = MD041FirstLineHeading::new(1, false);
405
406 let content = "---\ntitle: My Document\n---\n\nSome content here.";
408 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
409 let result = rule.check(&ctx).unwrap();
410 assert_eq!(result.len(), 1);
411 assert_eq!(result[0].line, 5); }
413
414 #[test]
415 fn test_html_comments_before_heading() {
416 let rule = MD041FirstLineHeading::default();
417
418 let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
421 let result = rule.check(&ctx).unwrap();
422 assert_eq!(result.len(), 1);
423 assert_eq!(result[0].line, 1); }
425
426 #[test]
427 fn test_different_heading_levels() {
428 let rule = MD041FirstLineHeading::new(2, false);
430
431 let content = "## Second Level Heading\n\nContent.";
432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
433 let result = rule.check(&ctx).unwrap();
434 assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
435
436 let content = "# First Level Heading\n\nContent.";
438 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
439 let result = rule.check(&ctx).unwrap();
440 assert_eq!(result.len(), 1);
441 assert!(result[0].message.contains("level 2 heading"));
442 }
443
444 #[test]
445 fn test_setext_headings() {
446 let rule = MD041FirstLineHeading::default();
447
448 let content = "My Document\n===========\n\nContent.";
450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
451 let result = rule.check(&ctx).unwrap();
452 assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
453
454 let content = "My Document\n-----------\n\nContent.";
456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
457 let result = rule.check(&ctx).unwrap();
458 assert_eq!(result.len(), 1);
459 assert!(result[0].message.contains("level 1 heading"));
460 }
461
462 #[test]
463 fn test_empty_document() {
464 let rule = MD041FirstLineHeading::default();
465
466 let content = "";
468 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
469 let result = rule.check(&ctx).unwrap();
470 assert!(result.is_empty(), "Expected no warnings for empty document");
471 }
472
473 #[test]
474 fn test_whitespace_only_document() {
475 let rule = MD041FirstLineHeading::default();
476
477 let content = " \n\n \t\n";
479 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
480 let result = rule.check(&ctx).unwrap();
481 assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
482 }
483
484 #[test]
485 fn test_front_matter_then_whitespace() {
486 let rule = MD041FirstLineHeading::default();
487
488 let content = "---\ntitle: Test\n---\n\n \n\n";
490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
491 let result = rule.check(&ctx).unwrap();
492 assert!(
493 result.is_empty(),
494 "Expected no warnings when no content after front matter"
495 );
496 }
497
498 #[test]
499 fn test_multiple_front_matter_types() {
500 let rule = MD041FirstLineHeading::new(1, true);
501
502 let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
505 let result = rule.check(&ctx).unwrap();
506 assert_eq!(result.len(), 1);
507 assert!(result[0].message.contains("level 1 heading"));
508
509 let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
512 let result = rule.check(&ctx).unwrap();
513 assert_eq!(result.len(), 1);
514 assert!(result[0].message.contains("level 1 heading"));
515
516 let content = "---\ntitle: My Document\n---\n\nContent.";
518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
519 let result = rule.check(&ctx).unwrap();
520 assert!(
521 result.is_empty(),
522 "Expected no warnings for YAML front matter with title"
523 );
524
525 let content = "+++\ntitle: My Document\n+++\n\nContent.";
527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
528 let result = rule.check(&ctx).unwrap();
529 assert!(result.is_empty(), "Expected no warnings when title: pattern is found");
530 }
531
532 #[test]
533 fn test_malformed_front_matter() {
534 let rule = MD041FirstLineHeading::new(1, true);
535
536 let content = "- --\ntitle: My Document\n- --\n\nContent.";
538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
539 let result = rule.check(&ctx).unwrap();
540 assert!(
541 result.is_empty(),
542 "Expected no warnings for malformed front matter with title"
543 );
544 }
545
546 #[test]
547 fn test_front_matter_with_heading() {
548 let rule = MD041FirstLineHeading::default();
549
550 let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
552 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
553 let result = rule.check(&ctx).unwrap();
554 assert!(
555 result.is_empty(),
556 "Expected no warnings when first line after front matter is correct heading"
557 );
558 }
559
560 #[test]
561 fn test_no_fix_suggestion() {
562 let rule = MD041FirstLineHeading::default();
563
564 let content = "Not a heading\n\nContent.";
566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
567 let result = rule.check(&ctx).unwrap();
568 assert_eq!(result.len(), 1);
569 assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
570 }
571
572 #[test]
573 fn test_complex_document_structure() {
574 let rule = MD041FirstLineHeading::default();
575
576 let content =
578 "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
580 let result = rule.check(&ctx).unwrap();
581 assert_eq!(result.len(), 1);
582 assert_eq!(result[0].line, 5); }
584
585 #[test]
586 fn test_heading_with_special_characters() {
587 let rule = MD041FirstLineHeading::default();
588
589 let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
592 let result = rule.check(&ctx).unwrap();
593 assert!(
594 result.is_empty(),
595 "Expected no warnings for heading with inline formatting"
596 );
597 }
598
599 #[test]
600 fn test_level_configuration() {
601 for level in 1..=6 {
603 let rule = MD041FirstLineHeading::new(level, false);
604
605 let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
607 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
608 let result = rule.check(&ctx).unwrap();
609 assert!(
610 result.is_empty(),
611 "Expected no warnings for correct level {level} heading"
612 );
613
614 let wrong_level = if level == 1 { 2 } else { 1 };
616 let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
617 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
618 let result = rule.check(&ctx).unwrap();
619 assert_eq!(result.len(), 1);
620 assert!(result[0].message.contains(&format!("level {level} heading")));
621 }
622 }
623
624 #[test]
625 fn test_issue_152_multiline_html_heading() {
626 let rule = MD041FirstLineHeading::default();
627
628 let content = "<h1>\nSome text\n</h1>";
630 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
631 let result = rule.check(&ctx).unwrap();
632 assert!(
633 result.is_empty(),
634 "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
635 );
636 }
637
638 #[test]
639 fn test_multiline_html_heading_with_attributes() {
640 let rule = MD041FirstLineHeading::default();
641
642 let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
645 let result = rule.check(&ctx).unwrap();
646 assert!(
647 result.is_empty(),
648 "Multi-line HTML heading with attributes should be recognized"
649 );
650 }
651
652 #[test]
653 fn test_multiline_html_heading_wrong_level() {
654 let rule = MD041FirstLineHeading::default();
655
656 let content = "<h2>\nSome text\n</h2>";
658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
659 let result = rule.check(&ctx).unwrap();
660 assert_eq!(result.len(), 1);
661 assert!(result[0].message.contains("level 1 heading"));
662 }
663
664 #[test]
665 fn test_multiline_html_heading_with_content_after() {
666 let rule = MD041FirstLineHeading::default();
667
668 let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
670 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
671 let result = rule.check(&ctx).unwrap();
672 assert!(
673 result.is_empty(),
674 "Multi-line HTML heading followed by content should be valid"
675 );
676 }
677
678 #[test]
679 fn test_multiline_html_heading_incomplete() {
680 let rule = MD041FirstLineHeading::default();
681
682 let content = "<h1>\nSome text\n\nMore content without closing tag";
684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
685 let result = rule.check(&ctx).unwrap();
686 assert_eq!(result.len(), 1);
687 assert!(result[0].message.contains("level 1 heading"));
688 }
689
690 #[test]
691 fn test_singleline_html_heading_still_works() {
692 let rule = MD041FirstLineHeading::default();
693
694 let content = "<h1>My Document</h1>\n\nContent.";
696 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
697 let result = rule.check(&ctx).unwrap();
698 assert!(
699 result.is_empty(),
700 "Single-line HTML headings should still be recognized"
701 );
702 }
703
704 #[test]
705 fn test_multiline_html_heading_with_nested_tags() {
706 let rule = MD041FirstLineHeading::default();
707
708 let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
711 let result = rule.check(&ctx).unwrap();
712 assert!(
713 result.is_empty(),
714 "Multi-line HTML heading with nested tags should be recognized"
715 );
716 }
717
718 #[test]
719 fn test_multiline_html_heading_various_levels() {
720 for level in 1..=6 {
722 let rule = MD041FirstLineHeading::new(level, false);
723
724 let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
726 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
727 let result = rule.check(&ctx).unwrap();
728 assert!(
729 result.is_empty(),
730 "Multi-line HTML heading at level {level} should be recognized"
731 );
732
733 let wrong_level = if level == 1 { 2 } else { 1 };
735 let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
736 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
737 let result = rule.check(&ctx).unwrap();
738 assert_eq!(result.len(), 1);
739 assert!(result[0].message.contains(&format!("level {level} heading")));
740 }
741 }
742}