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