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