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