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 Some(Fix {
172 range: ctx.line_index.line_content_range(line_num + 1), replacement,
174 })
175 };
176
177 let (start_line, start_col, end_line, end_col) =
179 calculate_heading_range(line_num + 1, &line_info.content);
180
181 return Ok(vec![LintWarning {
182 message,
183 line: start_line,
184 column: start_col,
185 end_line,
186 end_column: end_col,
187 severity: Severity::Warning,
188 fix,
189 rule_name: Some(self.name().to_string()),
190 }]);
191 }
192 }
193
194 Ok(vec![])
195 }
196
197 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
198 let content = ctx.content;
199
200 let first_heading = ctx
202 .lines
203 .iter()
204 .enumerate()
205 .find_map(|(line_num, line_info)| line_info.heading.as_ref().map(|h| (line_num, line_info, h)));
206
207 if let Some((line_num, line_info, heading)) = first_heading {
208 let first_content_line = ctx
211 .lines
212 .iter()
213 .enumerate()
214 .find(|(_, line_info)| !line_info.in_front_matter && !line_info.content.trim().is_empty())
215 .map(|(idx, _)| idx);
216
217 if let Some(first_line_idx) = first_content_line
218 && line_num == first_line_idx
219 {
220 return Ok(content.to_string());
221 }
222
223 if heading.level == self.config.level as u8 {
225 return Ok(content.to_string());
226 }
227
228 let lines: Vec<&str> = content.lines().collect();
229 let mut fixed_lines = Vec::new();
230 let mut i = 0;
231
232 while i < lines.len() {
233 if i == line_num {
234 let indent = " ".repeat(line_info.indent);
236 let heading_text = heading.text.trim();
237
238 match heading.style {
239 crate::lint_context::HeadingStyle::ATX => {
240 let hashes = "#".repeat(self.config.level as usize);
241 if heading.has_closing_sequence {
242 fixed_lines.push(format!("{indent}{hashes} {heading_text} {hashes}"));
244 } else {
245 fixed_lines.push(format!("{indent}{hashes} {heading_text}"));
247 }
248 i += 1;
249 }
250 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2 => {
251 fixed_lines.push(lines[i].to_string()); i += 1;
254 if i < lines.len() {
255 let underline = if self.config.level == 1 { "=======" } else { "-------" };
257 fixed_lines.push(underline.to_string());
258 i += 1;
259 }
260 }
261 }
262 continue;
263 }
264
265 fixed_lines.push(lines[i].to_string());
266 i += 1;
267 }
268
269 Ok(fixed_lines.join("\n"))
270 } else {
271 Ok(content.to_string())
273 }
274 }
275
276 fn category(&self) -> RuleCategory {
278 RuleCategory::Heading
279 }
280
281 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
283 ctx.content.is_empty() || (!ctx.has_char('#') && !ctx.has_char('=') && !ctx.has_char('-'))
285 }
286
287 fn as_any(&self) -> &dyn std::any::Any {
288 self
289 }
290
291 fn default_config_section(&self) -> Option<(String, toml::Value)> {
292 let default_config = MD002Config::default();
293 let json_value = serde_json::to_value(&default_config).ok()?;
294 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
295
296 if let toml::Value::Table(table) = toml_value {
297 if !table.is_empty() {
298 Some((MD002Config::RULE_NAME.to_string(), toml::Value::Table(table)))
299 } else {
300 None
301 }
302 } else {
303 None
304 }
305 }
306
307 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
308 where
309 Self: Sized,
310 {
311 let rule_config = crate::rule_config_serde::load_rule_config::<MD002Config>(config);
312 Box::new(Self::from_config_struct(rule_config))
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::lint_context::LintContext;
320
321 #[test]
322 fn test_default_config() {
323 let rule = MD002FirstHeadingH1::default();
324 assert_eq!(rule.config.level, 1);
325 }
326
327 #[test]
328 fn test_custom_config() {
329 let rule = MD002FirstHeadingH1::new(2);
330 assert_eq!(rule.config.level, 2);
331 }
332
333 #[test]
334 fn test_correct_h1_first_heading() {
335 let rule = MD002FirstHeadingH1::new(1);
336 let content = "# Main Title\n\n## Subsection\n\nContent here";
337 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
338 let result = rule.check(&ctx).unwrap();
339
340 assert_eq!(result.len(), 0);
341 }
342
343 #[test]
344 fn test_incorrect_h2_first_heading() {
345 let rule = MD002FirstHeadingH1::new(1);
347 let content = "## Introduction\n\nContent here\n\n# Main Title";
348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
349 let result = rule.check(&ctx).unwrap();
350
351 assert_eq!(result.len(), 0); }
353
354 #[test]
355 fn test_empty_document() {
356 let rule = MD002FirstHeadingH1::default();
357 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
358 let result = rule.check(&ctx).unwrap();
359
360 assert_eq!(result.len(), 0);
361 }
362
363 #[test]
364 fn test_document_with_no_headings() {
365 let rule = MD002FirstHeadingH1::default();
366 let content = "This is just paragraph text.\n\nMore paragraph text.\n\n- List item 1\n- List item 2";
367 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
368 let result = rule.check(&ctx).unwrap();
369
370 assert_eq!(result.len(), 0);
371 }
372
373 #[test]
374 fn test_setext_style_heading() {
375 let rule = MD002FirstHeadingH1::new(1);
377 let content = "Introduction\n------------\n\nContent here";
378 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
379 let result = rule.check(&ctx).unwrap();
380
381 assert_eq!(result.len(), 0); }
383
384 #[test]
385 fn test_correct_setext_h1() {
386 let rule = MD002FirstHeadingH1::new(1);
387 let content = "Main Title\n==========\n\nContent here";
388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
389 let result = rule.check(&ctx).unwrap();
390
391 assert_eq!(result.len(), 0);
392 }
393
394 #[test]
395 fn test_with_front_matter() {
396 let rule = MD002FirstHeadingH1::new(1);
398 let content = "---\ntitle: Test Document\nauthor: Test Author\n---\n## Introduction\n\nContent";
399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
400 let result = rule.check(&ctx).unwrap();
401
402 assert_eq!(result.len(), 0); }
404
405 #[test]
406 fn test_fix_atx_heading() {
407 let rule = MD002FirstHeadingH1::new(1);
409 let content = "## Introduction\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, content); }
415
416 #[test]
417 fn test_fix_closed_atx_heading() {
418 let rule = MD002FirstHeadingH1::new(1);
420 let content = "## Introduction ##\n\nContent here";
421 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
422
423 let fixed = rule.fix(&ctx).unwrap();
424 assert_eq!(fixed, content); }
426
427 #[test]
428 fn test_fix_setext_heading() {
429 let rule = MD002FirstHeadingH1::new(1);
431 let content = "Introduction\n------------\n\nContent here";
432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
433
434 let fixed = rule.fix(&ctx).unwrap();
435 assert_eq!(fixed, content); }
437
438 #[test]
439 fn test_fix_with_indented_heading() {
440 let rule = MD002FirstHeadingH1::new(1);
442 let content = " ## Introduction\n\nContent here";
443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
444
445 let fixed = rule.fix(&ctx).unwrap();
446 assert_eq!(fixed, content); }
448
449 #[test]
450 fn test_custom_level_requirement() {
451 let rule = MD002FirstHeadingH1::new(2);
453 let content = "# Main Title\n\n## Subsection";
454 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
455 let result = rule.check(&ctx).unwrap();
456
457 assert_eq!(result.len(), 0); }
459
460 #[test]
461 fn test_fix_to_custom_level() {
462 let rule = MD002FirstHeadingH1::new(2);
464 let content = "# Main Title\n\nContent";
465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
466
467 let fixed = rule.fix(&ctx).unwrap();
468 assert_eq!(fixed, content); }
470
471 #[test]
472 fn test_multiple_headings() {
473 let rule = MD002FirstHeadingH1::new(1);
475 let content = "### Introduction\n\n# Main Title\n\n## Section\n\n#### Subsection";
476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
477 let result = rule.check(&ctx).unwrap();
478
479 assert_eq!(result.len(), 0); }
481
482 #[test]
483 fn test_should_skip_optimization() {
484 let rule = MD002FirstHeadingH1::default();
485
486 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
488 assert!(rule.should_skip(&ctx));
489
490 let ctx = LintContext::new(
492 "Just paragraph text\n\nMore text",
493 crate::config::MarkdownFlavor::Standard,
494 );
495 assert!(rule.should_skip(&ctx));
496
497 let ctx = LintContext::new("Some text\n# Heading", crate::config::MarkdownFlavor::Standard);
499 assert!(!rule.should_skip(&ctx));
500
501 let ctx = LintContext::new("Title\n=====", crate::config::MarkdownFlavor::Standard);
503 assert!(!rule.should_skip(&ctx));
504 }
505
506 #[test]
507 fn test_rule_metadata() {
508 let rule = MD002FirstHeadingH1::default();
509 assert_eq!(rule.name(), "MD002");
510 assert_eq!(rule.description(), "First heading should be top level");
511 assert_eq!(rule.category(), RuleCategory::Heading);
512 }
513
514 #[test]
515 fn test_from_config_struct() {
516 let config = MD002Config { level: 3 };
517 let rule = MD002FirstHeadingH1::from_config_struct(config);
518 assert_eq!(rule.config.level, 3);
519 }
520
521 #[test]
522 fn test_fix_preserves_content_structure() {
523 let rule = MD002FirstHeadingH1::new(1);
525 let content = "### Heading\n\nParagraph 1\n\n## Section\n\nParagraph 2";
526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
527
528 let fixed = rule.fix(&ctx).unwrap();
529 assert_eq!(fixed, content); }
531
532 #[test]
533 fn test_long_setext_underline() {
534 let rule = MD002FirstHeadingH1::new(1);
536 let content = "Short Title\n----------------------------------------\n\nContent";
537 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
538
539 let fixed = rule.fix(&ctx).unwrap();
540 assert_eq!(fixed, content); }
542
543 #[test]
544 fn test_fix_already_correct() {
545 let rule = MD002FirstHeadingH1::new(1);
546 let content = "# Correct Heading\n\nContent";
547 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
548
549 let fixed = rule.fix(&ctx).unwrap();
550 assert_eq!(fixed, content);
551 }
552
553 #[test]
554 fn test_heading_with_special_characters() {
555 let rule = MD002FirstHeadingH1::new(1);
557 let content = "## Heading with **bold** and _italic_ text\n\nContent";
558 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
559 let result = rule.check(&ctx).unwrap();
560
561 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
564 assert_eq!(fixed, content); }
566
567 #[test]
568 fn test_atx_heading_with_extra_spaces() {
569 let rule = MD002FirstHeadingH1::new(1);
571 let content = "## Introduction \n\nContent";
572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
573
574 let fixed = rule.fix(&ctx).unwrap();
575 assert_eq!(fixed, content); }
577
578 #[test]
579 fn test_md002_does_not_trigger_when_first_line_is_heading() {
580 let rule = MD002FirstHeadingH1::new(1);
584 let content = "## Introduction\n\nContent here";
585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
586 let result = rule.check(&ctx).unwrap();
587
588 assert_eq!(result.len(), 0, "MD002 should not trigger when first line is a heading");
590 }
591
592 #[test]
593 fn test_md002_triggers_when_heading_is_not_first_line() {
594 let rule = MD002FirstHeadingH1::new(1);
596 let content = "Some text before heading\n\n## Introduction\n\nContent";
597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
598 let result = rule.check(&ctx).unwrap();
599
600 assert_eq!(
601 result.len(),
602 1,
603 "MD002 should trigger when heading is not on first line"
604 );
605 assert!(result[0].message.contains("First heading should be level 1"));
606 }
607
608 #[test]
609 fn test_md002_with_front_matter_and_first_line_heading() {
610 let rule = MD002FirstHeadingH1::new(1);
612 let content = "---\ntitle: Test\n---\n## Introduction\n\nContent";
613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
614 let result = rule.check(&ctx).unwrap();
615
616 assert_eq!(
617 result.len(),
618 0,
619 "MD002 should not trigger when first line after front matter is a heading"
620 );
621 }
622
623 #[test]
624 fn test_md002_with_front_matter_and_delayed_heading() {
625 let rule = MD002FirstHeadingH1::new(1);
627 let content = "---\ntitle: Test\n---\nSome text\n\n## Introduction\n\nContent";
628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
629 let result = rule.check(&ctx).unwrap();
630
631 assert_eq!(
632 result.len(),
633 1,
634 "MD002 should trigger when heading is not immediately after front matter"
635 );
636 }
637
638 #[test]
639 fn test_md002_fix_does_not_change_first_line_heading() {
640 let rule = MD002FirstHeadingH1::new(1);
642 let content = "### Third Level Heading\n\nContent";
643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
644 let fixed = rule.fix(&ctx).unwrap();
645
646 assert_eq!(fixed, content, "Fix should not change heading on first line");
647 }
648}