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(|(style, _)| style)
91 .unwrap_or(HeadingStyle::Atx)
92 }
93}
94
95impl Rule for MD003HeadingStyle {
96 fn name(&self) -> &'static str {
97 "MD003"
98 }
99
100 fn description(&self) -> &'static str {
101 "Heading style"
102 }
103
104 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
105 let mut result = Vec::new();
106
107 let target_style = self.get_target_style(ctx);
109
110 for (line_num, line_info) in ctx.lines.iter().enumerate() {
112 if let Some(heading) = &line_info.heading {
113 if !heading.is_valid {
115 continue;
116 }
117
118 let level = heading.level;
119
120 let current_style = match heading.style {
122 crate::lint_context::HeadingStyle::ATX => {
123 if heading.has_closing_sequence {
124 HeadingStyle::AtxClosed
125 } else {
126 HeadingStyle::Atx
127 }
128 }
129 crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
130 crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
131 };
132
133 let expected_style = match target_style {
135 HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
136 if level > 2 {
137 HeadingStyle::Atx
139 } else if level == 1 {
140 HeadingStyle::Setext1
141 } else {
142 HeadingStyle::Setext2
143 }
144 }
145 HeadingStyle::SetextWithAtx => {
146 if level <= 2 {
147 if level == 1 {
149 HeadingStyle::Setext1
150 } else {
151 HeadingStyle::Setext2
152 }
153 } else {
154 HeadingStyle::Atx
156 }
157 }
158 HeadingStyle::SetextWithAtxClosed => {
159 if level <= 2 {
160 if level == 1 {
162 HeadingStyle::Setext1
163 } else {
164 HeadingStyle::Setext2
165 }
166 } else {
167 HeadingStyle::AtxClosed
169 }
170 }
171 _ => target_style,
172 };
173
174 if current_style != expected_style {
175 let fix = {
177 use crate::rules::heading_utils::HeadingUtils;
178
179 let converted_heading =
181 HeadingUtils::convert_heading_style(&heading.raw_text, level as u32, expected_style);
182
183 let line = line_info.content(ctx.content);
185 let original_indent = &line[..line_info.indent];
186 let final_heading = format!("{original_indent}{converted_heading}");
187
188 let range = ctx.line_index.line_content_range(line_num + 1);
190
191 Some(crate::rule::Fix {
192 range,
193 replacement: final_heading,
194 })
195 };
196
197 let (start_line, start_col, end_line, end_col) =
199 calculate_heading_range(line_num + 1, line_info.content(ctx.content));
200
201 result.push(LintWarning {
202 rule_name: Some(self.name().to_string()),
203 line: start_line,
204 column: start_col,
205 end_line,
206 end_column: end_col,
207 message: format!(
208 "Heading style should be {}, found {}",
209 match expected_style {
210 HeadingStyle::Atx => "# Heading",
211 HeadingStyle::AtxClosed => "# Heading #",
212 HeadingStyle::Setext1 => "Heading\n=======",
213 HeadingStyle::Setext2 => "Heading\n-------",
214 HeadingStyle::Consistent => "consistent with the first heading",
215 HeadingStyle::SetextWithAtx => "setext-with-atx style",
216 HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
217 },
218 match current_style {
219 HeadingStyle::Atx => "# Heading",
220 HeadingStyle::AtxClosed => "# Heading #",
221 HeadingStyle::Setext1 => "Heading (underlined with =)",
222 HeadingStyle::Setext2 => "Heading (underlined with -)",
223 HeadingStyle::Consistent => "consistent style",
224 HeadingStyle::SetextWithAtx => "setext-with-atx style",
225 HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
226 }
227 ),
228 severity: Severity::Warning,
229 fix,
230 });
231 }
232 }
233 }
234
235 Ok(result)
236 }
237
238 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
239 let warnings = self.check(ctx)?;
241 let warnings =
242 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
243
244 if warnings.is_empty() {
246 return Ok(ctx.content.to_string());
247 }
248
249 let mut fixes: Vec<_> = warnings
251 .iter()
252 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
253 .collect();
254 fixes.sort_by(|a, b| b.0.cmp(&a.0));
255
256 let mut result = ctx.content.to_string();
258 for (start, end, replacement) in fixes {
259 if start < result.len() && end <= result.len() && start <= end {
260 result.replace_range(start..end, replacement);
261 }
262 }
263
264 Ok(result)
265 }
266
267 fn category(&self) -> RuleCategory {
268 RuleCategory::Heading
269 }
270
271 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
272 if ctx.content.is_empty() || !ctx.likely_has_headings() {
274 return true;
275 }
276 !ctx.lines.iter().any(|line| line.heading.is_some())
278 }
279
280 fn as_any(&self) -> &dyn std::any::Any {
281 self
282 }
283
284 fn default_config_section(&self) -> Option<(String, toml::Value)> {
285 let default_config = MD003Config::default();
286 let json_value = serde_json::to_value(&default_config).ok()?;
287 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
288
289 if let toml::Value::Table(table) = toml_value {
290 if !table.is_empty() {
291 Some((MD003Config::RULE_NAME.to_string(), toml::Value::Table(table)))
292 } else {
293 None
294 }
295 } else {
296 None
297 }
298 }
299
300 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
301 where
302 Self: Sized,
303 {
304 let rule_config = crate::rule_config_serde::load_rule_config::<MD003Config>(config);
305 Box::new(Self::from_config_struct(rule_config))
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use crate::lint_context::LintContext;
313
314 #[test]
315 fn test_atx_heading_style() {
316 let rule = MD003HeadingStyle::default();
317 let content = "# Heading 1\n## Heading 2\n### Heading 3";
318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
319 let result = rule.check(&ctx).unwrap();
320 assert!(result.is_empty());
321 }
322
323 #[test]
324 fn test_setext_heading_style() {
325 let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
326 let content = "Heading 1\n=========\n\nHeading 2\n---------";
327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
328 let result = rule.check(&ctx).unwrap();
329 assert!(result.is_empty());
330 }
331
332 #[test]
333 fn test_front_matter() {
334 let rule = MD003HeadingStyle::default();
335 let content = "---\ntitle: Test\n---\n\n# Heading 1\n## Heading 2";
336
337 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
339 let result = rule.check(&ctx).unwrap();
340 assert!(
341 result.is_empty(),
342 "No warnings expected for content with front matter, found: {result:?}"
343 );
344 }
345
346 #[test]
347 fn test_consistent_heading_style() {
348 let rule = MD003HeadingStyle::default();
350 let content = "# Heading 1\n## Heading 2\n### Heading 3";
351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
352 let result = rule.check(&ctx).unwrap();
353 assert!(result.is_empty());
354 }
355
356 #[test]
357 fn test_with_different_styles() {
358 let rule = MD003HeadingStyle::new(HeadingStyle::Consistent);
360 let content = "# Heading 1\n## Heading 2\n### Heading 3";
361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
362 let result = rule.check(&ctx).unwrap();
363
364 assert!(
366 result.is_empty(),
367 "No warnings expected for consistent ATX style, found: {result:?}"
368 );
369
370 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
372 let content = "# Heading 1 #\nHeading 2\n-----\n### Heading 3";
373 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
374 let result = rule.check(&ctx).unwrap();
375 assert!(
376 !result.is_empty(),
377 "Should have warnings for inconsistent heading styles"
378 );
379
380 let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
382 let content = "Heading 1\n=========\nHeading 2\n---------\n### Heading 3";
383 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
384 let result = rule.check(&ctx).unwrap();
385 assert!(
387 result.is_empty(),
388 "No warnings expected for setext style with ATX for level 3, found: {result:?}"
389 );
390 }
391
392 #[test]
393 fn test_setext_with_atx_style() {
394 let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtx);
395 let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3\n\n#### Heading 4";
397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
398 let result = rule.check(&ctx).unwrap();
399 assert!(
400 result.is_empty(),
401 "SesetxtWithAtx style should accept setext for h1/h2 and ATX for h3+"
402 );
403
404 let content_wrong = "# Heading 1\n## Heading 2\n### Heading 3";
406 let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
407 let result_wrong = rule.check(&ctx_wrong).unwrap();
408 assert_eq!(
409 result_wrong.len(),
410 2,
411 "Should flag ATX headings for h1/h2 with setext_with_atx style"
412 );
413 }
414
415 #[test]
416 fn test_fix_preserves_attribute_lists() {
417 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
419 let content = "# Heading { #custom-id .class } #";
420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
421
422 let warnings = rule.check(&ctx).unwrap();
424 assert_eq!(warnings.len(), 1);
425 let fix = warnings[0].fix.as_ref().expect("Should have a fix");
426 assert!(
427 fix.replacement.contains("{ #custom-id .class }"),
428 "check() fix should preserve attribute list, got: {}",
429 fix.replacement
430 );
431
432 let fixed = rule.fix(&ctx).unwrap();
434 assert!(
435 fixed.contains("{ #custom-id .class }"),
436 "fix() should preserve attribute list, got: {fixed}"
437 );
438 assert!(
439 !fixed.contains(" #\n") && !fixed.ends_with(" #"),
440 "fix() should remove ATX closed trailing hashes, got: {fixed}"
441 );
442 }
443
444 #[test]
445 fn test_setext_with_atx_closed_style() {
446 let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtxClosed);
447 let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3 ###\n\n#### Heading 4 ####";
449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
450 let result = rule.check(&ctx).unwrap();
451 assert!(
452 result.is_empty(),
453 "SetextWithAtxClosed style should accept setext for h1/h2 and ATX closed for h3+"
454 );
455
456 let content_wrong = "Heading 1\n=========\n\n### Heading 3\n\n#### Heading 4";
458 let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
459 let result_wrong = rule.check(&ctx_wrong).unwrap();
460 assert_eq!(
461 result_wrong.len(),
462 2,
463 "Should flag non-closed ATX headings for h3+ with setext_with_atx_closed style"
464 );
465 }
466}