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