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 lazy_static::lazy_static;
11use regex::Regex;
12use toml;
13
14mod md003_config;
15use md003_config::MD003Config;
16
17lazy_static! {
18 static ref FRONT_MATTER_DELIMITER: Regex = Regex::new(r"^---\s*$").unwrap();
19 static ref QUICK_HEADING_CHECK: Regex = Regex::new(r"(?m)^(\s*)#|^(\s*)[^\s].*\n(\s*)(=+|-+)\s*$").unwrap();
20}
21
22#[derive(Clone, Default)]
24pub struct MD003HeadingStyle {
25 config: MD003Config,
26}
27
28impl MD003HeadingStyle {
29 pub fn new(style: HeadingStyle) -> Self {
30 Self {
31 config: MD003Config { style },
32 }
33 }
34
35 pub fn from_config_struct(config: MD003Config) -> Self {
36 Self { config }
37 }
38
39 fn is_consistent_mode(&self) -> bool {
41 self.config.style == HeadingStyle::Consistent
43 }
44
45 fn get_target_style(&self, ctx: &crate::lint_context::LintContext) -> HeadingStyle {
47 if !self.is_consistent_mode() {
48 return self.config.style;
49 }
50
51 for line_info in &ctx.lines {
53 if let Some(heading) = &line_info.heading {
54 return 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 }
67 }
68
69 HeadingStyle::Atx
71 }
72}
73
74impl Rule for MD003HeadingStyle {
75 fn name(&self) -> &'static str {
76 "MD003"
77 }
78
79 fn description(&self) -> &'static str {
80 "Heading style"
81 }
82
83 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
84 let mut result = Vec::new();
85
86 let target_style = self.get_target_style(ctx);
88
89 let line_index = crate::utils::range_utils::LineIndex::new(ctx.content.to_string());
91
92 for (line_num, line_info) in ctx.lines.iter().enumerate() {
94 if let Some(heading) = &line_info.heading {
95 let level = heading.level;
96
97 let current_style = match heading.style {
99 crate::lint_context::HeadingStyle::ATX => {
100 if heading.has_closing_sequence {
101 HeadingStyle::AtxClosed
102 } else {
103 HeadingStyle::Atx
104 }
105 }
106 crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
107 crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
108 };
109
110 let expected_style = match target_style {
112 HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
113 if level > 2 {
114 HeadingStyle::Atx
116 } else if level == 1 {
117 HeadingStyle::Setext1
118 } else {
119 HeadingStyle::Setext2
120 }
121 }
122 HeadingStyle::SetextWithAtx => {
123 if level <= 2 {
124 if level == 1 {
126 HeadingStyle::Setext1
127 } else {
128 HeadingStyle::Setext2
129 }
130 } else {
131 HeadingStyle::Atx
133 }
134 }
135 HeadingStyle::SetextWithAtxClosed => {
136 if level <= 2 {
137 if level == 1 {
139 HeadingStyle::Setext1
140 } else {
141 HeadingStyle::Setext2
142 }
143 } else {
144 HeadingStyle::AtxClosed
146 }
147 }
148 _ => target_style,
149 };
150
151 if current_style != expected_style {
152 let fix = {
154 use crate::rules::heading_utils::HeadingUtils;
155
156 let converted_heading =
158 HeadingUtils::convert_heading_style(&heading.text, level as u32, expected_style);
159
160 let final_heading = format!("{}{}", " ".repeat(line_info.indent), converted_heading);
162
163 let range = line_index.line_content_range(line_num + 1);
165
166 Some(crate::rule::Fix {
167 range,
168 replacement: final_heading,
169 })
170 };
171
172 let (start_line, start_col, end_line, end_col) =
174 calculate_heading_range(line_num + 1, &line_info.content);
175
176 result.push(LintWarning {
177 rule_name: Some(self.name()),
178 line: start_line,
179 column: start_col,
180 end_line,
181 end_column: end_col,
182 message: format!(
183 "Heading style should be {}, found {}",
184 match expected_style {
185 HeadingStyle::Atx => "# Heading",
186 HeadingStyle::AtxClosed => "# Heading #",
187 HeadingStyle::Setext1 => "Heading\n=======",
188 HeadingStyle::Setext2 => "Heading\n-------",
189 HeadingStyle::Consistent => "consistent with the first heading",
190 HeadingStyle::SetextWithAtx => "setext_with_atx style",
191 HeadingStyle::SetextWithAtxClosed => "setext_with_atx_closed style",
192 },
193 match current_style {
194 HeadingStyle::Atx => "# Heading",
195 HeadingStyle::AtxClosed => "# Heading #",
196 HeadingStyle::Setext1 => "Heading (underlined with =)",
197 HeadingStyle::Setext2 => "Heading (underlined with -)",
198 HeadingStyle::Consistent => "consistent style",
199 HeadingStyle::SetextWithAtx => "setext_with_atx style",
200 HeadingStyle::SetextWithAtxClosed => "setext_with_atx_closed style",
201 }
202 ),
203 severity: Severity::Warning,
204 fix,
205 });
206 }
207 }
208 }
209
210 Ok(result)
211 }
212
213 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
214 let warnings = self.check(ctx)?;
216
217 if warnings.is_empty() {
219 return Ok(ctx.content.to_string());
220 }
221
222 let mut fixes: Vec<_> = warnings
224 .iter()
225 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
226 .collect();
227 fixes.sort_by(|a, b| b.0.cmp(&a.0));
228
229 let mut result = ctx.content.to_string();
231 for (start, end, replacement) in fixes {
232 if start < result.len() && end <= result.len() && start <= end {
233 result.replace_range(start..end, replacement);
234 }
235 }
236
237 Ok(result)
238 }
239
240 fn category(&self) -> RuleCategory {
241 RuleCategory::Heading
242 }
243
244 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
245 ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.heading.is_some())
247 }
248
249 fn as_any(&self) -> &dyn std::any::Any {
250 self
251 }
252
253 fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
254 None
255 }
256
257 fn default_config_section(&self) -> Option<(String, toml::Value)> {
258 let default_config = MD003Config::default();
259 let json_value = serde_json::to_value(&default_config).ok()?;
260 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
261
262 if let toml::Value::Table(table) = toml_value {
263 if !table.is_empty() {
264 Some((MD003Config::RULE_NAME.to_string(), toml::Value::Table(table)))
265 } else {
266 None
267 }
268 } else {
269 None
270 }
271 }
272
273 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
274 where
275 Self: Sized,
276 {
277 let rule_config = crate::rule_config_serde::load_rule_config::<MD003Config>(config);
278 Box::new(Self::from_config_struct(rule_config))
279 }
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);
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);
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);
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);
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);
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);
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);
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);
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);
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_setext_with_atx_closed_style() {
390 let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtxClosed);
391 let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3 ###\n\n#### Heading 4 ####";
393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
394 let result = rule.check(&ctx).unwrap();
395 assert!(
396 result.is_empty(),
397 "SetextWithAtxClosed style should accept setext for h1/h2 and ATX closed for h3+"
398 );
399
400 let content_wrong = "Heading 1\n=========\n\n### Heading 3\n\n#### Heading 4";
402 let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard);
403 let result_wrong = rule.check(&ctx_wrong).unwrap();
404 assert_eq!(
405 result_wrong.len(),
406 2,
407 "Should flag non-closed ATX headings for h3+ with setext_with_atx_closed style"
408 );
409 }
410}