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            && heading.level != self.config.level as u8
127        {
128            let message = format!(
129                "First heading should be level {}, found level {}",
130                self.config.level, heading.level
131            );
132
133            // Calculate the fix
134            let fix = {
135                let replacement = crate::rules::heading_utils::HeadingUtils::convert_heading_style(
136                    &heading.text,
137                    self.config.level,
138                    match heading.style {
139                        crate::lint_context::HeadingStyle::ATX => {
140                            if heading.has_closing_sequence {
141                                HeadingStyle::AtxClosed
142                            } else {
143                                HeadingStyle::Atx
144                            }
145                        }
146                        crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
147                        crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
148                    },
149                );
150
151                // Use line content range to replace the entire heading line
152                let line_index = crate::utils::range_utils::LineIndex::new(content.to_string());
153                Some(Fix {
154                    range: line_index.line_content_range(line_num + 1), // Convert to 1-indexed
155                    replacement,
156                })
157            };
158
159            // Calculate precise range: highlight the entire first heading
160            let (start_line, start_col, end_line, end_col) = calculate_heading_range(line_num + 1, &line_info.content);
161
162            return Ok(vec![LintWarning {
163                message,
164                line: start_line,
165                column: start_col,
166                end_line,
167                end_column: end_col,
168                severity: Severity::Warning,
169                fix,
170                rule_name: Some(self.name()),
171            }]);
172        }
173
174        Ok(vec![])
175    }
176
177    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
178        let content = ctx.content;
179
180        // Find the first heading using pre-computed line info
181        let first_heading = ctx
182            .lines
183            .iter()
184            .enumerate()
185            .find_map(|(line_num, line_info)| line_info.heading.as_ref().map(|h| (line_num, line_info, h)));
186
187        if let Some((line_num, line_info, heading)) = first_heading {
188            if heading.level == self.config.level as u8 {
189                return Ok(content.to_string());
190            }
191
192            let lines: Vec<&str> = content.lines().collect();
193            let mut fixed_lines = Vec::new();
194            let mut i = 0;
195
196            while i < lines.len() {
197                if i == line_num {
198                    // This is the first heading line that needs fixing
199                    let indent = " ".repeat(line_info.indent);
200                    let heading_text = heading.text.trim();
201
202                    match heading.style {
203                        crate::lint_context::HeadingStyle::ATX => {
204                            let hashes = "#".repeat(self.config.level as usize);
205                            if heading.has_closing_sequence {
206                                // Preserve closed ATX: # Heading #
207                                fixed_lines.push(format!("{indent}{hashes} {heading_text} {hashes}"));
208                            } else {
209                                // Standard ATX: # Heading
210                                fixed_lines.push(format!("{indent}{hashes} {heading_text}"));
211                            }
212                            i += 1;
213                        }
214                        crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2 => {
215                            // For Setext, we need to update the underline
216                            fixed_lines.push(lines[i].to_string()); // Keep heading text as-is
217                            i += 1;
218                            if i < lines.len() {
219                                // Replace the underline
220                                let underline = if self.config.level == 1 { "=======" } else { "-------" };
221                                fixed_lines.push(underline.to_string());
222                                i += 1;
223                            }
224                        }
225                    }
226                    continue;
227                }
228
229                fixed_lines.push(lines[i].to_string());
230                i += 1;
231            }
232
233            Ok(fixed_lines.join("\n"))
234        } else {
235            // No headings found
236            Ok(content.to_string())
237        }
238    }
239
240    /// Get the category of this rule for selective processing
241    fn category(&self) -> RuleCategory {
242        RuleCategory::Heading
243    }
244
245    /// Check if this rule should be skipped
246    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
247        let content = ctx.content;
248        content.is_empty() || (!content.contains('#') && !content.contains('=') && !content.contains('-'))
249    }
250
251    fn as_any(&self) -> &dyn std::any::Any {
252        self
253    }
254
255    fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
256        None
257    }
258
259    fn default_config_section(&self) -> Option<(String, toml::Value)> {
260        let default_config = MD002Config::default();
261        let json_value = serde_json::to_value(&default_config).ok()?;
262        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
263
264        if let toml::Value::Table(table) = toml_value {
265            if !table.is_empty() {
266                Some((MD002Config::RULE_NAME.to_string(), toml::Value::Table(table)))
267            } else {
268                None
269            }
270        } else {
271            None
272        }
273    }
274
275    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
276    where
277        Self: Sized,
278    {
279        let rule_config = crate::rule_config_serde::load_rule_config::<MD002Config>(config);
280        Box::new(Self::from_config_struct(rule_config))
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::lint_context::LintContext;
288
289    #[test]
290    fn test_default_config() {
291        let rule = MD002FirstHeadingH1::default();
292        assert_eq!(rule.config.level, 1);
293    }
294
295    #[test]
296    fn test_custom_config() {
297        let rule = MD002FirstHeadingH1::new(2);
298        assert_eq!(rule.config.level, 2);
299    }
300
301    #[test]
302    fn test_correct_h1_first_heading() {
303        let rule = MD002FirstHeadingH1::new(1);
304        let content = "# Main Title\n\n## Subsection\n\nContent here";
305        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
306        let result = rule.check(&ctx).unwrap();
307
308        assert_eq!(result.len(), 0);
309    }
310
311    #[test]
312    fn test_incorrect_h2_first_heading() {
313        let rule = MD002FirstHeadingH1::new(1);
314        let content = "## Introduction\n\nContent here\n\n# Main Title";
315        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
316        let result = rule.check(&ctx).unwrap();
317
318        assert_eq!(result.len(), 1);
319        assert!(
320            result[0]
321                .message
322                .contains("First heading should be level 1, found level 2")
323        );
324        assert_eq!(result[0].line, 1);
325    }
326
327    #[test]
328    fn test_empty_document() {
329        let rule = MD002FirstHeadingH1::default();
330        let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
331        let result = rule.check(&ctx).unwrap();
332
333        assert_eq!(result.len(), 0);
334    }
335
336    #[test]
337    fn test_document_with_no_headings() {
338        let rule = MD002FirstHeadingH1::default();
339        let content = "This is just paragraph text.\n\nMore paragraph text.\n\n- List item 1\n- List item 2";
340        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
341        let result = rule.check(&ctx).unwrap();
342
343        assert_eq!(result.len(), 0);
344    }
345
346    #[test]
347    fn test_setext_style_heading() {
348        let rule = MD002FirstHeadingH1::new(1);
349        let content = "Introduction\n------------\n\nContent here";
350        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
351        let result = rule.check(&ctx).unwrap();
352
353        assert_eq!(result.len(), 1);
354        assert!(
355            result[0]
356                .message
357                .contains("First heading should be level 1, found level 2")
358        );
359    }
360
361    #[test]
362    fn test_correct_setext_h1() {
363        let rule = MD002FirstHeadingH1::new(1);
364        let content = "Main Title\n==========\n\nContent here";
365        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
366        let result = rule.check(&ctx).unwrap();
367
368        assert_eq!(result.len(), 0);
369    }
370
371    #[test]
372    fn test_with_front_matter() {
373        let rule = MD002FirstHeadingH1::new(1);
374        let content = "---\ntitle: Test Document\nauthor: Test Author\n---\n\n## Introduction\n\nContent";
375        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
376        let result = rule.check(&ctx).unwrap();
377
378        assert_eq!(result.len(), 1);
379        assert!(
380            result[0]
381                .message
382                .contains("First heading should be level 1, found level 2")
383        );
384    }
385
386    #[test]
387    fn test_fix_atx_heading() {
388        let rule = MD002FirstHeadingH1::new(1);
389        let content = "## Introduction\n\nContent here";
390        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
391
392        let fixed = rule.fix(&ctx).unwrap();
393        assert_eq!(fixed, "# Introduction\n\nContent here");
394    }
395
396    #[test]
397    fn test_fix_closed_atx_heading() {
398        let rule = MD002FirstHeadingH1::new(1);
399        let content = "## Introduction ##\n\nContent here";
400        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
401
402        let fixed = rule.fix(&ctx).unwrap();
403        assert_eq!(fixed, "# Introduction #\n\nContent here");
404    }
405
406    #[test]
407    fn test_fix_setext_heading() {
408        let rule = MD002FirstHeadingH1::new(1);
409        let content = "Introduction\n------------\n\nContent here";
410        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
411
412        let fixed = rule.fix(&ctx).unwrap();
413        assert_eq!(fixed, "Introduction\n=======\n\nContent here");
414    }
415
416    #[test]
417    fn test_fix_with_indented_heading() {
418        let rule = MD002FirstHeadingH1::new(1);
419        let content = "  ## Introduction\n\nContent here";
420        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
421
422        let fixed = rule.fix(&ctx).unwrap();
423        assert_eq!(fixed, "  # Introduction\n\nContent here");
424    }
425
426    #[test]
427    fn test_custom_level_requirement() {
428        let rule = MD002FirstHeadingH1::new(2);
429        let content = "# Main Title\n\n## Subsection";
430        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
431        let result = rule.check(&ctx).unwrap();
432
433        assert_eq!(result.len(), 1);
434        assert!(
435            result[0]
436                .message
437                .contains("First heading should be level 2, found level 1")
438        );
439    }
440
441    #[test]
442    fn test_fix_to_custom_level() {
443        let rule = MD002FirstHeadingH1::new(2);
444        let content = "# Main Title\n\nContent";
445        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
446
447        let fixed = rule.fix(&ctx).unwrap();
448        assert_eq!(fixed, "## Main Title\n\nContent");
449    }
450
451    #[test]
452    fn test_multiple_headings() {
453        let rule = MD002FirstHeadingH1::new(1);
454        let content = "### Introduction\n\n# Main Title\n\n## Section\n\n#### Subsection";
455        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
456        let result = rule.check(&ctx).unwrap();
457
458        // Only the first heading matters
459        assert_eq!(result.len(), 1);
460        assert!(
461            result[0]
462                .message
463                .contains("First heading should be level 1, found level 3")
464        );
465        assert_eq!(result[0].line, 1);
466    }
467
468    #[test]
469    fn test_should_skip_optimization() {
470        let rule = MD002FirstHeadingH1::default();
471
472        // Should skip empty content
473        let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
474        assert!(rule.should_skip(&ctx));
475
476        // Should skip content without heading indicators
477        let ctx = LintContext::new(
478            "Just paragraph text\n\nMore text",
479            crate::config::MarkdownFlavor::Standard,
480        );
481        assert!(rule.should_skip(&ctx));
482
483        // Should not skip content with ATX heading
484        let ctx = LintContext::new("Some text\n# Heading", crate::config::MarkdownFlavor::Standard);
485        assert!(!rule.should_skip(&ctx));
486
487        // Should not skip content with potential setext heading
488        let ctx = LintContext::new("Title\n=====", crate::config::MarkdownFlavor::Standard);
489        assert!(!rule.should_skip(&ctx));
490    }
491
492    #[test]
493    fn test_rule_metadata() {
494        let rule = MD002FirstHeadingH1::default();
495        assert_eq!(rule.name(), "MD002");
496        assert_eq!(rule.description(), "First heading should be top level");
497        assert_eq!(rule.category(), RuleCategory::Heading);
498    }
499
500    #[test]
501    fn test_from_config_struct() {
502        let config = MD002Config { level: 3 };
503        let rule = MD002FirstHeadingH1::from_config_struct(config);
504        assert_eq!(rule.config.level, 3);
505    }
506
507    #[test]
508    fn test_fix_preserves_content_structure() {
509        let rule = MD002FirstHeadingH1::new(1);
510        let content = "### Heading\n\nParagraph 1\n\n## Section\n\nParagraph 2";
511        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
512
513        let fixed = rule.fix(&ctx).unwrap();
514        assert_eq!(fixed, "# Heading\n\nParagraph 1\n\n## Section\n\nParagraph 2");
515    }
516
517    #[test]
518    fn test_long_setext_underline() {
519        let rule = MD002FirstHeadingH1::new(1);
520        let content = "Short Title\n----------------------------------------\n\nContent";
521        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
522
523        let fixed = rule.fix(&ctx).unwrap();
524        // The fix should use a reasonable length underline, not preserve the exact length
525        assert!(fixed.starts_with("Short Title\n======="));
526    }
527
528    #[test]
529    fn test_fix_already_correct() {
530        let rule = MD002FirstHeadingH1::new(1);
531        let content = "# Correct Heading\n\nContent";
532        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
533
534        let fixed = rule.fix(&ctx).unwrap();
535        assert_eq!(fixed, content);
536    }
537
538    #[test]
539    fn test_heading_with_special_characters() {
540        let rule = MD002FirstHeadingH1::new(1);
541        let content = "## Heading with **bold** and _italic_ text\n\nContent";
542        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
543        let result = rule.check(&ctx).unwrap();
544
545        assert_eq!(result.len(), 1);
546
547        let fixed = rule.fix(&ctx).unwrap();
548        assert_eq!(fixed, "# Heading with **bold** and _italic_ text\n\nContent");
549    }
550
551    #[test]
552    fn test_atx_heading_with_extra_spaces() {
553        let rule = MD002FirstHeadingH1::new(1);
554        let content = "##    Introduction    \n\nContent";
555        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
556
557        let fixed = rule.fix(&ctx).unwrap();
558        assert_eq!(fixed, "# Introduction\n\nContent");
559    }
560}