1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::rule_config_serde::RuleConfig;
8use crate::rules::heading_utils::HeadingStyle;
9use crate::utils::range_utils::calculate_heading_range;
10use toml;
11
12mod md003_config;
13use md003_config::MD003Config;
14
15#[derive(Clone, Default)]
17pub struct MD003HeadingStyle {
18 config: MD003Config,
19}
20
21impl MD003HeadingStyle {
22 pub fn new(style: HeadingStyle) -> Self {
23 Self {
24 config: MD003Config { style },
25 }
26 }
27
28 pub fn from_config_struct(config: MD003Config) -> Self {
29 Self { config }
30 }
31
32 fn is_consistent_mode(&self) -> bool {
34 self.config.style == HeadingStyle::Consistent
36 }
37
38 fn get_target_style(&self, ctx: &crate::lint_context::LintContext) -> HeadingStyle {
40 if !self.is_consistent_mode() {
41 return self.config.style;
42 }
43
44 let mut style_counts = std::collections::HashMap::new();
46
47 for line_info in &ctx.lines {
48 if let Some(heading) = &line_info.heading {
49 if !heading.is_valid {
51 continue;
52 }
53
54 let style = match heading.style {
56 crate::lint_context::HeadingStyle::ATX => {
57 if heading.has_closing_sequence {
58 HeadingStyle::AtxClosed
59 } else {
60 HeadingStyle::Atx
61 }
62 }
63 crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
64 crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
65 };
66 *style_counts.entry(style).or_insert(0) += 1;
67 }
68 }
69
70 style_counts
73 .into_iter()
74 .max_by(|(style_a, count_a), (style_b, count_b)| {
75 match count_a.cmp(count_b) {
76 std::cmp::Ordering::Equal => {
77 let priority = |s: &HeadingStyle| match s {
79 HeadingStyle::Atx => 0,
80 HeadingStyle::Setext1 => 1,
81 HeadingStyle::Setext2 => 2,
82 HeadingStyle::AtxClosed => 3,
83 _ => 4,
84 };
85 priority(style_b).cmp(&priority(style_a)) }
87 other => other,
88 }
89 })
90 .map_or(HeadingStyle::Atx, |(style, _)| style)
91 }
92}
93
94impl Rule for MD003HeadingStyle {
95 fn name(&self) -> &'static str {
96 "MD003"
97 }
98
99 fn description(&self) -> &'static str {
100 "Heading style"
101 }
102
103 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
104 let mut result = Vec::new();
105
106 let target_style = self.get_target_style(ctx);
108
109 for (line_num, line_info) in ctx.lines.iter().enumerate() {
111 if let Some(heading) = &line_info.heading {
112 if !heading.is_valid {
114 continue;
115 }
116
117 let level = heading.level;
118
119 let current_style = match heading.style {
121 crate::lint_context::HeadingStyle::ATX => {
122 if heading.has_closing_sequence {
123 HeadingStyle::AtxClosed
124 } else {
125 HeadingStyle::Atx
126 }
127 }
128 crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
129 crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
130 };
131
132 let expected_style = match target_style {
134 HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
135 if level > 2 {
136 HeadingStyle::Atx
138 } else if level == 1 {
139 HeadingStyle::Setext1
140 } else {
141 HeadingStyle::Setext2
142 }
143 }
144 HeadingStyle::SetextWithAtx => {
145 if level <= 2 {
146 if level == 1 {
148 HeadingStyle::Setext1
149 } else {
150 HeadingStyle::Setext2
151 }
152 } else {
153 HeadingStyle::Atx
155 }
156 }
157 HeadingStyle::SetextWithAtxClosed => {
158 if level <= 2 {
159 if level == 1 {
161 HeadingStyle::Setext1
162 } else {
163 HeadingStyle::Setext2
164 }
165 } else {
166 HeadingStyle::AtxClosed
168 }
169 }
170 _ => target_style,
171 };
172
173 if current_style != expected_style {
174 let fix = {
176 use crate::rules::heading_utils::HeadingUtils;
177
178 let converted_heading =
180 HeadingUtils::convert_heading_style(&heading.raw_text, level as u32, expected_style);
181
182 let line = line_info.content(ctx.content);
184 let original_indent = &line[..line_info.indent];
185 let final_heading = format!("{original_indent}{converted_heading}");
186
187 let range = ctx.line_index.line_content_range(line_num + 1);
189
190 Some(crate::rule::Fix {
191 range,
192 replacement: final_heading,
193 })
194 };
195
196 let (start_line, start_col, end_line, end_col) =
198 calculate_heading_range(line_num + 1, line_info.content(ctx.content));
199
200 result.push(LintWarning {
201 rule_name: Some(self.name().to_string()),
202 line: start_line,
203 column: start_col,
204 end_line,
205 end_column: end_col,
206 message: format!(
207 "Heading style should be {}, found {}",
208 match expected_style {
209 HeadingStyle::Atx => "# Heading",
210 HeadingStyle::AtxClosed => "# Heading #",
211 HeadingStyle::Setext1 => "Heading\n=======",
212 HeadingStyle::Setext2 => "Heading\n-------",
213 HeadingStyle::Consistent => "consistent with the first heading",
214 HeadingStyle::SetextWithAtx => "setext-with-atx style",
215 HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
216 },
217 match current_style {
218 HeadingStyle::Atx => "# Heading",
219 HeadingStyle::AtxClosed => "# Heading #",
220 HeadingStyle::Setext1 => "Heading (underlined with =)",
221 HeadingStyle::Setext2 => "Heading (underlined with -)",
222 HeadingStyle::Consistent => "consistent style",
223 HeadingStyle::SetextWithAtx => "setext-with-atx style",
224 HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
225 }
226 ),
227 severity: Severity::Warning,
228 fix,
229 });
230 }
231 }
232 }
233
234 Ok(result)
235 }
236
237 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
238 let warnings = self.check(ctx)?;
240 let warnings =
241 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
242
243 if warnings.is_empty() {
245 return Ok(ctx.content.to_string());
246 }
247
248 let mut fixes: Vec<_> = warnings
250 .iter()
251 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
252 .collect();
253 fixes.sort_by(|a, b| b.0.cmp(&a.0));
254
255 let mut result = ctx.content.to_string();
257 for (start, end, replacement) in fixes {
258 if start < result.len() && end <= result.len() && start <= end {
259 result.replace_range(start..end, replacement);
260 }
261 }
262
263 Ok(result)
264 }
265
266 fn category(&self) -> RuleCategory {
267 RuleCategory::Heading
268 }
269
270 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
271 if ctx.content.is_empty() || !ctx.likely_has_headings() {
273 return true;
274 }
275 !ctx.lines.iter().any(|line| line.heading.is_some())
277 }
278
279 fn as_any(&self) -> &dyn std::any::Any {
280 self
281 }
282
283 fn default_config_section(&self) -> Option<(String, toml::Value)> {
284 let default_config = MD003Config::default();
285 let json_value = serde_json::to_value(&default_config).ok()?;
286 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
287
288 if let toml::Value::Table(table) = toml_value {
289 if !table.is_empty() {
290 Some((MD003Config::RULE_NAME.to_string(), toml::Value::Table(table)))
291 } else {
292 None
293 }
294 } else {
295 None
296 }
297 }
298
299 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
300 where
301 Self: Sized,
302 {
303 let rule_config = crate::rule_config_serde::load_rule_config::<MD003Config>(config);
304 Box::new(Self::from_config_struct(rule_config))
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use crate::lint_context::LintContext;
312
313 #[test]
314 fn test_atx_heading_style() {
315 let rule = MD003HeadingStyle::default();
316 let content = "# Heading 1\n## Heading 2\n### Heading 3";
317 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
318 let result = rule.check(&ctx).unwrap();
319 assert!(result.is_empty());
320 }
321
322 #[test]
323 fn test_setext_heading_style() {
324 let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
325 let content = "Heading 1\n=========\n\nHeading 2\n---------";
326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
327 let result = rule.check(&ctx).unwrap();
328 assert!(result.is_empty());
329 }
330
331 #[test]
332 fn test_front_matter() {
333 let rule = MD003HeadingStyle::default();
334 let content = "---\ntitle: Test\n---\n\n# Heading 1\n## Heading 2";
335
336 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
338 let result = rule.check(&ctx).unwrap();
339 assert!(
340 result.is_empty(),
341 "No warnings expected for content with front matter, found: {result:?}"
342 );
343 }
344
345 #[test]
346 fn test_consistent_heading_style() {
347 let rule = MD003HeadingStyle::default();
349 let content = "# Heading 1\n## Heading 2\n### Heading 3";
350 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
351 let result = rule.check(&ctx).unwrap();
352 assert!(result.is_empty());
353 }
354
355 #[test]
356 fn test_with_different_styles() {
357 let rule = MD003HeadingStyle::new(HeadingStyle::Consistent);
359 let content = "# Heading 1\n## Heading 2\n### Heading 3";
360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
361 let result = rule.check(&ctx).unwrap();
362
363 assert!(
365 result.is_empty(),
366 "No warnings expected for consistent ATX style, found: {result:?}"
367 );
368
369 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
371 let content = "# Heading 1 #\nHeading 2\n-----\n### Heading 3";
372 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
373 let result = rule.check(&ctx).unwrap();
374 assert!(
375 !result.is_empty(),
376 "Should have warnings for inconsistent heading styles"
377 );
378
379 let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
381 let content = "Heading 1\n=========\nHeading 2\n---------\n### Heading 3";
382 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
383 let result = rule.check(&ctx).unwrap();
384 assert!(
386 result.is_empty(),
387 "No warnings expected for setext style with ATX for level 3, found: {result:?}"
388 );
389 }
390
391 #[test]
392 fn test_setext_with_atx_style() {
393 let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtx);
394 let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3\n\n#### Heading 4";
396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
397 let result = rule.check(&ctx).unwrap();
398 assert!(
399 result.is_empty(),
400 "SesetxtWithAtx style should accept setext for h1/h2 and ATX for h3+"
401 );
402
403 let content_wrong = "# Heading 1\n## Heading 2\n### Heading 3";
405 let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
406 let result_wrong = rule.check(&ctx_wrong).unwrap();
407 assert_eq!(
408 result_wrong.len(),
409 2,
410 "Should flag ATX headings for h1/h2 with setext_with_atx style"
411 );
412 }
413
414 #[test]
415 fn test_fix_preserves_attribute_lists() {
416 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
418 let content = "# Heading { #custom-id .class } #";
419 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
420
421 let warnings = rule.check(&ctx).unwrap();
423 assert_eq!(warnings.len(), 1);
424 let fix = warnings[0].fix.as_ref().expect("Should have a fix");
425 assert!(
426 fix.replacement.contains("{ #custom-id .class }"),
427 "check() fix should preserve attribute list, got: {}",
428 fix.replacement
429 );
430
431 let fixed = rule.fix(&ctx).unwrap();
433 assert!(
434 fixed.contains("{ #custom-id .class }"),
435 "fix() should preserve attribute list, got: {fixed}"
436 );
437 assert!(
438 !fixed.contains(" #\n") && !fixed.ends_with(" #"),
439 "fix() should remove ATX closed trailing hashes, got: {fixed}"
440 );
441 }
442
443 #[test]
444 fn test_setext_with_atx_closed_style() {
445 let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtxClosed);
446 let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3 ###\n\n#### Heading 4 ####";
448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
449 let result = rule.check(&ctx).unwrap();
450 assert!(
451 result.is_empty(),
452 "SetextWithAtxClosed style should accept setext for h1/h2 and ATX closed for h3+"
453 );
454
455 let content_wrong = "Heading 1\n=========\n\n### Heading 3\n\n#### Heading 4";
457 let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
458 let result_wrong = rule.check(&ctx_wrong).unwrap();
459 assert_eq!(
460 result_wrong.len(),
461 2,
462 "Should flag non-closed ATX headings for h3+ with setext_with_atx_closed style"
463 );
464 }
465}