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", "iOS")
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        let first = chars[0];
82        let rest = &chars[1..];
83        let has_upper_in_rest = rest.iter().any(|c| c.is_uppercase());
84        let has_lower_in_rest = rest.iter().any(|c| c.is_lowercase());
85
86        // Case 1: Mixed case after first character (like "iPhone", "macOS", "GitHub", "JavaScript")
87        if has_upper_in_rest && has_lower_in_rest {
88            return true;
89        }
90
91        // Case 2: Lowercase first + uppercase in rest (like "iOS", "eBay")
92        if first.is_lowercase() && has_upper_in_rest {
93            return true;
94        }
95
96        false
97    }
98
99    /// Check if a word is an all-caps acronym (2+ consecutive uppercase letters)
100    /// Examples: "API", "GPU", "HTTP2", "IO" return true
101    /// Examples: "A", "iPhone", "npm" return false
102    fn is_all_caps_acronym(&self, word: &str) -> bool {
103        // Skip single-letter words (handled by title case rules)
104        if word.len() < 2 {
105            return false;
106        }
107
108        let mut consecutive_upper = 0;
109        let mut max_consecutive = 0;
110
111        for c in word.chars() {
112            if c.is_uppercase() {
113                consecutive_upper += 1;
114                max_consecutive = max_consecutive.max(consecutive_upper);
115            } else if c.is_lowercase() {
116                // Any lowercase letter means not all-caps
117                return false;
118            } else {
119                // Non-letter (number, punctuation) - reset counter but don't fail
120                consecutive_upper = 0;
121            }
122        }
123
124        // Must have at least 2 consecutive uppercase letters
125        max_consecutive >= 2
126    }
127
128    /// Check if a word should be preserved as-is
129    fn should_preserve_word(&self, word: &str) -> bool {
130        // Check ignore_words list (case-sensitive exact match)
131        if self.config.ignore_words.iter().any(|w| w == word) {
132            return true;
133        }
134
135        // Check if word has internal capitals and preserve_cased_words is enabled
136        if self.config.preserve_cased_words && self.has_internal_capitals(word) {
137            return true;
138        }
139
140        // Check if word is an all-caps acronym (2+ consecutive uppercase)
141        if self.config.preserve_cased_words && self.is_all_caps_acronym(word) {
142            return true;
143        }
144
145        false
146    }
147
148    /// Check if a word is a "lowercase word" (articles, prepositions, etc.)
149    fn is_lowercase_word(&self, word: &str) -> bool {
150        self.lowercase_set.contains(&word.to_lowercase())
151    }
152
153    /// Apply title case to a single word
154    fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
155        if word.is_empty() {
156            return word.to_string();
157        }
158
159        // Preserve words in ignore list or with internal capitals
160        if self.should_preserve_word(word) {
161            return word.to_string();
162        }
163
164        // First and last words are always capitalized
165        if is_first || is_last {
166            return self.capitalize_first(word);
167        }
168
169        // Check if it's a lowercase word (articles, prepositions, etc.)
170        if self.is_lowercase_word(word) {
171            return word.to_lowercase();
172        }
173
174        // Regular word - capitalize first letter
175        self.capitalize_first(word)
176    }
177
178    /// Capitalize the first letter of a word, handling Unicode properly
179    fn capitalize_first(&self, word: &str) -> String {
180        let mut chars = word.chars();
181        match chars.next() {
182            None => String::new(),
183            Some(first) => {
184                let first_upper: String = first.to_uppercase().collect();
185                let rest: String = chars.collect();
186                format!("{}{}", first_upper, rest.to_lowercase())
187            }
188        }
189    }
190
191    /// Apply title case to text (using titlecase crate as base, then our customizations)
192    fn apply_title_case(&self, text: &str) -> String {
193        // Use the titlecase crate for the base transformation
194        let base_result = titlecase::titlecase(text);
195
196        // Get words from both original and transformed text to compare
197        let original_words: Vec<&str> = text.split_whitespace().collect();
198        let transformed_words: Vec<&str> = base_result.split_whitespace().collect();
199        let total_words = transformed_words.len();
200
201        let result_words: Vec<String> = transformed_words
202            .iter()
203            .enumerate()
204            .map(|(i, word)| {
205                let is_first = i == 0;
206                let is_last = i == total_words - 1;
207
208                // Check if the ORIGINAL word should be preserved (for acronyms like "API")
209                if let Some(original_word) = original_words.get(i)
210                    && self.should_preserve_word(original_word)
211                {
212                    return (*original_word).to_string();
213                }
214
215                // Handle hyphenated words
216                if word.contains('-') {
217                    // Also check original for hyphenated preservation
218                    if let Some(original_word) = original_words.get(i) {
219                        return self.handle_hyphenated_word_with_original(word, original_word, is_first, is_last);
220                    }
221                    return self.handle_hyphenated_word(word, is_first, is_last);
222                }
223
224                self.title_case_word(word, is_first, is_last)
225            })
226            .collect();
227
228        result_words.join(" ")
229    }
230
231    /// Handle hyphenated words like "self-documenting"
232    fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
233        let parts: Vec<&str> = word.split('-').collect();
234        let total_parts = parts.len();
235
236        let result_parts: Vec<String> = parts
237            .iter()
238            .enumerate()
239            .map(|(i, part)| {
240                // First part of first word and last part of last word get special treatment
241                let part_is_first = is_first && i == 0;
242                let part_is_last = is_last && i == total_parts - 1;
243                self.title_case_word(part, part_is_first, part_is_last)
244            })
245            .collect();
246
247        result_parts.join("-")
248    }
249
250    /// Handle hyphenated words with original text for acronym preservation
251    fn handle_hyphenated_word_with_original(
252        &self,
253        word: &str,
254        original: &str,
255        is_first: bool,
256        is_last: bool,
257    ) -> String {
258        let parts: Vec<&str> = word.split('-').collect();
259        let original_parts: Vec<&str> = original.split('-').collect();
260        let total_parts = parts.len();
261
262        let result_parts: Vec<String> = parts
263            .iter()
264            .enumerate()
265            .map(|(i, part)| {
266                // Check if the original part should be preserved (for acronyms)
267                if let Some(original_part) = original_parts.get(i)
268                    && self.should_preserve_word(original_part)
269                {
270                    return (*original_part).to_string();
271                }
272
273                // First part of first word and last part of last word get special treatment
274                let part_is_first = is_first && i == 0;
275                let part_is_last = is_last && i == total_parts - 1;
276                self.title_case_word(part, part_is_first, part_is_last)
277            })
278            .collect();
279
280        result_parts.join("-")
281    }
282
283    /// Apply sentence case to text
284    fn apply_sentence_case(&self, text: &str) -> String {
285        if text.is_empty() {
286            return text.to_string();
287        }
288
289        let mut result = String::new();
290        let mut current_pos = 0;
291        let mut is_first_word = true;
292
293        // Use original text positions to preserve whitespace correctly
294        for word in text.split_whitespace() {
295            if let Some(pos) = text[current_pos..].find(word) {
296                let abs_pos = current_pos + pos;
297
298                // Preserve whitespace before this word
299                result.push_str(&text[current_pos..abs_pos]);
300
301                // Process the word
302                if is_first_word {
303                    // Check if word should be preserved BEFORE any capitalization
304                    if self.should_preserve_word(word) {
305                        // Preserve ignore-words exactly as-is, even at start
306                        result.push_str(word);
307                    } else {
308                        // First word: capitalize first letter, lowercase rest
309                        let mut chars = word.chars();
310                        if let Some(first) = chars.next() {
311                            let first_upper: String = first.to_uppercase().collect();
312                            result.push_str(&first_upper);
313                            let rest: String = chars.collect();
314                            result.push_str(&rest.to_lowercase());
315                        }
316                    }
317                    is_first_word = false;
318                } else {
319                    // Non-first words: preserve if needed, otherwise lowercase
320                    if self.should_preserve_word(word) {
321                        result.push_str(word);
322                    } else {
323                        result.push_str(&word.to_lowercase());
324                    }
325                }
326
327                current_pos = abs_pos + word.len();
328            }
329        }
330
331        // Preserve any trailing whitespace
332        if current_pos < text.len() {
333            result.push_str(&text[current_pos..]);
334        }
335
336        result
337    }
338
339    /// Apply all caps to text (preserve whitespace)
340    fn apply_all_caps(&self, text: &str) -> String {
341        if text.is_empty() {
342            return text.to_string();
343        }
344
345        let mut result = String::new();
346        let mut current_pos = 0;
347
348        // Use original text positions to preserve whitespace correctly
349        for word in text.split_whitespace() {
350            if let Some(pos) = text[current_pos..].find(word) {
351                let abs_pos = current_pos + pos;
352
353                // Preserve whitespace before this word
354                result.push_str(&text[current_pos..abs_pos]);
355
356                // Check if this word should be preserved
357                if self.should_preserve_word(word) {
358                    result.push_str(word);
359                } else {
360                    result.push_str(&word.to_uppercase());
361                }
362
363                current_pos = abs_pos + word.len();
364            }
365        }
366
367        // Preserve any trailing whitespace
368        if current_pos < text.len() {
369            result.push_str(&text[current_pos..]);
370        }
371
372        result
373    }
374
375    /// Parse heading text into segments
376    fn parse_segments(&self, text: &str) -> Vec<HeadingSegment> {
377        let mut segments = Vec::new();
378        let mut last_end = 0;
379
380        // Collect all special regions (code and links)
381        let mut special_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
382
383        // Find inline code spans
384        for mat in INLINE_CODE_REGEX.find_iter(text) {
385            special_regions.push((mat.start(), mat.end(), HeadingSegment::Code(mat.as_str().to_string())));
386        }
387
388        // Find links
389        for caps in LINK_REGEX.captures_iter(text) {
390            let full_match = caps.get(0).unwrap();
391            let text_match = caps.get(1).or_else(|| caps.get(2));
392
393            if let Some(text_m) = text_match {
394                special_regions.push((
395                    full_match.start(),
396                    full_match.end(),
397                    HeadingSegment::Link {
398                        full: full_match.as_str().to_string(),
399                        text_start: text_m.start() - full_match.start(),
400                        text_end: text_m.end() - full_match.start(),
401                    },
402                ));
403            }
404        }
405
406        // Sort by start position
407        special_regions.sort_by_key(|(start, _, _)| *start);
408
409        // Remove overlapping regions (code takes precedence)
410        let mut filtered_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
411        for region in special_regions {
412            let overlaps = filtered_regions.iter().any(|(s, e, _)| region.0 < *e && region.1 > *s);
413            if !overlaps {
414                filtered_regions.push(region);
415            }
416        }
417
418        // Build segments
419        for (start, end, segment) in filtered_regions {
420            // Add text before this special region
421            if start > last_end {
422                let text_segment = &text[last_end..start];
423                if !text_segment.is_empty() {
424                    segments.push(HeadingSegment::Text(text_segment.to_string()));
425                }
426            }
427            segments.push(segment);
428            last_end = end;
429        }
430
431        // Add remaining text
432        if last_end < text.len() {
433            let remaining = &text[last_end..];
434            if !remaining.is_empty() {
435                segments.push(HeadingSegment::Text(remaining.to_string()));
436            }
437        }
438
439        // If no segments were found, treat the whole thing as text
440        if segments.is_empty() && !text.is_empty() {
441            segments.push(HeadingSegment::Text(text.to_string()));
442        }
443
444        segments
445    }
446
447    /// Apply capitalization to heading text
448    fn apply_capitalization(&self, text: &str) -> String {
449        // Strip custom ID if present and re-add later
450        let (main_text, custom_id) = if let Some(mat) = CUSTOM_ID_REGEX.find(text) {
451            (&text[..mat.start()], Some(mat.as_str()))
452        } else {
453            (text, None)
454        };
455
456        // Parse into segments
457        let segments = self.parse_segments(main_text);
458
459        // Count text segments to determine first/last word context
460        let text_segments: Vec<usize> = segments
461            .iter()
462            .enumerate()
463            .filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
464            .collect();
465
466        // Determine if the last segment overall is a text segment
467        // If the last segment is Code or Link, then the last text segment should NOT
468        // treat its last word as the heading's last word (for lowercase-words respect)
469        let last_segment_is_text = segments
470            .last()
471            .map(|s| matches!(s, HeadingSegment::Text(_)))
472            .unwrap_or(false);
473
474        // Apply capitalization to each segment
475        let mut result_parts: Vec<String> = Vec::new();
476
477        for (i, segment) in segments.iter().enumerate() {
478            match segment {
479                HeadingSegment::Text(t) => {
480                    let is_first_text = text_segments.first() == Some(&i);
481                    // A text segment is "last" only if it's the last text segment AND
482                    // the last segment overall is also text. If there's Code/Link after,
483                    // the last word should respect lowercase-words.
484                    let is_last_text = text_segments.last() == Some(&i) && last_segment_is_text;
485
486                    let capitalized = match self.config.style {
487                        HeadingCapStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
488                        HeadingCapStyle::SentenceCase => {
489                            if is_first_text {
490                                self.apply_sentence_case(t)
491                            } else {
492                                // For non-first segments in sentence case, lowercase
493                                self.apply_sentence_case_non_first(t)
494                            }
495                        }
496                        HeadingCapStyle::AllCaps => self.apply_all_caps(t),
497                    };
498                    result_parts.push(capitalized);
499                }
500                HeadingSegment::Code(c) => {
501                    result_parts.push(c.clone());
502                }
503                HeadingSegment::Link {
504                    full,
505                    text_start,
506                    text_end,
507                } => {
508                    // Apply capitalization to link text only
509                    let link_text = &full[*text_start..*text_end];
510                    let capitalized_text = match self.config.style {
511                        HeadingCapStyle::TitleCase => self.apply_title_case(link_text),
512                        HeadingCapStyle::SentenceCase => link_text.to_lowercase(),
513                        HeadingCapStyle::AllCaps => self.apply_all_caps(link_text),
514                    };
515
516                    let mut new_link = String::new();
517                    new_link.push_str(&full[..*text_start]);
518                    new_link.push_str(&capitalized_text);
519                    new_link.push_str(&full[*text_end..]);
520                    result_parts.push(new_link);
521                }
522            }
523        }
524
525        let mut result = result_parts.join("");
526
527        // Re-add custom ID if present
528        if let Some(id) = custom_id {
529            result.push_str(id);
530        }
531
532        result
533    }
534
535    /// Apply title case to a text segment with first/last awareness
536    fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
537        let words: Vec<&str> = text.split_whitespace().collect();
538        let total_words = words.len();
539
540        if total_words == 0 {
541            return text.to_string();
542        }
543
544        let result_words: Vec<String> = words
545            .iter()
546            .enumerate()
547            .map(|(i, word)| {
548                let is_first = is_first_segment && i == 0;
549                let is_last = is_last_segment && i == total_words - 1;
550
551                // Handle hyphenated words
552                if word.contains('-') {
553                    return self.handle_hyphenated_word(word, is_first, is_last);
554                }
555
556                self.title_case_word(word, is_first, is_last)
557            })
558            .collect();
559
560        // Preserve original spacing
561        let mut result = String::new();
562        let mut word_iter = result_words.iter();
563        let mut in_word = false;
564
565        for c in text.chars() {
566            if c.is_whitespace() {
567                if in_word {
568                    in_word = false;
569                }
570                result.push(c);
571            } else if !in_word {
572                if let Some(word) = word_iter.next() {
573                    result.push_str(word);
574                }
575                in_word = true;
576            }
577        }
578
579        result
580    }
581
582    /// Apply sentence case to non-first segments (just lowercase, preserve whitespace)
583    fn apply_sentence_case_non_first(&self, text: &str) -> String {
584        if text.is_empty() {
585            return text.to_string();
586        }
587
588        let lower = text.to_lowercase();
589        let mut result = String::new();
590        let mut current_pos = 0;
591
592        for word in lower.split_whitespace() {
593            if let Some(pos) = lower[current_pos..].find(word) {
594                let abs_pos = current_pos + pos;
595
596                // Preserve whitespace before this word
597                result.push_str(&lower[current_pos..abs_pos]);
598
599                // Check if this word should be preserved
600                let original_word = &text[abs_pos..abs_pos + word.len()];
601                if self.should_preserve_word(original_word) {
602                    result.push_str(original_word);
603                } else {
604                    result.push_str(word);
605                }
606
607                current_pos = abs_pos + word.len();
608            }
609        }
610
611        // Preserve any trailing whitespace
612        if current_pos < lower.len() {
613            result.push_str(&lower[current_pos..]);
614        }
615
616        result
617    }
618
619    /// Get byte range for a line
620    fn get_line_byte_range(&self, content: &str, line_num: usize, line_index: &LineIndex) -> Range<usize> {
621        let start_pos = line_index.get_line_start_byte(line_num).unwrap_or(content.len());
622        let line = content.lines().nth(line_num - 1).unwrap_or("");
623        Range {
624            start: start_pos,
625            end: start_pos + line.len(),
626        }
627    }
628
629    /// Fix an ATX heading line
630    fn fix_atx_heading(&self, _line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
631        // Parse the line to preserve structure
632        let indent = " ".repeat(heading.marker_column);
633        let hashes = "#".repeat(heading.level as usize);
634
635        // Apply capitalization to the text
636        let fixed_text = self.apply_capitalization(&heading.raw_text);
637
638        // Reconstruct with closing sequence if present
639        let closing = &heading.closing_sequence;
640        if heading.has_closing_sequence {
641            format!("{indent}{hashes} {fixed_text} {closing}")
642        } else {
643            format!("{indent}{hashes} {fixed_text}")
644        }
645    }
646
647    /// Fix a Setext heading line
648    fn fix_setext_heading(&self, line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
649        // Apply capitalization to the text
650        let fixed_text = self.apply_capitalization(&heading.raw_text);
651
652        // Preserve leading whitespace from original line
653        let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
654
655        format!("{leading_ws}{fixed_text}")
656    }
657}
658
659impl Rule for MD063HeadingCapitalization {
660    fn name(&self) -> &'static str {
661        "MD063"
662    }
663
664    fn description(&self) -> &'static str {
665        "Heading capitalization"
666    }
667
668    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
669        // Skip if rule is disabled or no headings
670        !self.config.enabled || !ctx.likely_has_headings() || !ctx.lines.iter().any(|line| line.heading.is_some())
671    }
672
673    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
674        if !self.config.enabled {
675            return Ok(Vec::new());
676        }
677
678        let content = ctx.content;
679
680        if content.is_empty() {
681            return Ok(Vec::new());
682        }
683
684        let mut warnings = Vec::new();
685        let line_index = &ctx.line_index;
686
687        for (line_num, line_info) in ctx.lines.iter().enumerate() {
688            if let Some(heading) = &line_info.heading {
689                // Check level filter
690                if heading.level < self.config.min_level || heading.level > self.config.max_level {
691                    continue;
692                }
693
694                // Skip headings in code blocks (indented headings)
695                if line_info.indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
696                    continue;
697                }
698
699                // Apply capitalization and compare
700                let original_text = &heading.raw_text;
701                let fixed_text = self.apply_capitalization(original_text);
702
703                if original_text != &fixed_text {
704                    let line = line_info.content(ctx.content);
705                    let style_name = match self.config.style {
706                        HeadingCapStyle::TitleCase => "title case",
707                        HeadingCapStyle::SentenceCase => "sentence case",
708                        HeadingCapStyle::AllCaps => "ALL CAPS",
709                    };
710
711                    warnings.push(LintWarning {
712                        rule_name: Some(self.name().to_string()),
713                        line: line_num + 1,
714                        column: heading.content_column + 1,
715                        end_line: line_num + 1,
716                        end_column: heading.content_column + 1 + original_text.len(),
717                        message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
718                        severity: Severity::Warning,
719                        fix: Some(Fix {
720                            range: self.get_line_byte_range(content, line_num + 1, line_index),
721                            replacement: match heading.style {
722                                crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
723                                _ => self.fix_setext_heading(line, heading),
724                            },
725                        }),
726                    });
727                }
728            }
729        }
730
731        Ok(warnings)
732    }
733
734    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
735        if !self.config.enabled {
736            return Ok(ctx.content.to_string());
737        }
738
739        let content = ctx.content;
740
741        if content.is_empty() {
742            return Ok(content.to_string());
743        }
744
745        let lines: Vec<&str> = content.lines().collect();
746        let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
747
748        for (line_num, line_info) in ctx.lines.iter().enumerate() {
749            if let Some(heading) = &line_info.heading {
750                // Check level filter
751                if heading.level < self.config.min_level || heading.level > self.config.max_level {
752                    continue;
753                }
754
755                // Skip headings in code blocks
756                if line_info.indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
757                    continue;
758                }
759
760                let original_text = &heading.raw_text;
761                let fixed_text = self.apply_capitalization(original_text);
762
763                if original_text != &fixed_text {
764                    let line = line_info.content(ctx.content);
765                    fixed_lines[line_num] = match heading.style {
766                        crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
767                        _ => self.fix_setext_heading(line, heading),
768                    };
769                }
770            }
771        }
772
773        // Reconstruct content preserving line endings
774        let mut result = String::with_capacity(content.len());
775        for (i, line) in fixed_lines.iter().enumerate() {
776            result.push_str(line);
777            if i < fixed_lines.len() - 1 || content.ends_with('\n') {
778                result.push('\n');
779            }
780        }
781
782        Ok(result)
783    }
784
785    fn as_any(&self) -> &dyn std::any::Any {
786        self
787    }
788
789    fn default_config_section(&self) -> Option<(String, toml::Value)> {
790        let json_value = serde_json::to_value(&self.config).ok()?;
791        Some((
792            self.name().to_string(),
793            crate::rule_config_serde::json_to_toml_value(&json_value)?,
794        ))
795    }
796
797    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
798    where
799        Self: Sized,
800    {
801        let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
802        Box::new(Self::from_config_struct(rule_config))
803    }
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809    use crate::lint_context::LintContext;
810
811    fn create_rule() -> MD063HeadingCapitalization {
812        let config = MD063Config {
813            enabled: true,
814            ..Default::default()
815        };
816        MD063HeadingCapitalization::from_config_struct(config)
817    }
818
819    fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
820        let config = MD063Config {
821            enabled: true,
822            style,
823            ..Default::default()
824        };
825        MD063HeadingCapitalization::from_config_struct(config)
826    }
827
828    // Title case tests
829    #[test]
830    fn test_title_case_basic() {
831        let rule = create_rule();
832        let content = "# hello world\n";
833        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
834        let result = rule.check(&ctx).unwrap();
835        assert_eq!(result.len(), 1);
836        assert!(result[0].message.contains("Hello World"));
837    }
838
839    #[test]
840    fn test_title_case_lowercase_words() {
841        let rule = create_rule();
842        let content = "# the quick brown fox\n";
843        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
844        let result = rule.check(&ctx).unwrap();
845        assert_eq!(result.len(), 1);
846        // "The" should be capitalized (first word), "quick", "brown", "fox" should be capitalized
847        assert!(result[0].message.contains("The Quick Brown Fox"));
848    }
849
850    #[test]
851    fn test_title_case_already_correct() {
852        let rule = create_rule();
853        let content = "# The Quick Brown Fox\n";
854        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
855        let result = rule.check(&ctx).unwrap();
856        assert!(result.is_empty(), "Already correct heading should not be flagged");
857    }
858
859    #[test]
860    fn test_title_case_hyphenated() {
861        let rule = create_rule();
862        let content = "# self-documenting code\n";
863        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864        let result = rule.check(&ctx).unwrap();
865        assert_eq!(result.len(), 1);
866        assert!(result[0].message.contains("Self-Documenting Code"));
867    }
868
869    // Sentence case tests
870    #[test]
871    fn test_sentence_case_basic() {
872        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
873        let content = "# The Quick Brown Fox\n";
874        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875        let result = rule.check(&ctx).unwrap();
876        assert_eq!(result.len(), 1);
877        assert!(result[0].message.contains("The quick brown fox"));
878    }
879
880    #[test]
881    fn test_sentence_case_already_correct() {
882        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
883        let content = "# The quick brown fox\n";
884        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
885        let result = rule.check(&ctx).unwrap();
886        assert!(result.is_empty());
887    }
888
889    // All caps tests
890    #[test]
891    fn test_all_caps_basic() {
892        let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
893        let content = "# hello world\n";
894        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
895        let result = rule.check(&ctx).unwrap();
896        assert_eq!(result.len(), 1);
897        assert!(result[0].message.contains("HELLO WORLD"));
898    }
899
900    // Preserve tests
901    #[test]
902    fn test_preserve_ignore_words() {
903        let config = MD063Config {
904            enabled: true,
905            ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
906            ..Default::default()
907        };
908        let rule = MD063HeadingCapitalization::from_config_struct(config);
909
910        let content = "# using iPhone on macOS\n";
911        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
912        let result = rule.check(&ctx).unwrap();
913        assert_eq!(result.len(), 1);
914        // iPhone and macOS should be preserved
915        assert!(result[0].message.contains("iPhone"));
916        assert!(result[0].message.contains("macOS"));
917    }
918
919    #[test]
920    fn test_preserve_cased_words() {
921        let rule = create_rule();
922        let content = "# using GitHub actions\n";
923        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
924        let result = rule.check(&ctx).unwrap();
925        assert_eq!(result.len(), 1);
926        // GitHub should be preserved (has internal capital)
927        assert!(result[0].message.contains("GitHub"));
928    }
929
930    // Inline code tests
931    #[test]
932    fn test_inline_code_preserved() {
933        let rule = create_rule();
934        let content = "# using `const` in javascript\n";
935        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
936        let result = rule.check(&ctx).unwrap();
937        assert_eq!(result.len(), 1);
938        // `const` should be preserved, rest capitalized
939        assert!(result[0].message.contains("`const`"));
940        assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
941    }
942
943    // Level filter tests
944    #[test]
945    fn test_level_filter() {
946        let config = MD063Config {
947            enabled: true,
948            min_level: 2,
949            max_level: 4,
950            ..Default::default()
951        };
952        let rule = MD063HeadingCapitalization::from_config_struct(config);
953
954        let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
955        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956        let result = rule.check(&ctx).unwrap();
957
958        // Only h2 and h3 should be flagged (h1 < min_level, h5 > max_level)
959        assert_eq!(result.len(), 2);
960        assert_eq!(result[0].line, 2); // h2
961        assert_eq!(result[1].line, 3); // h3
962    }
963
964    // Fix tests
965    #[test]
966    fn test_fix_atx_heading() {
967        let rule = create_rule();
968        let content = "# hello world\n";
969        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
970        let fixed = rule.fix(&ctx).unwrap();
971        assert_eq!(fixed, "# Hello World\n");
972    }
973
974    #[test]
975    fn test_fix_multiple_headings() {
976        let rule = create_rule();
977        let content = "# first heading\n\n## second heading\n";
978        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
979        let fixed = rule.fix(&ctx).unwrap();
980        assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
981    }
982
983    // Setext heading tests
984    #[test]
985    fn test_setext_heading() {
986        let rule = create_rule();
987        let content = "hello world\n============\n";
988        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
989        let result = rule.check(&ctx).unwrap();
990        assert_eq!(result.len(), 1);
991        assert!(result[0].message.contains("Hello World"));
992    }
993
994    // Custom ID tests
995    #[test]
996    fn test_custom_id_preserved() {
997        let rule = create_rule();
998        let content = "# getting started {#intro}\n";
999        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1000        let result = rule.check(&ctx).unwrap();
1001        assert_eq!(result.len(), 1);
1002        // Custom ID should be preserved
1003        assert!(result[0].message.contains("{#intro}"));
1004    }
1005
1006    #[test]
1007    fn test_md063_disabled_by_default() {
1008        let rule = MD063HeadingCapitalization::new();
1009        let content = "# hello world\n";
1010        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1011
1012        // Should return no warnings when disabled
1013        let warnings = rule.check(&ctx).unwrap();
1014        assert_eq!(warnings.len(), 0);
1015
1016        // Should return content unchanged when disabled
1017        let fixed = rule.fix(&ctx).unwrap();
1018        assert_eq!(fixed, content);
1019    }
1020
1021    // Acronym preservation tests
1022    #[test]
1023    fn test_preserve_all_caps_acronyms() {
1024        let rule = create_rule();
1025        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1026
1027        // Basic acronyms should be preserved
1028        let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
1029        assert_eq!(fixed, "# Using API in Production\n");
1030
1031        // Multiple acronyms
1032        let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
1033        assert_eq!(fixed, "# API and GPU Integration\n");
1034
1035        // Two-letter acronyms
1036        let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
1037        assert_eq!(fixed, "# IO Performance Guide\n");
1038
1039        // Acronyms with numbers
1040        let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
1041        assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
1042    }
1043
1044    #[test]
1045    fn test_preserve_acronyms_in_hyphenated_words() {
1046        let rule = create_rule();
1047        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1048
1049        // Acronyms at start of hyphenated word
1050        let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
1051        assert_eq!(fixed, "# API-Driven Architecture\n");
1052
1053        // Multiple acronyms with hyphens
1054        let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
1055        assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
1056    }
1057
1058    #[test]
1059    fn test_single_letters_not_treated_as_acronyms() {
1060        let rule = create_rule();
1061        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1062
1063        // Single uppercase letters should follow title case rules, not be preserved
1064        let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
1065        assert_eq!(fixed, "# I Am a Heading\n");
1066    }
1067
1068    #[test]
1069    fn test_lowercase_terms_need_ignore_words() {
1070        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1071
1072        // Without ignore_words: npm gets capitalized
1073        let rule = create_rule();
1074        let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1075        assert_eq!(fixed, "# Using Npm Packages\n");
1076
1077        // With ignore_words: npm preserved
1078        let config = MD063Config {
1079            enabled: true,
1080            ignore_words: vec!["npm".to_string()],
1081            ..Default::default()
1082        };
1083        let rule = MD063HeadingCapitalization::from_config_struct(config);
1084        let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1085        assert_eq!(fixed, "# Using npm Packages\n");
1086    }
1087
1088    #[test]
1089    fn test_acronyms_with_mixed_case_preserved() {
1090        let rule = create_rule();
1091        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1092
1093        // Both acronyms (API, GPU) and mixed-case (GitHub) should be preserved
1094        let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
1095        assert_eq!(fixed, "# Using API with GitHub\n");
1096    }
1097
1098    #[test]
1099    fn test_real_world_acronyms() {
1100        let rule = create_rule();
1101        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1102
1103        // Common technical acronyms from tested repositories
1104        let content = "# FFI bindings for CPU optimization\n";
1105        let fixed = rule.fix(&ctx(content)).unwrap();
1106        assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
1107
1108        let content = "# DOM manipulation and SSR rendering\n";
1109        let fixed = rule.fix(&ctx(content)).unwrap();
1110        assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
1111
1112        let content = "# CVE security and RNN models\n";
1113        let fixed = rule.fix(&ctx(content)).unwrap();
1114        assert_eq!(fixed, "# CVE Security and RNN Models\n");
1115    }
1116
1117    #[test]
1118    fn test_is_all_caps_acronym() {
1119        let rule = create_rule();
1120
1121        // Should return true for all-caps with 2+ letters
1122        assert!(rule.is_all_caps_acronym("API"));
1123        assert!(rule.is_all_caps_acronym("IO"));
1124        assert!(rule.is_all_caps_acronym("GPU"));
1125        assert!(rule.is_all_caps_acronym("HTTP2")); // Numbers don't break it
1126
1127        // Should return false for single letters
1128        assert!(!rule.is_all_caps_acronym("A"));
1129        assert!(!rule.is_all_caps_acronym("I"));
1130
1131        // Should return false for words with lowercase
1132        assert!(!rule.is_all_caps_acronym("Api"));
1133        assert!(!rule.is_all_caps_acronym("npm"));
1134        assert!(!rule.is_all_caps_acronym("iPhone"));
1135    }
1136
1137    #[test]
1138    fn test_sentence_case_ignore_words_first_word() {
1139        let config = MD063Config {
1140            enabled: true,
1141            style: HeadingCapStyle::SentenceCase,
1142            ignore_words: vec!["nvim".to_string()],
1143            ..Default::default()
1144        };
1145        let rule = MD063HeadingCapitalization::from_config_struct(config);
1146
1147        // "nvim" as first word should be preserved exactly
1148        let content = "# nvim config\n";
1149        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1150        let result = rule.check(&ctx).unwrap();
1151        assert!(
1152            result.is_empty(),
1153            "nvim in ignore-words should not be flagged. Got: {result:?}"
1154        );
1155
1156        // Verify fix also preserves it
1157        let fixed = rule.fix(&ctx).unwrap();
1158        assert_eq!(fixed, "# nvim config\n");
1159    }
1160
1161    #[test]
1162    fn test_sentence_case_ignore_words_not_first() {
1163        let config = MD063Config {
1164            enabled: true,
1165            style: HeadingCapStyle::SentenceCase,
1166            ignore_words: vec!["nvim".to_string()],
1167            ..Default::default()
1168        };
1169        let rule = MD063HeadingCapitalization::from_config_struct(config);
1170
1171        // "nvim" in middle should also be preserved
1172        let content = "# Using nvim editor\n";
1173        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1174        let result = rule.check(&ctx).unwrap();
1175        assert!(
1176            result.is_empty(),
1177            "nvim in ignore-words should be preserved. Got: {result:?}"
1178        );
1179    }
1180
1181    #[test]
1182    fn test_preserve_cased_words_ios() {
1183        let config = MD063Config {
1184            enabled: true,
1185            style: HeadingCapStyle::SentenceCase,
1186            preserve_cased_words: true,
1187            ..Default::default()
1188        };
1189        let rule = MD063HeadingCapitalization::from_config_struct(config);
1190
1191        // "iOS" should be preserved (has mixed case: lowercase 'i' + uppercase 'OS')
1192        let content = "## This is iOS\n";
1193        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1194        let result = rule.check(&ctx).unwrap();
1195        assert!(
1196            result.is_empty(),
1197            "iOS should be preserved with preserve-cased-words. Got: {result:?}"
1198        );
1199
1200        // Verify fix also preserves it
1201        let fixed = rule.fix(&ctx).unwrap();
1202        assert_eq!(fixed, "## This is iOS\n");
1203    }
1204
1205    #[test]
1206    fn test_preserve_cased_words_ios_title_case() {
1207        let config = MD063Config {
1208            enabled: true,
1209            style: HeadingCapStyle::TitleCase,
1210            preserve_cased_words: true,
1211            ..Default::default()
1212        };
1213        let rule = MD063HeadingCapitalization::from_config_struct(config);
1214
1215        // "iOS" should be preserved in title case too
1216        let content = "# developing for iOS\n";
1217        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1218        let fixed = rule.fix(&ctx).unwrap();
1219        assert_eq!(fixed, "# Developing for iOS\n");
1220    }
1221
1222    #[test]
1223    fn test_has_internal_capitals_ios() {
1224        let rule = create_rule();
1225
1226        // iOS should be detected as having internal capitals
1227        assert!(
1228            rule.has_internal_capitals("iOS"),
1229            "iOS has mixed case (lowercase i, uppercase OS)"
1230        );
1231
1232        // Other mixed-case words
1233        assert!(rule.has_internal_capitals("iPhone"));
1234        assert!(rule.has_internal_capitals("macOS"));
1235        assert!(rule.has_internal_capitals("GitHub"));
1236        assert!(rule.has_internal_capitals("JavaScript"));
1237        assert!(rule.has_internal_capitals("eBay"));
1238
1239        // All-caps should NOT be detected (handled by is_all_caps_acronym)
1240        assert!(!rule.has_internal_capitals("API"));
1241        assert!(!rule.has_internal_capitals("GPU"));
1242
1243        // All-lowercase should NOT be detected
1244        assert!(!rule.has_internal_capitals("npm"));
1245        assert!(!rule.has_internal_capitals("config"));
1246
1247        // Regular capitalized words should NOT be detected
1248        assert!(!rule.has_internal_capitals("The"));
1249        assert!(!rule.has_internal_capitals("Hello"));
1250    }
1251
1252    #[test]
1253    fn test_lowercase_words_before_trailing_code() {
1254        let config = MD063Config {
1255            enabled: true,
1256            style: HeadingCapStyle::TitleCase,
1257            lowercase_words: vec![
1258                "a".to_string(),
1259                "an".to_string(),
1260                "and".to_string(),
1261                "at".to_string(),
1262                "but".to_string(),
1263                "by".to_string(),
1264                "for".to_string(),
1265                "from".to_string(),
1266                "into".to_string(),
1267                "nor".to_string(),
1268                "on".to_string(),
1269                "onto".to_string(),
1270                "or".to_string(),
1271                "the".to_string(),
1272                "to".to_string(),
1273                "upon".to_string(),
1274                "via".to_string(),
1275                "vs".to_string(),
1276                "with".to_string(),
1277                "without".to_string(),
1278            ],
1279            preserve_cased_words: true,
1280            ..Default::default()
1281        };
1282        let rule = MD063HeadingCapitalization::from_config_struct(config);
1283
1284        // Test: "subtitle with a `app`" (all lowercase input)
1285        // Expected fix: "Subtitle With a `app`" - capitalize "Subtitle" and "With",
1286        // but keep "a" lowercase (it's in lowercase-words and not the last word)
1287        // Incorrect: "Subtitle with A `app`" (would incorrectly capitalize "a")
1288        let content = "## subtitle with a `app`\n";
1289        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1290        let result = rule.check(&ctx).unwrap();
1291
1292        // Should flag it
1293        assert!(!result.is_empty(), "Should flag incorrect capitalization");
1294        let fixed = rule.fix(&ctx).unwrap();
1295        // "a" should remain lowercase (not "A") because inline code at end doesn't change lowercase-words behavior
1296        assert!(
1297            fixed.contains("with a `app`"),
1298            "Expected 'with a `app`' but got: {fixed:?}"
1299        );
1300        assert!(
1301            !fixed.contains("with A `app`"),
1302            "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
1303        );
1304        // "Subtitle" should be capitalized, "with" and "a" should remain lowercase (they're in lowercase-words)
1305        assert!(
1306            fixed.contains("Subtitle with a `app`"),
1307            "Expected 'Subtitle with a `app`' but got: {fixed:?}"
1308        );
1309    }
1310
1311    #[test]
1312    fn test_lowercase_words_preserved_before_trailing_code_variant() {
1313        let config = MD063Config {
1314            enabled: true,
1315            style: HeadingCapStyle::TitleCase,
1316            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1317            ..Default::default()
1318        };
1319        let rule = MD063HeadingCapitalization::from_config_struct(config);
1320
1321        // Another variant: "Title with the `code`"
1322        let content = "## Title with the `code`\n";
1323        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1324        let fixed = rule.fix(&ctx).unwrap();
1325        // "the" should remain lowercase
1326        assert!(
1327            fixed.contains("with the `code`"),
1328            "Expected 'with the `code`' but got: {fixed:?}"
1329        );
1330        assert!(
1331            !fixed.contains("with The `code`"),
1332            "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
1333        );
1334    }
1335
1336    #[test]
1337    fn test_last_word_capitalized_when_no_trailing_code() {
1338        // Verify that when there's NO trailing code, the last word IS capitalized
1339        // (even if it's in lowercase-words) - this is the normal title case behavior
1340        let config = MD063Config {
1341            enabled: true,
1342            style: HeadingCapStyle::TitleCase,
1343            lowercase_words: vec!["a".to_string(), "the".to_string()],
1344            ..Default::default()
1345        };
1346        let rule = MD063HeadingCapitalization::from_config_struct(config);
1347
1348        // "title with a word" - "word" is last, should be capitalized
1349        // "a" is in lowercase-words and not last, so should be lowercase
1350        let content = "## title with a word\n";
1351        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1352        let fixed = rule.fix(&ctx).unwrap();
1353        // "a" should be lowercase, "word" should be capitalized (it's last)
1354        assert!(
1355            fixed.contains("With a Word"),
1356            "Expected 'With a Word' but got: {fixed:?}"
1357        );
1358    }
1359
1360    #[test]
1361    fn test_multiple_lowercase_words_before_code() {
1362        let config = MD063Config {
1363            enabled: true,
1364            style: HeadingCapStyle::TitleCase,
1365            lowercase_words: vec![
1366                "a".to_string(),
1367                "the".to_string(),
1368                "with".to_string(),
1369                "for".to_string(),
1370            ],
1371            ..Default::default()
1372        };
1373        let rule = MD063HeadingCapitalization::from_config_struct(config);
1374
1375        // Multiple lowercase words before code - all should remain lowercase
1376        let content = "## Guide for the `user`\n";
1377        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1378        let fixed = rule.fix(&ctx).unwrap();
1379        assert!(
1380            fixed.contains("for the `user`"),
1381            "Expected 'for the `user`' but got: {fixed:?}"
1382        );
1383        assert!(
1384            !fixed.contains("For The `user`"),
1385            "Should not capitalize lowercase words before code. Got: {fixed:?}"
1386        );
1387    }
1388
1389    #[test]
1390    fn test_code_in_middle_normal_rules_apply() {
1391        let config = MD063Config {
1392            enabled: true,
1393            style: HeadingCapStyle::TitleCase,
1394            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1395            ..Default::default()
1396        };
1397        let rule = MD063HeadingCapitalization::from_config_struct(config);
1398
1399        // Code in the middle - normal title case rules apply (last word capitalized)
1400        let content = "## Using `const` for the code\n";
1401        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1402        let fixed = rule.fix(&ctx).unwrap();
1403        // "for" and "the" should be lowercase (middle), "code" should be capitalized (last)
1404        assert!(
1405            fixed.contains("for the Code"),
1406            "Expected 'for the Code' but got: {fixed:?}"
1407        );
1408    }
1409
1410    #[test]
1411    fn test_link_at_end_same_as_code() {
1412        let config = MD063Config {
1413            enabled: true,
1414            style: HeadingCapStyle::TitleCase,
1415            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1416            ..Default::default()
1417        };
1418        let rule = MD063HeadingCapitalization::from_config_struct(config);
1419
1420        // Link at the end - same behavior as code (lowercase words before should remain lowercase)
1421        let content = "## Guide for the [link](./page.md)\n";
1422        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1423        let fixed = rule.fix(&ctx).unwrap();
1424        // "for" and "the" should remain lowercase (not last word because link follows)
1425        assert!(
1426            fixed.contains("for the [Link]"),
1427            "Expected 'for the [Link]' but got: {fixed:?}"
1428        );
1429        assert!(
1430            !fixed.contains("for The [Link]"),
1431            "Should not capitalize 'the' before link. Got: {fixed:?}"
1432        );
1433    }
1434
1435    #[test]
1436    fn test_multiple_code_segments() {
1437        let config = MD063Config {
1438            enabled: true,
1439            style: HeadingCapStyle::TitleCase,
1440            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1441            ..Default::default()
1442        };
1443        let rule = MD063HeadingCapitalization::from_config_struct(config);
1444
1445        // Multiple code segments - last segment is code, so lowercase words before should remain lowercase
1446        let content = "## Using `const` with a `variable`\n";
1447        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1448        let fixed = rule.fix(&ctx).unwrap();
1449        // "a" should remain lowercase (not last word because code follows)
1450        assert!(
1451            fixed.contains("with a `variable`"),
1452            "Expected 'with a `variable`' but got: {fixed:?}"
1453        );
1454        assert!(
1455            !fixed.contains("with A `variable`"),
1456            "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1457        );
1458    }
1459
1460    #[test]
1461    fn test_code_and_link_combination() {
1462        let config = MD063Config {
1463            enabled: true,
1464            style: HeadingCapStyle::TitleCase,
1465            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1466            ..Default::default()
1467        };
1468        let rule = MD063HeadingCapitalization::from_config_struct(config);
1469
1470        // Code then link - last segment is link, so lowercase words before code should remain lowercase
1471        let content = "## Guide for the `code` [link](./page.md)\n";
1472        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1473        let fixed = rule.fix(&ctx).unwrap();
1474        // "for" and "the" should remain lowercase (not last word because link follows)
1475        assert!(
1476            fixed.contains("for the `code`"),
1477            "Expected 'for the `code`' but got: {fixed:?}"
1478        );
1479    }
1480
1481    #[test]
1482    fn test_text_after_code_capitalizes_last() {
1483        let config = MD063Config {
1484            enabled: true,
1485            style: HeadingCapStyle::TitleCase,
1486            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1487            ..Default::default()
1488        };
1489        let rule = MD063HeadingCapitalization::from_config_struct(config);
1490
1491        // Code in middle, text after - last word should be capitalized
1492        let content = "## Using `const` for the code\n";
1493        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1494        let fixed = rule.fix(&ctx).unwrap();
1495        // "for" and "the" should be lowercase, "code" is last word, should be capitalized
1496        assert!(
1497            fixed.contains("for the Code"),
1498            "Expected 'for the Code' but got: {fixed:?}"
1499        );
1500    }
1501
1502    #[test]
1503    fn test_preserve_cased_words_with_trailing_code() {
1504        let config = MD063Config {
1505            enabled: true,
1506            style: HeadingCapStyle::TitleCase,
1507            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1508            preserve_cased_words: true,
1509            ..Default::default()
1510        };
1511        let rule = MD063HeadingCapitalization::from_config_struct(config);
1512
1513        // Preserve-cased words should still work with trailing code
1514        let content = "## Guide for iOS `app`\n";
1515        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1516        let fixed = rule.fix(&ctx).unwrap();
1517        // "iOS" should be preserved, "for" should be lowercase
1518        assert!(
1519            fixed.contains("for iOS `app`"),
1520            "Expected 'for iOS `app`' but got: {fixed:?}"
1521        );
1522        assert!(
1523            !fixed.contains("For iOS `app`"),
1524            "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
1525        );
1526    }
1527
1528    #[test]
1529    fn test_ignore_words_with_trailing_code() {
1530        let config = MD063Config {
1531            enabled: true,
1532            style: HeadingCapStyle::TitleCase,
1533            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1534            ignore_words: vec!["npm".to_string()],
1535            ..Default::default()
1536        };
1537        let rule = MD063HeadingCapitalization::from_config_struct(config);
1538
1539        // Ignore-words should still work with trailing code
1540        let content = "## Using npm with a `script`\n";
1541        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1542        let fixed = rule.fix(&ctx).unwrap();
1543        // "npm" should be preserved, "with" and "a" should be lowercase
1544        assert!(
1545            fixed.contains("npm with a `script`"),
1546            "Expected 'npm with a `script`' but got: {fixed:?}"
1547        );
1548        assert!(
1549            !fixed.contains("with A `script`"),
1550            "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1551        );
1552    }
1553
1554    #[test]
1555    fn test_empty_text_segment_edge_case() {
1556        let config = MD063Config {
1557            enabled: true,
1558            style: HeadingCapStyle::TitleCase,
1559            lowercase_words: vec!["a".to_string(), "with".to_string()],
1560            ..Default::default()
1561        };
1562        let rule = MD063HeadingCapitalization::from_config_struct(config);
1563
1564        // Edge case: code at start, then text with lowercase word, then code at end
1565        let content = "## `start` with a `end`\n";
1566        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1567        let fixed = rule.fix(&ctx).unwrap();
1568        // "with" is first word in text segment, so capitalized (correct)
1569        // "a" should remain lowercase (not last word because code follows) - this is the key test
1570        assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
1571        assert!(
1572            !fixed.contains("A `end`"),
1573            "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1574        );
1575    }
1576
1577    #[test]
1578    fn test_sentence_case_with_trailing_code() {
1579        let config = MD063Config {
1580            enabled: true,
1581            style: HeadingCapStyle::SentenceCase,
1582            lowercase_words: vec!["a".to_string(), "the".to_string()],
1583            ..Default::default()
1584        };
1585        let rule = MD063HeadingCapitalization::from_config_struct(config);
1586
1587        // Sentence case should also respect lowercase words before code
1588        let content = "## guide for the `user`\n";
1589        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1590        let fixed = rule.fix(&ctx).unwrap();
1591        // First word capitalized, rest lowercase including "the" before code
1592        assert!(
1593            fixed.contains("Guide for the `user`"),
1594            "Expected 'Guide for the `user`' but got: {fixed:?}"
1595        );
1596    }
1597
1598    #[test]
1599    fn test_hyphenated_word_before_code() {
1600        let config = MD063Config {
1601            enabled: true,
1602            style: HeadingCapStyle::TitleCase,
1603            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1604            ..Default::default()
1605        };
1606        let rule = MD063HeadingCapitalization::from_config_struct(config);
1607
1608        // Hyphenated word before code - last part should respect lowercase-words
1609        let content = "## Self-contained with a `feature`\n";
1610        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1611        let fixed = rule.fix(&ctx).unwrap();
1612        // "with" and "a" should remain lowercase (not last word because code follows)
1613        assert!(
1614            fixed.contains("with a `feature`"),
1615            "Expected 'with a `feature`' but got: {fixed:?}"
1616        );
1617    }
1618}