Skip to main content

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