rumdl_lib/rules/
md002_first_heading_h1.rs

1use crate::rule::Rule;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, RuleCategory, Severity};
3use crate::rule_config_serde::RuleConfig;
4use crate::rules::heading_utils::HeadingStyle;
5use crate::utils::range_utils::calculate_heading_range;
6use toml;
7
8mod md002_config;
9use md002_config::MD002Config;
10
11/// Rule MD002: First heading should be a top-level heading
12///
13/// See [docs/md002.md](../../docs/md002.md) for full documentation, configuration, and examples.
14///
15/// This rule enforces that the first heading in a document is a top-level heading (typically h1),
16/// which establishes the main topic or title of the document.
17///
18/// ## Purpose
19///
20/// - **Document Structure**: Ensures proper document hierarchy with a single top-level heading
21/// - **Accessibility**: Improves screen reader navigation by providing a clear document title
22/// - **SEO**: Helps search engines identify the primary topic of the document
23/// - **Readability**: Provides users with a clear understanding of the document's main subject
24///
25/// ## Configuration Options
26///
27/// The rule supports customizing the required level for the first heading:
28///
29/// ```yaml
30/// MD002:
31///   level: 1  # The heading level required for the first heading (default: 1)
32/// ```
33///
34/// Setting `level: 2` would require the first heading to be an h2 instead of h1.
35///
36/// ## Examples
37///
38/// ### Correct (with default configuration)
39///
40/// ```markdown
41/// # Document Title
42///
43/// ## Section 1
44///
45/// Content here...
46///
47/// ## Section 2
48///
49/// More content...
50/// ```
51///
52/// ### Incorrect (with default configuration)
53///
54/// ```markdown
55/// ## Introduction
56///
57/// Content here...
58///
59/// # Main Title
60///
61/// More content...
62/// ```
63///
64/// ## Behavior
65///
66/// This rule:
67/// - Ignores front matter (YAML metadata at the beginning of the document)
68/// - Works with both ATX (`#`) and Setext (underlined) heading styles
69/// - Only examines the first heading it encounters
70/// - Does not apply to documents with no headings
71///
72/// ## Fix Behavior
73///
74/// When applying automatic fixes, this rule:
75/// - Changes the level of the first heading to match the configured level
76/// - Preserves the original heading style (ATX, closed ATX, or Setext)
77/// - Maintains indentation and other formatting
78///
79/// ## Rationale
80///
81/// Having a single top-level heading establishes the document's primary topic and creates
82/// a logical structure. This follows semantic HTML principles where each page should have
83/// a single `<h1>` element that defines its main subject.
84///
85#[derive(Debug, Clone, Default)]
86pub struct MD002FirstHeadingH1 {
87    config: MD002Config,
88}
89
90impl MD002FirstHeadingH1 {
91    pub fn new(level: u32) -> Self {
92        Self {
93            config: MD002Config { level },
94        }
95    }
96
97    pub fn from_config_struct(config: MD002Config) -> Self {
98        Self { config }
99    }
100}
101
102impl Rule for MD002FirstHeadingH1 {
103    fn name(&self) -> &'static str {
104        "MD002"
105    }
106
107    fn description(&self) -> &'static str {
108        "First heading should be top level"
109    }
110
111    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
112        let content = ctx.content;
113        // Early return for empty content
114        if content.is_empty() {
115            return Ok(vec![]);
116        }
117
118        // Find the first heading using pre-computed line info
119        let first_heading = ctx
120            .lines
121            .iter()
122            .enumerate()
123            .find_map(|(line_num, line_info)| line_info.heading.as_ref().map(|h| (line_num, line_info, h)));
124
125        if let Some((line_num, line_info, heading)) = first_heading {
126            // Check if the first heading is on the first non-empty line after front matter.
127            // If it is AND it's already H1, MD002 should not trigger (already correct).
128            // If it is but NOT H1, MD002 still should not trigger for markdownlint compatibility
129            // (MD002 is implicitly disabled when MD041 would be satisfied).
130            let first_content_line = ctx
131                .lines
132                .iter()
133                .enumerate()
134                .find(|(_, line_info)| !line_info.in_front_matter && !line_info.content.trim().is_empty())
135                .map(|(idx, _)| idx);
136
137            // If the first heading is on the first content line, don't trigger MD002
138            // This matches markdownlint behavior where MD002 doesn't apply to first-line headings
139            if let Some(first_line_idx) = first_content_line
140                && line_num == first_line_idx
141            {
142                return Ok(vec![]);
143            }
144
145            // Otherwise check if the heading level is correct
146            if heading.level != self.config.level as u8 {
147                let message = format!(
148                    "First heading should be level {}, found level {}",
149                    self.config.level, heading.level
150                );
151
152                // Calculate the fix
153                let fix = {
154                    let replacement = crate::rules::heading_utils::HeadingUtils::convert_heading_style(
155                        &heading.text,
156                        self.config.level,
157                        match heading.style {
158                            crate::lint_context::HeadingStyle::ATX => {
159                                if heading.has_closing_sequence {
160                                    HeadingStyle::AtxClosed
161                                } else {
162                                    HeadingStyle::Atx
163                                }
164                            }
165                            crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
166                            crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
167                        },
168                    );
169
170                    // Use line content range to replace the entire heading line
171                    let line_index = crate::utils::range_utils::LineIndex::new(content.to_string());
172                    Some(Fix {
173                        range: line_index.line_content_range(line_num + 1), // Convert to 1-indexed
174                        replacement,
175                    })
176                };
177
178                // Calculate precise range: highlight the entire first heading
179                let (start_line, start_col, end_line, end_col) =
180                    calculate_heading_range(line_num + 1, &line_info.content);
181
182                return Ok(vec![LintWarning {
183                    message,
184                    line: start_line,
185                    column: start_col,
186                    end_line,
187                    end_column: end_col,
188                    severity: Severity::Warning,
189                    fix,
190                    rule_name: Some(self.name()),
191                }]);
192            }
193        }
194
195        Ok(vec![])
196    }
197
198    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
199        let content = ctx.content;
200
201        // Find the first heading using pre-computed line info
202        let first_heading = ctx
203            .lines
204            .iter()
205            .enumerate()
206            .find_map(|(line_num, line_info)| line_info.heading.as_ref().map(|h| (line_num, line_info, h)));
207
208        if let Some((line_num, line_info, heading)) = first_heading {
209            // Check if the first heading is on the first non-empty line after front matter.
210            // If it is, MD002 should not apply (markdownlint compatibility).
211            let first_content_line = ctx
212                .lines
213                .iter()
214                .enumerate()
215                .find(|(_, line_info)| !line_info.in_front_matter && !line_info.content.trim().is_empty())
216                .map(|(idx, _)| idx);
217
218            if let Some(first_line_idx) = first_content_line
219                && line_num == first_line_idx
220            {
221                return Ok(content.to_string());
222            }
223
224            // If we're here, the heading is not on the first line, so check if it needs fixing
225            if heading.level == self.config.level as u8 {
226                return Ok(content.to_string());
227            }
228
229            let lines: Vec<&str> = content.lines().collect();
230            let mut fixed_lines = Vec::new();
231            let mut i = 0;
232
233            while i < lines.len() {
234                if i == line_num {
235                    // This is the first heading line that needs fixing
236                    let indent = " ".repeat(line_info.indent);
237                    let heading_text = heading.text.trim();
238
239                    match heading.style {
240                        crate::lint_context::HeadingStyle::ATX => {
241                            let hashes = "#".repeat(self.config.level as usize);
242                            if heading.has_closing_sequence {
243                                // Preserve closed ATX: # Heading #
244                                fixed_lines.push(format!("{indent}{hashes} {heading_text} {hashes}"));
245                            } else {
246                                // Standard ATX: # Heading
247                                fixed_lines.push(format!("{indent}{hashes} {heading_text}"));
248                            }
249                            i += 1;
250                        }
251                        crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2 => {
252                            // For Setext, we need to update the underline
253                            fixed_lines.push(lines[i].to_string()); // Keep heading text as-is
254                            i += 1;
255                            if i < lines.len() {
256                                // Replace the underline
257                                let underline = if self.config.level == 1 { "=======" } else { "-------" };
258                                fixed_lines.push(underline.to_string());
259                                i += 1;
260                            }
261                        }
262                    }
263                    continue;
264                }
265
266                fixed_lines.push(lines[i].to_string());
267                i += 1;
268            }
269
270            Ok(fixed_lines.join("\n"))
271        } else {
272            // No headings found
273            Ok(content.to_string())
274        }
275    }
276
277    /// Get the category of this rule for selective processing
278    fn category(&self) -> RuleCategory {
279        RuleCategory::Heading
280    }
281
282    /// Check if this rule should be skipped
283    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
284        let content = ctx.content;
285        content.is_empty() || (!content.contains('#') && !content.contains('=') && !content.contains('-'))
286    }
287
288    fn as_any(&self) -> &dyn std::any::Any {
289        self
290    }
291
292    fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
293        None
294    }
295
296    fn default_config_section(&self) -> Option<(String, toml::Value)> {
297        let default_config = MD002Config::default();
298        let json_value = serde_json::to_value(&default_config).ok()?;
299        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
300
301        if let toml::Value::Table(table) = toml_value {
302            if !table.is_empty() {
303                Some((MD002Config::RULE_NAME.to_string(), toml::Value::Table(table)))
304            } else {
305                None
306            }
307        } else {
308            None
309        }
310    }
311
312    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
313    where
314        Self: Sized,
315    {
316        let rule_config = crate::rule_config_serde::load_rule_config::<MD002Config>(config);
317        Box::new(Self::from_config_struct(rule_config))
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::lint_context::LintContext;
325
326    #[test]
327    fn test_default_config() {
328        let rule = MD002FirstHeadingH1::default();
329        assert_eq!(rule.config.level, 1);
330    }
331
332    #[test]
333    fn test_custom_config() {
334        let rule = MD002FirstHeadingH1::new(2);
335        assert_eq!(rule.config.level, 2);
336    }
337
338    #[test]
339    fn test_correct_h1_first_heading() {
340        let rule = MD002FirstHeadingH1::new(1);
341        let content = "# Main Title\n\n## Subsection\n\nContent here";
342        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
343        let result = rule.check(&ctx).unwrap();
344
345        assert_eq!(result.len(), 0);
346    }
347
348    #[test]
349    fn test_incorrect_h2_first_heading() {
350        // When heading is on first line, MD002 doesn't trigger (markdownlint compatibility)
351        let rule = MD002FirstHeadingH1::new(1);
352        let content = "## Introduction\n\nContent here\n\n# Main Title";
353        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
354        let result = rule.check(&ctx).unwrap();
355
356        assert_eq!(result.len(), 0); // MD002 doesn't trigger for first-line headings
357    }
358
359    #[test]
360    fn test_empty_document() {
361        let rule = MD002FirstHeadingH1::default();
362        let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
363        let result = rule.check(&ctx).unwrap();
364
365        assert_eq!(result.len(), 0);
366    }
367
368    #[test]
369    fn test_document_with_no_headings() {
370        let rule = MD002FirstHeadingH1::default();
371        let content = "This is just paragraph text.\n\nMore paragraph text.\n\n- List item 1\n- List item 2";
372        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
373        let result = rule.check(&ctx).unwrap();
374
375        assert_eq!(result.len(), 0);
376    }
377
378    #[test]
379    fn test_setext_style_heading() {
380        // When heading is on first line, MD002 doesn't trigger (markdownlint compatibility)
381        let rule = MD002FirstHeadingH1::new(1);
382        let content = "Introduction\n------------\n\nContent here";
383        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
384        let result = rule.check(&ctx).unwrap();
385
386        assert_eq!(result.len(), 0); // MD002 doesn't trigger for first-line headings
387    }
388
389    #[test]
390    fn test_correct_setext_h1() {
391        let rule = MD002FirstHeadingH1::new(1);
392        let content = "Main Title\n==========\n\nContent here";
393        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
394        let result = rule.check(&ctx).unwrap();
395
396        assert_eq!(result.len(), 0);
397    }
398
399    #[test]
400    fn test_with_front_matter() {
401        // When heading is immediately after front matter, MD002 doesn't trigger (markdownlint compatibility)
402        let rule = MD002FirstHeadingH1::new(1);
403        let content = "---\ntitle: Test Document\nauthor: Test Author\n---\n## Introduction\n\nContent";
404        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
405        let result = rule.check(&ctx).unwrap();
406
407        assert_eq!(result.len(), 0); // MD002 doesn't trigger for first-line headings after front matter
408    }
409
410    #[test]
411    fn test_fix_atx_heading() {
412        // When heading is on first line, MD002 doesn't fix (markdownlint compatibility)
413        let rule = MD002FirstHeadingH1::new(1);
414        let content = "## Introduction\n\nContent here";
415        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
416
417        let fixed = rule.fix(&ctx).unwrap();
418        assert_eq!(fixed, content); // No fix applied for first-line headings
419    }
420
421    #[test]
422    fn test_fix_closed_atx_heading() {
423        // When heading is on first line, MD002 doesn't fix (markdownlint compatibility)
424        let rule = MD002FirstHeadingH1::new(1);
425        let content = "## Introduction ##\n\nContent here";
426        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
427
428        let fixed = rule.fix(&ctx).unwrap();
429        assert_eq!(fixed, content); // No fix applied for first-line headings
430    }
431
432    #[test]
433    fn test_fix_setext_heading() {
434        // When heading is on first line, MD002 doesn't fix (markdownlint compatibility)
435        let rule = MD002FirstHeadingH1::new(1);
436        let content = "Introduction\n------------\n\nContent here";
437        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
438
439        let fixed = rule.fix(&ctx).unwrap();
440        assert_eq!(fixed, content); // No fix applied for first-line headings
441    }
442
443    #[test]
444    fn test_fix_with_indented_heading() {
445        // When heading is on first line, MD002 doesn't fix (markdownlint compatibility)
446        let rule = MD002FirstHeadingH1::new(1);
447        let content = "  ## Introduction\n\nContent here";
448        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
449
450        let fixed = rule.fix(&ctx).unwrap();
451        assert_eq!(fixed, content); // No fix applied for first-line headings
452    }
453
454    #[test]
455    fn test_custom_level_requirement() {
456        // When heading is on first line, MD002 doesn't trigger (markdownlint compatibility)
457        let rule = MD002FirstHeadingH1::new(2);
458        let content = "# Main Title\n\n## Subsection";
459        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
460        let result = rule.check(&ctx).unwrap();
461
462        assert_eq!(result.len(), 0); // MD002 doesn't trigger for first-line headings
463    }
464
465    #[test]
466    fn test_fix_to_custom_level() {
467        // When heading is on first line, MD002 doesn't fix (markdownlint compatibility)
468        let rule = MD002FirstHeadingH1::new(2);
469        let content = "# Main Title\n\nContent";
470        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
471
472        let fixed = rule.fix(&ctx).unwrap();
473        assert_eq!(fixed, content); // No fix applied for first-line headings
474    }
475
476    #[test]
477    fn test_multiple_headings() {
478        // When heading is on first line, MD002 doesn't trigger (markdownlint compatibility)
479        let rule = MD002FirstHeadingH1::new(1);
480        let content = "### Introduction\n\n# Main Title\n\n## Section\n\n#### Subsection";
481        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
482        let result = rule.check(&ctx).unwrap();
483
484        assert_eq!(result.len(), 0); // MD002 doesn't trigger for first-line headings
485    }
486
487    #[test]
488    fn test_should_skip_optimization() {
489        let rule = MD002FirstHeadingH1::default();
490
491        // Should skip empty content
492        let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
493        assert!(rule.should_skip(&ctx));
494
495        // Should skip content without heading indicators
496        let ctx = LintContext::new(
497            "Just paragraph text\n\nMore text",
498            crate::config::MarkdownFlavor::Standard,
499        );
500        assert!(rule.should_skip(&ctx));
501
502        // Should not skip content with ATX heading
503        let ctx = LintContext::new("Some text\n# Heading", crate::config::MarkdownFlavor::Standard);
504        assert!(!rule.should_skip(&ctx));
505
506        // Should not skip content with potential setext heading
507        let ctx = LintContext::new("Title\n=====", crate::config::MarkdownFlavor::Standard);
508        assert!(!rule.should_skip(&ctx));
509    }
510
511    #[test]
512    fn test_rule_metadata() {
513        let rule = MD002FirstHeadingH1::default();
514        assert_eq!(rule.name(), "MD002");
515        assert_eq!(rule.description(), "First heading should be top level");
516        assert_eq!(rule.category(), RuleCategory::Heading);
517    }
518
519    #[test]
520    fn test_from_config_struct() {
521        let config = MD002Config { level: 3 };
522        let rule = MD002FirstHeadingH1::from_config_struct(config);
523        assert_eq!(rule.config.level, 3);
524    }
525
526    #[test]
527    fn test_fix_preserves_content_structure() {
528        // When heading is on first line, MD002 doesn't fix (markdownlint compatibility)
529        let rule = MD002FirstHeadingH1::new(1);
530        let content = "### Heading\n\nParagraph 1\n\n## Section\n\nParagraph 2";
531        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
532
533        let fixed = rule.fix(&ctx).unwrap();
534        assert_eq!(fixed, content); // No fix applied for first-line headings
535    }
536
537    #[test]
538    fn test_long_setext_underline() {
539        // When heading is on first line, MD002 doesn't fix (markdownlint compatibility)
540        let rule = MD002FirstHeadingH1::new(1);
541        let content = "Short Title\n----------------------------------------\n\nContent";
542        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
543
544        let fixed = rule.fix(&ctx).unwrap();
545        assert_eq!(fixed, content); // No fix applied for first-line headings
546    }
547
548    #[test]
549    fn test_fix_already_correct() {
550        let rule = MD002FirstHeadingH1::new(1);
551        let content = "# Correct Heading\n\nContent";
552        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
553
554        let fixed = rule.fix(&ctx).unwrap();
555        assert_eq!(fixed, content);
556    }
557
558    #[test]
559    fn test_heading_with_special_characters() {
560        // When heading is on first line, MD002 doesn't trigger (markdownlint compatibility)
561        let rule = MD002FirstHeadingH1::new(1);
562        let content = "## Heading with **bold** and _italic_ text\n\nContent";
563        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
564        let result = rule.check(&ctx).unwrap();
565
566        assert_eq!(result.len(), 0); // MD002 doesn't trigger for first-line headings
567
568        let fixed = rule.fix(&ctx).unwrap();
569        assert_eq!(fixed, content); // No fix applied for first-line headings
570    }
571
572    #[test]
573    fn test_atx_heading_with_extra_spaces() {
574        // When heading is on first line, MD002 doesn't fix (markdownlint compatibility)
575        let rule = MD002FirstHeadingH1::new(1);
576        let content = "##    Introduction    \n\nContent";
577        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
578
579        let fixed = rule.fix(&ctx).unwrap();
580        assert_eq!(fixed, content); // No fix applied for first-line headings
581    }
582
583    #[test]
584    fn test_md002_does_not_trigger_when_first_line_is_heading() {
585        // This tests markdownlint compatibility: MD002 should not trigger
586        // when the first line is a heading (even if it's not level 1)
587        // because MD041 would handle this case
588        let rule = MD002FirstHeadingH1::new(1);
589        let content = "## Introduction\n\nContent here";
590        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
591        let result = rule.check(&ctx).unwrap();
592
593        // MD002 should NOT trigger because the heading is on the first line
594        assert_eq!(result.len(), 0, "MD002 should not trigger when first line is a heading");
595    }
596
597    #[test]
598    fn test_md002_triggers_when_heading_is_not_first_line() {
599        // MD002 should still trigger when the heading is NOT on the first line
600        let rule = MD002FirstHeadingH1::new(1);
601        let content = "Some text before heading\n\n## Introduction\n\nContent";
602        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
603        let result = rule.check(&ctx).unwrap();
604
605        assert_eq!(
606            result.len(),
607            1,
608            "MD002 should trigger when heading is not on first line"
609        );
610        assert!(result[0].message.contains("First heading should be level 1"));
611    }
612
613    #[test]
614    fn test_md002_with_front_matter_and_first_line_heading() {
615        // MD002 should not trigger when the first line after front matter is a heading
616        let rule = MD002FirstHeadingH1::new(1);
617        let content = "---\ntitle: Test\n---\n## Introduction\n\nContent";
618        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
619        let result = rule.check(&ctx).unwrap();
620
621        assert_eq!(
622            result.len(),
623            0,
624            "MD002 should not trigger when first line after front matter is a heading"
625        );
626    }
627
628    #[test]
629    fn test_md002_with_front_matter_and_delayed_heading() {
630        // MD002 should trigger when the heading is not immediately after front matter
631        let rule = MD002FirstHeadingH1::new(1);
632        let content = "---\ntitle: Test\n---\nSome text\n\n## Introduction\n\nContent";
633        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
634        let result = rule.check(&ctx).unwrap();
635
636        assert_eq!(
637            result.len(),
638            1,
639            "MD002 should trigger when heading is not immediately after front matter"
640        );
641    }
642
643    #[test]
644    fn test_md002_fix_does_not_change_first_line_heading() {
645        // Fix should not change a heading that's on the first line
646        let rule = MD002FirstHeadingH1::new(1);
647        let content = "### Third Level Heading\n\nContent";
648        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
649        let fixed = rule.fix(&ctx).unwrap();
650
651        assert_eq!(fixed, content, "Fix should not change heading on first line");
652    }
653}