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