rumdl_lib/rules/
md003_heading_style.rs

1//!
2//! Rule MD003: Heading style
3//!
4//! See [docs/md003.md](../../docs/md003.md) for full documentation, configuration, and examples.
5
6use 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/// Rule MD003: Heading style
23#[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    /// Check if we should use consistent mode (detect first style)
40    fn is_consistent_mode(&self) -> bool {
41        // Check for the Consistent variant explicitly
42        self.config.style == HeadingStyle::Consistent
43    }
44
45    /// Gets the target heading style based on configuration and document content
46    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        // Find the first heading from cached info
52        for line_info in &ctx.lines {
53            if let Some(heading) = &line_info.heading {
54                // Map from LintContext heading style to rules heading style
55                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        // Default to ATX if no headings found
70        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        // Get the target style using cached heading information
87        let target_style = self.get_target_style(ctx);
88
89        // Create LineIndex once outside the loop
90        let line_index = crate::utils::range_utils::LineIndex::new(ctx.content.to_string());
91
92        // Process headings using cached heading information
93        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                // Map the cached heading style to the rule's HeadingStyle
98                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                // Determine expected style based on level and target
111                let expected_style = match target_style {
112                    HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
113                        if level > 2 {
114                            // Setext only supports levels 1-2, so levels 3+ must be ATX
115                            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                            // Use Setext for h1/h2
125                            if level == 1 {
126                                HeadingStyle::Setext1
127                            } else {
128                                HeadingStyle::Setext2
129                            }
130                        } else {
131                            // Use ATX for h3-h6
132                            HeadingStyle::Atx
133                        }
134                    }
135                    HeadingStyle::SetextWithAtxClosed => {
136                        if level <= 2 {
137                            // Use Setext for h1/h2
138                            if level == 1 {
139                                HeadingStyle::Setext1
140                            } else {
141                                HeadingStyle::Setext2
142                            }
143                        } else {
144                            // Use ATX closed for h3-h6
145                            HeadingStyle::AtxClosed
146                        }
147                    }
148                    _ => target_style,
149                };
150
151                if current_style != expected_style {
152                    // Generate fix for this heading
153                    let fix = {
154                        use crate::rules::heading_utils::HeadingUtils;
155
156                        // Convert heading to target style
157                        let converted_heading =
158                            HeadingUtils::convert_heading_style(&heading.text, level as u32, expected_style);
159
160                        // Add indentation
161                        let final_heading = format!("{}{}", " ".repeat(line_info.indent), converted_heading);
162
163                        // Calculate the correct range for the heading
164                        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                    // Calculate precise character range for the heading marker
173                    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        // Get all warnings with their fixes
215        let warnings = self.check(ctx)?;
216
217        // If no warnings, return original content
218        if warnings.is_empty() {
219            return Ok(ctx.content.to_string());
220        }
221
222        // Collect all fixes and sort by range start (descending) to apply from end to beginning
223        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        // Apply fixes from end to beginning to preserve byte offsets
230        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        // Skip if content is empty or has no headings
246        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        // Test should detect headings and apply consistent style
311        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        // Default rule uses Atx which serves as our "consistent" mode
322        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        // Test with consistent style (ATX)
332        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        // Make test more resilient
338        assert!(
339            result.is_empty(),
340            "No warnings expected for consistent ATX style, found: {result:?}"
341        );
342
343        // Test with incorrect style
344        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        // Test with setext style
354        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        // The level 3 heading can't be setext, so it's valid as ATX
359        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        // Setext for h1/h2, ATX for h3-h6
369        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        // Test incorrect usage - ATX for h1/h2
378        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        // Setext for h1/h2, ATX closed for h3-h6
392        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        // Test incorrect usage - regular ATX for h3+
401        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}