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 && 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 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 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), replacement,
156 })
157 };
158
159 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 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 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 fixed_lines.push(format!("{indent}{hashes} {heading_text} {hashes}"));
208 } else {
209 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 fixed_lines.push(lines[i].to_string()); i += 1;
218 if i < lines.len() {
219 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 Ok(content.to_string())
237 }
238 }
239
240 fn category(&self) -> RuleCategory {
242 RuleCategory::Heading
243 }
244
245 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 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 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
474 assert!(rule.should_skip(&ctx));
475
476 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 let ctx = LintContext::new("Some text\n# Heading", crate::config::MarkdownFlavor::Standard);
485 assert!(!rule.should_skip(&ctx));
486
487 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 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}