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 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 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); }
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 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); }
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 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); }
409
410 #[test]
411 fn test_fix_atx_heading() {
412 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); }
420
421 #[test]
422 fn test_fix_closed_atx_heading() {
423 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); }
431
432 #[test]
433 fn test_fix_setext_heading() {
434 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); }
442
443 #[test]
444 fn test_fix_with_indented_heading() {
445 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); }
453
454 #[test]
455 fn test_custom_level_requirement() {
456 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); }
464
465 #[test]
466 fn test_fix_to_custom_level() {
467 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); }
475
476 #[test]
477 fn test_multiple_headings() {
478 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); }
486
487 #[test]
488 fn test_should_skip_optimization() {
489 let rule = MD002FirstHeadingH1::default();
490
491 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
493 assert!(rule.should_skip(&ctx));
494
495 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 let ctx = LintContext::new("Some text\n# Heading", crate::config::MarkdownFlavor::Standard);
504 assert!(!rule.should_skip(&ctx));
505
506 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 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); }
536
537 #[test]
538 fn test_long_setext_underline() {
539 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); }
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 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); let fixed = rule.fix(&ctx).unwrap();
569 assert_eq!(fixed, content); }
571
572 #[test]
573 fn test_atx_heading_with_extra_spaces() {
574 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); }
582
583 #[test]
584 fn test_md002_does_not_trigger_when_first_line_is_heading() {
585 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 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 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 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 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 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}