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#[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 if content.is_empty() {
115 return Ok(vec![]);
116 }
117
118 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 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 let Some(first_line_idx) = first_content_line
140 && line_num == first_line_idx
141 {
142 return Ok(vec![]);
143 }
144
145 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 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 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), replacement,
175 })
176 };
177
178 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 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 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 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 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 fixed_lines.push(format!("{indent}{hashes} {heading_text} {hashes}"));
245 } else {
246 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 fixed_lines.push(lines[i].to_string()); i += 1;
255 if i < lines.len() {
256 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 Ok(content.to_string())
274 }
275 }
276
277 fn category(&self) -> RuleCategory {
279 RuleCategory::Heading
280 }
281
282 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
284 ctx.content.is_empty() || (!ctx.has_char('#') && !ctx.has_char('=') && !ctx.has_char('-'))
286 }
287
288 fn as_any(&self) -> &dyn std::any::Any {
289 self
290 }
291
292 fn default_config_section(&self) -> Option<(String, toml::Value)> {
293 let default_config = MD002Config::default();
294 let json_value = serde_json::to_value(&default_config).ok()?;
295 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
296
297 if let toml::Value::Table(table) = toml_value {
298 if !table.is_empty() {
299 Some((MD002Config::RULE_NAME.to_string(), toml::Value::Table(table)))
300 } else {
301 None
302 }
303 } else {
304 None
305 }
306 }
307
308 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
309 where
310 Self: Sized,
311 {
312 let rule_config = crate::rule_config_serde::load_rule_config::<MD002Config>(config);
313 Box::new(Self::from_config_struct(rule_config))
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use crate::lint_context::LintContext;
321
322 #[test]
323 fn test_default_config() {
324 let rule = MD002FirstHeadingH1::default();
325 assert_eq!(rule.config.level, 1);
326 }
327
328 #[test]
329 fn test_custom_config() {
330 let rule = MD002FirstHeadingH1::new(2);
331 assert_eq!(rule.config.level, 2);
332 }
333
334 #[test]
335 fn test_correct_h1_first_heading() {
336 let rule = MD002FirstHeadingH1::new(1);
337 let content = "# Main Title\n\n## Subsection\n\nContent here";
338 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
339 let result = rule.check(&ctx).unwrap();
340
341 assert_eq!(result.len(), 0);
342 }
343
344 #[test]
345 fn test_incorrect_h2_first_heading() {
346 let rule = MD002FirstHeadingH1::new(1);
348 let content = "## Introduction\n\nContent here\n\n# Main Title";
349 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
350 let result = rule.check(&ctx).unwrap();
351
352 assert_eq!(result.len(), 0); }
354
355 #[test]
356 fn test_empty_document() {
357 let rule = MD002FirstHeadingH1::default();
358 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
359 let result = rule.check(&ctx).unwrap();
360
361 assert_eq!(result.len(), 0);
362 }
363
364 #[test]
365 fn test_document_with_no_headings() {
366 let rule = MD002FirstHeadingH1::default();
367 let content = "This is just paragraph text.\n\nMore paragraph text.\n\n- List item 1\n- List item 2";
368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
369 let result = rule.check(&ctx).unwrap();
370
371 assert_eq!(result.len(), 0);
372 }
373
374 #[test]
375 fn test_setext_style_heading() {
376 let rule = MD002FirstHeadingH1::new(1);
378 let content = "Introduction\n------------\n\nContent here";
379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
380 let result = rule.check(&ctx).unwrap();
381
382 assert_eq!(result.len(), 0); }
384
385 #[test]
386 fn test_correct_setext_h1() {
387 let rule = MD002FirstHeadingH1::new(1);
388 let content = "Main Title\n==========\n\nContent here";
389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
390 let result = rule.check(&ctx).unwrap();
391
392 assert_eq!(result.len(), 0);
393 }
394
395 #[test]
396 fn test_with_front_matter() {
397 let rule = MD002FirstHeadingH1::new(1);
399 let content = "---\ntitle: Test Document\nauthor: Test Author\n---\n## Introduction\n\nContent";
400 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
401 let result = rule.check(&ctx).unwrap();
402
403 assert_eq!(result.len(), 0); }
405
406 #[test]
407 fn test_fix_atx_heading() {
408 let rule = MD002FirstHeadingH1::new(1);
410 let content = "## Introduction\n\nContent here";
411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
412
413 let fixed = rule.fix(&ctx).unwrap();
414 assert_eq!(fixed, content); }
416
417 #[test]
418 fn test_fix_closed_atx_heading() {
419 let rule = MD002FirstHeadingH1::new(1);
421 let content = "## Introduction ##\n\nContent here";
422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
423
424 let fixed = rule.fix(&ctx).unwrap();
425 assert_eq!(fixed, content); }
427
428 #[test]
429 fn test_fix_setext_heading() {
430 let rule = MD002FirstHeadingH1::new(1);
432 let content = "Introduction\n------------\n\nContent here";
433 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
434
435 let fixed = rule.fix(&ctx).unwrap();
436 assert_eq!(fixed, content); }
438
439 #[test]
440 fn test_fix_with_indented_heading() {
441 let rule = MD002FirstHeadingH1::new(1);
443 let content = " ## Introduction\n\nContent here";
444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
445
446 let fixed = rule.fix(&ctx).unwrap();
447 assert_eq!(fixed, content); }
449
450 #[test]
451 fn test_custom_level_requirement() {
452 let rule = MD002FirstHeadingH1::new(2);
454 let content = "# Main Title\n\n## Subsection";
455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
456 let result = rule.check(&ctx).unwrap();
457
458 assert_eq!(result.len(), 0); }
460
461 #[test]
462 fn test_fix_to_custom_level() {
463 let rule = MD002FirstHeadingH1::new(2);
465 let content = "# Main Title\n\nContent";
466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
467
468 let fixed = rule.fix(&ctx).unwrap();
469 assert_eq!(fixed, content); }
471
472 #[test]
473 fn test_multiple_headings() {
474 let rule = MD002FirstHeadingH1::new(1);
476 let content = "### Introduction\n\n# Main Title\n\n## Section\n\n#### Subsection";
477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
478 let result = rule.check(&ctx).unwrap();
479
480 assert_eq!(result.len(), 0); }
482
483 #[test]
484 fn test_should_skip_optimization() {
485 let rule = MD002FirstHeadingH1::default();
486
487 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
489 assert!(rule.should_skip(&ctx));
490
491 let ctx = LintContext::new(
493 "Just paragraph text\n\nMore text",
494 crate::config::MarkdownFlavor::Standard,
495 );
496 assert!(rule.should_skip(&ctx));
497
498 let ctx = LintContext::new("Some text\n# Heading", crate::config::MarkdownFlavor::Standard);
500 assert!(!rule.should_skip(&ctx));
501
502 let ctx = LintContext::new("Title\n=====", crate::config::MarkdownFlavor::Standard);
504 assert!(!rule.should_skip(&ctx));
505 }
506
507 #[test]
508 fn test_rule_metadata() {
509 let rule = MD002FirstHeadingH1::default();
510 assert_eq!(rule.name(), "MD002");
511 assert_eq!(rule.description(), "First heading should be top level");
512 assert_eq!(rule.category(), RuleCategory::Heading);
513 }
514
515 #[test]
516 fn test_from_config_struct() {
517 let config = MD002Config { level: 3 };
518 let rule = MD002FirstHeadingH1::from_config_struct(config);
519 assert_eq!(rule.config.level, 3);
520 }
521
522 #[test]
523 fn test_fix_preserves_content_structure() {
524 let rule = MD002FirstHeadingH1::new(1);
526 let content = "### Heading\n\nParagraph 1\n\n## Section\n\nParagraph 2";
527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
528
529 let fixed = rule.fix(&ctx).unwrap();
530 assert_eq!(fixed, content); }
532
533 #[test]
534 fn test_long_setext_underline() {
535 let rule = MD002FirstHeadingH1::new(1);
537 let content = "Short Title\n----------------------------------------\n\nContent";
538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
539
540 let fixed = rule.fix(&ctx).unwrap();
541 assert_eq!(fixed, content); }
543
544 #[test]
545 fn test_fix_already_correct() {
546 let rule = MD002FirstHeadingH1::new(1);
547 let content = "# Correct Heading\n\nContent";
548 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
549
550 let fixed = rule.fix(&ctx).unwrap();
551 assert_eq!(fixed, content);
552 }
553
554 #[test]
555 fn test_heading_with_special_characters() {
556 let rule = MD002FirstHeadingH1::new(1);
558 let content = "## Heading with **bold** and _italic_ text\n\nContent";
559 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
560 let result = rule.check(&ctx).unwrap();
561
562 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
565 assert_eq!(fixed, content); }
567
568 #[test]
569 fn test_atx_heading_with_extra_spaces() {
570 let rule = MD002FirstHeadingH1::new(1);
572 let content = "## Introduction \n\nContent";
573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
574
575 let fixed = rule.fix(&ctx).unwrap();
576 assert_eq!(fixed, content); }
578
579 #[test]
580 fn test_md002_does_not_trigger_when_first_line_is_heading() {
581 let rule = MD002FirstHeadingH1::new(1);
585 let content = "## Introduction\n\nContent here";
586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
587 let result = rule.check(&ctx).unwrap();
588
589 assert_eq!(result.len(), 0, "MD002 should not trigger when first line is a heading");
591 }
592
593 #[test]
594 fn test_md002_triggers_when_heading_is_not_first_line() {
595 let rule = MD002FirstHeadingH1::new(1);
597 let content = "Some text before heading\n\n## Introduction\n\nContent";
598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
599 let result = rule.check(&ctx).unwrap();
600
601 assert_eq!(
602 result.len(),
603 1,
604 "MD002 should trigger when heading is not on first line"
605 );
606 assert!(result[0].message.contains("First heading should be level 1"));
607 }
608
609 #[test]
610 fn test_md002_with_front_matter_and_first_line_heading() {
611 let rule = MD002FirstHeadingH1::new(1);
613 let content = "---\ntitle: Test\n---\n## Introduction\n\nContent";
614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
615 let result = rule.check(&ctx).unwrap();
616
617 assert_eq!(
618 result.len(),
619 0,
620 "MD002 should not trigger when first line after front matter is a heading"
621 );
622 }
623
624 #[test]
625 fn test_md002_with_front_matter_and_delayed_heading() {
626 let rule = MD002FirstHeadingH1::new(1);
628 let content = "---\ntitle: Test\n---\nSome text\n\n## Introduction\n\nContent";
629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
630 let result = rule.check(&ctx).unwrap();
631
632 assert_eq!(
633 result.len(),
634 1,
635 "MD002 should trigger when heading is not immediately after front matter"
636 );
637 }
638
639 #[test]
640 fn test_md002_fix_does_not_change_first_line_heading() {
641 let rule = MD002FirstHeadingH1::new(1);
643 let content = "### Third Level Heading\n\nContent";
644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
645 let fixed = rule.fix(&ctx).unwrap();
646
647 assert_eq!(fixed, content, "Fix should not change heading on first line");
648 }
649}