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 let style = match heading.style {
51 crate::lint_context::HeadingStyle::ATX => {
52 if heading.has_closing_sequence {
53 HeadingStyle::AtxClosed
54 } else {
55 HeadingStyle::Atx
56 }
57 }
58 crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
59 crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
60 };
61 *style_counts.entry(style).or_insert(0) += 1;
62 }
63 }
64
65 style_counts
68 .into_iter()
69 .max_by(|(style_a, count_a), (style_b, count_b)| {
70 match count_a.cmp(count_b) {
71 std::cmp::Ordering::Equal => {
72 let priority = |s: &HeadingStyle| match s {
74 HeadingStyle::Atx => 0,
75 HeadingStyle::Setext1 => 1,
76 HeadingStyle::Setext2 => 2,
77 HeadingStyle::AtxClosed => 3,
78 _ => 4,
79 };
80 priority(style_b).cmp(&priority(style_a)) }
82 other => other,
83 }
84 })
85 .map(|(style, _)| style)
86 .unwrap_or(HeadingStyle::Atx)
87 }
88}
89
90impl Rule for MD003HeadingStyle {
91 fn name(&self) -> &'static str {
92 "MD003"
93 }
94
95 fn description(&self) -> &'static str {
96 "Heading style"
97 }
98
99 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
100 let mut result = Vec::new();
101
102 let target_style = self.get_target_style(ctx);
104
105 for (line_num, line_info) in ctx.lines.iter().enumerate() {
107 if let Some(heading) = &line_info.heading {
108 let level = heading.level;
109
110 let current_style = match heading.style {
112 crate::lint_context::HeadingStyle::ATX => {
113 if heading.has_closing_sequence {
114 HeadingStyle::AtxClosed
115 } else {
116 HeadingStyle::Atx
117 }
118 }
119 crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
120 crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
121 };
122
123 let expected_style = match target_style {
125 HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
126 if level > 2 {
127 HeadingStyle::Atx
129 } else if level == 1 {
130 HeadingStyle::Setext1
131 } else {
132 HeadingStyle::Setext2
133 }
134 }
135 HeadingStyle::SetextWithAtx => {
136 if level <= 2 {
137 if level == 1 {
139 HeadingStyle::Setext1
140 } else {
141 HeadingStyle::Setext2
142 }
143 } else {
144 HeadingStyle::Atx
146 }
147 }
148 HeadingStyle::SetextWithAtxClosed => {
149 if level <= 2 {
150 if level == 1 {
152 HeadingStyle::Setext1
153 } else {
154 HeadingStyle::Setext2
155 }
156 } else {
157 HeadingStyle::AtxClosed
159 }
160 }
161 _ => target_style,
162 };
163
164 if current_style != expected_style {
165 let fix = {
167 use crate::rules::heading_utils::HeadingUtils;
168
169 let converted_heading =
171 HeadingUtils::convert_heading_style(&heading.text, level as u32, expected_style);
172
173 let final_heading = format!("{}{}", " ".repeat(line_info.indent), converted_heading);
175
176 let range = ctx.line_index.line_content_range(line_num + 1);
178
179 Some(crate::rule::Fix {
180 range,
181 replacement: final_heading,
182 })
183 };
184
185 let (start_line, start_col, end_line, end_col) =
187 calculate_heading_range(line_num + 1, line_info.content(ctx.content));
188
189 result.push(LintWarning {
190 rule_name: Some(self.name().to_string()),
191 line: start_line,
192 column: start_col,
193 end_line,
194 end_column: end_col,
195 message: format!(
196 "Heading style should be {}, found {}",
197 match expected_style {
198 HeadingStyle::Atx => "# Heading",
199 HeadingStyle::AtxClosed => "# Heading #",
200 HeadingStyle::Setext1 => "Heading\n=======",
201 HeadingStyle::Setext2 => "Heading\n-------",
202 HeadingStyle::Consistent => "consistent with the first heading",
203 HeadingStyle::SetextWithAtx => "setext_with_atx style",
204 HeadingStyle::SetextWithAtxClosed => "setext_with_atx_closed style",
205 },
206 match current_style {
207 HeadingStyle::Atx => "# Heading",
208 HeadingStyle::AtxClosed => "# Heading #",
209 HeadingStyle::Setext1 => "Heading (underlined with =)",
210 HeadingStyle::Setext2 => "Heading (underlined with -)",
211 HeadingStyle::Consistent => "consistent style",
212 HeadingStyle::SetextWithAtx => "setext_with_atx style",
213 HeadingStyle::SetextWithAtxClosed => "setext_with_atx_closed style",
214 }
215 ),
216 severity: Severity::Warning,
217 fix,
218 });
219 }
220 }
221 }
222
223 Ok(result)
224 }
225
226 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
227 let warnings = self.check(ctx)?;
229
230 if warnings.is_empty() {
232 return Ok(ctx.content.to_string());
233 }
234
235 let mut fixes: Vec<_> = warnings
237 .iter()
238 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
239 .collect();
240 fixes.sort_by(|a, b| b.0.cmp(&a.0));
241
242 let mut result = ctx.content.to_string();
244 for (start, end, replacement) in fixes {
245 if start < result.len() && end <= result.len() && start <= end {
246 result.replace_range(start..end, replacement);
247 }
248 }
249
250 Ok(result)
251 }
252
253 fn category(&self) -> RuleCategory {
254 RuleCategory::Heading
255 }
256
257 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
258 if ctx.content.is_empty() || !ctx.likely_has_headings() {
260 return true;
261 }
262 !ctx.lines.iter().any(|line| line.heading.is_some())
264 }
265
266 fn as_any(&self) -> &dyn std::any::Any {
267 self
268 }
269
270 fn default_config_section(&self) -> Option<(String, toml::Value)> {
271 let default_config = MD003Config::default();
272 let json_value = serde_json::to_value(&default_config).ok()?;
273 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
274
275 if let toml::Value::Table(table) = toml_value {
276 if !table.is_empty() {
277 Some((MD003Config::RULE_NAME.to_string(), toml::Value::Table(table)))
278 } else {
279 None
280 }
281 } else {
282 None
283 }
284 }
285
286 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
287 where
288 Self: Sized,
289 {
290 let rule_config = crate::rule_config_serde::load_rule_config::<MD003Config>(config);
291 Box::new(Self::from_config_struct(rule_config))
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use crate::lint_context::LintContext;
299
300 #[test]
301 fn test_atx_heading_style() {
302 let rule = MD003HeadingStyle::default();
303 let content = "# Heading 1\n## Heading 2\n### Heading 3";
304 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
305 let result = rule.check(&ctx).unwrap();
306 assert!(result.is_empty());
307 }
308
309 #[test]
310 fn test_setext_heading_style() {
311 let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
312 let content = "Heading 1\n=========\n\nHeading 2\n---------";
313 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
314 let result = rule.check(&ctx).unwrap();
315 assert!(result.is_empty());
316 }
317
318 #[test]
319 fn test_front_matter() {
320 let rule = MD003HeadingStyle::default();
321 let content = "---\ntitle: Test\n---\n\n# Heading 1\n## Heading 2";
322
323 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
325 let result = rule.check(&ctx).unwrap();
326 assert!(
327 result.is_empty(),
328 "No warnings expected for content with front matter, found: {result:?}"
329 );
330 }
331
332 #[test]
333 fn test_consistent_heading_style() {
334 let rule = MD003HeadingStyle::default();
336 let content = "# Heading 1\n## Heading 2\n### Heading 3";
337 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
338 let result = rule.check(&ctx).unwrap();
339 assert!(result.is_empty());
340 }
341
342 #[test]
343 fn test_with_different_styles() {
344 let rule = MD003HeadingStyle::new(HeadingStyle::Consistent);
346 let content = "# Heading 1\n## Heading 2\n### Heading 3";
347 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
348 let result = rule.check(&ctx).unwrap();
349
350 assert!(
352 result.is_empty(),
353 "No warnings expected for consistent ATX style, found: {result:?}"
354 );
355
356 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
358 let content = "# Heading 1 #\nHeading 2\n-----\n### Heading 3";
359 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
360 let result = rule.check(&ctx).unwrap();
361 assert!(
362 !result.is_empty(),
363 "Should have warnings for inconsistent heading styles"
364 );
365
366 let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
368 let content = "Heading 1\n=========\nHeading 2\n---------\n### Heading 3";
369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
370 let result = rule.check(&ctx).unwrap();
371 assert!(
373 result.is_empty(),
374 "No warnings expected for setext style with ATX for level 3, found: {result:?}"
375 );
376 }
377
378 #[test]
379 fn test_setext_with_atx_style() {
380 let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtx);
381 let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3\n\n#### Heading 4";
383 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
384 let result = rule.check(&ctx).unwrap();
385 assert!(
386 result.is_empty(),
387 "SesetxtWithAtx style should accept setext for h1/h2 and ATX for h3+"
388 );
389
390 let content_wrong = "# Heading 1\n## Heading 2\n### Heading 3";
392 let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
393 let result_wrong = rule.check(&ctx_wrong).unwrap();
394 assert_eq!(
395 result_wrong.len(),
396 2,
397 "Should flag ATX headings for h1/h2 with setext_with_atx style"
398 );
399 }
400
401 #[test]
402 fn test_setext_with_atx_closed_style() {
403 let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtxClosed);
404 let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3 ###\n\n#### Heading 4 ####";
406 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
407 let result = rule.check(&ctx).unwrap();
408 assert!(
409 result.is_empty(),
410 "SetextWithAtxClosed style should accept setext for h1/h2 and ATX closed for h3+"
411 );
412
413 let content_wrong = "Heading 1\n=========\n\n### Heading 3\n\n#### Heading 4";
415 let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
416 let result_wrong = rule.check(&ctx_wrong).unwrap();
417 assert_eq!(
418 result_wrong.len(),
419 2,
420 "Should flag non-closed ATX headings for h3+ with setext_with_atx_closed style"
421 );
422 }
423}