rumdl_lib/rules/md063_heading_capitalization/
mod.rs

1/// Rule MD063: Heading capitalization
2///
3/// See [docs/md063.md](../../docs/md063.md) for full documentation, configuration, and examples.
4///
5/// This rule enforces consistent capitalization styles for markdown headings.
6/// It supports title case, sentence case, and all caps styles.
7///
8/// **Note:** This rule is disabled by default. Enable it in your configuration:
9/// ```toml
10/// [MD063]
11/// enabled = true
12/// style = "title_case"
13/// ```
14use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
15use crate::utils::range_utils::LineIndex;
16use regex::Regex;
17use std::collections::HashSet;
18use std::ops::Range;
19use std::sync::LazyLock;
20
21mod md063_config;
22pub use md063_config::{HeadingCapStyle, MD063Config};
23
24// Regex to match inline code spans (backticks)
25static INLINE_CODE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`+[^`]+`+").unwrap());
26
27// Regex to match markdown links [text](url) or [text][ref]
28static LINK_REGEX: LazyLock<Regex> =
29    LazyLock::new(|| Regex::new(r"\[([^\]]*)\]\([^)]*\)|\[([^\]]*)\]\[[^\]]*\]").unwrap());
30
31// Regex to match custom header IDs {#id}
32static CUSTOM_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*\{#[^}]+\}\s*$").unwrap());
33
34/// Represents a segment of heading text
35#[derive(Debug, Clone)]
36enum HeadingSegment {
37    /// Regular text that should be capitalized
38    Text(String),
39    /// Inline code that should be preserved as-is
40    Code(String),
41    /// Link with text that may be capitalized and URL that's preserved
42    Link {
43        full: String,
44        text_start: usize,
45        text_end: usize,
46    },
47}
48
49/// Rule MD063: Heading capitalization
50#[derive(Clone)]
51pub struct MD063HeadingCapitalization {
52    config: MD063Config,
53    lowercase_set: HashSet<String>,
54}
55
56impl Default for MD063HeadingCapitalization {
57    fn default() -> Self {
58        Self::new()
59    }
60}
61
62impl MD063HeadingCapitalization {
63    pub fn new() -> Self {
64        let config = MD063Config::default();
65        let lowercase_set = config.lowercase_words.iter().cloned().collect();
66        Self { config, lowercase_set }
67    }
68
69    pub fn from_config_struct(config: MD063Config) -> Self {
70        let lowercase_set = config.lowercase_words.iter().cloned().collect();
71        Self { config, lowercase_set }
72    }
73
74    /// Check if a word has internal capitals (like "iPhone", "macOS", "GitHub")
75    fn has_internal_capitals(&self, word: &str) -> bool {
76        let chars: Vec<char> = word.chars().collect();
77        if chars.len() < 2 {
78            return false;
79        }
80
81        // Check for mixed case (both upper AND lower after first char)
82        // This preserves "JavaScript", "iPhone", "macOS" but NOT "ALL", "API"
83        let rest = &chars[1..];
84        let has_upper = rest.iter().any(|c| c.is_uppercase());
85        let has_lower = rest.iter().any(|c| c.is_lowercase());
86        has_upper && has_lower
87    }
88
89    /// Check if a word is an all-caps acronym (2+ consecutive uppercase letters)
90    /// Examples: "API", "GPU", "HTTP2", "IO" return true
91    /// Examples: "A", "iPhone", "npm" return false
92    fn is_all_caps_acronym(&self, word: &str) -> bool {
93        // Skip single-letter words (handled by title case rules)
94        if word.len() < 2 {
95            return false;
96        }
97
98        let mut consecutive_upper = 0;
99        let mut max_consecutive = 0;
100
101        for c in word.chars() {
102            if c.is_uppercase() {
103                consecutive_upper += 1;
104                max_consecutive = max_consecutive.max(consecutive_upper);
105            } else if c.is_lowercase() {
106                // Any lowercase letter means not all-caps
107                return false;
108            } else {
109                // Non-letter (number, punctuation) - reset counter but don't fail
110                consecutive_upper = 0;
111            }
112        }
113
114        // Must have at least 2 consecutive uppercase letters
115        max_consecutive >= 2
116    }
117
118    /// Check if a word should be preserved as-is
119    fn should_preserve_word(&self, word: &str) -> bool {
120        // Check ignore_words list (case-sensitive exact match)
121        if self.config.ignore_words.iter().any(|w| w == word) {
122            return true;
123        }
124
125        // Check if word has internal capitals and preserve_cased_words is enabled
126        if self.config.preserve_cased_words && self.has_internal_capitals(word) {
127            return true;
128        }
129
130        // Check if word is an all-caps acronym (2+ consecutive uppercase)
131        if self.config.preserve_cased_words && self.is_all_caps_acronym(word) {
132            return true;
133        }
134
135        false
136    }
137
138    /// Check if a word is a "lowercase word" (articles, prepositions, etc.)
139    fn is_lowercase_word(&self, word: &str) -> bool {
140        self.lowercase_set.contains(&word.to_lowercase())
141    }
142
143    /// Apply title case to a single word
144    fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
145        if word.is_empty() {
146            return word.to_string();
147        }
148
149        // Preserve words in ignore list or with internal capitals
150        if self.should_preserve_word(word) {
151            return word.to_string();
152        }
153
154        // First and last words are always capitalized
155        if is_first || is_last {
156            return self.capitalize_first(word);
157        }
158
159        // Check if it's a lowercase word (articles, prepositions, etc.)
160        if self.is_lowercase_word(word) {
161            return word.to_lowercase();
162        }
163
164        // Regular word - capitalize first letter
165        self.capitalize_first(word)
166    }
167
168    /// Capitalize the first letter of a word, handling Unicode properly
169    fn capitalize_first(&self, word: &str) -> String {
170        let mut chars = word.chars();
171        match chars.next() {
172            None => String::new(),
173            Some(first) => {
174                let first_upper: String = first.to_uppercase().collect();
175                let rest: String = chars.collect();
176                format!("{}{}", first_upper, rest.to_lowercase())
177            }
178        }
179    }
180
181    /// Apply title case to text (using titlecase crate as base, then our customizations)
182    fn apply_title_case(&self, text: &str) -> String {
183        // Use the titlecase crate for the base transformation
184        let base_result = titlecase::titlecase(text);
185
186        // Get words from both original and transformed text to compare
187        let original_words: Vec<&str> = text.split_whitespace().collect();
188        let transformed_words: Vec<&str> = base_result.split_whitespace().collect();
189        let total_words = transformed_words.len();
190
191        let result_words: Vec<String> = transformed_words
192            .iter()
193            .enumerate()
194            .map(|(i, word)| {
195                let is_first = i == 0;
196                let is_last = i == total_words - 1;
197
198                // Check if the ORIGINAL word should be preserved (for acronyms like "API")
199                if let Some(original_word) = original_words.get(i)
200                    && self.should_preserve_word(original_word)
201                {
202                    return (*original_word).to_string();
203                }
204
205                // Handle hyphenated words
206                if word.contains('-') {
207                    // Also check original for hyphenated preservation
208                    if let Some(original_word) = original_words.get(i) {
209                        return self.handle_hyphenated_word_with_original(word, original_word, is_first, is_last);
210                    }
211                    return self.handle_hyphenated_word(word, is_first, is_last);
212                }
213
214                self.title_case_word(word, is_first, is_last)
215            })
216            .collect();
217
218        result_words.join(" ")
219    }
220
221    /// Handle hyphenated words like "self-documenting"
222    fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
223        let parts: Vec<&str> = word.split('-').collect();
224        let total_parts = parts.len();
225
226        let result_parts: Vec<String> = parts
227            .iter()
228            .enumerate()
229            .map(|(i, part)| {
230                // First part of first word and last part of last word get special treatment
231                let part_is_first = is_first && i == 0;
232                let part_is_last = is_last && i == total_parts - 1;
233                self.title_case_word(part, part_is_first, part_is_last)
234            })
235            .collect();
236
237        result_parts.join("-")
238    }
239
240    /// Handle hyphenated words with original text for acronym preservation
241    fn handle_hyphenated_word_with_original(
242        &self,
243        word: &str,
244        original: &str,
245        is_first: bool,
246        is_last: bool,
247    ) -> String {
248        let parts: Vec<&str> = word.split('-').collect();
249        let original_parts: Vec<&str> = original.split('-').collect();
250        let total_parts = parts.len();
251
252        let result_parts: Vec<String> = parts
253            .iter()
254            .enumerate()
255            .map(|(i, part)| {
256                // Check if the original part should be preserved (for acronyms)
257                if let Some(original_part) = original_parts.get(i)
258                    && self.should_preserve_word(original_part)
259                {
260                    return (*original_part).to_string();
261                }
262
263                // First part of first word and last part of last word get special treatment
264                let part_is_first = is_first && i == 0;
265                let part_is_last = is_last && i == total_parts - 1;
266                self.title_case_word(part, part_is_first, part_is_last)
267            })
268            .collect();
269
270        result_parts.join("-")
271    }
272
273    /// Apply sentence case to text
274    fn apply_sentence_case(&self, text: &str) -> String {
275        if text.is_empty() {
276            return text.to_string();
277        }
278
279        let mut result = String::new();
280        let mut current_pos = 0;
281        let mut is_first_word = true;
282
283        // Use original text positions to preserve whitespace correctly
284        for word in text.split_whitespace() {
285            if let Some(pos) = text[current_pos..].find(word) {
286                let abs_pos = current_pos + pos;
287
288                // Preserve whitespace before this word
289                result.push_str(&text[current_pos..abs_pos]);
290
291                // Process the word
292                if is_first_word {
293                    // First word: capitalize first letter, lowercase rest
294                    let mut chars = word.chars();
295                    if let Some(first) = chars.next() {
296                        let first_upper: String = first.to_uppercase().collect();
297                        result.push_str(&first_upper);
298                        let rest: String = chars.collect();
299                        if self.should_preserve_word(word) {
300                            result.push_str(&rest);
301                        } else {
302                            result.push_str(&rest.to_lowercase());
303                        }
304                    }
305                    is_first_word = false;
306                } else {
307                    // Non-first words: preserve if needed, otherwise lowercase
308                    if self.should_preserve_word(word) {
309                        result.push_str(word);
310                    } else {
311                        result.push_str(&word.to_lowercase());
312                    }
313                }
314
315                current_pos = abs_pos + word.len();
316            }
317        }
318
319        // Preserve any trailing whitespace
320        if current_pos < text.len() {
321            result.push_str(&text[current_pos..]);
322        }
323
324        result
325    }
326
327    /// Apply all caps to text (preserve whitespace)
328    fn apply_all_caps(&self, text: &str) -> String {
329        if text.is_empty() {
330            return text.to_string();
331        }
332
333        let mut result = String::new();
334        let mut current_pos = 0;
335
336        // Use original text positions to preserve whitespace correctly
337        for word in text.split_whitespace() {
338            if let Some(pos) = text[current_pos..].find(word) {
339                let abs_pos = current_pos + pos;
340
341                // Preserve whitespace before this word
342                result.push_str(&text[current_pos..abs_pos]);
343
344                // Check if this word should be preserved
345                if self.should_preserve_word(word) {
346                    result.push_str(word);
347                } else {
348                    result.push_str(&word.to_uppercase());
349                }
350
351                current_pos = abs_pos + word.len();
352            }
353        }
354
355        // Preserve any trailing whitespace
356        if current_pos < text.len() {
357            result.push_str(&text[current_pos..]);
358        }
359
360        result
361    }
362
363    /// Parse heading text into segments
364    fn parse_segments(&self, text: &str) -> Vec<HeadingSegment> {
365        let mut segments = Vec::new();
366        let mut last_end = 0;
367
368        // Collect all special regions (code and links)
369        let mut special_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
370
371        // Find inline code spans
372        for mat in INLINE_CODE_REGEX.find_iter(text) {
373            special_regions.push((mat.start(), mat.end(), HeadingSegment::Code(mat.as_str().to_string())));
374        }
375
376        // Find links
377        for caps in LINK_REGEX.captures_iter(text) {
378            let full_match = caps.get(0).unwrap();
379            let text_match = caps.get(1).or_else(|| caps.get(2));
380
381            if let Some(text_m) = text_match {
382                special_regions.push((
383                    full_match.start(),
384                    full_match.end(),
385                    HeadingSegment::Link {
386                        full: full_match.as_str().to_string(),
387                        text_start: text_m.start() - full_match.start(),
388                        text_end: text_m.end() - full_match.start(),
389                    },
390                ));
391            }
392        }
393
394        // Sort by start position
395        special_regions.sort_by_key(|(start, _, _)| *start);
396
397        // Remove overlapping regions (code takes precedence)
398        let mut filtered_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
399        for region in special_regions {
400            let overlaps = filtered_regions.iter().any(|(s, e, _)| region.0 < *e && region.1 > *s);
401            if !overlaps {
402                filtered_regions.push(region);
403            }
404        }
405
406        // Build segments
407        for (start, end, segment) in filtered_regions {
408            // Add text before this special region
409            if start > last_end {
410                let text_segment = &text[last_end..start];
411                if !text_segment.is_empty() {
412                    segments.push(HeadingSegment::Text(text_segment.to_string()));
413                }
414            }
415            segments.push(segment);
416            last_end = end;
417        }
418
419        // Add remaining text
420        if last_end < text.len() {
421            let remaining = &text[last_end..];
422            if !remaining.is_empty() {
423                segments.push(HeadingSegment::Text(remaining.to_string()));
424            }
425        }
426
427        // If no segments were found, treat the whole thing as text
428        if segments.is_empty() && !text.is_empty() {
429            segments.push(HeadingSegment::Text(text.to_string()));
430        }
431
432        segments
433    }
434
435    /// Apply capitalization to heading text
436    fn apply_capitalization(&self, text: &str) -> String {
437        // Strip custom ID if present and re-add later
438        let (main_text, custom_id) = if let Some(mat) = CUSTOM_ID_REGEX.find(text) {
439            (&text[..mat.start()], Some(mat.as_str()))
440        } else {
441            (text, None)
442        };
443
444        // Parse into segments
445        let segments = self.parse_segments(main_text);
446
447        // Count text segments to determine first/last word context
448        let text_segments: Vec<usize> = segments
449            .iter()
450            .enumerate()
451            .filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
452            .collect();
453
454        // Apply capitalization to each segment
455        let mut result_parts: Vec<String> = Vec::new();
456
457        for (i, segment) in segments.iter().enumerate() {
458            match segment {
459                HeadingSegment::Text(t) => {
460                    let is_first_text = text_segments.first() == Some(&i);
461                    let is_last_text = text_segments.last() == Some(&i);
462
463                    let capitalized = match self.config.style {
464                        HeadingCapStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
465                        HeadingCapStyle::SentenceCase => {
466                            if is_first_text {
467                                self.apply_sentence_case(t)
468                            } else {
469                                // For non-first segments in sentence case, lowercase
470                                self.apply_sentence_case_non_first(t)
471                            }
472                        }
473                        HeadingCapStyle::AllCaps => self.apply_all_caps(t),
474                    };
475                    result_parts.push(capitalized);
476                }
477                HeadingSegment::Code(c) => {
478                    result_parts.push(c.clone());
479                }
480                HeadingSegment::Link {
481                    full,
482                    text_start,
483                    text_end,
484                } => {
485                    // Apply capitalization to link text only
486                    let link_text = &full[*text_start..*text_end];
487                    let capitalized_text = match self.config.style {
488                        HeadingCapStyle::TitleCase => self.apply_title_case(link_text),
489                        HeadingCapStyle::SentenceCase => link_text.to_lowercase(),
490                        HeadingCapStyle::AllCaps => self.apply_all_caps(link_text),
491                    };
492
493                    let mut new_link = String::new();
494                    new_link.push_str(&full[..*text_start]);
495                    new_link.push_str(&capitalized_text);
496                    new_link.push_str(&full[*text_end..]);
497                    result_parts.push(new_link);
498                }
499            }
500        }
501
502        let mut result = result_parts.join("");
503
504        // Re-add custom ID if present
505        if let Some(id) = custom_id {
506            result.push_str(id);
507        }
508
509        result
510    }
511
512    /// Apply title case to a text segment with first/last awareness
513    fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
514        let words: Vec<&str> = text.split_whitespace().collect();
515        let total_words = words.len();
516
517        if total_words == 0 {
518            return text.to_string();
519        }
520
521        let result_words: Vec<String> = words
522            .iter()
523            .enumerate()
524            .map(|(i, word)| {
525                let is_first = is_first_segment && i == 0;
526                let is_last = is_last_segment && i == total_words - 1;
527
528                // Handle hyphenated words
529                if word.contains('-') {
530                    return self.handle_hyphenated_word(word, is_first, is_last);
531                }
532
533                self.title_case_word(word, is_first, is_last)
534            })
535            .collect();
536
537        // Preserve original spacing
538        let mut result = String::new();
539        let mut word_iter = result_words.iter();
540        let mut in_word = false;
541
542        for c in text.chars() {
543            if c.is_whitespace() {
544                if in_word {
545                    in_word = false;
546                }
547                result.push(c);
548            } else if !in_word {
549                if let Some(word) = word_iter.next() {
550                    result.push_str(word);
551                }
552                in_word = true;
553            }
554        }
555
556        result
557    }
558
559    /// Apply sentence case to non-first segments (just lowercase, preserve whitespace)
560    fn apply_sentence_case_non_first(&self, text: &str) -> String {
561        if text.is_empty() {
562            return text.to_string();
563        }
564
565        let lower = text.to_lowercase();
566        let mut result = String::new();
567        let mut current_pos = 0;
568
569        for word in lower.split_whitespace() {
570            if let Some(pos) = lower[current_pos..].find(word) {
571                let abs_pos = current_pos + pos;
572
573                // Preserve whitespace before this word
574                result.push_str(&lower[current_pos..abs_pos]);
575
576                // Check if this word should be preserved
577                let original_word = &text[abs_pos..abs_pos + word.len()];
578                if self.should_preserve_word(original_word) {
579                    result.push_str(original_word);
580                } else {
581                    result.push_str(word);
582                }
583
584                current_pos = abs_pos + word.len();
585            }
586        }
587
588        // Preserve any trailing whitespace
589        if current_pos < lower.len() {
590            result.push_str(&lower[current_pos..]);
591        }
592
593        result
594    }
595
596    /// Get byte range for a line
597    fn get_line_byte_range(&self, content: &str, line_num: usize, line_index: &LineIndex) -> Range<usize> {
598        let start_pos = line_index.get_line_start_byte(line_num).unwrap_or(content.len());
599        let line = content.lines().nth(line_num - 1).unwrap_or("");
600        Range {
601            start: start_pos,
602            end: start_pos + line.len(),
603        }
604    }
605
606    /// Fix an ATX heading line
607    fn fix_atx_heading(&self, _line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
608        // Parse the line to preserve structure
609        let indent = " ".repeat(heading.marker_column);
610        let hashes = "#".repeat(heading.level as usize);
611
612        // Apply capitalization to the text
613        let fixed_text = self.apply_capitalization(&heading.raw_text);
614
615        // Reconstruct with closing sequence if present
616        let closing = &heading.closing_sequence;
617        if heading.has_closing_sequence {
618            format!("{indent}{hashes} {fixed_text} {closing}")
619        } else {
620            format!("{indent}{hashes} {fixed_text}")
621        }
622    }
623
624    /// Fix a Setext heading line
625    fn fix_setext_heading(&self, line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
626        // Apply capitalization to the text
627        let fixed_text = self.apply_capitalization(&heading.raw_text);
628
629        // Preserve leading whitespace from original line
630        let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
631
632        format!("{leading_ws}{fixed_text}")
633    }
634}
635
636impl Rule for MD063HeadingCapitalization {
637    fn name(&self) -> &'static str {
638        "MD063"
639    }
640
641    fn description(&self) -> &'static str {
642        "Heading capitalization"
643    }
644
645    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
646        // Skip if rule is disabled or no headings
647        !self.config.enabled || !ctx.likely_has_headings() || !ctx.lines.iter().any(|line| line.heading.is_some())
648    }
649
650    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
651        if !self.config.enabled {
652            return Ok(Vec::new());
653        }
654
655        let content = ctx.content;
656
657        if content.is_empty() {
658            return Ok(Vec::new());
659        }
660
661        let mut warnings = Vec::new();
662        let line_index = &ctx.line_index;
663
664        for (line_num, line_info) in ctx.lines.iter().enumerate() {
665            if let Some(heading) = &line_info.heading {
666                // Check level filter
667                if heading.level < self.config.min_level || heading.level > self.config.max_level {
668                    continue;
669                }
670
671                // Skip headings in code blocks (indented headings)
672                if line_info.indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
673                    continue;
674                }
675
676                // Apply capitalization and compare
677                let original_text = &heading.raw_text;
678                let fixed_text = self.apply_capitalization(original_text);
679
680                if original_text != &fixed_text {
681                    let line = line_info.content(ctx.content);
682                    let style_name = match self.config.style {
683                        HeadingCapStyle::TitleCase => "title case",
684                        HeadingCapStyle::SentenceCase => "sentence case",
685                        HeadingCapStyle::AllCaps => "ALL CAPS",
686                    };
687
688                    warnings.push(LintWarning {
689                        rule_name: Some(self.name().to_string()),
690                        line: line_num + 1,
691                        column: heading.content_column + 1,
692                        end_line: line_num + 1,
693                        end_column: heading.content_column + 1 + original_text.len(),
694                        message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
695                        severity: Severity::Warning,
696                        fix: Some(Fix {
697                            range: self.get_line_byte_range(content, line_num + 1, line_index),
698                            replacement: match heading.style {
699                                crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
700                                _ => self.fix_setext_heading(line, heading),
701                            },
702                        }),
703                    });
704                }
705            }
706        }
707
708        Ok(warnings)
709    }
710
711    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
712        if !self.config.enabled {
713            return Ok(ctx.content.to_string());
714        }
715
716        let content = ctx.content;
717
718        if content.is_empty() {
719            return Ok(content.to_string());
720        }
721
722        let lines: Vec<&str> = content.lines().collect();
723        let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
724
725        for (line_num, line_info) in ctx.lines.iter().enumerate() {
726            if let Some(heading) = &line_info.heading {
727                // Check level filter
728                if heading.level < self.config.min_level || heading.level > self.config.max_level {
729                    continue;
730                }
731
732                // Skip headings in code blocks
733                if line_info.indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
734                    continue;
735                }
736
737                let original_text = &heading.raw_text;
738                let fixed_text = self.apply_capitalization(original_text);
739
740                if original_text != &fixed_text {
741                    let line = line_info.content(ctx.content);
742                    fixed_lines[line_num] = match heading.style {
743                        crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
744                        _ => self.fix_setext_heading(line, heading),
745                    };
746                }
747            }
748        }
749
750        // Reconstruct content preserving line endings
751        let mut result = String::with_capacity(content.len());
752        for (i, line) in fixed_lines.iter().enumerate() {
753            result.push_str(line);
754            if i < fixed_lines.len() - 1 || content.ends_with('\n') {
755                result.push('\n');
756            }
757        }
758
759        Ok(result)
760    }
761
762    fn as_any(&self) -> &dyn std::any::Any {
763        self
764    }
765
766    fn default_config_section(&self) -> Option<(String, toml::Value)> {
767        let json_value = serde_json::to_value(&self.config).ok()?;
768        Some((
769            self.name().to_string(),
770            crate::rule_config_serde::json_to_toml_value(&json_value)?,
771        ))
772    }
773
774    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
775    where
776        Self: Sized,
777    {
778        let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
779        Box::new(Self::from_config_struct(rule_config))
780    }
781}
782
783#[cfg(test)]
784mod tests {
785    use super::*;
786    use crate::lint_context::LintContext;
787
788    fn create_rule() -> MD063HeadingCapitalization {
789        let config = MD063Config {
790            enabled: true,
791            ..Default::default()
792        };
793        MD063HeadingCapitalization::from_config_struct(config)
794    }
795
796    fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
797        let config = MD063Config {
798            enabled: true,
799            style,
800            ..Default::default()
801        };
802        MD063HeadingCapitalization::from_config_struct(config)
803    }
804
805    // Title case tests
806    #[test]
807    fn test_title_case_basic() {
808        let rule = create_rule();
809        let content = "# hello world\n";
810        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
811        let result = rule.check(&ctx).unwrap();
812        assert_eq!(result.len(), 1);
813        assert!(result[0].message.contains("Hello World"));
814    }
815
816    #[test]
817    fn test_title_case_lowercase_words() {
818        let rule = create_rule();
819        let content = "# the quick brown fox\n";
820        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
821        let result = rule.check(&ctx).unwrap();
822        assert_eq!(result.len(), 1);
823        // "The" should be capitalized (first word), "quick", "brown", "fox" should be capitalized
824        assert!(result[0].message.contains("The Quick Brown Fox"));
825    }
826
827    #[test]
828    fn test_title_case_already_correct() {
829        let rule = create_rule();
830        let content = "# The Quick Brown Fox\n";
831        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
832        let result = rule.check(&ctx).unwrap();
833        assert!(result.is_empty(), "Already correct heading should not be flagged");
834    }
835
836    #[test]
837    fn test_title_case_hyphenated() {
838        let rule = create_rule();
839        let content = "# self-documenting code\n";
840        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
841        let result = rule.check(&ctx).unwrap();
842        assert_eq!(result.len(), 1);
843        assert!(result[0].message.contains("Self-Documenting Code"));
844    }
845
846    // Sentence case tests
847    #[test]
848    fn test_sentence_case_basic() {
849        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
850        let content = "# The Quick Brown Fox\n";
851        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
852        let result = rule.check(&ctx).unwrap();
853        assert_eq!(result.len(), 1);
854        assert!(result[0].message.contains("The quick brown fox"));
855    }
856
857    #[test]
858    fn test_sentence_case_already_correct() {
859        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
860        let content = "# The quick brown fox\n";
861        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
862        let result = rule.check(&ctx).unwrap();
863        assert!(result.is_empty());
864    }
865
866    // All caps tests
867    #[test]
868    fn test_all_caps_basic() {
869        let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
870        let content = "# hello world\n";
871        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
872        let result = rule.check(&ctx).unwrap();
873        assert_eq!(result.len(), 1);
874        assert!(result[0].message.contains("HELLO WORLD"));
875    }
876
877    // Preserve tests
878    #[test]
879    fn test_preserve_ignore_words() {
880        let config = MD063Config {
881            enabled: true,
882            ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
883            ..Default::default()
884        };
885        let rule = MD063HeadingCapitalization::from_config_struct(config);
886
887        let content = "# using iPhone on macOS\n";
888        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
889        let result = rule.check(&ctx).unwrap();
890        assert_eq!(result.len(), 1);
891        // iPhone and macOS should be preserved
892        assert!(result[0].message.contains("iPhone"));
893        assert!(result[0].message.contains("macOS"));
894    }
895
896    #[test]
897    fn test_preserve_cased_words() {
898        let rule = create_rule();
899        let content = "# using GitHub actions\n";
900        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
901        let result = rule.check(&ctx).unwrap();
902        assert_eq!(result.len(), 1);
903        // GitHub should be preserved (has internal capital)
904        assert!(result[0].message.contains("GitHub"));
905    }
906
907    // Inline code tests
908    #[test]
909    fn test_inline_code_preserved() {
910        let rule = create_rule();
911        let content = "# using `const` in javascript\n";
912        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
913        let result = rule.check(&ctx).unwrap();
914        assert_eq!(result.len(), 1);
915        // `const` should be preserved, rest capitalized
916        assert!(result[0].message.contains("`const`"));
917        assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
918    }
919
920    // Level filter tests
921    #[test]
922    fn test_level_filter() {
923        let config = MD063Config {
924            enabled: true,
925            min_level: 2,
926            max_level: 4,
927            ..Default::default()
928        };
929        let rule = MD063HeadingCapitalization::from_config_struct(config);
930
931        let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
932        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933        let result = rule.check(&ctx).unwrap();
934
935        // Only h2 and h3 should be flagged (h1 < min_level, h5 > max_level)
936        assert_eq!(result.len(), 2);
937        assert_eq!(result[0].line, 2); // h2
938        assert_eq!(result[1].line, 3); // h3
939    }
940
941    // Fix tests
942    #[test]
943    fn test_fix_atx_heading() {
944        let rule = create_rule();
945        let content = "# hello world\n";
946        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
947        let fixed = rule.fix(&ctx).unwrap();
948        assert_eq!(fixed, "# Hello World\n");
949    }
950
951    #[test]
952    fn test_fix_multiple_headings() {
953        let rule = create_rule();
954        let content = "# first heading\n\n## second heading\n";
955        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956        let fixed = rule.fix(&ctx).unwrap();
957        assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
958    }
959
960    // Setext heading tests
961    #[test]
962    fn test_setext_heading() {
963        let rule = create_rule();
964        let content = "hello world\n============\n";
965        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
966        let result = rule.check(&ctx).unwrap();
967        assert_eq!(result.len(), 1);
968        assert!(result[0].message.contains("Hello World"));
969    }
970
971    // Custom ID tests
972    #[test]
973    fn test_custom_id_preserved() {
974        let rule = create_rule();
975        let content = "# getting started {#intro}\n";
976        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
977        let result = rule.check(&ctx).unwrap();
978        assert_eq!(result.len(), 1);
979        // Custom ID should be preserved
980        assert!(result[0].message.contains("{#intro}"));
981    }
982
983    #[test]
984    fn test_md063_disabled_by_default() {
985        let rule = MD063HeadingCapitalization::new();
986        let content = "# hello world\n";
987        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
988
989        // Should return no warnings when disabled
990        let warnings = rule.check(&ctx).unwrap();
991        assert_eq!(warnings.len(), 0);
992
993        // Should return content unchanged when disabled
994        let fixed = rule.fix(&ctx).unwrap();
995        assert_eq!(fixed, content);
996    }
997
998    // Acronym preservation tests
999    #[test]
1000    fn test_preserve_all_caps_acronyms() {
1001        let rule = create_rule();
1002        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1003
1004        // Basic acronyms should be preserved
1005        let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
1006        assert_eq!(fixed, "# Using API in Production\n");
1007
1008        // Multiple acronyms
1009        let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
1010        assert_eq!(fixed, "# API and GPU Integration\n");
1011
1012        // Two-letter acronyms
1013        let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
1014        assert_eq!(fixed, "# IO Performance Guide\n");
1015
1016        // Acronyms with numbers
1017        let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
1018        assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
1019    }
1020
1021    #[test]
1022    fn test_preserve_acronyms_in_hyphenated_words() {
1023        let rule = create_rule();
1024        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1025
1026        // Acronyms at start of hyphenated word
1027        let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
1028        assert_eq!(fixed, "# API-Driven Architecture\n");
1029
1030        // Multiple acronyms with hyphens
1031        let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
1032        assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
1033    }
1034
1035    #[test]
1036    fn test_single_letters_not_treated_as_acronyms() {
1037        let rule = create_rule();
1038        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1039
1040        // Single uppercase letters should follow title case rules, not be preserved
1041        let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
1042        assert_eq!(fixed, "# I Am a Heading\n");
1043    }
1044
1045    #[test]
1046    fn test_lowercase_terms_need_ignore_words() {
1047        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1048
1049        // Without ignore_words: npm gets capitalized
1050        let rule = create_rule();
1051        let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1052        assert_eq!(fixed, "# Using Npm Packages\n");
1053
1054        // With ignore_words: npm preserved
1055        let config = MD063Config {
1056            enabled: true,
1057            ignore_words: vec!["npm".to_string()],
1058            ..Default::default()
1059        };
1060        let rule = MD063HeadingCapitalization::from_config_struct(config);
1061        let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1062        assert_eq!(fixed, "# Using npm Packages\n");
1063    }
1064
1065    #[test]
1066    fn test_acronyms_with_mixed_case_preserved() {
1067        let rule = create_rule();
1068        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1069
1070        // Both acronyms (API, GPU) and mixed-case (GitHub) should be preserved
1071        let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
1072        assert_eq!(fixed, "# Using API with GitHub\n");
1073    }
1074
1075    #[test]
1076    fn test_real_world_acronyms() {
1077        let rule = create_rule();
1078        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1079
1080        // Common technical acronyms from tested repositories
1081        let content = "# FFI bindings for CPU optimization\n";
1082        let fixed = rule.fix(&ctx(content)).unwrap();
1083        assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
1084
1085        let content = "# DOM manipulation and SSR rendering\n";
1086        let fixed = rule.fix(&ctx(content)).unwrap();
1087        assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
1088
1089        let content = "# CVE security and RNN models\n";
1090        let fixed = rule.fix(&ctx(content)).unwrap();
1091        assert_eq!(fixed, "# CVE Security and RNN Models\n");
1092    }
1093
1094    #[test]
1095    fn test_is_all_caps_acronym() {
1096        let rule = create_rule();
1097
1098        // Should return true for all-caps with 2+ letters
1099        assert!(rule.is_all_caps_acronym("API"));
1100        assert!(rule.is_all_caps_acronym("IO"));
1101        assert!(rule.is_all_caps_acronym("GPU"));
1102        assert!(rule.is_all_caps_acronym("HTTP2")); // Numbers don't break it
1103
1104        // Should return false for single letters
1105        assert!(!rule.is_all_caps_acronym("A"));
1106        assert!(!rule.is_all_caps_acronym("I"));
1107
1108        // Should return false for words with lowercase
1109        assert!(!rule.is_all_caps_acronym("Api"));
1110        assert!(!rule.is_all_caps_acronym("npm"));
1111        assert!(!rule.is_all_caps_acronym("iPhone"));
1112    }
1113}