Skip to main content

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, RuleCategory, 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(super) 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 inline HTML tags commonly used in headings
32// Matches paired tags: <tag>content</tag>, <tag attr="val">content</tag>
33// Matches self-closing: <tag/>, <tag />
34// Uses explicit list of common inline tags to avoid backreference (not supported in Rust regex)
35static HTML_TAG_REGEX: LazyLock<Regex> = LazyLock::new(|| {
36    // Common inline HTML tags used in documentation headings
37    let tags = "kbd|abbr|code|span|sub|sup|mark|cite|dfn|var|samp|small|strong|em|b|i|u|s|q|br|wbr";
38    let pattern = format!(r"<({tags})(?:\s[^>]*)?>.*?</({tags})>|<({tags})(?:\s[^>]*)?\s*/?>");
39    Regex::new(&pattern).unwrap()
40});
41
42// Regex to match custom header IDs {#id}
43static CUSTOM_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*\{#[^}]+\}\s*$").unwrap());
44
45/// Represents a segment of heading text
46#[derive(Debug, Clone)]
47enum HeadingSegment {
48    /// Regular text that should be capitalized
49    Text(String),
50    /// Inline code that should be preserved as-is
51    Code(String),
52    /// Link with text that may be capitalized and URL that's preserved
53    Link {
54        full: String,
55        text_start: usize,
56        text_end: usize,
57    },
58    /// Inline HTML tag that should be preserved as-is
59    Html(String),
60}
61
62/// Rule MD063: Heading capitalization
63#[derive(Clone)]
64pub struct MD063HeadingCapitalization {
65    config: MD063Config,
66    lowercase_set: HashSet<String>,
67    /// Multi-word proper names from MD044 that must survive sentence-case transformation.
68    /// Populated via `from_config` when both rules are active.
69    proper_names: Vec<String>,
70}
71
72impl Default for MD063HeadingCapitalization {
73    fn default() -> Self {
74        Self::new()
75    }
76}
77
78impl MD063HeadingCapitalization {
79    pub fn new() -> Self {
80        let config = MD063Config::default();
81        let lowercase_set = config.lowercase_words.iter().cloned().collect();
82        Self {
83            config,
84            lowercase_set,
85            proper_names: Vec::new(),
86        }
87    }
88
89    pub fn from_config_struct(config: MD063Config) -> Self {
90        let lowercase_set = config.lowercase_words.iter().cloned().collect();
91        Self {
92            config,
93            lowercase_set,
94            proper_names: Vec::new(),
95        }
96    }
97
98    /// Match `pattern_lower` at `start` in `text` using Unicode-aware lowercasing.
99    /// Returns the end byte offset in `text` when the match succeeds.
100    ///
101    /// This avoids converting the full `text` to lowercase and then reusing those
102    /// offsets on the original string, which can panic for case-fold expansions
103    /// (e.g. `İ` -> `i̇`).
104    fn match_case_insensitive_at(text: &str, start: usize, pattern_lower: &str) -> Option<usize> {
105        if start > text.len() || !text.is_char_boundary(start) || pattern_lower.is_empty() {
106            return None;
107        }
108
109        let mut matched_bytes = 0;
110
111        for (offset, ch) in text[start..].char_indices() {
112            if matched_bytes >= pattern_lower.len() {
113                break;
114            }
115
116            let lowered: String = ch.to_lowercase().collect();
117            if !pattern_lower[matched_bytes..].starts_with(&lowered) {
118                return None;
119            }
120
121            matched_bytes += lowered.len();
122
123            if matched_bytes == pattern_lower.len() {
124                return Some(start + offset + ch.len_utf8());
125            }
126        }
127
128        None
129    }
130
131    /// Find the next case-insensitive match of `pattern_lower` in `text`,
132    /// returning byte offsets in the ORIGINAL string.
133    fn find_case_insensitive_match(text: &str, pattern_lower: &str, search_start: usize) -> Option<(usize, usize)> {
134        if pattern_lower.is_empty() || search_start >= text.len() || !text.is_char_boundary(search_start) {
135            return None;
136        }
137
138        for (offset, _) in text[search_start..].char_indices() {
139            let start = search_start + offset;
140            if let Some(end) = Self::match_case_insensitive_at(text, start, pattern_lower) {
141                return Some((start, end));
142            }
143        }
144
145        None
146    }
147
148    /// Build a map from word byte-position → canonical form for all proper names
149    /// that appear in the heading text (case-insensitive phrase match).
150    ///
151    /// This is used in `apply_sentence_case` so that words belonging to a proper
152    /// name phrase are never lowercased to begin with.
153    fn proper_name_canonical_forms(&self, text: &str) -> std::collections::HashMap<usize, &str> {
154        let mut map = std::collections::HashMap::new();
155
156        for name in &self.proper_names {
157            if name.is_empty() {
158                continue;
159            }
160            let name_lower = name.to_lowercase();
161            let canonical_words: Vec<&str> = name.split_whitespace().collect();
162            if canonical_words.is_empty() {
163                continue;
164            }
165            let mut search_start = 0;
166
167            while search_start < text.len() {
168                let Some((abs_pos, end_pos)) = Self::find_case_insensitive_match(text, &name_lower, search_start)
169                else {
170                    break;
171                };
172
173                // Require word boundaries
174                let before_ok = abs_pos == 0 || !text[..abs_pos].chars().last().is_some_and(char::is_alphanumeric);
175                let after_ok =
176                    end_pos >= text.len() || !text[end_pos..].chars().next().is_some_and(char::is_alphanumeric);
177
178                if before_ok && after_ok {
179                    // Map each word in the matched region to its canonical form.
180                    // We zip the words found in the text slice with the words of the
181                    // canonical name so that every word gets the right casing.
182                    let text_slice = &text[abs_pos..end_pos];
183                    let mut word_idx = 0;
184                    let mut slice_offset = 0;
185
186                    for text_word in text_slice.split_whitespace() {
187                        if let Some(w_rel) = text_slice[slice_offset..].find(text_word) {
188                            let word_abs = abs_pos + slice_offset + w_rel;
189                            if let Some(&canonical_word) = canonical_words.get(word_idx) {
190                                map.insert(word_abs, canonical_word);
191                            }
192                            slice_offset += w_rel + text_word.len();
193                            word_idx += 1;
194                        }
195                    }
196                }
197
198                // Advance by one Unicode scalar value to allow overlapping matches
199                // while staying on a UTF-8 char boundary.
200                search_start = abs_pos + text[abs_pos..].chars().next().map_or(1, char::len_utf8);
201            }
202        }
203
204        map
205    }
206
207    /// Check if a word has internal capitals (like "iPhone", "macOS", "GitHub", "iOS")
208    fn has_internal_capitals(&self, word: &str) -> bool {
209        let chars: Vec<char> = word.chars().collect();
210        if chars.len() < 2 {
211            return false;
212        }
213
214        let first = chars[0];
215        let rest = &chars[1..];
216        let has_upper_in_rest = rest.iter().any(|c| c.is_uppercase());
217        let has_lower_in_rest = rest.iter().any(|c| c.is_lowercase());
218
219        // Case 1: Mixed case after first character (like "iPhone", "macOS", "GitHub", "JavaScript")
220        if has_upper_in_rest && has_lower_in_rest {
221            return true;
222        }
223
224        // Case 2: Lowercase first + uppercase in rest (like "iOS", "eBay")
225        if first.is_lowercase() && has_upper_in_rest {
226            return true;
227        }
228
229        false
230    }
231
232    /// Check if a word is an all-caps acronym (2+ consecutive uppercase letters)
233    /// Examples: "API", "GPU", "HTTP2", "IO" return true
234    /// Examples: "A", "iPhone", "npm" return false
235    fn is_all_caps_acronym(&self, word: &str) -> bool {
236        // Skip single-letter words (handled by title case rules)
237        if word.len() < 2 {
238            return false;
239        }
240
241        let mut consecutive_upper = 0;
242        let mut max_consecutive = 0;
243
244        for c in word.chars() {
245            if c.is_uppercase() {
246                consecutive_upper += 1;
247                max_consecutive = max_consecutive.max(consecutive_upper);
248            } else if c.is_lowercase() {
249                // Any lowercase letter means not all-caps
250                return false;
251            } else {
252                // Non-letter (number, punctuation) - reset counter but don't fail
253                consecutive_upper = 0;
254            }
255        }
256
257        // Must have at least 2 consecutive uppercase letters
258        max_consecutive >= 2
259    }
260
261    /// Check if a word should be preserved as-is
262    fn should_preserve_word(&self, word: &str) -> bool {
263        // Check ignore_words list (case-sensitive exact match)
264        if self.config.ignore_words.iter().any(|w| w == word) {
265            return true;
266        }
267
268        // Numeric ordinals ("1st", "5th", "21st", ...) must always flow
269        // through the normal title-case path so mis-cased forms like
270        // "5Th" get normalised back to "5th". Skip the preserve_cased_words
271        // heuristics, which would otherwise treat "5Th" as intentionally
272        // mixed-case and leave it untouched.
273        let is_ordinal = Self::is_numeric_ordinal(word);
274
275        if !is_ordinal {
276            // Check if word has internal capitals and preserve_cased_words is enabled
277            if self.config.preserve_cased_words && self.has_internal_capitals(word) {
278                return true;
279            }
280
281            // Check if word is an all-caps acronym (2+ consecutive uppercase)
282            if self.config.preserve_cased_words && self.is_all_caps_acronym(word) {
283                return true;
284            }
285        }
286
287        // Preserve caret notation for control characters (^A, ^Z, ^@, etc.)
288        if self.is_caret_notation(word) {
289            return true;
290        }
291
292        false
293    }
294
295    /// Detect numeric ordinals like `1st`, `2nd`, `3rd`, `4th`, `21st`,
296    /// `100th`, ignoring the case of the suffix and any trailing
297    /// punctuation (e.g. `5th.`, `1st,`, `3rd!`).
298    ///
299    /// Such tokens have a fixed lower-case alphabetic suffix in title case
300    /// — `21st Century`, never `21St Century` — and must be detected
301    /// before applying the generic "capitalise first letter" rule.
302    fn is_numeric_ordinal(word: &str) -> bool {
303        let bytes = word.as_bytes();
304
305        // Require at least one leading ASCII digit followed by a letter.
306        let alpha_start = match bytes.iter().position(|&b| !b.is_ascii_digit()) {
307            Some(pos) if pos > 0 => pos,
308            _ => return false,
309        };
310
311        // Find where the alphabetic suffix ends (trailing punctuation, etc.).
312        let alpha_end = bytes[alpha_start..]
313            .iter()
314            .position(|b| !b.is_ascii_alphabetic())
315            .map_or(bytes.len(), |p| alpha_start + p);
316
317        let suffix = &word[alpha_start..alpha_end];
318        matches!(suffix.to_ascii_lowercase().as_str(), "st" | "nd" | "rd" | "th")
319    }
320
321    /// Check if a word is caret notation for control characters (e.g., ^A, ^C, ^Z)
322    fn is_caret_notation(&self, word: &str) -> bool {
323        let chars: Vec<char> = word.chars().collect();
324        // Pattern: ^ followed by uppercase letter or @[\]^_
325        if chars.len() >= 2 && chars[0] == '^' {
326            let second = chars[1];
327            // Control characters: ^@ (NUL) through ^_ (US), which includes ^A-^Z
328            if second.is_ascii_uppercase() || "@[\\]^_".contains(second) {
329                return true;
330            }
331        }
332        false
333    }
334
335    /// Check if a word is a "lowercase word" (articles, prepositions, etc.)
336    fn is_lowercase_word(&self, word: &str) -> bool {
337        self.lowercase_set.contains(&word.to_lowercase())
338    }
339
340    /// Apply title case to a single word
341    fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
342        if word.is_empty() {
343            return word.to_string();
344        }
345
346        // Preserve words in ignore list or with internal capitals
347        if self.should_preserve_word(word) {
348            return word.to_string();
349        }
350
351        // First and last words are always capitalized
352        if is_first || is_last {
353            return self.capitalize_first(word);
354        }
355
356        // Check if it's a lowercase word (articles, prepositions, etc.)
357        if self.is_lowercase_word(word) {
358            return Self::lowercase_preserving_composition(word);
359        }
360
361        // Regular word - capitalize first letter
362        self.capitalize_first(word)
363    }
364
365    /// Apply canonical proper-name casing while preserving any trailing punctuation
366    /// attached to the original whitespace token (e.g. `javascript,` -> `JavaScript,`).
367    fn apply_canonical_form_to_word(word: &str, canonical: &str) -> String {
368        let canonical_lower = canonical.to_lowercase();
369        if canonical_lower.is_empty() {
370            return canonical.to_string();
371        }
372
373        if let Some(end_pos) = Self::match_case_insensitive_at(word, 0, &canonical_lower) {
374            let mut out = String::with_capacity(canonical.len() + word.len().saturating_sub(end_pos));
375            out.push_str(canonical);
376            out.push_str(&word[end_pos..]);
377            out
378        } else {
379            canonical.to_string()
380        }
381    }
382
383    /// Capitalize the first letter of a word, handling Unicode properly
384    fn capitalize_first(&self, word: &str) -> String {
385        if word.is_empty() {
386            return String::new();
387        }
388
389        // Find the first alphabetic character to capitalize
390        let first_alpha_pos = word.find(|c: char| c.is_alphabetic());
391        let Some(pos) = first_alpha_pos else {
392            return word.to_string();
393        };
394
395        let prefix = &word[..pos];
396        let suffix = &word[pos..];
397
398        // Numeric ordinals ("1st", "21st", "5th", ...) keep their
399        // alphabetic suffix lower-cased even at title-case positions.
400        if Self::is_numeric_ordinal(word) {
401            let suffix_lower = Self::lowercase_preserving_composition(suffix);
402            return format!("{prefix}{suffix_lower}");
403        }
404
405        let mut chars = suffix.chars();
406        let first = chars.next().unwrap();
407        // Use composition-preserving uppercase to avoid decomposing
408        // precomposed characters (e.g., ῷ → Ω + combining marks + Ι)
409        let first_upper = Self::uppercase_preserving_composition(&first.to_string());
410        let rest: String = chars.collect();
411        let rest_lower = Self::lowercase_preserving_composition(&rest);
412        format!("{prefix}{first_upper}{rest_lower}")
413    }
414
415    /// Lowercase a string character-by-character, preserving precomposed
416    /// characters that would decompose during case conversion.
417    fn lowercase_preserving_composition(s: &str) -> String {
418        let mut result = String::with_capacity(s.len());
419        for c in s.chars() {
420            let lower: String = c.to_lowercase().collect();
421            if lower.chars().count() == 1 {
422                result.push_str(&lower);
423            } else {
424                // Lowercasing would decompose this character; keep original
425                result.push(c);
426            }
427        }
428        result
429    }
430
431    /// Uppercase a string character-by-character, preserving precomposed
432    /// characters that would decompose during case conversion.
433    /// For example, ῷ (U+1FF7) would decompose into Ω + combining marks + Ι
434    /// via to_uppercase(); this function keeps ῷ unchanged instead.
435    fn uppercase_preserving_composition(s: &str) -> String {
436        let mut result = String::with_capacity(s.len());
437        for c in s.chars() {
438            let upper: String = c.to_uppercase().collect();
439            if upper.chars().count() == 1 {
440                result.push_str(&upper);
441            } else {
442                // Uppercasing would decompose this character; keep original
443                result.push(c);
444            }
445        }
446        result
447    }
448
449    /// Apply title case to text, using our own title-case logic.
450    /// We avoid the external titlecase crate because it decomposes
451    /// precomposed Unicode characters during case conversion.
452    fn apply_title_case(&self, text: &str) -> String {
453        let canonical_forms = self.proper_name_canonical_forms(text);
454
455        let original_words: Vec<&str> = text.split_whitespace().collect();
456        let total_words = original_words.len();
457
458        // Pre-compute byte position of each word for canonical form lookup.
459        // Use usize::MAX as sentinel for unfound words so canonical_forms.get() returns None.
460        let mut word_positions: Vec<usize> = Vec::with_capacity(original_words.len());
461        let mut pos = 0;
462        for word in &original_words {
463            if let Some(rel) = text[pos..].find(word) {
464                word_positions.push(pos + rel);
465                pos = pos + rel + word.len();
466            } else {
467                word_positions.push(usize::MAX);
468            }
469        }
470
471        let result_words: Vec<String> = original_words
472            .iter()
473            .enumerate()
474            .map(|(i, word)| {
475                let after_period = i > 0 && original_words[i - 1].ends_with('.');
476                let is_first = i == 0 || after_period;
477                let is_last = i == total_words - 1;
478
479                // Words that are part of an MD044 proper name use the canonical form directly.
480                if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
481                    return Self::apply_canonical_form_to_word(word, canonical);
482                }
483
484                // Preserve words in ignore list or with internal capitals
485                if self.should_preserve_word(word) {
486                    return (*word).to_string();
487                }
488
489                // Handle hyphenated words
490                if word.contains('-') {
491                    return self.handle_hyphenated_word(word, is_first, is_last);
492                }
493
494                self.title_case_word(word, is_first, is_last)
495            })
496            .collect();
497
498        result_words.join(" ")
499    }
500
501    /// Handle hyphenated words like "self-documenting"
502    fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
503        let parts: Vec<&str> = word.split('-').collect();
504        let total_parts = parts.len();
505
506        let result_parts: Vec<String> = parts
507            .iter()
508            .enumerate()
509            .map(|(i, part)| {
510                // First part of first word and last part of last word get special treatment
511                let part_is_first = is_first && i == 0;
512                let part_is_last = is_last && i == total_parts - 1;
513                self.title_case_word(part, part_is_first, part_is_last)
514            })
515            .collect();
516
517        result_parts.join("-")
518    }
519
520    /// Apply sentence case to text
521    fn apply_sentence_case(&self, text: &str) -> String {
522        if text.is_empty() {
523            return text.to_string();
524        }
525
526        let canonical_forms = self.proper_name_canonical_forms(text);
527        let mut result = String::new();
528        let mut current_pos = 0;
529        let mut is_first_word = true;
530
531        // Use original text positions to preserve whitespace correctly
532        for word in text.split_whitespace() {
533            if let Some(pos) = text[current_pos..].find(word) {
534                let abs_pos = current_pos + pos;
535
536                // Preserve whitespace before this word
537                result.push_str(&text[current_pos..abs_pos]);
538
539                // Words that are part of an MD044 proper name use the canonical form
540                // directly, bypassing sentence-case lowercasing entirely.
541                if let Some(&canonical) = canonical_forms.get(&abs_pos) {
542                    result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
543                    is_first_word = false;
544                } else if is_first_word {
545                    // Check if word should be preserved BEFORE any capitalization
546                    if self.should_preserve_word(word) {
547                        // Preserve ignore-words exactly as-is, even at start
548                        result.push_str(word);
549                    } else {
550                        // First word: capitalize first letter, lowercase rest
551                        let mut chars = word.chars();
552                        if let Some(first) = chars.next() {
553                            result.push_str(&Self::uppercase_preserving_composition(&first.to_string()));
554                            let rest: String = chars.collect();
555                            result.push_str(&Self::lowercase_preserving_composition(&rest));
556                        }
557                    }
558                    is_first_word = false;
559                } else {
560                    // Non-first words: preserve if needed, otherwise lowercase
561                    if self.should_preserve_word(word) {
562                        result.push_str(word);
563                    } else {
564                        result.push_str(&Self::lowercase_preserving_composition(word));
565                    }
566                }
567
568                current_pos = abs_pos + word.len();
569            }
570        }
571
572        // Preserve any trailing whitespace
573        if current_pos < text.len() {
574            result.push_str(&text[current_pos..]);
575        }
576
577        result
578    }
579
580    /// Apply all caps to text (preserve whitespace)
581    fn apply_all_caps(&self, text: &str) -> String {
582        if text.is_empty() {
583            return text.to_string();
584        }
585
586        let canonical_forms = self.proper_name_canonical_forms(text);
587        let mut result = String::new();
588        let mut current_pos = 0;
589
590        // Use original text positions to preserve whitespace correctly
591        for word in text.split_whitespace() {
592            if let Some(pos) = text[current_pos..].find(word) {
593                let abs_pos = current_pos + pos;
594
595                // Preserve whitespace before this word
596                result.push_str(&text[current_pos..abs_pos]);
597
598                // Words that are part of an MD044 proper name use the canonical form directly.
599                // This prevents oscillation with MD044 when all-caps style is active.
600                if let Some(&canonical) = canonical_forms.get(&abs_pos) {
601                    result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
602                } else if self.should_preserve_word(word) {
603                    result.push_str(word);
604                } else {
605                    result.push_str(&Self::uppercase_preserving_composition(word));
606                }
607
608                current_pos = abs_pos + word.len();
609            }
610        }
611
612        // Preserve any trailing whitespace
613        if current_pos < text.len() {
614            result.push_str(&text[current_pos..]);
615        }
616
617        result
618    }
619
620    /// Parse heading text into segments
621    fn parse_segments(&self, text: &str) -> Vec<HeadingSegment> {
622        let mut segments = Vec::new();
623        let mut last_end = 0;
624
625        // Collect all special regions (code and links)
626        let mut special_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
627
628        // Find inline code spans
629        for mat in INLINE_CODE_REGEX.find_iter(text) {
630            special_regions.push((mat.start(), mat.end(), HeadingSegment::Code(mat.as_str().to_string())));
631        }
632
633        // Find links
634        for caps in LINK_REGEX.captures_iter(text) {
635            let full_match = caps.get(0).unwrap();
636            let text_match = caps.get(1).or_else(|| caps.get(2));
637
638            if let Some(text_m) = text_match {
639                special_regions.push((
640                    full_match.start(),
641                    full_match.end(),
642                    HeadingSegment::Link {
643                        full: full_match.as_str().to_string(),
644                        text_start: text_m.start() - full_match.start(),
645                        text_end: text_m.end() - full_match.start(),
646                    },
647                ));
648            }
649        }
650
651        // Find inline HTML tags
652        for mat in HTML_TAG_REGEX.find_iter(text) {
653            special_regions.push((mat.start(), mat.end(), HeadingSegment::Html(mat.as_str().to_string())));
654        }
655
656        // Sort by start position
657        special_regions.sort_by_key(|(start, _, _)| *start);
658
659        // Remove overlapping regions (code takes precedence)
660        let mut filtered_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
661        for region in special_regions {
662            let overlaps = filtered_regions.iter().any(|(s, e, _)| region.0 < *e && region.1 > *s);
663            if !overlaps {
664                filtered_regions.push(region);
665            }
666        }
667
668        // Build segments
669        for (start, end, segment) in filtered_regions {
670            // Add text before this special region
671            if start > last_end {
672                let text_segment = &text[last_end..start];
673                if !text_segment.is_empty() {
674                    segments.push(HeadingSegment::Text(text_segment.to_string()));
675                }
676            }
677            segments.push(segment);
678            last_end = end;
679        }
680
681        // Add remaining text
682        if last_end < text.len() {
683            let remaining = &text[last_end..];
684            if !remaining.is_empty() {
685                segments.push(HeadingSegment::Text(remaining.to_string()));
686            }
687        }
688
689        // If no segments were found, treat the whole thing as text
690        if segments.is_empty() && !text.is_empty() {
691            segments.push(HeadingSegment::Text(text.to_string()));
692        }
693
694        segments
695    }
696
697    /// Apply capitalization to heading text
698    fn apply_capitalization(&self, text: &str) -> String {
699        // Strip custom ID if present and re-add later
700        let (main_text, custom_id) = if let Some(mat) = CUSTOM_ID_REGEX.find(text) {
701            (&text[..mat.start()], Some(mat.as_str()))
702        } else {
703            (text, None)
704        };
705
706        // Parse into segments
707        let segments = self.parse_segments(main_text);
708
709        // Count text segments to determine first/last word context
710        let text_segments: Vec<usize> = segments
711            .iter()
712            .enumerate()
713            .filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
714            .collect();
715
716        // Determine if the first segment overall is a text segment
717        // For sentence case: if heading starts with code/link, the first text segment
718        // should NOT capitalize its first word (the heading already has a "first element")
719        let first_segment_is_text = segments.first().is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
720
721        // Determine if the last segment overall is a text segment
722        // If the last segment is Code or Link, then the last text segment should NOT
723        // treat its last word as the heading's last word (for lowercase-words respect)
724        let last_segment_is_text = segments.last().is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
725
726        // Apply capitalization to each segment
727        let mut result_parts: Vec<String> = Vec::new();
728
729        for (i, segment) in segments.iter().enumerate() {
730            match segment {
731                HeadingSegment::Text(t) => {
732                    let is_first_text = text_segments.first() == Some(&i);
733                    // A text segment is "last" only if it's the last text segment AND
734                    // the last segment overall is also text. If there's Code/Link after,
735                    // the last word should respect lowercase-words.
736                    let is_last_text = text_segments.last() == Some(&i) && last_segment_is_text;
737
738                    let capitalized = match self.config.style {
739                        HeadingCapStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
740                        HeadingCapStyle::SentenceCase => {
741                            // For sentence case, only capitalize first word if:
742                            // 1. This is the first text segment, AND
743                            // 2. The heading actually starts with text (not code/link)
744                            if is_first_text && first_segment_is_text {
745                                self.apply_sentence_case(t)
746                            } else {
747                                // Non-first segments OR heading starts with code/link
748                                self.apply_sentence_case_non_first(t)
749                            }
750                        }
751                        HeadingCapStyle::AllCaps => self.apply_all_caps(t),
752                    };
753                    result_parts.push(capitalized);
754                }
755                HeadingSegment::Code(c) => {
756                    result_parts.push(c.clone());
757                }
758                HeadingSegment::Link {
759                    full,
760                    text_start,
761                    text_end,
762                } => {
763                    // Apply capitalization to link text only
764                    let link_text = &full[*text_start..*text_end];
765                    let capitalized_text = match self.config.style {
766                        HeadingCapStyle::TitleCase => self.apply_title_case(link_text),
767                        // For sentence case, apply same preservation logic as non-first text
768                        // This preserves acronyms (API), brand names (iPhone), etc.
769                        HeadingCapStyle::SentenceCase => self.apply_sentence_case_non_first(link_text),
770                        HeadingCapStyle::AllCaps => self.apply_all_caps(link_text),
771                    };
772
773                    let mut new_link = String::new();
774                    new_link.push_str(&full[..*text_start]);
775                    new_link.push_str(&capitalized_text);
776                    new_link.push_str(&full[*text_end..]);
777                    result_parts.push(new_link);
778                }
779                HeadingSegment::Html(h) => {
780                    // Preserve HTML tags as-is (like code)
781                    result_parts.push(h.clone());
782                }
783            }
784        }
785
786        let mut result = result_parts.join("");
787
788        // Re-add custom ID if present
789        if let Some(id) = custom_id {
790            result.push_str(id);
791        }
792
793        result
794    }
795
796    /// Apply title case to a text segment with first/last awareness
797    fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
798        let canonical_forms = self.proper_name_canonical_forms(text);
799        let words: Vec<&str> = text.split_whitespace().collect();
800        let total_words = words.len();
801
802        if total_words == 0 {
803            return text.to_string();
804        }
805
806        // Pre-compute byte position of each word so we can look up canonical forms.
807        // Use usize::MAX as sentinel for unfound words so canonical_forms.get() returns None.
808        let mut word_positions: Vec<usize> = Vec::with_capacity(words.len());
809        let mut pos = 0;
810        for word in &words {
811            if let Some(rel) = text[pos..].find(word) {
812                word_positions.push(pos + rel);
813                pos = pos + rel + word.len();
814            } else {
815                word_positions.push(usize::MAX);
816            }
817        }
818
819        let result_words: Vec<String> = words
820            .iter()
821            .enumerate()
822            .map(|(i, word)| {
823                let after_period = i > 0 && words[i - 1].ends_with('.');
824                let is_first = (is_first_segment && i == 0) || after_period;
825                let is_last = is_last_segment && i == total_words - 1;
826
827                // Words that are part of an MD044 proper name use the canonical form directly.
828                if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
829                    return Self::apply_canonical_form_to_word(word, canonical);
830                }
831
832                // Handle hyphenated words
833                if word.contains('-') {
834                    return self.handle_hyphenated_word(word, is_first, is_last);
835                }
836
837                self.title_case_word(word, is_first, is_last)
838            })
839            .collect();
840
841        // Preserve original spacing
842        let mut result = String::new();
843        let mut word_iter = result_words.iter();
844        let mut in_word = false;
845
846        for c in text.chars() {
847            if c.is_whitespace() {
848                if in_word {
849                    in_word = false;
850                }
851                result.push(c);
852            } else if !in_word {
853                if let Some(word) = word_iter.next() {
854                    result.push_str(word);
855                }
856                in_word = true;
857            }
858        }
859
860        result
861    }
862
863    /// Apply sentence case to non-first segments (just lowercase, preserve whitespace)
864    fn apply_sentence_case_non_first(&self, text: &str) -> String {
865        if text.is_empty() {
866            return text.to_string();
867        }
868
869        let canonical_forms = self.proper_name_canonical_forms(text);
870        let mut result = String::new();
871        let mut current_pos = 0;
872
873        // Iterate over words in the original text so byte positions are consistent
874        // with the positions in canonical_forms (built from the same text).
875        for word in text.split_whitespace() {
876            if let Some(pos) = text[current_pos..].find(word) {
877                let abs_pos = current_pos + pos;
878
879                // Preserve whitespace before this word
880                result.push_str(&text[current_pos..abs_pos]);
881
882                // Words that are part of an MD044 proper name use the canonical form directly.
883                if let Some(&canonical) = canonical_forms.get(&abs_pos) {
884                    result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
885                } else if self.should_preserve_word(word) {
886                    result.push_str(word);
887                } else {
888                    result.push_str(&Self::lowercase_preserving_composition(word));
889                }
890
891                current_pos = abs_pos + word.len();
892            }
893        }
894
895        // Preserve any trailing whitespace
896        if current_pos < text.len() {
897            result.push_str(&text[current_pos..]);
898        }
899
900        result
901    }
902
903    /// Get byte range for a line
904    fn get_line_byte_range(&self, content: &str, line_num: usize, line_index: &LineIndex) -> Range<usize> {
905        let start_pos = line_index.get_line_start_byte(line_num).unwrap_or(content.len());
906        let line = content.lines().nth(line_num - 1).unwrap_or("");
907        Range {
908            start: start_pos,
909            end: start_pos + line.len(),
910        }
911    }
912
913    /// Fix an ATX heading line
914    fn fix_atx_heading(&self, _line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
915        // Parse the line to preserve structure
916        let indent = " ".repeat(heading.marker_column);
917        let hashes = "#".repeat(heading.level as usize);
918
919        // Apply capitalization to the text
920        let fixed_text = self.apply_capitalization(&heading.raw_text);
921
922        // Reconstruct with closing sequence if present
923        let closing = &heading.closing_sequence;
924        if heading.has_closing_sequence {
925            format!("{indent}{hashes} {fixed_text} {closing}")
926        } else {
927            format!("{indent}{hashes} {fixed_text}")
928        }
929    }
930
931    /// Fix a Setext heading line
932    fn fix_setext_heading(&self, line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
933        // Apply capitalization to the text
934        let fixed_text = self.apply_capitalization(&heading.raw_text);
935
936        // Preserve leading whitespace from original line
937        let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
938
939        format!("{leading_ws}{fixed_text}")
940    }
941}
942
943impl Rule for MD063HeadingCapitalization {
944    fn name(&self) -> &'static str {
945        "MD063"
946    }
947
948    fn description(&self) -> &'static str {
949        "Heading capitalization"
950    }
951
952    fn category(&self) -> RuleCategory {
953        RuleCategory::Heading
954    }
955
956    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
957        !ctx.likely_has_headings() || !ctx.lines.iter().any(|line| line.heading.is_some())
958    }
959
960    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
961        let content = ctx.content;
962
963        if content.is_empty() {
964            return Ok(Vec::new());
965        }
966
967        let mut warnings = Vec::new();
968        let line_index = &ctx.line_index;
969
970        for (line_num, line_info) in ctx.lines.iter().enumerate() {
971            if let Some(heading) = &line_info.heading {
972                // Check level filter
973                if heading.level < self.config.min_level || heading.level > self.config.max_level {
974                    continue;
975                }
976
977                // Skip headings in code blocks (indented headings)
978                if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
979                    continue;
980                }
981
982                // Skip invalid headings (e.g., `#tag` which lacks required space after #)
983                if !heading.is_valid {
984                    continue;
985                }
986
987                // Apply capitalization and compare
988                let original_text = &heading.raw_text;
989                let fixed_text = self.apply_capitalization(original_text);
990
991                if original_text != &fixed_text {
992                    let line = line_info.content(ctx.content);
993                    let style_name = match self.config.style {
994                        HeadingCapStyle::TitleCase => "title case",
995                        HeadingCapStyle::SentenceCase => "sentence case",
996                        HeadingCapStyle::AllCaps => "ALL CAPS",
997                    };
998
999                    warnings.push(LintWarning {
1000                        rule_name: Some(self.name().to_string()),
1001                        line: line_num + 1,
1002                        column: heading.content_column + 1,
1003                        end_line: line_num + 1,
1004                        end_column: heading.content_column + 1 + original_text.len(),
1005                        message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1006                        severity: Severity::Warning,
1007                        fix: Some(Fix::new(
1008                            self.get_line_byte_range(content, line_num + 1, line_index),
1009                            match heading.style {
1010                                crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
1011                                _ => self.fix_setext_heading(line, heading),
1012                            },
1013                        )),
1014                    });
1015                }
1016            }
1017        }
1018
1019        Ok(warnings)
1020    }
1021
1022    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1023        let content = ctx.content;
1024
1025        if content.is_empty() {
1026            return Ok(content.to_string());
1027        }
1028
1029        let lines = ctx.raw_lines();
1030        let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1031
1032        for (line_num, line_info) in ctx.lines.iter().enumerate() {
1033            // Skip lines where the rule is disabled via inline config
1034            if ctx.is_rule_disabled(self.name(), line_num + 1) {
1035                continue;
1036            }
1037
1038            if let Some(heading) = &line_info.heading {
1039                // Check level filter
1040                if heading.level < self.config.min_level || heading.level > self.config.max_level {
1041                    continue;
1042                }
1043
1044                // Skip headings in code blocks
1045                if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
1046                    continue;
1047                }
1048
1049                // Skip invalid headings (e.g., `#tag` which lacks required space after #)
1050                if !heading.is_valid {
1051                    continue;
1052                }
1053
1054                let original_text = &heading.raw_text;
1055                let fixed_text = self.apply_capitalization(original_text);
1056
1057                if original_text != &fixed_text {
1058                    let line = line_info.content(ctx.content);
1059                    fixed_lines[line_num] = match heading.style {
1060                        crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
1061                        _ => self.fix_setext_heading(line, heading),
1062                    };
1063                }
1064            }
1065        }
1066
1067        // Reconstruct content preserving line endings
1068        let mut result = String::with_capacity(content.len());
1069        for (i, line) in fixed_lines.iter().enumerate() {
1070            result.push_str(line);
1071            if i < fixed_lines.len() - 1 || content.ends_with('\n') {
1072                result.push('\n');
1073            }
1074        }
1075
1076        Ok(result)
1077    }
1078
1079    fn as_any(&self) -> &dyn std::any::Any {
1080        self
1081    }
1082
1083    fn default_config_section(&self) -> Option<(String, toml::Value)> {
1084        let json_value = serde_json::to_value(&self.config).ok()?;
1085        Some((
1086            self.name().to_string(),
1087            crate::rule_config_serde::json_to_toml_value(&json_value)?,
1088        ))
1089    }
1090
1091    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1092    where
1093        Self: Sized,
1094    {
1095        let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
1096        let md044_config =
1097            crate::rule_config_serde::load_rule_config::<crate::rules::md044_proper_names::MD044Config>(config);
1098        let mut rule = Self::from_config_struct(rule_config);
1099        rule.proper_names = md044_config.names;
1100        Box::new(rule)
1101    }
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106    use super::*;
1107    use crate::lint_context::LintContext;
1108
1109    fn create_rule() -> MD063HeadingCapitalization {
1110        let config = MD063Config {
1111            enabled: true,
1112            ..Default::default()
1113        };
1114        MD063HeadingCapitalization::from_config_struct(config)
1115    }
1116
1117    fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
1118        let config = MD063Config {
1119            enabled: true,
1120            style,
1121            ..Default::default()
1122        };
1123        MD063HeadingCapitalization::from_config_struct(config)
1124    }
1125
1126    // Title case tests
1127    #[test]
1128    fn test_title_case_basic() {
1129        let rule = create_rule();
1130        let content = "# hello world\n";
1131        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1132        let result = rule.check(&ctx).unwrap();
1133        assert_eq!(result.len(), 1);
1134        assert!(result[0].message.contains("Hello World"));
1135    }
1136
1137    #[test]
1138    fn test_title_case_lowercase_words() {
1139        let rule = create_rule();
1140        let content = "# the quick brown fox\n";
1141        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1142        let result = rule.check(&ctx).unwrap();
1143        assert_eq!(result.len(), 1);
1144        // "The" should be capitalized (first word), "quick", "brown", "fox" should be capitalized
1145        assert!(result[0].message.contains("The Quick Brown Fox"));
1146    }
1147
1148    #[test]
1149    fn test_title_case_already_correct() {
1150        let rule = create_rule();
1151        let content = "# The Quick Brown Fox\n";
1152        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1153        let result = rule.check(&ctx).unwrap();
1154        assert!(result.is_empty(), "Already correct heading should not be flagged");
1155    }
1156
1157    #[test]
1158    fn test_title_case_hyphenated() {
1159        let rule = create_rule();
1160        let content = "# self-documenting code\n";
1161        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1162        let result = rule.check(&ctx).unwrap();
1163        assert_eq!(result.len(), 1);
1164        assert!(result[0].message.contains("Self-Documenting Code"));
1165    }
1166
1167    // Sentence case tests
1168    #[test]
1169    fn test_sentence_case_basic() {
1170        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1171        let content = "# The Quick Brown Fox\n";
1172        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1173        let result = rule.check(&ctx).unwrap();
1174        assert_eq!(result.len(), 1);
1175        assert!(result[0].message.contains("The quick brown fox"));
1176    }
1177
1178    #[test]
1179    fn test_sentence_case_already_correct() {
1180        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1181        let content = "# The quick brown fox\n";
1182        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1183        let result = rule.check(&ctx).unwrap();
1184        assert!(result.is_empty());
1185    }
1186
1187    // All caps tests
1188    #[test]
1189    fn test_all_caps_basic() {
1190        let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1191        let content = "# hello world\n";
1192        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1193        let result = rule.check(&ctx).unwrap();
1194        assert_eq!(result.len(), 1);
1195        assert!(result[0].message.contains("HELLO WORLD"));
1196    }
1197
1198    // Preserve tests
1199    #[test]
1200    fn test_preserve_ignore_words() {
1201        let config = MD063Config {
1202            enabled: true,
1203            ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
1204            ..Default::default()
1205        };
1206        let rule = MD063HeadingCapitalization::from_config_struct(config);
1207
1208        let content = "# using iPhone on macOS\n";
1209        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1210        let result = rule.check(&ctx).unwrap();
1211        assert_eq!(result.len(), 1);
1212        // iPhone and macOS should be preserved
1213        assert!(result[0].message.contains("iPhone"));
1214        assert!(result[0].message.contains("macOS"));
1215    }
1216
1217    #[test]
1218    fn test_preserve_cased_words() {
1219        let rule = create_rule();
1220        let content = "# using GitHub actions\n";
1221        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1222        let result = rule.check(&ctx).unwrap();
1223        assert_eq!(result.len(), 1);
1224        // GitHub should be preserved (has internal capital)
1225        assert!(result[0].message.contains("GitHub"));
1226    }
1227
1228    // Inline code tests
1229    #[test]
1230    fn test_inline_code_preserved() {
1231        let rule = create_rule();
1232        let content = "# using `const` in javascript\n";
1233        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1234        let result = rule.check(&ctx).unwrap();
1235        assert_eq!(result.len(), 1);
1236        // `const` should be preserved, rest capitalized
1237        assert!(result[0].message.contains("`const`"));
1238        assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
1239    }
1240
1241    // Level filter tests
1242    #[test]
1243    fn test_level_filter() {
1244        let config = MD063Config {
1245            enabled: true,
1246            min_level: 2,
1247            max_level: 4,
1248            ..Default::default()
1249        };
1250        let rule = MD063HeadingCapitalization::from_config_struct(config);
1251
1252        let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
1253        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1254        let result = rule.check(&ctx).unwrap();
1255
1256        // Only h2 and h3 should be flagged (h1 < min_level, h5 > max_level)
1257        assert_eq!(result.len(), 2);
1258        assert_eq!(result[0].line, 2); // h2
1259        assert_eq!(result[1].line, 3); // h3
1260    }
1261
1262    // Fix tests
1263    #[test]
1264    fn test_fix_atx_heading() {
1265        let rule = create_rule();
1266        let content = "# hello world\n";
1267        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1268        let fixed = rule.fix(&ctx).unwrap();
1269        assert_eq!(fixed, "# Hello World\n");
1270    }
1271
1272    #[test]
1273    fn test_fix_multiple_headings() {
1274        let rule = create_rule();
1275        let content = "# first heading\n\n## second heading\n";
1276        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1277        let fixed = rule.fix(&ctx).unwrap();
1278        assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
1279    }
1280
1281    // Setext heading tests
1282    #[test]
1283    fn test_setext_heading() {
1284        let rule = create_rule();
1285        let content = "hello world\n============\n";
1286        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1287        let result = rule.check(&ctx).unwrap();
1288        assert_eq!(result.len(), 1);
1289        assert!(result[0].message.contains("Hello World"));
1290    }
1291
1292    // Custom ID tests
1293    #[test]
1294    fn test_custom_id_preserved() {
1295        let rule = create_rule();
1296        let content = "# getting started {#intro}\n";
1297        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1298        let result = rule.check(&ctx).unwrap();
1299        assert_eq!(result.len(), 1);
1300        // Custom ID should be preserved
1301        assert!(result[0].message.contains("{#intro}"));
1302    }
1303
1304    // Acronym preservation tests
1305    #[test]
1306    fn test_skip_obsidian_tags_not_headings() {
1307        let rule = create_rule();
1308
1309        // #tag (no space after #) is an Obsidian tag, not a heading
1310        let content = "# H1\n\n#tag\n";
1311        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1312        let result = rule.check(&ctx).unwrap();
1313        assert!(
1314            result.is_empty() || result.iter().all(|w| w.line != 3),
1315            "Obsidian tag #tag should not be treated as a heading: {result:?}"
1316        );
1317    }
1318
1319    #[test]
1320    fn test_skip_invalid_atx_headings_no_space() {
1321        let rule = create_rule();
1322
1323        // #NoSpace is not a valid ATX heading (requires space after #)
1324        let content = "#notaheading\n";
1325        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1326        let result = rule.check(&ctx).unwrap();
1327        assert!(
1328            result.is_empty(),
1329            "Invalid ATX heading without space should not be flagged: {result:?}"
1330        );
1331    }
1332
1333    #[test]
1334    fn test_fix_skips_obsidian_tags() {
1335        let rule = create_rule();
1336
1337        let content = "# hello world\n\n#tag\n";
1338        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1339        let fixed = rule.fix(&ctx).unwrap();
1340        // Should fix the real heading but leave the tag alone
1341        assert!(fixed.contains("#tag"), "Fix should not modify Obsidian tag #tag");
1342        assert!(fixed.contains("# Hello World"), "Fix should still fix real headings");
1343    }
1344
1345    #[test]
1346    fn test_preserve_all_caps_acronyms() {
1347        let rule = create_rule();
1348        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1349
1350        // Basic acronyms should be preserved
1351        let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
1352        assert_eq!(fixed, "# Using API in Production\n");
1353
1354        // Multiple acronyms
1355        let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
1356        assert_eq!(fixed, "# API and GPU Integration\n");
1357
1358        // Two-letter acronyms
1359        let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
1360        assert_eq!(fixed, "# IO Performance Guide\n");
1361
1362        // Acronyms with numbers
1363        let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
1364        assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
1365    }
1366
1367    #[test]
1368    fn test_preserve_acronyms_in_hyphenated_words() {
1369        let rule = create_rule();
1370        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1371
1372        // Acronyms at start of hyphenated word
1373        let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
1374        assert_eq!(fixed, "# API-Driven Architecture\n");
1375
1376        // Multiple acronyms with hyphens
1377        let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
1378        assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
1379    }
1380
1381    #[test]
1382    fn test_single_letters_not_treated_as_acronyms() {
1383        let rule = create_rule();
1384        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1385
1386        // Single uppercase letters should follow title case rules, not be preserved
1387        let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
1388        assert_eq!(fixed, "# I Am a Heading\n");
1389    }
1390
1391    #[test]
1392    fn test_lowercase_terms_need_ignore_words() {
1393        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1394
1395        // Without ignore_words: npm gets capitalized
1396        let rule = create_rule();
1397        let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1398        assert_eq!(fixed, "# Using Npm Packages\n");
1399
1400        // With ignore_words: npm preserved
1401        let config = MD063Config {
1402            enabled: true,
1403            ignore_words: vec!["npm".to_string()],
1404            ..Default::default()
1405        };
1406        let rule = MD063HeadingCapitalization::from_config_struct(config);
1407        let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1408        assert_eq!(fixed, "# Using npm Packages\n");
1409    }
1410
1411    #[test]
1412    fn test_acronyms_with_mixed_case_preserved() {
1413        let rule = create_rule();
1414        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1415
1416        // Both acronyms (API, GPU) and mixed-case (GitHub) should be preserved
1417        let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
1418        assert_eq!(fixed, "# Using API with GitHub\n");
1419    }
1420
1421    #[test]
1422    fn test_real_world_acronyms() {
1423        let rule = create_rule();
1424        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1425
1426        // Common technical acronyms from tested repositories
1427        let content = "# FFI bindings for CPU optimization\n";
1428        let fixed = rule.fix(&ctx(content)).unwrap();
1429        assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
1430
1431        let content = "# DOM manipulation and SSR rendering\n";
1432        let fixed = rule.fix(&ctx(content)).unwrap();
1433        assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
1434
1435        let content = "# CVE security and RNN models\n";
1436        let fixed = rule.fix(&ctx(content)).unwrap();
1437        assert_eq!(fixed, "# CVE Security and RNN Models\n");
1438    }
1439
1440    #[test]
1441    fn test_is_all_caps_acronym() {
1442        let rule = create_rule();
1443
1444        // Should return true for all-caps with 2+ letters
1445        assert!(rule.is_all_caps_acronym("API"));
1446        assert!(rule.is_all_caps_acronym("IO"));
1447        assert!(rule.is_all_caps_acronym("GPU"));
1448        assert!(rule.is_all_caps_acronym("HTTP2")); // Numbers don't break it
1449
1450        // Should return false for single letters
1451        assert!(!rule.is_all_caps_acronym("A"));
1452        assert!(!rule.is_all_caps_acronym("I"));
1453
1454        // Should return false for words with lowercase
1455        assert!(!rule.is_all_caps_acronym("Api"));
1456        assert!(!rule.is_all_caps_acronym("npm"));
1457        assert!(!rule.is_all_caps_acronym("iPhone"));
1458    }
1459
1460    #[test]
1461    fn test_sentence_case_ignore_words_first_word() {
1462        let config = MD063Config {
1463            enabled: true,
1464            style: HeadingCapStyle::SentenceCase,
1465            ignore_words: vec!["nvim".to_string()],
1466            ..Default::default()
1467        };
1468        let rule = MD063HeadingCapitalization::from_config_struct(config);
1469
1470        // "nvim" as first word should be preserved exactly
1471        let content = "# nvim config\n";
1472        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1473        let result = rule.check(&ctx).unwrap();
1474        assert!(
1475            result.is_empty(),
1476            "nvim in ignore-words should not be flagged. Got: {result:?}"
1477        );
1478
1479        // Verify fix also preserves it
1480        let fixed = rule.fix(&ctx).unwrap();
1481        assert_eq!(fixed, "# nvim config\n");
1482    }
1483
1484    #[test]
1485    fn test_sentence_case_ignore_words_not_first() {
1486        let config = MD063Config {
1487            enabled: true,
1488            style: HeadingCapStyle::SentenceCase,
1489            ignore_words: vec!["nvim".to_string()],
1490            ..Default::default()
1491        };
1492        let rule = MD063HeadingCapitalization::from_config_struct(config);
1493
1494        // "nvim" in middle should also be preserved
1495        let content = "# Using nvim editor\n";
1496        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1497        let result = rule.check(&ctx).unwrap();
1498        assert!(
1499            result.is_empty(),
1500            "nvim in ignore-words should be preserved. Got: {result:?}"
1501        );
1502    }
1503
1504    #[test]
1505    fn test_preserve_cased_words_ios() {
1506        let config = MD063Config {
1507            enabled: true,
1508            style: HeadingCapStyle::SentenceCase,
1509            preserve_cased_words: true,
1510            ..Default::default()
1511        };
1512        let rule = MD063HeadingCapitalization::from_config_struct(config);
1513
1514        // "iOS" should be preserved (has mixed case: lowercase 'i' + uppercase 'OS')
1515        let content = "## This is iOS\n";
1516        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1517        let result = rule.check(&ctx).unwrap();
1518        assert!(
1519            result.is_empty(),
1520            "iOS should be preserved with preserve-cased-words. Got: {result:?}"
1521        );
1522
1523        // Verify fix also preserves it
1524        let fixed = rule.fix(&ctx).unwrap();
1525        assert_eq!(fixed, "## This is iOS\n");
1526    }
1527
1528    #[test]
1529    fn test_preserve_cased_words_ios_title_case() {
1530        let config = MD063Config {
1531            enabled: true,
1532            style: HeadingCapStyle::TitleCase,
1533            preserve_cased_words: true,
1534            ..Default::default()
1535        };
1536        let rule = MD063HeadingCapitalization::from_config_struct(config);
1537
1538        // "iOS" should be preserved in title case too
1539        let content = "# developing for iOS\n";
1540        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1541        let fixed = rule.fix(&ctx).unwrap();
1542        assert_eq!(fixed, "# Developing for iOS\n");
1543    }
1544
1545    #[test]
1546    fn test_has_internal_capitals_ios() {
1547        let rule = create_rule();
1548
1549        // iOS should be detected as having internal capitals
1550        assert!(
1551            rule.has_internal_capitals("iOS"),
1552            "iOS has mixed case (lowercase i, uppercase OS)"
1553        );
1554
1555        // Other mixed-case words
1556        assert!(rule.has_internal_capitals("iPhone"));
1557        assert!(rule.has_internal_capitals("macOS"));
1558        assert!(rule.has_internal_capitals("GitHub"));
1559        assert!(rule.has_internal_capitals("JavaScript"));
1560        assert!(rule.has_internal_capitals("eBay"));
1561
1562        // All-caps should NOT be detected (handled by is_all_caps_acronym)
1563        assert!(!rule.has_internal_capitals("API"));
1564        assert!(!rule.has_internal_capitals("GPU"));
1565
1566        // All-lowercase should NOT be detected
1567        assert!(!rule.has_internal_capitals("npm"));
1568        assert!(!rule.has_internal_capitals("config"));
1569
1570        // Regular capitalized words should NOT be detected
1571        assert!(!rule.has_internal_capitals("The"));
1572        assert!(!rule.has_internal_capitals("Hello"));
1573    }
1574
1575    #[test]
1576    fn test_lowercase_words_before_trailing_code() {
1577        let config = MD063Config {
1578            enabled: true,
1579            style: HeadingCapStyle::TitleCase,
1580            lowercase_words: vec![
1581                "a".to_string(),
1582                "an".to_string(),
1583                "and".to_string(),
1584                "at".to_string(),
1585                "but".to_string(),
1586                "by".to_string(),
1587                "for".to_string(),
1588                "from".to_string(),
1589                "into".to_string(),
1590                "nor".to_string(),
1591                "on".to_string(),
1592                "onto".to_string(),
1593                "or".to_string(),
1594                "the".to_string(),
1595                "to".to_string(),
1596                "upon".to_string(),
1597                "via".to_string(),
1598                "vs".to_string(),
1599                "with".to_string(),
1600                "without".to_string(),
1601            ],
1602            preserve_cased_words: true,
1603            ..Default::default()
1604        };
1605        let rule = MD063HeadingCapitalization::from_config_struct(config);
1606
1607        // Test: "subtitle with a `app`" (all lowercase input)
1608        // Expected fix: "Subtitle With a `app`" - capitalize "Subtitle" and "With",
1609        // but keep "a" lowercase (it's in lowercase-words and not the last word)
1610        // Incorrect: "Subtitle with A `app`" (would incorrectly capitalize "a")
1611        let content = "## subtitle with a `app`\n";
1612        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1613        let result = rule.check(&ctx).unwrap();
1614
1615        // Should flag it
1616        assert!(!result.is_empty(), "Should flag incorrect capitalization");
1617        let fixed = rule.fix(&ctx).unwrap();
1618        // "a" should remain lowercase (not "A") because inline code at end doesn't change lowercase-words behavior
1619        assert!(
1620            fixed.contains("with a `app`"),
1621            "Expected 'with a `app`' but got: {fixed:?}"
1622        );
1623        assert!(
1624            !fixed.contains("with A `app`"),
1625            "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
1626        );
1627        // "Subtitle" should be capitalized, "with" and "a" should remain lowercase (they're in lowercase-words)
1628        assert!(
1629            fixed.contains("Subtitle with a `app`"),
1630            "Expected 'Subtitle with a `app`' but got: {fixed:?}"
1631        );
1632    }
1633
1634    #[test]
1635    fn test_lowercase_words_preserved_before_trailing_code_variant() {
1636        let config = MD063Config {
1637            enabled: true,
1638            style: HeadingCapStyle::TitleCase,
1639            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1640            ..Default::default()
1641        };
1642        let rule = MD063HeadingCapitalization::from_config_struct(config);
1643
1644        // Another variant: "Title with the `code`"
1645        let content = "## Title with the `code`\n";
1646        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1647        let fixed = rule.fix(&ctx).unwrap();
1648        // "the" should remain lowercase
1649        assert!(
1650            fixed.contains("with the `code`"),
1651            "Expected 'with the `code`' but got: {fixed:?}"
1652        );
1653        assert!(
1654            !fixed.contains("with The `code`"),
1655            "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
1656        );
1657    }
1658
1659    #[test]
1660    fn test_last_word_capitalized_when_no_trailing_code() {
1661        // Verify that when there's NO trailing code, the last word IS capitalized
1662        // (even if it's in lowercase-words) - this is the normal title case behavior
1663        let config = MD063Config {
1664            enabled: true,
1665            style: HeadingCapStyle::TitleCase,
1666            lowercase_words: vec!["a".to_string(), "the".to_string()],
1667            ..Default::default()
1668        };
1669        let rule = MD063HeadingCapitalization::from_config_struct(config);
1670
1671        // "title with a word" - "word" is last, should be capitalized
1672        // "a" is in lowercase-words and not last, so should be lowercase
1673        let content = "## title with a word\n";
1674        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1675        let fixed = rule.fix(&ctx).unwrap();
1676        // "a" should be lowercase, "word" should be capitalized (it's last)
1677        assert!(
1678            fixed.contains("With a Word"),
1679            "Expected 'With a Word' but got: {fixed:?}"
1680        );
1681    }
1682
1683    #[test]
1684    fn test_multiple_lowercase_words_before_code() {
1685        let config = MD063Config {
1686            enabled: true,
1687            style: HeadingCapStyle::TitleCase,
1688            lowercase_words: vec![
1689                "a".to_string(),
1690                "the".to_string(),
1691                "with".to_string(),
1692                "for".to_string(),
1693            ],
1694            ..Default::default()
1695        };
1696        let rule = MD063HeadingCapitalization::from_config_struct(config);
1697
1698        // Multiple lowercase words before code - all should remain lowercase
1699        let content = "## Guide for the `user`\n";
1700        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1701        let fixed = rule.fix(&ctx).unwrap();
1702        assert!(
1703            fixed.contains("for the `user`"),
1704            "Expected 'for the `user`' but got: {fixed:?}"
1705        );
1706        assert!(
1707            !fixed.contains("For The `user`"),
1708            "Should not capitalize lowercase words before code. Got: {fixed:?}"
1709        );
1710    }
1711
1712    #[test]
1713    fn test_code_in_middle_normal_rules_apply() {
1714        let config = MD063Config {
1715            enabled: true,
1716            style: HeadingCapStyle::TitleCase,
1717            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1718            ..Default::default()
1719        };
1720        let rule = MD063HeadingCapitalization::from_config_struct(config);
1721
1722        // Code in the middle - normal title case rules apply (last word capitalized)
1723        let content = "## Using `const` for the code\n";
1724        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1725        let fixed = rule.fix(&ctx).unwrap();
1726        // "for" and "the" should be lowercase (middle), "code" should be capitalized (last)
1727        assert!(
1728            fixed.contains("for the Code"),
1729            "Expected 'for the Code' but got: {fixed:?}"
1730        );
1731    }
1732
1733    #[test]
1734    fn test_link_at_end_same_as_code() {
1735        let config = MD063Config {
1736            enabled: true,
1737            style: HeadingCapStyle::TitleCase,
1738            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1739            ..Default::default()
1740        };
1741        let rule = MD063HeadingCapitalization::from_config_struct(config);
1742
1743        // Link at the end - same behavior as code (lowercase words before should remain lowercase)
1744        let content = "## Guide for the [link](./page.md)\n";
1745        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1746        let fixed = rule.fix(&ctx).unwrap();
1747        // "for" and "the" should remain lowercase (not last word because link follows)
1748        assert!(
1749            fixed.contains("for the [Link]"),
1750            "Expected 'for the [Link]' but got: {fixed:?}"
1751        );
1752        assert!(
1753            !fixed.contains("for The [Link]"),
1754            "Should not capitalize 'the' before link. Got: {fixed:?}"
1755        );
1756    }
1757
1758    #[test]
1759    fn test_multiple_code_segments() {
1760        let config = MD063Config {
1761            enabled: true,
1762            style: HeadingCapStyle::TitleCase,
1763            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1764            ..Default::default()
1765        };
1766        let rule = MD063HeadingCapitalization::from_config_struct(config);
1767
1768        // Multiple code segments - last segment is code, so lowercase words before should remain lowercase
1769        let content = "## Using `const` with a `variable`\n";
1770        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1771        let fixed = rule.fix(&ctx).unwrap();
1772        // "a" should remain lowercase (not last word because code follows)
1773        assert!(
1774            fixed.contains("with a `variable`"),
1775            "Expected 'with a `variable`' but got: {fixed:?}"
1776        );
1777        assert!(
1778            !fixed.contains("with A `variable`"),
1779            "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1780        );
1781    }
1782
1783    #[test]
1784    fn test_code_and_link_combination() {
1785        let config = MD063Config {
1786            enabled: true,
1787            style: HeadingCapStyle::TitleCase,
1788            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1789            ..Default::default()
1790        };
1791        let rule = MD063HeadingCapitalization::from_config_struct(config);
1792
1793        // Code then link - last segment is link, so lowercase words before code should remain lowercase
1794        let content = "## Guide for the `code` [link](./page.md)\n";
1795        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1796        let fixed = rule.fix(&ctx).unwrap();
1797        // "for" and "the" should remain lowercase (not last word because link follows)
1798        assert!(
1799            fixed.contains("for the `code`"),
1800            "Expected 'for the `code`' but got: {fixed:?}"
1801        );
1802    }
1803
1804    #[test]
1805    fn test_text_after_code_capitalizes_last() {
1806        let config = MD063Config {
1807            enabled: true,
1808            style: HeadingCapStyle::TitleCase,
1809            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1810            ..Default::default()
1811        };
1812        let rule = MD063HeadingCapitalization::from_config_struct(config);
1813
1814        // Code in middle, text after - last word should be capitalized
1815        let content = "## Using `const` for the code\n";
1816        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1817        let fixed = rule.fix(&ctx).unwrap();
1818        // "for" and "the" should be lowercase, "code" is last word, should be capitalized
1819        assert!(
1820            fixed.contains("for the Code"),
1821            "Expected 'for the Code' but got: {fixed:?}"
1822        );
1823    }
1824
1825    #[test]
1826    fn test_preserve_cased_words_with_trailing_code() {
1827        let config = MD063Config {
1828            enabled: true,
1829            style: HeadingCapStyle::TitleCase,
1830            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1831            preserve_cased_words: true,
1832            ..Default::default()
1833        };
1834        let rule = MD063HeadingCapitalization::from_config_struct(config);
1835
1836        // Preserve-cased words should still work with trailing code
1837        let content = "## Guide for iOS `app`\n";
1838        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1839        let fixed = rule.fix(&ctx).unwrap();
1840        // "iOS" should be preserved, "for" should be lowercase
1841        assert!(
1842            fixed.contains("for iOS `app`"),
1843            "Expected 'for iOS `app`' but got: {fixed:?}"
1844        );
1845        assert!(
1846            !fixed.contains("For iOS `app`"),
1847            "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
1848        );
1849    }
1850
1851    #[test]
1852    fn test_ignore_words_with_trailing_code() {
1853        let config = MD063Config {
1854            enabled: true,
1855            style: HeadingCapStyle::TitleCase,
1856            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1857            ignore_words: vec!["npm".to_string()],
1858            ..Default::default()
1859        };
1860        let rule = MD063HeadingCapitalization::from_config_struct(config);
1861
1862        // Ignore-words should still work with trailing code
1863        let content = "## Using npm with a `script`\n";
1864        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1865        let fixed = rule.fix(&ctx).unwrap();
1866        // "npm" should be preserved, "with" and "a" should be lowercase
1867        assert!(
1868            fixed.contains("npm with a `script`"),
1869            "Expected 'npm with a `script`' but got: {fixed:?}"
1870        );
1871        assert!(
1872            !fixed.contains("with A `script`"),
1873            "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1874        );
1875    }
1876
1877    #[test]
1878    fn test_empty_text_segment_edge_case() {
1879        let config = MD063Config {
1880            enabled: true,
1881            style: HeadingCapStyle::TitleCase,
1882            lowercase_words: vec!["a".to_string(), "with".to_string()],
1883            ..Default::default()
1884        };
1885        let rule = MD063HeadingCapitalization::from_config_struct(config);
1886
1887        // Edge case: code at start, then text with lowercase word, then code at end
1888        let content = "## `start` with a `end`\n";
1889        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1890        let fixed = rule.fix(&ctx).unwrap();
1891        // "with" is first word in text segment, so capitalized (correct)
1892        // "a" should remain lowercase (not last word because code follows) - this is the key test
1893        assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
1894        assert!(
1895            !fixed.contains("A `end`"),
1896            "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1897        );
1898    }
1899
1900    #[test]
1901    fn test_sentence_case_with_trailing_code() {
1902        let config = MD063Config {
1903            enabled: true,
1904            style: HeadingCapStyle::SentenceCase,
1905            lowercase_words: vec!["a".to_string(), "the".to_string()],
1906            ..Default::default()
1907        };
1908        let rule = MD063HeadingCapitalization::from_config_struct(config);
1909
1910        // Sentence case should also respect lowercase words before code
1911        let content = "## guide for the `user`\n";
1912        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1913        let fixed = rule.fix(&ctx).unwrap();
1914        // First word capitalized, rest lowercase including "the" before code
1915        assert!(
1916            fixed.contains("Guide for the `user`"),
1917            "Expected 'Guide for the `user`' but got: {fixed:?}"
1918        );
1919    }
1920
1921    #[test]
1922    fn test_hyphenated_word_before_code() {
1923        let config = MD063Config {
1924            enabled: true,
1925            style: HeadingCapStyle::TitleCase,
1926            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1927            ..Default::default()
1928        };
1929        let rule = MD063HeadingCapitalization::from_config_struct(config);
1930
1931        // Hyphenated word before code - last part should respect lowercase-words
1932        let content = "## Self-contained with a `feature`\n";
1933        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1934        let fixed = rule.fix(&ctx).unwrap();
1935        // "with" and "a" should remain lowercase (not last word because code follows)
1936        assert!(
1937            fixed.contains("with a `feature`"),
1938            "Expected 'with a `feature`' but got: {fixed:?}"
1939        );
1940    }
1941
1942    // Issue #228: Sentence case with inline code at heading start
1943    // When a heading starts with inline code, the first word after the code
1944    // should NOT be capitalized because the heading already has a "first element"
1945
1946    #[test]
1947    fn test_sentence_case_code_at_start_basic() {
1948        // The exact case from issue #228
1949        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1950        let content = "# `rumdl` is a linter\n";
1951        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1952        let result = rule.check(&ctx).unwrap();
1953        // Should be correct as-is: code is first, "is" stays lowercase
1954        assert!(
1955            result.is_empty(),
1956            "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
1957            result.iter().map(|w| &w.message).collect::<Vec<_>>()
1958        );
1959    }
1960
1961    #[test]
1962    fn test_sentence_case_code_at_start_incorrect_capitalization() {
1963        // Verify we detect incorrect capitalization after code at start
1964        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1965        let content = "# `rumdl` Is a Linter\n";
1966        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1967        let result = rule.check(&ctx).unwrap();
1968        // Should flag: "Is" and "Linter" should be lowercase
1969        assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
1970        assert!(
1971            result[0].message.contains("`rumdl` is a linter"),
1972            "Should suggest lowercase after code. Got: {:?}",
1973            result[0].message
1974        );
1975    }
1976
1977    #[test]
1978    fn test_sentence_case_code_at_start_fix() {
1979        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1980        let content = "# `rumdl` Is A Linter\n";
1981        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1982        let fixed = rule.fix(&ctx).unwrap();
1983        assert!(
1984            fixed.contains("# `rumdl` is a linter"),
1985            "Should fix to lowercase after code. Got: {fixed:?}"
1986        );
1987    }
1988
1989    #[test]
1990    fn test_sentence_case_text_at_start_still_capitalizes() {
1991        // Ensure normal headings still capitalize first word
1992        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1993        let content = "# the quick brown fox\n";
1994        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1995        let result = rule.check(&ctx).unwrap();
1996        assert_eq!(result.len(), 1);
1997        assert!(
1998            result[0].message.contains("The quick brown fox"),
1999            "Text-first heading should capitalize first word. Got: {:?}",
2000            result[0].message
2001        );
2002    }
2003
2004    #[test]
2005    fn test_sentence_case_link_at_start() {
2006        // Links at start: link text is lowercased, following text also lowercase
2007        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2008        // Use lowercase link text to avoid link text case flagging
2009        let content = "# [api](api.md) reference guide\n";
2010        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2011        let result = rule.check(&ctx).unwrap();
2012        // "reference" should be lowercase (link is first)
2013        assert!(
2014            result.is_empty(),
2015            "Heading with link at start should not capitalize 'reference'. Got: {:?}",
2016            result.iter().map(|w| &w.message).collect::<Vec<_>>()
2017        );
2018    }
2019
2020    #[test]
2021    fn test_sentence_case_link_preserves_acronyms() {
2022        // Acronyms in link text should be preserved (API, HTTP, etc.)
2023        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2024        let content = "# [API](api.md) Reference Guide\n";
2025        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2026        let result = rule.check(&ctx).unwrap();
2027        assert_eq!(result.len(), 1);
2028        // "API" should be preserved (acronym), "Reference Guide" should be lowercased
2029        assert!(
2030            result[0].message.contains("[API](api.md) reference guide"),
2031            "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
2032            result[0].message
2033        );
2034    }
2035
2036    #[test]
2037    fn test_sentence_case_link_preserves_brand_names() {
2038        // Brand names with internal capitals should be preserved
2039        let config = MD063Config {
2040            enabled: true,
2041            style: HeadingCapStyle::SentenceCase,
2042            preserve_cased_words: true,
2043            ..Default::default()
2044        };
2045        let rule = MD063HeadingCapitalization::from_config_struct(config);
2046        let content = "# [iPhone](iphone.md) Features Guide\n";
2047        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2048        let result = rule.check(&ctx).unwrap();
2049        assert_eq!(result.len(), 1);
2050        // "iPhone" should be preserved, "Features Guide" should be lowercased
2051        assert!(
2052            result[0].message.contains("[iPhone](iphone.md) features guide"),
2053            "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
2054            result[0].message
2055        );
2056    }
2057
2058    #[test]
2059    fn test_sentence_case_link_lowercases_regular_words() {
2060        // Regular words in link text should be lowercased
2061        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2062        let content = "# [Documentation](docs.md) Reference\n";
2063        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2064        let result = rule.check(&ctx).unwrap();
2065        assert_eq!(result.len(), 1);
2066        // "Documentation" should be lowercased (regular word)
2067        assert!(
2068            result[0].message.contains("[documentation](docs.md) reference"),
2069            "Should lowercase regular link text. Got: {:?}",
2070            result[0].message
2071        );
2072    }
2073
2074    #[test]
2075    fn test_sentence_case_link_at_start_correct_already() {
2076        // Link with correct casing should not be flagged
2077        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2078        let content = "# [API](api.md) reference guide\n";
2079        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2080        let result = rule.check(&ctx).unwrap();
2081        assert!(
2082            result.is_empty(),
2083            "Correctly cased heading with link should not be flagged. Got: {:?}",
2084            result.iter().map(|w| &w.message).collect::<Vec<_>>()
2085        );
2086    }
2087
2088    #[test]
2089    fn test_sentence_case_link_github_preserved() {
2090        // GitHub should be preserved (internal capitals)
2091        let config = MD063Config {
2092            enabled: true,
2093            style: HeadingCapStyle::SentenceCase,
2094            preserve_cased_words: true,
2095            ..Default::default()
2096        };
2097        let rule = MD063HeadingCapitalization::from_config_struct(config);
2098        let content = "# [GitHub](gh.md) Repository Setup\n";
2099        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2100        let result = rule.check(&ctx).unwrap();
2101        assert_eq!(result.len(), 1);
2102        assert!(
2103            result[0].message.contains("[GitHub](gh.md) repository setup"),
2104            "Should preserve 'GitHub'. Got: {:?}",
2105            result[0].message
2106        );
2107    }
2108
2109    #[test]
2110    fn test_sentence_case_multiple_code_spans() {
2111        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2112        let content = "# `foo` and `bar` are methods\n";
2113        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2114        let result = rule.check(&ctx).unwrap();
2115        // All text after first code should be lowercase
2116        assert!(
2117            result.is_empty(),
2118            "Should not capitalize words between/after code spans. Got: {:?}",
2119            result.iter().map(|w| &w.message).collect::<Vec<_>>()
2120        );
2121    }
2122
2123    #[test]
2124    fn test_sentence_case_code_only_heading() {
2125        // Heading with only code, no text
2126        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2127        let content = "# `rumdl`\n";
2128        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2129        let result = rule.check(&ctx).unwrap();
2130        assert!(
2131            result.is_empty(),
2132            "Code-only heading should be fine. Got: {:?}",
2133            result.iter().map(|w| &w.message).collect::<Vec<_>>()
2134        );
2135    }
2136
2137    #[test]
2138    fn test_sentence_case_code_at_end() {
2139        // Heading ending with code, text before should still capitalize first word
2140        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2141        let content = "# install the `rumdl` tool\n";
2142        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2143        let result = rule.check(&ctx).unwrap();
2144        // "install" should be capitalized (first word), rest lowercase
2145        assert_eq!(result.len(), 1);
2146        assert!(
2147            result[0].message.contains("Install the `rumdl` tool"),
2148            "First word should still be capitalized when text comes first. Got: {:?}",
2149            result[0].message
2150        );
2151    }
2152
2153    #[test]
2154    fn test_sentence_case_code_in_middle() {
2155        // Code in middle, text at start should capitalize first word
2156        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2157        let content = "# using the `rumdl` linter for markdown\n";
2158        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2159        let result = rule.check(&ctx).unwrap();
2160        // "using" should be capitalized, rest lowercase
2161        assert_eq!(result.len(), 1);
2162        assert!(
2163            result[0].message.contains("Using the `rumdl` linter for markdown"),
2164            "First word should be capitalized. Got: {:?}",
2165            result[0].message
2166        );
2167    }
2168
2169    #[test]
2170    fn test_sentence_case_preserved_word_after_code() {
2171        // Preserved words (like iPhone) should stay preserved even after code
2172        let config = MD063Config {
2173            enabled: true,
2174            style: HeadingCapStyle::SentenceCase,
2175            preserve_cased_words: true,
2176            ..Default::default()
2177        };
2178        let rule = MD063HeadingCapitalization::from_config_struct(config);
2179        let content = "# `swift` iPhone development\n";
2180        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2181        let result = rule.check(&ctx).unwrap();
2182        // "iPhone" should be preserved, "development" lowercase
2183        assert!(
2184            result.is_empty(),
2185            "Preserved words after code should stay. Got: {:?}",
2186            result.iter().map(|w| &w.message).collect::<Vec<_>>()
2187        );
2188    }
2189
2190    #[test]
2191    fn test_title_case_code_at_start_still_capitalizes() {
2192        // Title case should still capitalize words even after code at start
2193        let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2194        let content = "# `api` quick start guide\n";
2195        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2196        let result = rule.check(&ctx).unwrap();
2197        // Title case: all major words capitalized
2198        assert_eq!(result.len(), 1);
2199        assert!(
2200            result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
2201            "Title case should capitalize major words after code. Got: {:?}",
2202            result[0].message
2203        );
2204    }
2205
2206    // ======== HTML TAG TESTS ========
2207
2208    #[test]
2209    fn test_sentence_case_html_tag_at_start() {
2210        // HTML tag at start: text after should NOT capitalize first word
2211        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2212        let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
2213        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2214        let result = rule.check(&ctx).unwrap();
2215        // "is", "a", "Modifier", "Key" should all be lowercase (except preserved words)
2216        assert_eq!(result.len(), 1);
2217        let fixed = rule.fix(&ctx).unwrap();
2218        assert_eq!(
2219            fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
2220            "Text after HTML at start should be lowercase"
2221        );
2222    }
2223
2224    #[test]
2225    fn test_sentence_case_html_tag_preserves_content() {
2226        // Content inside HTML tags should be preserved as-is
2227        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2228        let content = "# The <abbr>API</abbr> documentation guide\n";
2229        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2230        let result = rule.check(&ctx).unwrap();
2231        // "The" is first, "API" inside tag preserved, rest lowercase
2232        assert!(
2233            result.is_empty(),
2234            "HTML tag content should be preserved. Got: {:?}",
2235            result.iter().map(|w| &w.message).collect::<Vec<_>>()
2236        );
2237    }
2238
2239    #[test]
2240    fn test_sentence_case_html_tag_at_start_with_acronym() {
2241        // HTML tag at start with acronym content
2242        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2243        let content = "# <abbr>API</abbr> Documentation Guide\n";
2244        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2245        let result = rule.check(&ctx).unwrap();
2246        assert_eq!(result.len(), 1);
2247        let fixed = rule.fix(&ctx).unwrap();
2248        assert_eq!(
2249            fixed, "# <abbr>API</abbr> documentation guide\n",
2250            "Text after HTML at start should be lowercase, HTML content preserved"
2251        );
2252    }
2253
2254    #[test]
2255    fn test_sentence_case_html_tag_in_middle() {
2256        // HTML tag in middle: first word still capitalized
2257        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2258        let content = "# using the <code>config</code> File\n";
2259        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2260        let result = rule.check(&ctx).unwrap();
2261        assert_eq!(result.len(), 1);
2262        let fixed = rule.fix(&ctx).unwrap();
2263        assert_eq!(
2264            fixed, "# Using the <code>config</code> file\n",
2265            "First word capitalized, HTML preserved, rest lowercase"
2266        );
2267    }
2268
2269    #[test]
2270    fn test_html_tag_strong_emphasis() {
2271        // <strong> tag handling
2272        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2273        let content = "# The <strong>Bold</strong> Way\n";
2274        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2275        let result = rule.check(&ctx).unwrap();
2276        assert_eq!(result.len(), 1);
2277        let fixed = rule.fix(&ctx).unwrap();
2278        assert_eq!(
2279            fixed, "# The <strong>Bold</strong> way\n",
2280            "<strong> tag content should be preserved"
2281        );
2282    }
2283
2284    #[test]
2285    fn test_html_tag_with_attributes() {
2286        // HTML tags with attributes should still be detected
2287        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2288        let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
2289        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2290        let result = rule.check(&ctx).unwrap();
2291        assert_eq!(result.len(), 1);
2292        let fixed = rule.fix(&ctx).unwrap();
2293        assert_eq!(
2294            fixed, "# <span class=\"highlight\">Important</span> notice here\n",
2295            "HTML tag with attributes should be preserved"
2296        );
2297    }
2298
2299    #[test]
2300    fn test_multiple_html_tags() {
2301        // Multiple HTML tags in heading
2302        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2303        let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
2304        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2305        let result = rule.check(&ctx).unwrap();
2306        assert_eq!(result.len(), 1);
2307        let fixed = rule.fix(&ctx).unwrap();
2308        assert_eq!(
2309            fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
2310            "Multiple HTML tags should all be preserved"
2311        );
2312    }
2313
2314    #[test]
2315    fn test_html_and_code_mixed() {
2316        // Mix of HTML tags and inline code
2317        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2318        let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
2319        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2320        let result = rule.check(&ctx).unwrap();
2321        assert_eq!(result.len(), 1);
2322        let fixed = rule.fix(&ctx).unwrap();
2323        assert_eq!(
2324            fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
2325            "HTML and code should both be preserved"
2326        );
2327    }
2328
2329    #[test]
2330    fn test_self_closing_html_tag() {
2331        // Self-closing tags like <br/>
2332        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2333        let content = "# Line one<br/>Line Two Here\n";
2334        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2335        let result = rule.check(&ctx).unwrap();
2336        assert_eq!(result.len(), 1);
2337        let fixed = rule.fix(&ctx).unwrap();
2338        assert_eq!(
2339            fixed, "# Line one<br/>line two here\n",
2340            "Self-closing HTML tags should be preserved"
2341        );
2342    }
2343
2344    #[test]
2345    fn test_title_case_with_html_tags() {
2346        // Title case with HTML tags
2347        let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2348        let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
2349        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2350        let result = rule.check(&ctx).unwrap();
2351        assert_eq!(result.len(), 1);
2352        let fixed = rule.fix(&ctx).unwrap();
2353        // "the" as first word should be "The", content inside <kbd> preserved
2354        assert!(
2355            fixed.contains("<kbd>ctrl</kbd>"),
2356            "HTML tag content should be preserved in title case. Got: {fixed}"
2357        );
2358        assert!(
2359            fixed.starts_with("# The ") || fixed.starts_with("# the "),
2360            "Title case should work with HTML. Got: {fixed}"
2361        );
2362    }
2363
2364    // ======== CARET NOTATION TESTS ========
2365
2366    #[test]
2367    fn test_sentence_case_preserves_caret_notation() {
2368        // Caret notation for control characters should be preserved
2369        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2370        let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
2371        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2372        let result = rule.check(&ctx).unwrap();
2373        // Should not flag - ^A and ^R are preserved
2374        assert!(
2375            result.is_empty(),
2376            "Caret notation should be preserved. Got: {:?}",
2377            result.iter().map(|w| &w.message).collect::<Vec<_>>()
2378        );
2379    }
2380
2381    #[test]
2382    fn test_sentence_case_caret_notation_various() {
2383        // Various caret notation patterns
2384        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2385
2386        // ^C for interrupt
2387        let content = "## Press ^C to cancel\n";
2388        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2389        let result = rule.check(&ctx).unwrap();
2390        assert!(
2391            result.is_empty(),
2392            "^C should be preserved. Got: {:?}",
2393            result.iter().map(|w| &w.message).collect::<Vec<_>>()
2394        );
2395
2396        // ^Z for suspend
2397        let content = "## Use ^Z for background\n";
2398        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2399        let result = rule.check(&ctx).unwrap();
2400        assert!(
2401            result.is_empty(),
2402            "^Z should be preserved. Got: {:?}",
2403            result.iter().map(|w| &w.message).collect::<Vec<_>>()
2404        );
2405
2406        // ^[ for escape
2407        let content = "## Press ^[ for escape\n";
2408        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2409        let result = rule.check(&ctx).unwrap();
2410        assert!(
2411            result.is_empty(),
2412            "^[ should be preserved. Got: {:?}",
2413            result.iter().map(|w| &w.message).collect::<Vec<_>>()
2414        );
2415    }
2416
2417    #[test]
2418    fn test_caret_notation_detection() {
2419        let rule = create_rule();
2420
2421        // Valid caret notation
2422        assert!(rule.is_caret_notation("^A"));
2423        assert!(rule.is_caret_notation("^Z"));
2424        assert!(rule.is_caret_notation("^C"));
2425        assert!(rule.is_caret_notation("^@")); // NUL
2426        assert!(rule.is_caret_notation("^[")); // ESC
2427        assert!(rule.is_caret_notation("^]")); // GS
2428        assert!(rule.is_caret_notation("^^")); // RS
2429        assert!(rule.is_caret_notation("^_")); // US
2430
2431        // Not caret notation
2432        assert!(!rule.is_caret_notation("^a")); // lowercase
2433        assert!(!rule.is_caret_notation("A")); // no caret
2434        assert!(!rule.is_caret_notation("^")); // caret alone
2435        assert!(!rule.is_caret_notation("^1")); // digit
2436    }
2437
2438    // MD044 proper names integration tests
2439    //
2440    // When MD063 (sentence case) and MD044 (proper names) are both active, MD063 must
2441    // preserve the exact capitalization of MD044 proper names rather than lowercasing them.
2442    // Without this, the two rules oscillate: MD044 re-capitalizes what MD063 lowercases.
2443
2444    fn create_sentence_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2445        let config = MD063Config {
2446            enabled: true,
2447            style: HeadingCapStyle::SentenceCase,
2448            ..Default::default()
2449        };
2450        let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2451        rule.proper_names = names;
2452        rule
2453    }
2454
2455    #[test]
2456    fn test_sentence_case_preserves_single_word_proper_name() {
2457        let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2458        // "javascript" in non-first position should become "JavaScript", not "javascript"
2459        let content = "# installing javascript\n";
2460        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2461        let result = rule.check(&ctx).unwrap();
2462        assert_eq!(result.len(), 1, "Should flag the heading");
2463        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2464        assert!(
2465            fix_text.contains("JavaScript"),
2466            "Fix should preserve proper name 'JavaScript', got: {fix_text:?}"
2467        );
2468        assert!(
2469            !fix_text.contains("javascript"),
2470            "Fix should not have lowercase 'javascript', got: {fix_text:?}"
2471        );
2472    }
2473
2474    #[test]
2475    fn test_sentence_case_preserves_multi_word_proper_name() {
2476        let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2477        // "Good Application" is a proper name; sentence case must not lowercase "Application"
2478        let content = "# using good application features\n";
2479        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2480        let result = rule.check(&ctx).unwrap();
2481        assert_eq!(result.len(), 1, "Should flag the heading");
2482        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2483        assert!(
2484            fix_text.contains("Good Application"),
2485            "Fix should preserve 'Good Application' as a phrase, got: {fix_text:?}"
2486        );
2487    }
2488
2489    #[test]
2490    fn test_sentence_case_proper_name_at_start_of_heading() {
2491        let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2492        // The proper name "Good Application" starts the heading; both words must be canonical
2493        let content = "# good application overview\n";
2494        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2495        let result = rule.check(&ctx).unwrap();
2496        assert_eq!(result.len(), 1, "Should flag the heading");
2497        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2498        assert!(
2499            fix_text.contains("Good Application"),
2500            "Fix should produce 'Good Application' at start of heading, got: {fix_text:?}"
2501        );
2502        assert!(
2503            fix_text.contains("overview"),
2504            "Non-proper-name word 'overview' should be lowercase, got: {fix_text:?}"
2505        );
2506    }
2507
2508    #[test]
2509    fn test_sentence_case_with_proper_names_no_oscillation() {
2510        // This is the core convergence test: applying the fix once must produce
2511        // output that is already correct (no further changes needed).
2512        let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2513
2514        // First application of fix
2515        let content = "# installing good application on your system\n";
2516        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2517        let result = rule.check(&ctx).unwrap();
2518        assert_eq!(result.len(), 1);
2519        let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
2520
2521        // The fixed heading should contain the proper name preserved
2522        assert!(
2523            fixed_heading.contains("Good Application"),
2524            "After fix, proper name must be preserved: {fixed_heading:?}"
2525        );
2526
2527        // Second application: must produce no further warnings (convergence)
2528        let fixed_line = format!("{fixed_heading}\n");
2529        let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
2530        let result2 = rule.check(&ctx2).unwrap();
2531        assert!(
2532            result2.is_empty(),
2533            "After one fix, heading must already satisfy both MD063 and MD044 - no oscillation. \
2534             Second pass warnings: {result2:?}"
2535        );
2536    }
2537
2538    #[test]
2539    fn test_sentence_case_proper_names_already_correct() {
2540        let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2541        // Heading already has correct sentence case with proper name preserved
2542        let content = "# Installing Good Application\n";
2543        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2544        let result = rule.check(&ctx).unwrap();
2545        assert!(
2546            result.is_empty(),
2547            "Correct sentence-case heading with proper name should not be flagged, got: {result:?}"
2548        );
2549    }
2550
2551    #[test]
2552    fn test_sentence_case_multiple_proper_names_in_heading() {
2553        let rule = create_sentence_case_rule_with_proper_names(vec!["TypeScript".to_string(), "React".to_string()]);
2554        let content = "# using typescript with react\n";
2555        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2556        let result = rule.check(&ctx).unwrap();
2557        assert_eq!(result.len(), 1);
2558        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2559        assert!(
2560            fix_text.contains("TypeScript"),
2561            "Fix should preserve 'TypeScript', got: {fix_text:?}"
2562        );
2563        assert!(
2564            fix_text.contains("React"),
2565            "Fix should preserve 'React', got: {fix_text:?}"
2566        );
2567    }
2568
2569    #[test]
2570    fn test_sentence_case_unicode_casefold_expansion_before_proper_name() {
2571        // Regression for Unicode case-fold expansion: `İ` lowercases to `i̇` (2 code points),
2572        // so matching offsets must be computed from the original text, not from a lowercased copy.
2573        let rule = create_sentence_case_rule_with_proper_names(vec!["Österreich".to_string()]);
2574        let content = "# İ österreich guide\n";
2575        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2576
2577        // Should not panic and should preserve canonical proper-name casing.
2578        let result = rule.check(&ctx).unwrap();
2579        assert_eq!(result.len(), 1, "Should flag heading for canonical proper-name casing");
2580        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2581        assert!(
2582            fix_text.contains("Österreich"),
2583            "Fix should preserve canonical 'Österreich', got: {fix_text:?}"
2584        );
2585    }
2586
2587    #[test]
2588    fn test_sentence_case_preserves_trailing_punctuation_on_proper_name() {
2589        let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2590        let content = "# using javascript, today\n";
2591        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2592        let result = rule.check(&ctx).unwrap();
2593        assert_eq!(result.len(), 1, "Should flag heading");
2594        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2595        assert!(
2596            fix_text.contains("JavaScript,"),
2597            "Fix should preserve trailing punctuation, got: {fix_text:?}"
2598        );
2599    }
2600
2601    // Title case + MD044 conflict tests
2602    //
2603    // In title case, short words like "the", "a", "of" are kept lowercase by MD063.
2604    // If those words are part of an MD044 proper name (e.g. "The Rolling Stones"),
2605    // the same oscillation problem occurs.  The fix must extend to title case too.
2606
2607    fn create_title_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2608        let config = MD063Config {
2609            enabled: true,
2610            style: HeadingCapStyle::TitleCase,
2611            ..Default::default()
2612        };
2613        let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2614        rule.proper_names = names;
2615        rule
2616    }
2617
2618    #[test]
2619    fn test_title_case_preserves_proper_name_with_lowercase_article() {
2620        // "The" is in the lowercase_words list for title case, so "the" in the middle
2621        // of a heading would normally stay lowercase.  But "The Rolling Stones" is a
2622        // proper name that must be capitalised exactly.
2623        let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
2624        let content = "# listening to the rolling stones today\n";
2625        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2626        let result = rule.check(&ctx).unwrap();
2627        assert_eq!(result.len(), 1, "Should flag the heading");
2628        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2629        assert!(
2630            fix_text.contains("The Rolling Stones"),
2631            "Fix should preserve proper name 'The Rolling Stones', got: {fix_text:?}"
2632        );
2633    }
2634
2635    #[test]
2636    fn test_title_case_proper_name_no_oscillation() {
2637        // One fix pass must produce output that title case already accepts.
2638        let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
2639        let content = "# listening to the rolling stones today\n";
2640        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2641        let result = rule.check(&ctx).unwrap();
2642        assert_eq!(result.len(), 1);
2643        let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
2644
2645        let fixed_line = format!("{fixed_heading}\n");
2646        let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
2647        let result2 = rule.check(&ctx2).unwrap();
2648        assert!(
2649            result2.is_empty(),
2650            "After one title-case fix, heading must already satisfy both rules. \
2651             Second pass warnings: {result2:?}"
2652        );
2653    }
2654
2655    #[test]
2656    fn test_title_case_unicode_casefold_expansion_before_proper_name() {
2657        let rule = create_title_case_rule_with_proper_names(vec!["Österreich".to_string()]);
2658        let content = "# İ österreich guide\n";
2659        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2660        let result = rule.check(&ctx).unwrap();
2661        assert_eq!(result.len(), 1, "Should flag the heading");
2662        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2663        assert!(
2664            fix_text.contains("Österreich"),
2665            "Fix should preserve canonical proper-name casing, got: {fix_text:?}"
2666        );
2667    }
2668
2669    // End-to-end integration test: from_config wires MD044 names into MD063
2670    //
2671    // This tests the actual code path used in production, where both rules are
2672    // configured in a rumdl.toml and the rule registry calls from_config.
2673
2674    #[test]
2675    fn test_from_config_loads_md044_names_into_md063() {
2676        use crate::config::{Config, RuleConfig};
2677        use crate::rule::Rule;
2678        use std::collections::BTreeMap;
2679
2680        let mut config = Config::default();
2681
2682        // Configure MD063 with sentence_case
2683        let mut md063_values = BTreeMap::new();
2684        md063_values.insert("style".to_string(), toml::Value::String("sentence_case".to_string()));
2685        md063_values.insert("enabled".to_string(), toml::Value::Boolean(true));
2686        config.rules.insert(
2687            "MD063".to_string(),
2688            RuleConfig {
2689                values: md063_values,
2690                severity: None,
2691            },
2692        );
2693
2694        // Configure MD044 with a proper name
2695        let mut md044_values = BTreeMap::new();
2696        md044_values.insert(
2697            "names".to_string(),
2698            toml::Value::Array(vec![toml::Value::String("Good Application".to_string())]),
2699        );
2700        config.rules.insert(
2701            "MD044".to_string(),
2702            RuleConfig {
2703                values: md044_values,
2704                severity: None,
2705            },
2706        );
2707
2708        // Build MD063 via the production code path
2709        let rule = MD063HeadingCapitalization::from_config(&config);
2710
2711        // Verify MD044 names were loaded: the fix must preserve "Good Application"
2712        let content = "# using good application features\n";
2713        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2714        let result = rule.check(&ctx).unwrap();
2715        assert_eq!(result.len(), 1, "Should flag the heading");
2716        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2717        assert!(
2718            fix_text.contains("Good Application"),
2719            "from_config should wire MD044 names into MD063; fix should preserve \
2720             'Good Application', got: {fix_text:?}"
2721        );
2722    }
2723
2724    #[test]
2725    fn test_title_case_short_word_not_confused_with_substring() {
2726        // Verify that short preposition matching ("in") does not trigger on
2727        // substrings of longer words ("insert"). Title case must capitalize
2728        // "insert" while keeping "in" lowercase.
2729        let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2730
2731        // "in" is a short preposition (should be lowercase in title case)
2732        // "insert" contains "in" as substring but is a regular word (should be capitalized)
2733        let content = "# in the insert\n";
2734        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2735        let result = rule.check(&ctx).unwrap();
2736        assert_eq!(result.len(), 1, "Should flag the heading");
2737        let fix = result[0].fix.as_ref().expect("Fix should be present");
2738        // "In" capitalized as first word, "the" lowercase as article, "Insert" capitalized
2739        assert!(
2740            fix.replacement.contains("In the Insert"),
2741            "Expected 'In the Insert', got: {:?}",
2742            fix.replacement
2743        );
2744    }
2745
2746    #[test]
2747    fn test_title_case_or_not_confused_with_orchestra() {
2748        let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2749
2750        // "or" is a conjunction (should be lowercase in title case)
2751        // "orchestra" contains "or" as substring but is a regular word
2752        let content = "# or the orchestra\n";
2753        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2754        let result = rule.check(&ctx).unwrap();
2755        assert_eq!(result.len(), 1, "Should flag the heading");
2756        let fix = result[0].fix.as_ref().expect("Fix should be present");
2757        // "Or" capitalized as first word, "the" lowercase, "Orchestra" capitalized
2758        assert!(
2759            fix.replacement.contains("Or the Orchestra"),
2760            "Expected 'Or the Orchestra', got: {:?}",
2761            fix.replacement
2762        );
2763    }
2764
2765    #[test]
2766    fn test_all_caps_preserves_all_words() {
2767        let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
2768
2769        let content = "# in the insert\n";
2770        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2771        let result = rule.check(&ctx).unwrap();
2772        assert_eq!(result.len(), 1, "Should flag the heading");
2773        let fix = result[0].fix.as_ref().expect("Fix should be present");
2774        assert!(
2775            fix.replacement.contains("IN THE INSERT"),
2776            "All caps should uppercase all words, got: {:?}",
2777            fix.replacement
2778        );
2779    }
2780
2781    // Numbered prefix tests — words following a period-terminated token must be capitalized
2782    #[test]
2783    fn test_title_case_numbered_prefix_lowercase_word() {
2784        // "to" follows "1." and must be treated as the start of a new phrase
2785        let rule = create_rule();
2786        let content = "## 1. To Be a Thing\n";
2787        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2788        let result = rule.check(&ctx).unwrap();
2789        assert!(
2790            result.is_empty(),
2791            "Should not flag '## 1. To Be a Thing', got: {result:?}"
2792        );
2793
2794        let content_lower = "## 1. to be a thing\n";
2795        let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2796        let result2 = rule.check(&ctx2).unwrap();
2797        assert!(!result2.is_empty(), "Should flag '## 1. to be a thing'");
2798        let fix = result2[0].fix.as_ref().expect("Should have a fix");
2799        assert!(
2800            fix.replacement.contains("1. To Be a Thing"),
2801            "Fix should capitalize 'To', got: {:?}",
2802            fix.replacement
2803        );
2804    }
2805
2806    #[test]
2807    fn test_title_case_numbered_prefix_article() {
2808        // "a" follows "2." and must be capitalized as the first word of the phrase
2809        let rule = create_rule();
2810        let content = "## 2. A Guide to the Galaxy\n";
2811        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2812        let result = rule.check(&ctx).unwrap();
2813        assert!(
2814            result.is_empty(),
2815            "Should not flag '## 2. A Guide to the Galaxy', got: {result:?}"
2816        );
2817
2818        let content_lower = "## 2. a guide to the galaxy\n";
2819        let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2820        let result2 = rule.check(&ctx2).unwrap();
2821        assert!(!result2.is_empty(), "Should flag '## 2. a guide to the galaxy'");
2822        let fix = result2[0].fix.as_ref().expect("Should have a fix");
2823        assert!(
2824            fix.replacement.contains("2. A Guide to the Galaxy"),
2825            "Fix should capitalize 'A', got: {:?}",
2826            fix.replacement
2827        );
2828    }
2829
2830    #[test]
2831    fn test_title_case_mid_sentence_period_word() {
2832        // "introduction" follows "1." embedded in a phrase — must be capitalized
2833        let rule = create_rule();
2834        let content = "## Step 1. Introduction to the Problem\n";
2835        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2836        let result = rule.check(&ctx).unwrap();
2837        assert!(
2838            result.is_empty(),
2839            "Should not flag '## Step 1. Introduction to the Problem', got: {result:?}"
2840        );
2841
2842        let content_lower = "## Step 1. introduction to the problem\n";
2843        let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2844        let result2 = rule.check(&ctx2).unwrap();
2845        assert!(
2846            !result2.is_empty(),
2847            "Should flag '## Step 1. introduction to the problem'"
2848        );
2849        let fix = result2[0].fix.as_ref().expect("Should have a fix");
2850        assert!(
2851            fix.replacement.contains("Step 1. Introduction to the Problem"),
2852            "Fix should capitalize 'Introduction', got: {:?}",
2853            fix.replacement
2854        );
2855    }
2856
2857    #[test]
2858    fn test_title_case_numbered_prefix_in_link_text() {
2859        // apply_title_case (link text path) must also respect after_period.
2860        // A heading whose only content is a link: ## [1. to be a thing](url)
2861        let config = MD063Config {
2862            enabled: true,
2863            style: HeadingCapStyle::TitleCase,
2864            ..Default::default()
2865        };
2866        let rule = MD063HeadingCapitalization::from_config_struct(config);
2867
2868        // Correct heading — link text already title-cased after numbered prefix
2869        let content = "## [1. To Be a Thing](https://example.com)\n";
2870        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2871        let result = rule.check(&ctx).unwrap();
2872        assert!(
2873            result.is_empty(),
2874            "Should not flag '## [1. To Be a Thing](url)', got: {result:?}"
2875        );
2876
2877        // Incorrect heading — "to" in link text must be capitalized after "1."
2878        let content_lower = "## [1. to be a thing](https://example.com)\n";
2879        let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2880        let result2 = rule.check(&ctx2).unwrap();
2881        assert!(!result2.is_empty(), "Should flag '## [1. to be a thing](url)'");
2882        let fix = result2[0].fix.as_ref().expect("Should have a fix");
2883        assert!(
2884            fix.replacement.contains("1. To Be a Thing"),
2885            "Fix should capitalize 'To' in link text, got: {:?}",
2886            fix.replacement
2887        );
2888    }
2889
2890    // Numeric-ordinal tests (issue #608): "1st", "2nd", "3rd", "4th", "21st"
2891    // and so on must keep their alphabetic suffix lower-cased in title case
2892    // and must be normalised back from mis-cased forms like "5Th".
2893
2894    #[test]
2895    fn test_is_numeric_ordinal_recognises_canonical_forms() {
2896        for word in &[
2897            "1st", "2nd", "3rd", "4th", "5th", "11th", "21st", "22nd", "23rd", "100th", "1ST", "5Th", "21St", "21sT",
2898        ] {
2899            assert!(
2900                MD063HeadingCapitalization::is_numeric_ordinal(word),
2901                "expected `{word}` to be detected as a numeric ordinal"
2902            );
2903        }
2904    }
2905
2906    #[test]
2907    fn test_is_numeric_ordinal_rejects_non_ordinals() {
2908        // Words without a digit prefix, an unrecognised alphabetic suffix,
2909        // or a non-ordinal alpha tail are all rejected. Compound forms with
2910        // hyphens are handled by `handle_hyphenated_word` so the helper's
2911        // behaviour on them is intentionally unconstrained.
2912        for word in &[
2913            "first", "1stop", "ist", "5", "th", "abc", "4G", "4K", "30s", "100k", "5x", "1.5", "iPhone6S",
2914        ] {
2915            assert!(
2916                !MD063HeadingCapitalization::is_numeric_ordinal(word),
2917                "expected `{word}` NOT to be detected as a numeric ordinal"
2918            );
2919        }
2920    }
2921
2922    #[test]
2923    fn test_is_numeric_ordinal_strips_trailing_punctuation() {
2924        for word in &["5th.", "1st,", "21st!", "3rd:", "4th)", "5th's"] {
2925            assert!(
2926                MD063HeadingCapitalization::is_numeric_ordinal(word),
2927                "expected `{word}` to be detected as a numeric ordinal (with punctuation)"
2928            );
2929        }
2930    }
2931
2932    #[test]
2933    fn test_title_case_ordinal_first_word_not_flagged() {
2934        let rule = create_rule();
2935        for content in &[
2936            "# 1st Place\n",
2937            "# 2nd Edition\n",
2938            "# 3rd Time\n",
2939            "# 5th Avenue\n",
2940            "# 21st Century Skills\n",
2941            "# 100th Customer\n",
2942        ] {
2943            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2944            let result = rule.check(&ctx).unwrap();
2945            assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
2946        }
2947    }
2948
2949    #[test]
2950    fn test_title_case_ordinal_mid_heading_not_flagged() {
2951        let rule = create_rule();
2952        for content in &[
2953            "# May 3rd Notes\n",
2954            "# Top 100th Customer\n",
2955            "# Notes for the 5th of May\n",
2956        ] {
2957            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2958            let result = rule.check(&ctx).unwrap();
2959            assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
2960        }
2961    }
2962
2963    #[test]
2964    fn test_title_case_ordinal_corrupted_form_is_fixed() {
2965        // The "sticky" case: a heading already mangled by the buggy
2966        // capitaliser must be flagged and corrected back, not left alone.
2967        let rule = create_rule();
2968        for (input, expected) in &[
2969            ("# 1St Place\n", "1st Place"),
2970            ("# 5Th Avenue\n", "5th Avenue"),
2971            ("# 21St Century Skills\n", "21st Century Skills"),
2972            ("# May 3Rd Notes\n", "May 3rd Notes"),
2973            ("# 22Nd Edition\n", "22nd Edition"),
2974        ] {
2975            let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
2976            let result = rule.check(&ctx).unwrap();
2977            assert!(!result.is_empty(), "Should flag {input:?}");
2978            let fix = result[0].fix.as_ref().expect("should have a fix");
2979            assert!(
2980                fix.replacement.contains(expected),
2981                "Fix for {input:?} should contain {expected:?}, got: {:?}",
2982                fix.replacement
2983            );
2984        }
2985    }
2986
2987    #[test]
2988    fn test_title_case_ordinal_lowercase_other_words_capitalised() {
2989        // Non-ordinal words around an ordinal still need title-casing.
2990        let rule = create_rule();
2991        let content = "# 5th avenue\n";
2992        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2993        let result = rule.check(&ctx).unwrap();
2994        assert_eq!(result.len(), 1);
2995        let fix = result[0].fix.as_ref().expect("should have a fix");
2996        assert!(
2997            fix.replacement.contains("5th Avenue"),
2998            "Fix should produce '5th Avenue', got: {:?}",
2999            fix.replacement
3000        );
3001    }
3002
3003    #[test]
3004    fn test_title_case_ordinal_with_trailing_punctuation() {
3005        let rule = create_rule();
3006        let content = "# Released on the 5th.\n";
3007        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3008        let result = rule.check(&ctx).unwrap();
3009        assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3010    }
3011
3012    #[test]
3013    fn test_title_case_ordinal_hyphenated() {
3014        let rule = create_rule();
3015        for content in &["# 21st-Century Skills\n", "# A 19th-Century Novel\n"] {
3016            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3017            let result = rule.check(&ctx).unwrap();
3018            assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3019        }
3020    }
3021
3022    #[test]
3023    fn test_sentence_case_ordinal_corrupted_form_is_fixed() {
3024        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3025        let content = "# 5Th avenue\n";
3026        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3027        let result = rule.check(&ctx).unwrap();
3028        assert_eq!(result.len(), 1);
3029        let fix = result[0].fix.as_ref().expect("should have a fix");
3030        assert!(
3031            fix.replacement.contains("5th avenue"),
3032            "Fix should produce '5th avenue', got: {:?}",
3033            fix.replacement
3034        );
3035    }
3036
3037    #[test]
3038    fn test_title_case_digit_acronym_unchanged() {
3039        // Non-ordinal digit-prefixed tokens (4G, 4K) must still be preserved
3040        // as all-caps acronyms — the ordinal carve-out must not catch them.
3041        let rule = create_rule();
3042        for content in &["# 4G Networks\n", "# 4K Streaming\n"] {
3043            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3044            let result = rule.check(&ctx).unwrap();
3045            assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3046        }
3047    }
3048}