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