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(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 has_opening = html_tags.iter().any(|tag| {
111 tag.line == first_line_idx + 1 && tag.tag_name == target_tag
113 && !tag.is_closing
114 });
115
116 if !has_opening {
117 return false;
118 }
119
120 const MAX_LINES_TO_SCAN: usize = 10;
122 let end_line = (first_line_idx + 1 + MAX_LINES_TO_SCAN).min(ctx.lines.len());
123
124 html_tags.iter().any(|tag| {
125 tag.line > first_line_idx + 1 && tag.line <= end_line
127 && tag.tag_name == target_tag
128 && tag.is_closing
129 })
130 }
131}
132
133impl Rule for MD041FirstLineHeading {
134 fn name(&self) -> &'static str {
135 "MD041"
136 }
137
138 fn description(&self) -> &'static str {
139 "First line in file should be a top level heading"
140 }
141
142 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
143 let mut warnings = Vec::new();
144
145 if self.should_skip(ctx) {
147 return Ok(warnings);
148 }
149
150 let mut first_content_line_num = None;
152 let mut skip_lines = 0;
153
154 if ctx.lines.first().map(|l| l.content(ctx.content).trim()) == Some("---") {
156 for (idx, line_info) in ctx.lines.iter().enumerate().skip(1) {
158 if line_info.content(ctx.content).trim() == "---" {
159 skip_lines = idx + 1;
160 break;
161 }
162 }
163 }
164
165 for (line_num, line_info) in ctx.lines.iter().enumerate().skip(skip_lines) {
166 let line_content = line_info.content(ctx.content).trim();
167 if line_info.in_esm_block {
169 continue;
170 }
171 if !line_content.is_empty() && !Self::is_non_content_line(line_info.content(ctx.content)) {
172 first_content_line_num = Some(line_num);
173 break;
174 }
175 }
176
177 if first_content_line_num.is_none() {
178 return Ok(warnings);
180 }
181
182 let first_line_idx = first_content_line_num.unwrap();
183
184 let first_line_info = &ctx.lines[first_line_idx];
186 let is_correct_heading = if let Some(heading) = &first_line_info.heading {
187 heading.level as usize == self.level
188 } else {
189 Self::is_html_heading(ctx, first_line_idx, self.level)
191 };
192
193 if !is_correct_heading {
194 let first_line = first_line_idx + 1; let first_line_content = first_line_info.content(ctx.content);
197 let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
198
199 warnings.push(LintWarning {
200 rule_name: Some(self.name().to_string()),
201 line: start_line,
202 column: start_col,
203 end_line,
204 end_column: end_col,
205 message: format!("First line in file should be a level {} heading", self.level),
206 severity: Severity::Warning,
207 fix: None, });
209 }
210 Ok(warnings)
211 }
212
213 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
214 Ok(ctx.content.to_string())
217 }
218
219 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
221 let only_directives = !ctx.content.is_empty()
226 && ctx.content.lines().filter(|l| !l.trim().is_empty()).all(|l| {
227 let t = l.trim();
228 (t.starts_with("{{#") && t.ends_with("}}"))
230 || (t.starts_with("<!--") && t.ends_with("-->"))
232 });
233
234 ctx.content.is_empty()
235 || (self.front_matter_title && self.has_front_matter_title(ctx.content))
236 || only_directives
237 }
238
239 fn as_any(&self) -> &dyn std::any::Any {
240 self
241 }
242
243 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
244 where
245 Self: Sized,
246 {
247 let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
249
250 let use_front_matter = !md041_config.front_matter_title.is_empty();
251
252 Box::new(MD041FirstLineHeading::with_pattern(
253 md041_config.level.as_usize(),
254 use_front_matter,
255 md041_config.front_matter_title_pattern,
256 ))
257 }
258
259 fn default_config_section(&self) -> Option<(String, toml::Value)> {
260 Some((
261 "MD041".to_string(),
262 toml::toml! {
263 level = 1
264 front-matter-title = "title"
265 front-matter-title-pattern = ""
266 }
267 .into(),
268 ))
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use crate::lint_context::LintContext;
276
277 #[test]
278 fn test_first_line_is_heading_correct_level() {
279 let rule = MD041FirstLineHeading::default();
280
281 let content = "# My Document\n\nSome content here.";
283 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
284 let result = rule.check(&ctx).unwrap();
285 assert!(
286 result.is_empty(),
287 "Expected no warnings when first line is a level 1 heading"
288 );
289 }
290
291 #[test]
292 fn test_first_line_is_heading_wrong_level() {
293 let rule = MD041FirstLineHeading::default();
294
295 let content = "## My Document\n\nSome content here.";
297 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
298 let result = rule.check(&ctx).unwrap();
299 assert_eq!(result.len(), 1);
300 assert_eq!(result[0].line, 1);
301 assert!(result[0].message.contains("level 1 heading"));
302 }
303
304 #[test]
305 fn test_first_line_not_heading() {
306 let rule = MD041FirstLineHeading::default();
307
308 let content = "This is not a heading\n\n# This is a heading";
310 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
311 let result = rule.check(&ctx).unwrap();
312 assert_eq!(result.len(), 1);
313 assert_eq!(result[0].line, 1);
314 assert!(result[0].message.contains("level 1 heading"));
315 }
316
317 #[test]
318 fn test_empty_lines_before_heading() {
319 let rule = MD041FirstLineHeading::default();
320
321 let content = "\n\n# My Document\n\nSome content.";
323 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
324 let result = rule.check(&ctx).unwrap();
325 assert!(
326 result.is_empty(),
327 "Expected no warnings when empty lines precede a valid heading"
328 );
329
330 let content = "\n\nNot a heading\n\nSome content.";
332 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
333 let result = rule.check(&ctx).unwrap();
334 assert_eq!(result.len(), 1);
335 assert_eq!(result[0].line, 3); assert!(result[0].message.contains("level 1 heading"));
337 }
338
339 #[test]
340 fn test_front_matter_with_title() {
341 let rule = MD041FirstLineHeading::new(1, true);
342
343 let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
345 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
346 let result = rule.check(&ctx).unwrap();
347 assert!(
348 result.is_empty(),
349 "Expected no warnings when front matter has title field"
350 );
351 }
352
353 #[test]
354 fn test_front_matter_without_title() {
355 let rule = MD041FirstLineHeading::new(1, true);
356
357 let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
359 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
360 let result = rule.check(&ctx).unwrap();
361 assert_eq!(result.len(), 1);
362 assert_eq!(result[0].line, 6); }
364
365 #[test]
366 fn test_front_matter_disabled() {
367 let rule = MD041FirstLineHeading::new(1, false);
368
369 let content = "---\ntitle: My Document\n---\n\nSome content here.";
371 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
372 let result = rule.check(&ctx).unwrap();
373 assert_eq!(result.len(), 1);
374 assert_eq!(result[0].line, 5); }
376
377 #[test]
378 fn test_html_comments_before_heading() {
379 let rule = MD041FirstLineHeading::default();
380
381 let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
383 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
384 let result = rule.check(&ctx).unwrap();
385 assert_eq!(result.len(), 1);
386 assert_eq!(result[0].line, 1); }
388
389 #[test]
390 fn test_different_heading_levels() {
391 let rule = MD041FirstLineHeading::new(2, false);
393
394 let content = "## Second Level Heading\n\nContent.";
395 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
396 let result = rule.check(&ctx).unwrap();
397 assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
398
399 let content = "# First Level Heading\n\nContent.";
401 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
402 let result = rule.check(&ctx).unwrap();
403 assert_eq!(result.len(), 1);
404 assert!(result[0].message.contains("level 2 heading"));
405 }
406
407 #[test]
408 fn test_setext_headings() {
409 let rule = MD041FirstLineHeading::default();
410
411 let content = "My Document\n===========\n\nContent.";
413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
414 let result = rule.check(&ctx).unwrap();
415 assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
416
417 let content = "My Document\n-----------\n\nContent.";
419 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
420 let result = rule.check(&ctx).unwrap();
421 assert_eq!(result.len(), 1);
422 assert!(result[0].message.contains("level 1 heading"));
423 }
424
425 #[test]
426 fn test_empty_document() {
427 let rule = MD041FirstLineHeading::default();
428
429 let content = "";
431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
432 let result = rule.check(&ctx).unwrap();
433 assert!(result.is_empty(), "Expected no warnings for empty document");
434 }
435
436 #[test]
437 fn test_whitespace_only_document() {
438 let rule = MD041FirstLineHeading::default();
439
440 let content = " \n\n \t\n";
442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
443 let result = rule.check(&ctx).unwrap();
444 assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
445 }
446
447 #[test]
448 fn test_front_matter_then_whitespace() {
449 let rule = MD041FirstLineHeading::default();
450
451 let content = "---\ntitle: Test\n---\n\n \n\n";
453 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
454 let result = rule.check(&ctx).unwrap();
455 assert!(
456 result.is_empty(),
457 "Expected no warnings when no content after front matter"
458 );
459 }
460
461 #[test]
462 fn test_multiple_front_matter_types() {
463 let rule = MD041FirstLineHeading::new(1, true);
464
465 let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
468 let result = rule.check(&ctx).unwrap();
469 assert_eq!(result.len(), 1);
470 assert!(result[0].message.contains("level 1 heading"));
471
472 let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
475 let result = rule.check(&ctx).unwrap();
476 assert_eq!(result.len(), 1);
477 assert!(result[0].message.contains("level 1 heading"));
478
479 let content = "---\ntitle: My Document\n---\n\nContent.";
481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
482 let result = rule.check(&ctx).unwrap();
483 assert!(
484 result.is_empty(),
485 "Expected no warnings for YAML front matter with title"
486 );
487
488 let content = "+++\ntitle: My Document\n+++\n\nContent.";
490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
491 let result = rule.check(&ctx).unwrap();
492 assert!(result.is_empty(), "Expected no warnings when title: pattern is found");
493 }
494
495 #[test]
496 fn test_malformed_front_matter() {
497 let rule = MD041FirstLineHeading::new(1, true);
498
499 let content = "- --\ntitle: My Document\n- --\n\nContent.";
501 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
502 let result = rule.check(&ctx).unwrap();
503 assert!(
504 result.is_empty(),
505 "Expected no warnings for malformed front matter with title"
506 );
507 }
508
509 #[test]
510 fn test_front_matter_with_heading() {
511 let rule = MD041FirstLineHeading::default();
512
513 let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
516 let result = rule.check(&ctx).unwrap();
517 assert!(
518 result.is_empty(),
519 "Expected no warnings when first line after front matter is correct heading"
520 );
521 }
522
523 #[test]
524 fn test_no_fix_suggestion() {
525 let rule = MD041FirstLineHeading::default();
526
527 let content = "Not a heading\n\nContent.";
529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
530 let result = rule.check(&ctx).unwrap();
531 assert_eq!(result.len(), 1);
532 assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
533 }
534
535 #[test]
536 fn test_complex_document_structure() {
537 let rule = MD041FirstLineHeading::default();
538
539 let content =
541 "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
543 let result = rule.check(&ctx).unwrap();
544 assert_eq!(result.len(), 1);
545 assert_eq!(result[0].line, 5); }
547
548 #[test]
549 fn test_heading_with_special_characters() {
550 let rule = MD041FirstLineHeading::default();
551
552 let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
555 let result = rule.check(&ctx).unwrap();
556 assert!(
557 result.is_empty(),
558 "Expected no warnings for heading with inline formatting"
559 );
560 }
561
562 #[test]
563 fn test_level_configuration() {
564 for level in 1..=6 {
566 let rule = MD041FirstLineHeading::new(level, false);
567
568 let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
570 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
571 let result = rule.check(&ctx).unwrap();
572 assert!(
573 result.is_empty(),
574 "Expected no warnings for correct level {level} heading"
575 );
576
577 let wrong_level = if level == 1 { 2 } else { 1 };
579 let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
580 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
581 let result = rule.check(&ctx).unwrap();
582 assert_eq!(result.len(), 1);
583 assert!(result[0].message.contains(&format!("level {level} heading")));
584 }
585 }
586
587 #[test]
588 fn test_issue_152_multiline_html_heading() {
589 let rule = MD041FirstLineHeading::default();
590
591 let content = "<h1>\nSome text\n</h1>";
593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
594 let result = rule.check(&ctx).unwrap();
595 assert!(
596 result.is_empty(),
597 "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
598 );
599 }
600
601 #[test]
602 fn test_multiline_html_heading_with_attributes() {
603 let rule = MD041FirstLineHeading::default();
604
605 let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
608 let result = rule.check(&ctx).unwrap();
609 assert!(
610 result.is_empty(),
611 "Multi-line HTML heading with attributes should be recognized"
612 );
613 }
614
615 #[test]
616 fn test_multiline_html_heading_wrong_level() {
617 let rule = MD041FirstLineHeading::default();
618
619 let content = "<h2>\nSome text\n</h2>";
621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
622 let result = rule.check(&ctx).unwrap();
623 assert_eq!(result.len(), 1);
624 assert!(result[0].message.contains("level 1 heading"));
625 }
626
627 #[test]
628 fn test_multiline_html_heading_with_content_after() {
629 let rule = MD041FirstLineHeading::default();
630
631 let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
634 let result = rule.check(&ctx).unwrap();
635 assert!(
636 result.is_empty(),
637 "Multi-line HTML heading followed by content should be valid"
638 );
639 }
640
641 #[test]
642 fn test_multiline_html_heading_incomplete() {
643 let rule = MD041FirstLineHeading::default();
644
645 let content = "<h1>\nSome text\n\nMore content without closing tag";
647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
648 let result = rule.check(&ctx).unwrap();
649 assert_eq!(result.len(), 1);
650 assert!(result[0].message.contains("level 1 heading"));
651 }
652
653 #[test]
654 fn test_singleline_html_heading_still_works() {
655 let rule = MD041FirstLineHeading::default();
656
657 let content = "<h1>My Document</h1>\n\nContent.";
659 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
660 let result = rule.check(&ctx).unwrap();
661 assert!(
662 result.is_empty(),
663 "Single-line HTML headings should still be recognized"
664 );
665 }
666
667 #[test]
668 fn test_multiline_html_heading_with_nested_tags() {
669 let rule = MD041FirstLineHeading::default();
670
671 let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
674 let result = rule.check(&ctx).unwrap();
675 assert!(
676 result.is_empty(),
677 "Multi-line HTML heading with nested tags should be recognized"
678 );
679 }
680
681 #[test]
682 fn test_multiline_html_heading_various_levels() {
683 for level in 1..=6 {
685 let rule = MD041FirstLineHeading::new(level, false);
686
687 let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
689 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
690 let result = rule.check(&ctx).unwrap();
691 assert!(
692 result.is_empty(),
693 "Multi-line HTML heading at level {level} should be recognized"
694 );
695
696 let wrong_level = if level == 1 { 2 } else { 1 };
698 let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
699 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
700 let result = rule.check(&ctx).unwrap();
701 assert_eq!(result.len(), 1);
702 assert!(result[0].message.contains(&format!("level {level} heading")));
703 }
704 }
705}