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::header_id_utils::{HTML_TAG_ATTRIBUTES_PATTERN, HTML_TAG_NAME_PATTERN, is_backslash_escaped};
16use crate::utils::html_elements::is_void_element;
17use crate::utils::mdg;
18use crate::utils::range_utils::byte_to_char_count;
19use regex::Regex;
20use std::collections::HashSet;
21use std::sync::LazyLock;
22
23mod md063_config;
24pub(super) use md063_config::{HeadingCapStyle, MD063Config};
25
26// Regex to match inline code spans (backticks)
27static INLINE_CODE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`+[^`]+`+").unwrap());
28
29// Regex to match markdown links [text](url) or [text][ref].
30// The inline-URL part allows one level of nested parentheses so URLs like
31// `https://example.com/docs/v(2)beta` are matched in full; otherwise the URL
32// would be truncated at the first ')' and its tail title-cased as prose.
33static LINK_REGEX: LazyLock<Regex> =
34    LazyLock::new(|| Regex::new(r"\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)|\[([^\]]*)\]\[[^\]]*\]").unwrap());
35
36// One inline HTML token: a comment, a closing tag (name in group 1), or an open
37// tag (name in group 2). `<!-->` and `<!--->` are complete comments, so a later
38// `-->` is text. Attributes follow the CommonMark grammar, so a quoted value may
39// contain `>`. Elements are paired by name in `html_regions`, since the regex
40// engine has no backreferences.
41static HTML_TOKEN_REGEX: LazyLock<Regex> = LazyLock::new(|| {
42    let pattern = format!(
43        r"<!-->|<!--->|<!--.*?-->|</({HTML_TAG_NAME_PATTERN})\s*>|<({HTML_TAG_NAME_PATTERN}){HTML_TAG_ATTRIBUTES_PATTERN}\s*/?>"
44    );
45    Regex::new(&pattern).unwrap()
46});
47
48// Elements that paint content of their own without holding text: the replaced
49// elements (images, media, frames, canvases, embedded SVG and MathML) and the
50// form controls. An empty one is still visible, so it counts as a word of the
51// heading, as a Markdown image does.
52const SELF_RENDERING_ELEMENTS: &[&str] = &[
53    "img", "video", "audio", "iframe", "embed", "object", "canvas", "svg", "math", "input", "select", "textarea",
54    "button", "meter", "progress",
55];
56
57// Regex to match custom header IDs {#id}
58static CUSTOM_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*\{#[^}]+\}\s*$").unwrap());
59
60/// Represents a segment of heading text
61#[derive(Debug, Clone)]
62enum HeadingSegment {
63    /// Regular text that should be capitalized
64    Text(String),
65    /// Inline code that should be preserved as-is
66    Code(String),
67    /// Link with text that may be capitalized and URL that's preserved
68    Link {
69        full: String,
70        text_start: usize,
71        text_end: usize,
72    },
73    /// Inline HTML tag that should be preserved as-is
74    Html(String),
75    /// Image `![alt](url)` preserved as-is, including alt text. Unlike link
76    /// text, image alt text is not recased.
77    Image(String),
78}
79
80impl HeadingSegment {
81    /// Whether the segment shows a reader nothing: an empty element such as an
82    /// anchor, or a comment. Such a segment does not move the heading's first or
83    /// last word. An element that paints content of its own, such as an image or
84    /// a form control, is visible even without text.
85    fn renders_nothing(&self) -> bool {
86        match self {
87            HeadingSegment::Html(html) => {
88                let paints_something = HTML_TOKEN_REGEX.captures_iter(html).any(|token| {
89                    token.get(2).is_some_and(|name| {
90                        let name = name.as_str().to_ascii_lowercase();
91                        SELF_RENDERING_ELEMENTS.contains(&name.as_str())
92                    })
93                });
94                !paints_something && HTML_TOKEN_REGEX.replace_all(html, "").trim().is_empty()
95            }
96            _ => false,
97        }
98    }
99}
100
101/// Rule MD063: Heading capitalization
102#[derive(Clone)]
103pub struct MD063HeadingCapitalization {
104    config: MD063Config,
105    lowercase_set: HashSet<String>,
106    /// Multi-word proper names from MD044 that must survive sentence-case transformation.
107    /// Populated via `from_config` when both rules are active.
108    proper_names: Vec<String>,
109}
110
111impl Default for MD063HeadingCapitalization {
112    fn default() -> Self {
113        Self::new()
114    }
115}
116
117impl MD063HeadingCapitalization {
118    pub fn new() -> Self {
119        let config = MD063Config::default();
120        let lowercase_set = config.lowercase_words.iter().cloned().collect();
121        Self {
122            config,
123            lowercase_set,
124            proper_names: Vec::new(),
125        }
126    }
127
128    pub fn from_config_struct(config: MD063Config) -> Self {
129        let lowercase_set = config.lowercase_words.iter().cloned().collect();
130        Self {
131            config,
132            lowercase_set,
133            proper_names: Vec::new(),
134        }
135    }
136
137    /// Match `pattern_lower` at `start` in `text` using Unicode-aware lowercasing.
138    /// Returns the end byte offset in `text` when the match succeeds.
139    ///
140    /// This avoids converting the full `text` to lowercase and then reusing those
141    /// offsets on the original string, which can panic for case-fold expansions
142    /// (e.g. `İ` -> `i̇`).
143    fn match_case_insensitive_at(text: &str, start: usize, pattern_lower: &str) -> Option<usize> {
144        if start > text.len() || !text.is_char_boundary(start) || pattern_lower.is_empty() {
145            return None;
146        }
147
148        let mut matched_bytes = 0;
149
150        for (offset, ch) in text[start..].char_indices() {
151            if matched_bytes >= pattern_lower.len() {
152                break;
153            }
154
155            let lowered: String = ch.to_lowercase().collect();
156            if !pattern_lower[matched_bytes..].starts_with(&lowered) {
157                return None;
158            }
159
160            matched_bytes += lowered.len();
161
162            if matched_bytes == pattern_lower.len() {
163                return Some(start + offset + ch.len_utf8());
164            }
165        }
166
167        None
168    }
169
170    /// Find the next case-insensitive match of `pattern_lower` in `text`,
171    /// returning byte offsets in the ORIGINAL string.
172    fn find_case_insensitive_match(text: &str, pattern_lower: &str, search_start: usize) -> Option<(usize, usize)> {
173        if pattern_lower.is_empty() || search_start >= text.len() || !text.is_char_boundary(search_start) {
174            return None;
175        }
176
177        for (offset, _) in text[search_start..].char_indices() {
178            let start = search_start + offset;
179            if let Some(end) = Self::match_case_insensitive_at(text, start, pattern_lower) {
180                return Some((start, end));
181            }
182        }
183
184        None
185    }
186
187    /// Build a map from word byte-position → canonical form for all proper names
188    /// that appear in the heading text (case-insensitive phrase match).
189    ///
190    /// This is used in `apply_sentence_case_from` so that words belonging to a proper
191    /// name phrase are never lowercased to begin with.
192    fn proper_name_canonical_forms(&self, text: &str) -> std::collections::HashMap<usize, &str> {
193        let mut map = std::collections::HashMap::new();
194
195        for name in &self.proper_names {
196            if name.is_empty() {
197                continue;
198            }
199            let name_lower = name.to_lowercase();
200            let canonical_words: Vec<&str> = name.split_whitespace().collect();
201            if canonical_words.is_empty() {
202                continue;
203            }
204            let mut search_start = 0;
205
206            while search_start < text.len() {
207                let Some((abs_pos, end_pos)) = Self::find_case_insensitive_match(text, &name_lower, search_start)
208                else {
209                    break;
210                };
211
212                // Require word boundaries
213                let before_ok = abs_pos == 0 || !text[..abs_pos].chars().last().is_some_and(char::is_alphanumeric);
214                let after_ok =
215                    end_pos >= text.len() || !text[end_pos..].chars().next().is_some_and(char::is_alphanumeric);
216
217                if before_ok && after_ok {
218                    // Map each word in the matched region to its canonical form.
219                    // We zip the words found in the text slice with the words of the
220                    // canonical name so that every word gets the right casing.
221                    let text_slice = &text[abs_pos..end_pos];
222                    let mut word_idx = 0;
223                    let mut slice_offset = 0;
224
225                    for text_word in text_slice.split_whitespace() {
226                        if let Some(w_rel) = text_slice[slice_offset..].find(text_word) {
227                            let word_abs = abs_pos + slice_offset + w_rel;
228                            if let Some(&canonical_word) = canonical_words.get(word_idx) {
229                                map.insert(word_abs, canonical_word);
230                            }
231                            slice_offset += w_rel + text_word.len();
232                            word_idx += 1;
233                        }
234                    }
235                }
236
237                // Advance by one Unicode scalar value to allow overlapping matches
238                // while staying on a UTF-8 char boundary.
239                search_start = abs_pos + text[abs_pos..].chars().next().map_or(1, char::len_utf8);
240            }
241        }
242
243        map
244    }
245
246    /// Check if a word has internal capitals (like "iPhone", "macOS", "GitHub", "iOS")
247    fn has_internal_capitals(&self, word: &str) -> bool {
248        let chars: Vec<char> = word.chars().collect();
249        if chars.len() < 2 {
250            return false;
251        }
252
253        let first = chars[0];
254        let rest = &chars[1..];
255        let has_upper_in_rest = rest.iter().any(|c| c.is_uppercase());
256        let has_lower_in_rest = rest.iter().any(|c| c.is_lowercase());
257
258        // Case 1: Mixed case after first character (like "iPhone", "macOS", "GitHub", "JavaScript")
259        if has_upper_in_rest && has_lower_in_rest {
260            return true;
261        }
262
263        // Case 2: Lowercase first + uppercase in rest (like "iOS", "eBay")
264        if first.is_lowercase() && has_upper_in_rest {
265            return true;
266        }
267
268        false
269    }
270
271    /// Check if a word is an all-caps acronym (2+ consecutive uppercase letters)
272    /// Examples: "API", "GPU", "HTTP2", "IO" return true
273    /// Examples: "A", "iPhone", "npm" return false
274    fn is_all_caps_acronym(&self, word: &str) -> bool {
275        // Skip single-letter words (handled by title case rules)
276        if word.len() < 2 {
277            return false;
278        }
279
280        let mut consecutive_upper = 0;
281        let mut max_consecutive = 0;
282
283        for c in word.chars() {
284            if c.is_uppercase() {
285                consecutive_upper += 1;
286                max_consecutive = max_consecutive.max(consecutive_upper);
287            } else if c.is_lowercase() {
288                // Any lowercase letter means not all-caps
289                return false;
290            } else {
291                // Non-letter (number, punctuation) - reset counter but don't fail
292                consecutive_upper = 0;
293            }
294        }
295
296        // Must have at least 2 consecutive uppercase letters
297        max_consecutive >= 2
298    }
299
300    /// Check if a word should be preserved as-is
301    fn should_preserve_word(&self, word: &str) -> bool {
302        // Check ignore_words list (case-sensitive exact match)
303        if self.is_ignored_word(word) {
304            return true;
305        }
306
307        // Numeric ordinals ("1st", "5th", "21st", ...) must always flow
308        // through the normal title-case path so mis-cased forms like
309        // "5Th" get normalised back to "5th". Skip the preserve_cased_words
310        // heuristics, which would otherwise treat "5Th" as intentionally
311        // mixed-case and leave it untouched.
312        let is_ordinal = Self::is_numeric_ordinal(word);
313
314        if !is_ordinal {
315            // Check if word has internal capitals and preserve_cased_words is enabled
316            if self.config.preserve_cased_words && self.has_internal_capitals(word) {
317                return true;
318            }
319
320            // Check if word is an all-caps acronym (2+ consecutive uppercase)
321            if self.config.preserve_cased_words && self.is_all_caps_acronym(word) {
322                return true;
323            }
324        }
325
326        // Preserve caret notation for control characters (^A, ^Z, ^@, etc.)
327        if self.is_caret_notation(word) {
328            return true;
329        }
330
331        false
332    }
333
334    /// Whether `word` is an `ignore-words` entry, case-sensitively. A token is
335    /// matched as written first (entries like `C++` or `.NET` carry their own
336    /// punctuation), then again with trailing punctuation peeled off one character
337    /// at a time, so `Kubernetes?`, `Kubernetes),` and `C++,` match the entries
338    /// `Kubernetes` and `C++`.
339    fn is_ignored_word(&self, word: &str) -> bool {
340        let mut candidate = word;
341        loop {
342            if self.config.ignore_words.iter().any(|ignored| ignored == candidate) {
343                return true;
344            }
345            match candidate.char_indices().next_back() {
346                Some((i, c)) if Self::is_trailing_punctuation(c) => candidate = &candidate[..i],
347                _ => return false,
348            }
349        }
350    }
351
352    /// Punctuation that can follow a word without becoming part of it: sentence
353    /// punctuation and closing brackets or quotes, in ASCII, Latin, CJK and fullwidth
354    /// forms. Symbols such as `+` or `#` and combining marks are not in the set, so
355    /// `c++` stays distinct from `c` and a decomposed accent stays with its letter.
356    fn is_trailing_punctuation(c: char) -> bool {
357        matches!(
358            c,
359            '.' | ','
360                | ';'
361                | ':'
362                | '!'
363                | '?'
364                | ')'
365                | ']'
366                | '}'
367                | '"'
368                | '\''
369                | '\u{2026}'
370                | '\u{2019}'
371                | '\u{201d}'
372                | '\u{203a}'
373                | '\u{bb}'
374                | '\u{3001}'
375                | '\u{3002}'
376                | '\u{3009}'
377                | '\u{300b}'
378                | '\u{300d}'
379                | '\u{300f}'
380                | '\u{3011}'
381                | '\u{ff01}'
382                | '\u{ff09}'
383                | '\u{ff0c}'
384                | '\u{ff0e}'
385                | '\u{ff1a}'
386                | '\u{ff1b}'
387                | '\u{ff1f}'
388        )
389    }
390
391    /// Detect numeric ordinals like `1st`, `2nd`, `3rd`, `4th`, `21st`,
392    /// `100th`, ignoring the case of the suffix and any punctuation wrapping
393    /// the token (e.g. `5th.`, `1st,`, `(2nd)`, `"3rd"`).
394    ///
395    /// Such tokens have a fixed lower-case alphabetic suffix in title case
396    /// — `21st Century`, never `21St Century` — and must be detected
397    /// before applying the generic "capitalise first letter" rule.
398    fn is_numeric_ordinal(word: &str) -> bool {
399        // The word arrives as a whitespace-split token, punctuation included, so
400        // both wrappers come off before the digits are read. Only the wrapping:
401        // an interior separator (`2-nd`) leaves a token that is not an ordinal,
402        // and a token with no alphanumeric core is rejected by the digit scan.
403        let core = word.trim_matches(|c: char| !c.is_alphanumeric());
404        let bytes = core.as_bytes();
405
406        // Require at least one leading ASCII digit followed by a letter.
407        let alpha_start = match bytes.iter().position(|&b| !b.is_ascii_digit()) {
408            Some(pos) if pos > 0 => pos,
409            _ => return false,
410        };
411
412        // Find where the alphabetic suffix ends (a possessive `'s`, and so on).
413        let alpha_end = bytes[alpha_start..]
414            .iter()
415            .position(|b| !b.is_ascii_alphabetic())
416            .map_or(bytes.len(), |p| alpha_start + p);
417
418        let suffix = &core[alpha_start..alpha_end];
419        matches!(suffix.to_ascii_lowercase().as_str(), "st" | "nd" | "rd" | "th")
420    }
421
422    /// Check if a word is caret notation for control characters (e.g., ^A, ^C, ^Z)
423    fn is_caret_notation(&self, word: &str) -> bool {
424        let chars: Vec<char> = word.chars().collect();
425        // Pattern: ^ followed by uppercase letter or @[\]^_
426        if chars.len() >= 2 && chars[0] == '^' {
427            let second = chars[1];
428            // Control characters: ^@ (NUL) through ^_ (US), which includes ^A-^Z
429            if second.is_ascii_uppercase() || "@[\\]^_".contains(second) {
430                return true;
431            }
432        }
433        false
434    }
435
436    /// Check if a word is a "lowercase word" (articles, prepositions, etc.)
437    fn is_lowercase_word(&self, word: &str) -> bool {
438        self.lowercase_set.contains(&word.to_lowercase())
439    }
440
441    /// Apply title case to a single word
442    fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
443        if word.is_empty() {
444            return word.to_string();
445        }
446
447        // Preserve words in ignore list or with internal capitals
448        if self.should_preserve_word(word) {
449            return word.to_string();
450        }
451
452        // First and last words are always capitalized
453        if is_first || is_last {
454            return self.capitalize_first(word);
455        }
456
457        // Check if it's a lowercase word (articles, prepositions, etc.)
458        if self.is_lowercase_word(word) {
459            return Self::lowercase_preserving_composition(word);
460        }
461
462        // Regular word - capitalize first letter
463        self.capitalize_first(word)
464    }
465
466    /// Apply canonical proper-name casing while preserving any trailing punctuation
467    /// attached to the original whitespace token (e.g. `javascript,` -> `JavaScript,`).
468    fn apply_canonical_form_to_word(word: &str, canonical: &str) -> String {
469        let canonical_lower = canonical.to_lowercase();
470        if canonical_lower.is_empty() {
471            return canonical.to_string();
472        }
473
474        if let Some(end_pos) = Self::match_case_insensitive_at(word, 0, &canonical_lower) {
475            let mut out = String::with_capacity(canonical.len() + word.len().saturating_sub(end_pos));
476            out.push_str(canonical);
477            out.push_str(&word[end_pos..]);
478            out
479        } else {
480            canonical.to_string()
481        }
482    }
483
484    /// Capitalize the first letter of a word, handling Unicode properly
485    fn capitalize_first(&self, word: &str) -> String {
486        if word.is_empty() {
487            return String::new();
488        }
489
490        // Find the first alphabetic character to capitalize
491        let first_alpha_pos = word.find(|c: char| c.is_alphabetic());
492        let Some(pos) = first_alpha_pos else {
493            return word.to_string();
494        };
495
496        let prefix = &word[..pos];
497        let suffix = &word[pos..];
498
499        // Numeric ordinals ("1st", "21st", "5th", ...) keep their
500        // alphabetic suffix lower-cased even at title-case positions.
501        if Self::is_numeric_ordinal(word) {
502            let suffix_lower = Self::lowercase_preserving_composition(suffix);
503            return format!("{prefix}{suffix_lower}");
504        }
505
506        let mut chars = suffix.chars();
507        let first = chars.next().unwrap();
508        // Use composition-preserving uppercase to avoid decomposing
509        // precomposed characters (e.g., ῷ → Ω + combining marks + Ι)
510        let first_upper = Self::uppercase_preserving_composition(&first.to_string());
511        let rest: String = chars.collect();
512        let rest_lower = Self::lowercase_preserving_composition(&rest);
513        format!("{prefix}{first_upper}{rest_lower}")
514    }
515
516    /// Lowercase a string character-by-character, preserving precomposed
517    /// characters that would decompose during case conversion.
518    fn lowercase_preserving_composition(s: &str) -> String {
519        let mut result = String::with_capacity(s.len());
520        for c in s.chars() {
521            let lower: String = c.to_lowercase().collect();
522            if lower.chars().count() == 1 {
523                result.push_str(&lower);
524            } else {
525                // Lowercasing would decompose this character; keep original
526                result.push(c);
527            }
528        }
529        result
530    }
531
532    /// Return the sentence-case spelling of English first-person pronouns found
533    /// in `word`, when at least one uses its required uppercase `I`.
534    ///
535    /// Sentence case normally lowercases every mid-sentence token, but `I` is
536    /// uppercase wherever it appears. Recognize its standard contractions as
537    /// well, including a typographic apostrophe and adjacent punctuation or
538    /// Markdown emphasis. Only already-uppercase instances are recognized: a
539    /// lowercase `i` may intentionally be a variable and is outside MD063's
540    /// responsibility to reinterpret.
541    fn sentence_case_first_person_pronouns(word: &str) -> Option<String> {
542        fn is_emphasis_marker(c: char) -> bool {
543            matches!(c, '*' | '_' | '~')
544        }
545
546        // These characters commonly join identifiers or abbreviations. Treating
547        // them as word boundaries would misread technical forms such as `I/O`,
548        // `A.I.` or `I-am` as the pronoun.
549        fn is_word_connector(c: char) -> bool {
550            matches!(c, '/' | '\\' | '-' | '.' | '+' | '&')
551        }
552
553        fn is_word_boundary(c: char) -> bool {
554            !c.is_alphanumeric() && !is_word_connector(c)
555        }
556
557        fn is_pronoun_at(word: &str, pos: usize) -> bool {
558            let left = word[..pos].trim_end_matches(is_emphasis_marker);
559            if !left.chars().next_back().is_none_or(is_word_boundary) {
560                return false;
561            }
562
563            let after_i = word[pos + 1..].trim_start_matches(is_emphasis_marker);
564            let Some(apostrophe) = after_i.chars().next() else {
565                return true;
566            };
567            if !matches!(apostrophe, '\'' | '’') {
568                return is_word_boundary(apostrophe);
569            }
570
571            let after_apostrophe = after_i[apostrophe.len_utf8()..].trim_start_matches(is_emphasis_marker);
572            let suffix_end = after_apostrophe
573                .find(|c: char| !c.is_ascii_alphabetic())
574                .unwrap_or(after_apostrophe.len());
575            let suffix = &after_apostrophe[..suffix_end];
576            if suffix.is_empty() {
577                // A trailing apostrophe can be a closing quotation mark around `I`.
578                return after_apostrophe.chars().next().is_none_or(is_word_boundary);
579            }
580            if !matches!(suffix.to_ascii_lowercase().as_str(), "d" | "ll" | "m" | "ve") {
581                return false;
582            }
583
584            let after_suffix = after_apostrophe[suffix_end..].trim_start_matches(is_emphasis_marker);
585            after_suffix.chars().next().is_none_or(is_word_boundary)
586        }
587
588        let pronoun_positions: Vec<usize> = word
589            .char_indices()
590            .filter_map(|(pos, c)| (c == 'I' && is_pronoun_at(word, pos)).then_some(pos))
591            .collect();
592        if pronoun_positions.is_empty() {
593            return None;
594        }
595
596        let mut result = String::with_capacity(word.len());
597        let mut copied_through = 0;
598        for pos in pronoun_positions {
599            result.push_str(&Self::lowercase_preserving_composition(&word[copied_through..pos]));
600            result.push('I');
601            copied_through = pos + 1;
602        }
603        result.push_str(&Self::lowercase_preserving_composition(&word[copied_through..]));
604        Some(result)
605    }
606
607    /// Uppercase a string character-by-character, preserving precomposed
608    /// characters that would decompose during case conversion.
609    /// For example, ῷ (U+1FF7) would decompose into Ω + combining marks + Ι
610    /// via to_uppercase(); this function keeps ῷ unchanged instead.
611    fn uppercase_preserving_composition(s: &str) -> String {
612        let mut result = String::with_capacity(s.len());
613        for c in s.chars() {
614            let upper: String = c.to_uppercase().collect();
615            if upper.chars().count() == 1 {
616                result.push_str(&upper);
617            } else {
618                // Uppercasing would decompose this character; keep original
619                result.push(c);
620            }
621        }
622        result
623    }
624
625    /// Apply title case to text, using our own title-case logic.
626    /// We avoid the external titlecase crate because it decomposes
627    /// precomposed Unicode characters during case conversion.
628    fn apply_title_case(&self, text: &str) -> String {
629        let canonical_forms = self.proper_name_canonical_forms(text);
630
631        let original_words: Vec<&str> = text.split_whitespace().collect();
632        let total_words = original_words.len();
633
634        // Pre-compute byte position of each word for canonical form lookup.
635        // Use usize::MAX as sentinel for unfound words so canonical_forms.get() returns None.
636        let mut word_positions: Vec<usize> = Vec::with_capacity(original_words.len());
637        let mut pos = 0;
638        for word in &original_words {
639            if let Some(rel) = text[pos..].find(word) {
640                word_positions.push(pos + rel);
641                pos = pos + rel + word.len();
642            } else {
643                word_positions.push(usize::MAX);
644            }
645        }
646
647        let result_words: Vec<String> = original_words
648            .iter()
649            .enumerate()
650            .map(|(i, word)| {
651                let after_period = i > 0 && original_words[i - 1].ends_with('.');
652                let is_first = i == 0 || after_period;
653                let is_last = i == total_words - 1;
654
655                // Words that are part of an MD044 proper name use the canonical form directly.
656                if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
657                    return Self::apply_canonical_form_to_word(word, canonical);
658                }
659
660                // Preserve words in ignore list or with internal capitals
661                if self.should_preserve_word(word) {
662                    return (*word).to_string();
663                }
664
665                // An ignore-words entry is kept whole, hyphens included
666                if self.is_ignored_word(word) {
667                    return (*word).to_string();
668                }
669
670                // Handle hyphenated words
671                if word.contains('-') {
672                    return self.handle_hyphenated_word(word, is_first, is_last);
673                }
674
675                self.title_case_word(word, is_first, is_last)
676            })
677            .collect();
678
679        result_words.join(" ")
680    }
681
682    /// Handle hyphenated words like "self-documenting"
683    fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
684        let parts: Vec<&str> = word.split('-').collect();
685        let total_parts = parts.len();
686
687        let result_parts: Vec<String> = parts
688            .iter()
689            .enumerate()
690            .map(|(i, part)| {
691                // First part of first word and last part of last word get special treatment
692                let part_is_first = is_first && i == 0;
693                let part_is_last = is_last && i == total_parts - 1;
694                self.title_case_word(part, part_is_first, part_is_last)
695            })
696            .collect();
697
698        result_parts.join("-")
699    }
700
701    /// True when a word ends a sentence, so the next word is capitalized.
702    ///
703    /// The comparison is against the end of the whole word rather than a scan through
704    /// it, so `Overview: details` restarts and `a:b` does not. That keeps the boundary
705    /// where a reader sees one and leaves `https://example.com` alone.
706    fn ends_sentence(&self, word: &str) -> bool {
707        self.config
708            .sentence_case_restart_after
709            .iter()
710            .any(|boundary| !boundary.is_empty() && word.ends_with(boundary.as_str()))
711    }
712
713    /// Apply sentence case to text, capitalizing the leading word only when it opens
714    /// the heading. A segment that follows a code span or link continues the sentence
715    /// the earlier segment started, so it begins mid-sentence.
716    fn apply_sentence_case_from(&self, text: &str, starts_sentence: bool) -> String {
717        if text.is_empty() {
718            return text.to_string();
719        }
720
721        let canonical_forms = self.proper_name_canonical_forms(text);
722        let mut result = String::new();
723        let mut current_pos = 0;
724        let mut at_sentence_start = starts_sentence;
725
726        // Use original text positions to preserve whitespace correctly
727        for word in text.split_whitespace() {
728            if let Some(pos) = text[current_pos..].find(word) {
729                let abs_pos = current_pos + pos;
730
731                // Preserve whitespace before this word
732                result.push_str(&text[current_pos..abs_pos]);
733
734                // Words that are part of an MD044 proper name use the canonical form
735                // directly, bypassing sentence-case lowercasing entirely.
736                if let Some(&canonical) = canonical_forms.get(&abs_pos) {
737                    result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
738                } else if self.is_ignored_word(word) {
739                    // Explicit ignore-words promise exact byte-for-byte preservation.
740                    result.push_str(word);
741                } else if let Some(pronoun) = Self::sentence_case_first_person_pronouns(word) {
742                    // The English pronoun `I` remains uppercase in every sentence
743                    // position; normalize only the contraction suffix.
744                    result.push_str(&pronoun);
745                } else if at_sentence_start {
746                    // Check if word should be preserved BEFORE any capitalization
747                    if self.should_preserve_word(word) {
748                        // Preserve ignore-words exactly as-is, even at start
749                        result.push_str(word);
750                    } else {
751                        // Sentence-initial word: capitalize first letter, lowercase rest
752                        let mut chars = word.chars();
753                        if let Some(first) = chars.next() {
754                            result.push_str(&Self::uppercase_preserving_composition(&first.to_string()));
755                            let rest: String = chars.collect();
756                            result.push_str(&Self::lowercase_preserving_composition(&rest));
757                        }
758                    }
759                } else {
760                    // Mid-sentence words: preserve if needed, otherwise lowercase
761                    if self.should_preserve_word(word) {
762                        result.push_str(word);
763                    } else {
764                        result.push_str(&Self::lowercase_preserving_composition(word));
765                    }
766                }
767
768                at_sentence_start = self.ends_sentence(word);
769                current_pos = abs_pos + word.len();
770            }
771        }
772
773        // Preserve any trailing whitespace
774        if current_pos < text.len() {
775            result.push_str(&text[current_pos..]);
776        }
777
778        result
779    }
780
781    /// Apply all caps to text (preserve whitespace)
782    fn apply_all_caps(&self, text: &str) -> String {
783        if text.is_empty() {
784            return text.to_string();
785        }
786
787        let canonical_forms = self.proper_name_canonical_forms(text);
788        let mut result = String::new();
789        let mut current_pos = 0;
790
791        // Use original text positions to preserve whitespace correctly
792        for word in text.split_whitespace() {
793            if let Some(pos) = text[current_pos..].find(word) {
794                let abs_pos = current_pos + pos;
795
796                // Preserve whitespace before this word
797                result.push_str(&text[current_pos..abs_pos]);
798
799                // Words that are part of an MD044 proper name use the canonical form directly.
800                // This prevents oscillation with MD044 when all-caps style is active.
801                if let Some(&canonical) = canonical_forms.get(&abs_pos) {
802                    result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
803                } else if self.should_preserve_word(word) {
804                    result.push_str(word);
805                } else {
806                    result.push_str(&Self::uppercase_preserving_composition(word));
807                }
808
809                current_pos = abs_pos + word.len();
810            }
811        }
812
813        // Preserve any trailing whitespace
814        if current_pos < text.len() {
815            result.push_str(&text[current_pos..]);
816        }
817
818        result
819    }
820
821    /// Parse heading text into segments
822    fn parse_segments(&self, text: &str) -> Vec<HeadingSegment> {
823        let mut segments = Vec::new();
824        let mut last_end = 0;
825
826        // Collect all special regions (code and links)
827        let mut special_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
828
829        // Find inline code spans
830        for mat in INLINE_CODE_REGEX.find_iter(text) {
831            special_regions.push((mat.start(), mat.end(), HeadingSegment::Code(mat.as_str().to_string())));
832        }
833
834        // Find links
835        for caps in LINK_REGEX.captures_iter(text) {
836            let full_match = caps.get(0).unwrap();
837
838            // A '!' immediately before the match makes this an image. Preserve
839            // the whole image (including the leading '!' and its alt text)
840            // rather than recasing the alt text as if it were link text.
841            if full_match.start() >= 1 && text.as_bytes()[full_match.start() - 1] == b'!' {
842                let region_start = full_match.start() - 1;
843                special_regions.push((
844                    region_start,
845                    full_match.end(),
846                    HeadingSegment::Image(text[region_start..full_match.end()].to_string()),
847                ));
848                continue;
849            }
850
851            let text_match = caps.get(1).or_else(|| caps.get(2));
852
853            if let Some(text_m) = text_match {
854                special_regions.push((
855                    full_match.start(),
856                    full_match.end(),
857                    HeadingSegment::Link {
858                        full: full_match.as_str().to_string(),
859                        text_start: text_m.start() - full_match.start(),
860                        text_end: text_m.end() - full_match.start(),
861                    },
862                ));
863            }
864        }
865
866        // Find inline HTML: a tag token is never prose, and neither is the content
867        // of an element closed on the same line. A tag inside a code span is code.
868        let code_ranges: Vec<(usize, usize)> = special_regions
869            .iter()
870            .filter(|(_, _, segment)| matches!(segment, HeadingSegment::Code(_)))
871            .map(|(start, end, _)| (*start, *end))
872            .collect();
873        for (start, end) in Self::html_regions(text, &code_ranges) {
874            special_regions.push((start, end, HeadingSegment::Html(text[start..end].to_string())));
875        }
876
877        // Sort by start position
878        special_regions.sort_by_key(|(start, _, _)| *start);
879
880        // Drop regions that overlap one already kept. After sorting by start
881        // position, the earliest-starting region wins a conflict.
882        let mut filtered_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
883        for region in special_regions {
884            let overlaps = filtered_regions.iter().any(|(s, e, _)| region.0 < *e && region.1 > *s);
885            if !overlaps {
886                filtered_regions.push(region);
887            }
888        }
889
890        // Build segments
891        for (start, end, segment) in filtered_regions {
892            // Add text before this special region
893            if start > last_end {
894                let text_segment = &text[last_end..start];
895                if !text_segment.is_empty() {
896                    segments.push(HeadingSegment::Text(text_segment.to_string()));
897                }
898            }
899            segments.push(segment);
900            last_end = end;
901        }
902
903        // Add remaining text
904        if last_end < text.len() {
905            let remaining = &text[last_end..];
906            if !remaining.is_empty() {
907                segments.push(HeadingSegment::Text(remaining.to_string()));
908            }
909        }
910
911        // If no segments were found, treat the whole thing as text
912        if segments.is_empty() && !text.is_empty() {
913            segments.push(HeadingSegment::Text(text.to_string()));
914        }
915
916        segments
917    }
918
919    /// Byte ranges of `text` that are HTML and therefore never recased.
920    ///
921    /// Every tag token and comment is one region. When a closing tag pairs with
922    /// the nearest earlier open tag of the same name, the whole element becomes
923    /// one region, so `<b>bold <i>inner</i> more</b>` is preserved verbatim
924    /// while the prose after an unpaired tag (`<br>`) stays prose. A self-closing
925    /// token (`<span/>`) and a void element (`<br>`) hold no content, so neither
926    /// opens an element and a later closing tag of the same name belongs to the
927    /// enclosing one. A token that starts inside a code span is code, and one
928    /// whose `<` is backslash-escaped is text, so neither is markup; the scan
929    /// resumes just past its `<`, since that text may hold a real tag of its
930    /// own. Tokens are read one after another as a browser tokenizes them, so a
931    /// tag written inside another tag's attribute value is part of that value.
932    fn html_regions(text: &str, code_ranges: &[(usize, usize)]) -> Vec<(usize, usize)> {
933        let mut regions: Vec<(usize, usize)> = Vec::new();
934        let mut open_elements: Vec<(String, usize)> = Vec::new();
935
936        let mut pos = 0;
937        while let Some(token) = HTML_TOKEN_REGEX.captures_at(text, pos) {
938            let whole = token.get(0).unwrap();
939            if code_ranges
940                .iter()
941                .any(|&(start, end)| start <= whole.start() && whole.start() < end)
942                || is_backslash_escaped(text, whole.start())
943            {
944                // The token is text, and text may hold a tag of its own past its `<`.
945                pos = whole.start() + 1;
946                continue;
947            }
948            pos = whole.end();
949
950            if let Some(closing) = token.get(1) {
951                let name = closing.as_str().to_ascii_lowercase();
952                if let Some(depth) = open_elements.iter().rposition(|(open_name, _)| *open_name == name) {
953                    let element_start = open_elements[depth].1;
954                    open_elements.truncate(depth);
955                    regions.retain(|&(start, _)| start < element_start);
956                    regions.push((element_start, whole.end()));
957                    continue;
958                }
959            } else if let Some(opening) = token.get(2) {
960                let name = opening.as_str().to_ascii_lowercase();
961                if !whole.as_str().ends_with("/>") && !is_void_element(&name) {
962                    open_elements.push((name, whole.start()));
963                }
964            }
965
966            regions.push((whole.start(), whole.end()));
967        }
968
969        regions
970    }
971
972    /// Preserve paired JSX elements, like the existing inline HTML regions.
973    /// Only examine tags intersecting this heading, rather than scanning every
974    /// document tag again for each heading.
975    fn mdx_html_regions(
976        ctx: &crate::lint_context::LintContext,
977        start: usize,
978        end: usize,
979    ) -> (Vec<(usize, usize)>, bool) {
980        let html = ctx.html_tags();
981        let components = ctx.jsx_component_tags();
982        let mut tags = Vec::new();
983        for cached in [html.as_slice(), components.as_slice()] {
984            let first = cached.partition_point(|tag| tag.byte_end <= start);
985            tags.extend(cached[first..].iter().take_while(|tag| tag.byte_offset < end));
986        }
987        tags.sort_unstable_by_key(|tag| tag.byte_offset);
988        let mut regions = Vec::new();
989        let mut open = Vec::new();
990        let mut requires_source_map = false;
991        for tag in tags {
992            let source = &ctx.content[tag.byte_offset..tag.byte_end];
993            requires_source_map |= !HTML_TOKEN_REGEX
994                .find(source)
995                .is_some_and(|token| token.start() == 0 && token.end() == source.len());
996            if tag.is_closing {
997                if let Some(depth) = open.iter().rposition(|(name, _)| *name == tag.tag_name.as_str()) {
998                    let (_, element_start) = open[depth];
999                    open.truncate(depth);
1000                    regions.push((element_start, tag.byte_end));
1001                }
1002            } else if !tag.is_self_closing && !is_void_element(&tag.tag_name) {
1003                open.push((tag.tag_name.as_str(), tag.byte_offset));
1004            }
1005            regions.push((tag.byte_offset, tag.byte_end));
1006        }
1007        (regions, requires_source_map)
1008    }
1009
1010    /// Preserve cached MDX code when heading text maps directly to its source.
1011    fn apply_heading_capitalization(
1012        &self,
1013        ctx: &crate::lint_context::LintContext,
1014        line_index: usize,
1015        heading: &crate::lint_context::HeadingInfo,
1016    ) -> String {
1017        if ctx.flavor != crate::config::MarkdownFlavor::MDX {
1018            return self.apply_capitalization(&heading.raw_text, ctx.flavor);
1019        }
1020        let line_info = &ctx.lines[line_index];
1021        if heading.text_lines > 1 {
1022            // Joined Setext text has a different byte map. Keep code-bearing
1023            // headings intact rather than applying source offsets to that map.
1024            let first = line_index.saturating_sub(heading.text_lines - 1);
1025            let start = ctx.lines[first].byte_offset;
1026            let end = line_info.byte_offset + line_info.byte_len;
1027            let (_, requires_source_map) = Self::mdx_html_regions(ctx, start, end);
1028            if ctx.overlaps_mdx_inline_code(start, end) || requires_source_map {
1029                return heading.raw_text.clone();
1030            }
1031            return self.apply_capitalization(&heading.raw_text, ctx.flavor);
1032        }
1033        let line = line_info.content(ctx.content);
1034        let Some(relative_start) = line.find(&heading.raw_text) else {
1035            return heading.raw_text.clone();
1036        };
1037        let start = line_info.byte_offset + relative_start;
1038        let end = start + heading.raw_text.len();
1039        let (mut ranges, _) = Self::mdx_html_regions(ctx, start, end);
1040        ranges.extend(ctx.mdx_inline_code_ranges(start, end));
1041        let ranges = ranges
1042            .into_iter()
1043            .map(|(range_start, range_end)| (range_start.max(start) - start, range_end.min(end) - start))
1044            .collect();
1045        self.apply_capitalization_with_ranges(&heading.raw_text, ctx.flavor, ranges)
1046    }
1047
1048    /// Preserve template identifiers and string values while recasing prose.
1049    fn apply_capitalization(&self, text: &str, flavor: crate::config::MarkdownFlavor) -> String {
1050        self.apply_capitalization_with_ranges(text, flavor, Vec::new())
1051    }
1052
1053    fn apply_capitalization_with_ranges(
1054        &self,
1055        text: &str,
1056        flavor: crate::config::MarkdownFlavor,
1057        mut ranges: Vec<(usize, usize)>,
1058    ) -> String {
1059        ranges.extend(crate::utils::jinja_utils::find_jinja_ranges(text));
1060        ranges.extend(crate::utils::shortcode_utils::shortcode_ranges(text));
1061        ranges.sort_unstable();
1062        if ranges.is_empty() {
1063            return self.apply_capitalization_to_segments(text, flavor);
1064        }
1065
1066        // Recovery ranges for malformed markup can overlap. Protect their union
1067        // so every slice below remains ordered and each tag is copied intact.
1068        let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
1069        for (start, end) in ranges {
1070            if let Some(last) = merged.last_mut()
1071                && start <= last.1
1072            {
1073                last.1 = last.1.max(end);
1074            } else {
1075                merged.push((start, end));
1076            }
1077        }
1078
1079        // Template tokens contain no letters, so every style preserves them.
1080        // JSX tokens remain HTML segments so their visible-word context stays
1081        // the same as the original element. A space inside each template token keeps
1082        // words on opposite sides of a tag separate during casing. Choose a
1083        // delimiter absent from the input so literal private-use characters
1084        // cannot collide with them.
1085        let mut delimiter = String::from("\u{e000}");
1086        while text.contains(&delimiter) {
1087            delimiter.push('\u{e000}');
1088        }
1089        let mut masked = String::with_capacity(text.len());
1090        let mut templates = Vec::with_capacity(merged.len());
1091        let mut last_end = 0;
1092        for (index, (start, end)) in merged.into_iter().enumerate() {
1093            let source = &text[start..end];
1094            let token = if flavor == crate::config::MarkdownFlavor::MDX && source.starts_with('<') {
1095                let body = if HeadingSegment::Html(source.to_string()).renders_nothing() {
1096                    ""
1097                } else {
1098                    "0"
1099                };
1100                format!("<span data-md063=\"{delimiter}{index}{delimiter}\">{body}</span>")
1101            } else if flavor == crate::config::MarkdownFlavor::MDX
1102                && crate::utils::mdx_comments::is_comment_only_expression(&text[start..end])
1103            {
1104                // A comment renders nothing, so it must not consume the first
1105                // visible word when applying sentence case.
1106                format!("<!--{delimiter}{index}{delimiter}-->")
1107            } else {
1108                format!("{delimiter}{index}{delimiter} {delimiter}{index}{delimiter}")
1109            };
1110            masked.push_str(&text[last_end..start]);
1111            masked.push_str(&token);
1112            templates.push((token, &text[start..end]));
1113            last_end = end;
1114        }
1115        masked.push_str(&text[last_end..]);
1116
1117        let capitalized = self.apply_capitalization_to_segments(&masked, flavor);
1118        let mut result = String::with_capacity(capitalized.len());
1119        let mut cursor = 0;
1120        // Casing preserves token order. Walk forward so restoration does not
1121        // repeatedly scan a heading containing many template tags.
1122        for (token, template) in templates {
1123            let Some(offset) = capitalized[cursor..].find(&token) else {
1124                // Preserve the source if a configured transformation consumed a
1125                // token; an internal placeholder must never become an autofix.
1126                return text.to_string();
1127            };
1128            let start = cursor + offset;
1129            result.push_str(&capitalized[cursor..start]);
1130            result.push_str(template);
1131            cursor = start + token.len();
1132        }
1133        result.push_str(&capitalized[cursor..]);
1134        result
1135    }
1136
1137    /// Apply capitalization to heading text after protecting template tags.
1138    fn apply_capitalization_to_segments(&self, text: &str, flavor: crate::config::MarkdownFlavor) -> String {
1139        // Strip custom ID if present and re-add later
1140        let (main_text, custom_id) = if let Some(mat) = CUSTOM_ID_REGEX.find(text) {
1141            (&text[..mat.start()], Some(mat.as_str()))
1142        } else {
1143            (text, None)
1144        };
1145
1146        // Markdown with Gherkin spells every structure as a `Keyword: name` heading, and
1147        // a keyword names a structure only when spelled exactly, so recasing starts after
1148        // the colon and the keyword is copied through verbatim. The split precedes segment
1149        // parsing so the keyword keeps its own spacing and never counts as the heading's
1150        // first or last word, and so a code span the split declines stays visible to the
1151        // parser below instead of having its contents recased.
1152        let (keyword, main_text) = if flavor == crate::config::MarkdownFlavor::MDG {
1153            mdg::keyword_split(main_text).unwrap_or(("", main_text))
1154        } else {
1155            ("", main_text)
1156        };
1157
1158        // Parse into segments
1159        let segments = self.parse_segments(main_text);
1160
1161        // Count text segments to determine first/last word context
1162        let text_segments: Vec<usize> = segments
1163            .iter()
1164            .enumerate()
1165            .filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
1166            .collect();
1167
1168        // Sentence case starts with visible prose. A link label is prose too, even
1169        // though its Markdown destination is kept opaque. Invisible HTML, such as
1170        // an empty anchor or a comment, is looked past.
1171        let first_segment_starts_sentence = segments
1172            .iter()
1173            .find(|s| !s.renders_nothing())
1174            .is_some_and(|s| matches!(s, HeadingSegment::Text(_) | HeadingSegment::Link { .. }));
1175
1176        // If the last visible segment is Code or Link, then the last text segment should
1177        // NOT treat its last word as the heading's last word (for lowercase-words respect)
1178        let last_segment_is_text = segments
1179            .iter()
1180            .rev()
1181            .find(|s| !s.renders_nothing())
1182            .is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
1183
1184        // Apply capitalization to each segment
1185        let mut result_parts: Vec<String> = Vec::new();
1186
1187        // Where the heading's sentence currently stands, carried across segments so a
1188        // boundary in one segment governs the next.
1189        let mut at_sentence_start = first_segment_starts_sentence;
1190
1191        for (i, segment) in segments.iter().enumerate() {
1192            // Whether this segment closes a sentence, which decides how the next one
1193            // starts. Only prose can, and the prose of a heading is what this rule
1194            // capitalizes: plain text and link text. Code, HTML and images are opaque,
1195            // so a boundary inside them is not one a reader is offered.
1196            at_sentence_start = match segment {
1197                HeadingSegment::Text(t) => {
1198                    let is_first_text = text_segments.first() == Some(&i);
1199                    // A text segment is "last" only if it's the last text segment AND
1200                    // the last segment overall is also text. If there's Code/Link after,
1201                    // the last word should respect lowercase-words.
1202                    let is_last_text = text_segments.last() == Some(&i) && last_segment_is_text;
1203
1204                    let capitalized = match self.config.style {
1205                        HeadingCapStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
1206                        HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(t, at_sentence_start),
1207                        HeadingCapStyle::AllCaps => self.apply_all_caps(t),
1208                    };
1209                    let ends_sentence = self.ends_sentence(capitalized.trim_end());
1210                    result_parts.push(capitalized);
1211                    ends_sentence
1212                }
1213                HeadingSegment::Code(c) => {
1214                    result_parts.push(c.clone());
1215                    false
1216                }
1217                HeadingSegment::Link {
1218                    full,
1219                    text_start,
1220                    text_end,
1221                } => {
1222                    // Apply capitalization to link text only
1223                    let link_text = &full[*text_start..*text_end];
1224                    let capitalized_text = match self.config.style {
1225                        HeadingCapStyle::TitleCase => self.apply_title_case(link_text),
1226                        // For sentence case, apply same preservation logic as text
1227                        // This preserves acronyms (API), brand names (iPhone), etc.
1228                        HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(link_text, at_sentence_start),
1229                        HeadingCapStyle::AllCaps => self.apply_all_caps(link_text),
1230                    };
1231                    // The link's own text ends the sentence, not its destination: a reader
1232                    // sees `[see:](url)` as `see:`, so the boundary is where they read it.
1233                    let ends_sentence = self.ends_sentence(capitalized_text.trim_end());
1234
1235                    let mut new_link = String::new();
1236                    new_link.push_str(&full[..*text_start]);
1237                    new_link.push_str(&capitalized_text);
1238                    new_link.push_str(&full[*text_end..]);
1239                    result_parts.push(new_link);
1240                    ends_sentence
1241                }
1242                HeadingSegment::Html(h) => {
1243                    // Preserve HTML tags as-is (like code). Markup that renders
1244                    // nothing is invisible to the reader, so the sentence stands
1245                    // where it stood before it.
1246                    result_parts.push(h.clone());
1247                    segment.renders_nothing() && at_sentence_start
1248                }
1249                HeadingSegment::Image(img) => {
1250                    // Preserve images as-is, including alt text.
1251                    result_parts.push(img.clone());
1252                    false
1253                }
1254            };
1255        }
1256
1257        let mut result = String::with_capacity(text.len());
1258        result.push_str(keyword);
1259        result.push_str(&result_parts.join(""));
1260
1261        // Re-add custom ID if present
1262        if let Some(id) = custom_id {
1263            result.push_str(id);
1264        }
1265
1266        result
1267    }
1268
1269    /// Apply title case to a text segment with first/last awareness
1270    fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
1271        let canonical_forms = self.proper_name_canonical_forms(text);
1272        let words: Vec<&str> = text.split_whitespace().collect();
1273        let total_words = words.len();
1274
1275        if total_words == 0 {
1276            return text.to_string();
1277        }
1278
1279        // Pre-compute byte position of each word so we can look up canonical forms.
1280        // Use usize::MAX as sentinel for unfound words so canonical_forms.get() returns None.
1281        let mut word_positions: Vec<usize> = Vec::with_capacity(words.len());
1282        let mut pos = 0;
1283        for word in &words {
1284            if let Some(rel) = text[pos..].find(word) {
1285                word_positions.push(pos + rel);
1286                pos = pos + rel + word.len();
1287            } else {
1288                word_positions.push(usize::MAX);
1289            }
1290        }
1291
1292        let result_words: Vec<String> = words
1293            .iter()
1294            .enumerate()
1295            .map(|(i, word)| {
1296                let after_period = i > 0 && words[i - 1].ends_with('.');
1297                let is_first = (is_first_segment && i == 0) || after_period;
1298                let is_last = is_last_segment && i == total_words - 1;
1299
1300                // Words that are part of an MD044 proper name use the canonical form directly.
1301                if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
1302                    return Self::apply_canonical_form_to_word(word, canonical);
1303                }
1304
1305                // An ignore-words entry is kept whole, hyphens included
1306                if self.is_ignored_word(word) {
1307                    return (*word).to_string();
1308                }
1309
1310                // Handle hyphenated words
1311                if word.contains('-') {
1312                    return self.handle_hyphenated_word(word, is_first, is_last);
1313                }
1314
1315                self.title_case_word(word, is_first, is_last)
1316            })
1317            .collect();
1318
1319        // Preserve original spacing
1320        let mut result = String::new();
1321        let mut word_iter = result_words.iter();
1322        let mut in_word = false;
1323
1324        for c in text.chars() {
1325            if c.is_whitespace() {
1326                if in_word {
1327                    in_word = false;
1328                }
1329                result.push(c);
1330            } else if !in_word {
1331                if let Some(word) = word_iter.next() {
1332                    result.push_str(word);
1333                }
1334                in_word = true;
1335            }
1336        }
1337
1338        result
1339    }
1340
1341    /// Fix an ATX heading line
1342    fn fix_atx_heading(&self, line: &str, heading: &crate::lint_context::HeadingInfo, fixed_text: &str) -> String {
1343        // Everything before the `#`s stays: the indentation and, for a heading
1344        // that opens a list item, the list marker
1345        let indent = &line[..heading.marker_column];
1346        let hashes = "#".repeat(heading.level as usize);
1347
1348        // Reconstruct with closing sequence if present
1349        let closing = &heading.closing_sequence;
1350        if heading.has_closing_sequence {
1351            format!("{indent}{hashes} {fixed_text} {closing}")
1352        } else {
1353            format!("{indent}{hashes} {fixed_text}")
1354        }
1355    }
1356
1357    /// Fix a Setext heading line
1358    fn fix_setext_heading(&self, line: &str, heading: &crate::lint_context::HeadingInfo, fixed_text: &str) -> String {
1359        // Everything before the text stays: the indentation and, for a heading
1360        // that opens a list item, the list marker
1361        let leading_ws = &line[..heading.content_column];
1362
1363        format!("{leading_ws}{fixed_text}")
1364    }
1365
1366    /// Rewrite `line` word by word, taking each word's replacement from `words`
1367    /// and keeping the line's own whitespace runs.
1368    fn rewrite_words<'w>(line: &str, words: &mut impl Iterator<Item = &'w str>) -> String {
1369        let mut result = String::with_capacity(line.len());
1370        let mut in_word = false;
1371        for c in line.chars() {
1372            if c.is_whitespace() {
1373                in_word = false;
1374                result.push(c);
1375            } else if !in_word {
1376                if let Some(word) = words.next() {
1377                    result.push_str(word);
1378                }
1379                in_word = true;
1380            }
1381        }
1382        result
1383    }
1384
1385    /// Capitalize a setext heading whose text spans several source lines.
1386    ///
1387    /// The heading's text is the whole paragraph its underline ends, so the
1388    /// capitalization runs over the joined text and the result is written back
1389    /// word by word onto the original lines, keeping their line breaks and
1390    /// their indentation. A hard break's backslash goes with the line ending
1391    /// it marks and is left out of the joined text, so it is put back after
1392    /// the words; the last line ends with no break, and a backslash inside a
1393    /// code span is code, so those are in the text and come back with it.
1394    ///
1395    /// Returns `None` when the capitalized text does not hold one word per
1396    /// original word. Prose keeps its whitespace through the capitalization,
1397    /// but link text is rebuilt from its words alone, so padding inside a link
1398    /// label changes the count. The heading is then reported without a fix
1399    /// rather than rewritten from a mapping whose words do not line up.
1400    fn fix_setext_heading_span(
1401        &self,
1402        ctx: &crate::lint_context::LintContext,
1403        first_idx: usize,
1404        last_idx: usize,
1405        heading: &crate::lint_context::HeadingInfo,
1406        quoted: bool,
1407        flavor: crate::config::MarkdownFlavor,
1408    ) -> Option<Vec<String>> {
1409        let quote_depth = if quoted {
1410            ctx.lines
1411                .get(last_idx + 1)
1412                .and_then(|underline| crate::utils::blockquote::parse_blockquote_prefix(underline.content(ctx.content)))
1413                .map_or(0, |prefix| prefix.nesting_level)
1414        } else {
1415            0
1416        };
1417        let bodies: Vec<(&str, &str, &str)> = (first_idx..=last_idx)
1418            .map(|idx| {
1419                let line = ctx.lines[idx].content(ctx.content);
1420                // The first line may open with a list marker, which is not
1421                // one of the heading's words; a line inside a blockquote
1422                // carries the quote markers, which are not either
1423                let (prefix, line) = if idx == first_idx {
1424                    line.split_at(heading.content_column)
1425                } else if quoted {
1426                    // Only the heading's own quote markers are the quote's; a
1427                    // `>` further in is text of the heading
1428                    let margin = crate::utils::blockquote::quote_margin_len(line, quote_depth).unwrap_or(0);
1429                    line.split_at(margin)
1430                } else {
1431                    ("", line)
1432                };
1433                if idx < last_idx && ctx.line_ends_with_hard_break(idx + 1) {
1434                    let (body, hard_break) = line.split_at(line.len() - 1);
1435                    (prefix, body, hard_break)
1436                } else {
1437                    (prefix, line, "")
1438                }
1439            })
1440            .collect();
1441        let fixed_text = self.apply_capitalization(&heading.raw_text, flavor);
1442        let fixed_words: Vec<&str> = fixed_text.split_whitespace().collect();
1443        let original_words: usize = bodies.iter().map(|(_, body, _)| body.split_whitespace().count()).sum();
1444        if fixed_words.len() != original_words {
1445            return None;
1446        }
1447
1448        let mut words = fixed_words.into_iter();
1449        Some(
1450            bodies
1451                .iter()
1452                .map(|(prefix, body, hard_break)| {
1453                    format!("{prefix}{}{hard_break}", Self::rewrite_words(body, &mut words))
1454                })
1455                .collect(),
1456        )
1457    }
1458}
1459
1460impl Rule for MD063HeadingCapitalization {
1461    fn name(&self) -> &'static str {
1462        "MD063"
1463    }
1464
1465    fn description(&self) -> &'static str {
1466        "Heading capitalization"
1467    }
1468
1469    fn category(&self) -> RuleCategory {
1470        RuleCategory::Heading
1471    }
1472
1473    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1474        !ctx.likely_has_headings() || ctx.headings().next().is_none()
1475    }
1476
1477    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1478        let content = ctx.content;
1479
1480        if content.is_empty() {
1481            return Ok(Vec::new());
1482        }
1483
1484        let mut warnings = Vec::new();
1485
1486        let edges = crate::utils::blockquote::container_edges(ctx);
1487
1488        for parsed in ctx.headings() {
1489            let line_num = parsed.line_num - 1;
1490            let line_info = parsed.line_info;
1491            let heading = parsed.heading;
1492            // Check level filter
1493            if heading.level < self.config.min_level || heading.level > self.config.max_level {
1494                continue;
1495            }
1496
1497            // Skip headings in code blocks (indented headings)
1498            if !parsed.is_blockquote()
1499                && crate::utils::blockquote::is_indented_past_edge(&edges, line_num, line_info.content(ctx.content))
1500                && matches!(heading.style, crate::lint_context::HeadingStyle::ATX)
1501            {
1502                continue;
1503            }
1504
1505            // Apply capitalization and compare
1506            let original_text = &heading.raw_text;
1507            let fixed_text = self.apply_heading_capitalization(ctx, line_num, heading);
1508
1509            if original_text != &fixed_text {
1510                let line = line_info.content(ctx.content);
1511                let style_name = match self.config.style {
1512                    HeadingCapStyle::TitleCase => "title case",
1513                    HeadingCapStyle::SentenceCase => "sentence case",
1514                    HeadingCapStyle::AllCaps => "ALL CAPS",
1515                };
1516
1517                // A setext heading's text is the whole paragraph its
1518                // underline ends, so the warning covers every one of those
1519                // lines and the fix rewrites them where they stand.
1520                if heading.text_lines > 1 {
1521                    let first_idx = line_num + 1 - heading.text_lines;
1522                    let first_line = ctx.lines[first_idx].content(ctx.content);
1523                    let fix = self
1524                        .fix_setext_heading_span(ctx, first_idx, line_num, heading, parsed.is_blockquote(), ctx.flavor)
1525                        .map(|rewritten| {
1526                            let range = ctx.line_content_byte_range(first_idx + 1).start
1527                                ..ctx.line_content_byte_range(line_num + 1).end;
1528                            Fix::new(range, rewritten.join("\n"))
1529                        });
1530                    warnings.push(LintWarning {
1531                        rule_name: Some(self.name().to_string()),
1532                        line: first_idx + 1,
1533                        column: byte_to_char_count(first_line, heading.content_column),
1534                        end_line: line_num + 1,
1535                        end_column: line.trim_end().chars().count() + 1,
1536                        message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1537                        severity: Severity::Warning,
1538                        fix,
1539                    });
1540                    continue;
1541                }
1542
1543                warnings.push(LintWarning {
1544                    rule_name: Some(self.name().to_string()),
1545                    line: line_num + 1,
1546                    column: byte_to_char_count(line, heading.content_column),
1547                    end_line: line_num + 1,
1548                    end_column: byte_to_char_count(line, heading.content_column) + original_text.chars().count(),
1549                    message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1550                    severity: Severity::Warning,
1551                    fix: Some(Fix::new(
1552                        ctx.line_content_byte_range(line_num + 1),
1553                        match heading.style {
1554                            crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading, &fixed_text),
1555                            _ => self.fix_setext_heading(line, heading, &fixed_text),
1556                        },
1557                    )),
1558                });
1559            }
1560        }
1561
1562        Ok(warnings)
1563    }
1564
1565    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1566        let content = ctx.content;
1567
1568        if content.is_empty() {
1569            return Ok(content.to_string());
1570        }
1571
1572        let lines = ctx.raw_lines();
1573        let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1574
1575        let edges = crate::utils::blockquote::container_edges(ctx);
1576
1577        for parsed in ctx.headings() {
1578            let line_num = parsed.line_num - 1;
1579            let line_info = parsed.line_info;
1580            let heading = parsed.heading;
1581            // Skip lines where the rule is disabled via inline config
1582            if ctx.is_rule_disabled(self.name(), line_num + 1) {
1583                continue;
1584            }
1585
1586            // Check level filter
1587            if heading.level < self.config.min_level || heading.level > self.config.max_level {
1588                continue;
1589            }
1590
1591            // Skip headings in code blocks
1592            if !parsed.is_blockquote()
1593                && crate::utils::blockquote::is_indented_past_edge(&edges, line_num, line_info.content(ctx.content))
1594                && matches!(heading.style, crate::lint_context::HeadingStyle::ATX)
1595            {
1596                continue;
1597            }
1598
1599            let original_text = &heading.raw_text;
1600            let fixed_text = self.apply_heading_capitalization(ctx, line_num, heading);
1601
1602            if original_text != &fixed_text {
1603                let line = line_info.content(ctx.content);
1604                // A setext heading's text is the whole paragraph its
1605                // underline ends, so each of those lines keeps its own words.
1606                if heading.text_lines > 1 {
1607                    let first_idx = line_num + 1 - heading.text_lines;
1608                    // The warning is dropped when any of the heading's
1609                    // lines is disabled, and the rewrite goes with it.
1610                    if (first_idx..line_num).any(|idx| ctx.is_rule_disabled(self.name(), idx + 1)) {
1611                        continue;
1612                    }
1613                    if let Some(rewritten) = self.fix_setext_heading_span(
1614                        ctx,
1615                        first_idx,
1616                        line_num,
1617                        heading,
1618                        parsed.is_blockquote(),
1619                        ctx.flavor,
1620                    ) {
1621                        fixed_lines[first_idx..=line_num].clone_from_slice(&rewritten);
1622                    }
1623                    continue;
1624                }
1625                fixed_lines[line_num] = match heading.style {
1626                    crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading, &fixed_text),
1627                    _ => self.fix_setext_heading(line, heading, &fixed_text),
1628                };
1629            }
1630        }
1631
1632        // Reconstruct content preserving line endings
1633        let mut result = String::with_capacity(content.len());
1634        for (i, line) in fixed_lines.iter().enumerate() {
1635            result.push_str(line);
1636            if i < fixed_lines.len() - 1 || content.ends_with('\n') {
1637                result.push('\n');
1638            }
1639        }
1640
1641        Ok(result)
1642    }
1643
1644    fn as_any(&self) -> &dyn std::any::Any {
1645        self
1646    }
1647
1648    crate::impl_rule_config_sections!(MD063Config);
1649
1650    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1651    where
1652        Self: Sized,
1653    {
1654        let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
1655        let md044_config =
1656            crate::rule_config_serde::load_rule_config::<crate::rules::md044_proper_names::MD044Config>(config);
1657        let mut rule = Self::from_config_struct(rule_config);
1658        rule.proper_names = md044_config.names;
1659        Box::new(rule)
1660    }
1661}
1662
1663#[cfg(test)]
1664mod tests {
1665    use super::*;
1666    use crate::lint_context::LintContext;
1667
1668    fn create_rule() -> MD063HeadingCapitalization {
1669        let config = MD063Config {
1670            enabled: true,
1671            ..Default::default()
1672        };
1673        MD063HeadingCapitalization::from_config_struct(config)
1674    }
1675
1676    fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
1677        let config = MD063Config {
1678            enabled: true,
1679            style,
1680            ..Default::default()
1681        };
1682        MD063HeadingCapitalization::from_config_struct(config)
1683    }
1684
1685    #[test]
1686    fn test_heading_indent_counts_from_the_list_item_content_column() {
1687        let rule = create_rule();
1688        for (source, expected) in [
1689            ("- x\n\n    ## hello world\n", "- x\n\n    ## Hello World\n"),
1690            ("- x\n\n     ## hello world\n", "- x\n\n     ## Hello World\n"),
1691            ("1. x\n\n    ## hello world\n", "1. x\n\n    ## Hello World\n"),
1692            ("10. x\n\n    ## hello world\n", "10. x\n\n    ## Hello World\n"),
1693            ("10. x\n\n     ## hello world\n", "10. x\n\n     ## Hello World\n"),
1694            ("- x\n\n\t## hello world\n", "- x\n\n\t## Hello World\n"),
1695        ] {
1696            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1697            assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source:?}");
1698            assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source:?}");
1699        }
1700    }
1701
1702    #[test]
1703    fn test_heading_four_columns_past_the_list_item_content_column_is_code() {
1704        let rule = create_rule();
1705        for source in [
1706            "- x\n\n      ## hello world\n",
1707            "1. x\n\n       ## hello world\n",
1708            "10. x\n\n        ## hello world\n",
1709            "- x\n\n\t\t## hello world\n",
1710            "    ## hello world\n",
1711        ] {
1712            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1713            assert!(rule.check(&ctx).unwrap().is_empty(), "{source:?}");
1714            assert_eq!(rule.fix(&ctx).unwrap(), source, "{source:?}");
1715        }
1716    }
1717
1718    // Title case tests
1719
1720    #[test]
1721    fn test_mdx_sentence_case_distinguishes_comments_from_comments_plus_code() {
1722        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1723        for (source, expected) in [
1724            ("# { /* hidden */ } hello world\n", "# { /* hidden */ } Hello world\n"),
1725            (
1726                "export const name = \"Visible\"\n\n# {/* c */ name} hello world\n",
1727                "export const name = \"Visible\"\n\n# {/* c */ name} hello world\n",
1728            ),
1729        ] {
1730            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1731            assert_eq!(rule.fix(&ctx).unwrap(), expected);
1732        }
1733    }
1734
1735    #[test]
1736    fn test_mdx_leading_comment_does_not_consume_sentence_start() {
1737        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1738        for (source, expected) in [
1739            (
1740                "# {/* literal comment */} hello world\n",
1741                "# {/* literal comment */} Hello world\n",
1742            ),
1743            (
1744                "{/* literal comment */} hello world\n===\n",
1745                "{/* literal comment */} Hello world\n===\n",
1746            ),
1747        ] {
1748            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1749            assert_eq!(rule.fix(&ctx).unwrap(), expected);
1750        }
1751    }
1752
1753    #[test]
1754    fn test_mdx_heading_expression_code_is_preserved() {
1755        for (style, before, after) in [
1756            (HeadingCapStyle::TitleCase, "Hello", "World"),
1757            (HeadingCapStyle::SentenceCase, "Hello", "world"),
1758            (HeadingCapStyle::AllCaps, "HELLO", "WORLD"),
1759        ] {
1760            let rule = create_rule_with_style(style);
1761            for expression in ["name", "data.text", r#""literal value""#, "name.toLowerCase()"] {
1762                let source = format!("# hello {{{expression}}} world\n");
1763                let expected = format!("# {before} {{{expression}}} {after}\n");
1764                let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
1765                assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1766                assert_eq!(
1767                    rule.fix(&ctx).unwrap(),
1768                    expected,
1769                    "expression code is literal: {source}"
1770                );
1771                let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::MDX, None);
1772                assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1773                assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1774            }
1775        }
1776    }
1777
1778    #[test]
1779    fn test_mdx_heading_link_labels_and_adjacent_expressions_preserve_code() {
1780        let rule = create_rule();
1781        for (source, expected) in [
1782            (
1783                "# hello [{name} world](target) friend\n",
1784                "# Hello [{name} World](target) Friend\n",
1785            ),
1786            ("# hello {name}{data.text} world\n", "# Hello {name}{data.text} World\n"),
1787            (
1788                "# hello {/* literal comment */} world\n",
1789                "# Hello {/* literal comment */} World\n",
1790            ),
1791            ("# 日本語 {name} world\n", "# 日本語 {name} World\n"),
1792        ] {
1793            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1794            assert_eq!(rule.fix(&ctx).unwrap(), expected);
1795        }
1796    }
1797
1798    #[test]
1799    fn test_mdx_expression_protection_is_flavor_specific() {
1800        let rule = create_rule();
1801        let source = "# hello {name} world\n";
1802        let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1803        assert_eq!(rule.fix(&ctx).unwrap(), "# Hello {Name} World\n");
1804    }
1805
1806    #[test]
1807    fn test_mdx_heading_attribute_expressions_keep_existing_html_protection() {
1808        let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1809        let source = "# hello <span title={name}>world</span> friend\n";
1810        let expected = "# HELLO <span title={name}>world</span> FRIEND\n";
1811        let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1812        assert_eq!(rule.fix(&ctx).unwrap(), expected);
1813    }
1814
1815    #[test]
1816    fn test_heading_template_tags_are_preserved() {
1817        for (style, before, after) in [
1818            (HeadingCapStyle::TitleCase, "Hello", "World"),
1819            (HeadingCapStyle::SentenceCase, "Hello", "world"),
1820            (HeadingCapStyle::AllCaps, "HELLO", "WORLD"),
1821        ] {
1822            let rule = create_rule_with_style(style);
1823            for template in [
1824                "{{ name }}",
1825                "{{ data['display_name'] }}",
1826                "{{< note title=\"literal value\" >}}",
1827                "{{% note title=\"literal value\" %}}",
1828            ] {
1829                let source = format!("# hello {template} world\n");
1830                let expected = format!("# {before} {template} {after}\n");
1831                let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1832                assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1833                assert_eq!(rule.fix(&ctx).unwrap(), expected, "template code is literal: {source}");
1834                let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
1835                assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1836                assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1837            }
1838        }
1839    }
1840
1841    #[test]
1842    fn test_heading_template_control_tags_and_link_labels_are_preserved() {
1843        let rule = create_rule();
1844        for (source, expected) in [
1845            (
1846                "# hello {% if enabled %}yes{% else %}no{% endif %} world\n",
1847                "# Hello {% if enabled %}Yes{% else %}No{% endif %} World\n",
1848            ),
1849            (
1850                "# hello [{{ name }} world](target) friend\n",
1851                "# Hello [{{ name }} World](target) Friend\n",
1852            ),
1853            (
1854                "# hello {{ name }} and {{ data['display_name'] }} world\n",
1855                "# Hello {{ name }} and {{ data['display_name'] }} World\n",
1856            ),
1857        ] {
1858            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1859            assert_eq!(rule.fix(&ctx).unwrap(), expected);
1860        }
1861    }
1862
1863    #[test]
1864    fn test_heading_template_tokens_do_not_collide_with_literal_text() {
1865        let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1866        let source = "# 日本語 \u{e000}0\u{e000} {{ name }} world\n";
1867        let expected = "# 日本語 \u{e000}0\u{e000} {{ name }} WORLD\n";
1868        let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1869        assert_eq!(rule.fix(&ctx).unwrap(), expected);
1870    }
1871
1872    #[test]
1873    fn test_an_escaped_tag_does_not_hide_the_element_written_inside_it() {
1874        // `\<span` is text, so the `<a>` where its attribute value would be is a
1875        // real element and only its bytes are HTML.
1876        let text = r#"\<span title='<a id="x"></a>'>foo"#;
1877        assert_eq!(MD063HeadingCapitalization::html_regions(text, &[]), vec![(14, 28)]);
1878    }
1879
1880    #[test]
1881    fn test_title_case_basic() {
1882        let rule = create_rule();
1883        let content = "# hello world\n";
1884        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1885        let result = rule.check(&ctx).unwrap();
1886        assert_eq!(result.len(), 1);
1887        assert!(result[0].message.contains("Hello World"));
1888    }
1889
1890    #[test]
1891    fn test_title_case_lowercase_words() {
1892        let rule = create_rule();
1893        let content = "# the quick brown fox\n";
1894        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1895        let result = rule.check(&ctx).unwrap();
1896        assert_eq!(result.len(), 1);
1897        // "The" should be capitalized (first word), "quick", "brown", "fox" should be capitalized
1898        assert!(result[0].message.contains("The Quick Brown Fox"));
1899    }
1900
1901    #[test]
1902    fn test_title_case_already_correct() {
1903        let rule = create_rule();
1904        let content = "# The Quick Brown Fox\n";
1905        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1906        let result = rule.check(&ctx).unwrap();
1907        assert!(result.is_empty(), "Already correct heading should not be flagged");
1908    }
1909
1910    #[test]
1911    fn test_title_case_hyphenated() {
1912        let rule = create_rule();
1913        let content = "# self-documenting code\n";
1914        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1915        let result = rule.check(&ctx).unwrap();
1916        assert_eq!(result.len(), 1);
1917        assert!(result[0].message.contains("Self-Documenting Code"));
1918    }
1919
1920    #[test]
1921    fn test_title_case_preserves_url_with_nested_parens() {
1922        let rule = create_rule();
1923        // The URL contains a parenthesised segment followed by more URL text.
1924        let content = "# guide for [the api](https://example.com/docs/v(2)beta)\n";
1925        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1926        let fixed = rule.fix(&ctx).unwrap();
1927        // The whole URL, including the lowercase "beta" after the nested
1928        // parens, must be preserved exactly and never title-cased.
1929        assert!(
1930            fixed.contains("https://example.com/docs/v(2)beta"),
1931            "URL with nested parens was corrupted: {fixed:?}"
1932        );
1933    }
1934
1935    #[test]
1936    fn test_title_case_does_not_recase_image_alt() {
1937        let rule = create_rule();
1938        let content = "# overview ![a small icon](icon.png)\n";
1939        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1940        let fixed = rule.fix(&ctx).unwrap();
1941        // Image (alt text and all) is preserved as-is; only prose is recased.
1942        assert!(
1943            fixed.contains("![a small icon](icon.png)"),
1944            "image alt text was modified: {fixed:?}"
1945        );
1946        assert!(
1947            fixed.contains("# Overview"),
1948            "surrounding prose should still be title-cased: {fixed:?}"
1949        );
1950    }
1951
1952    // Sentence case tests
1953    #[test]
1954    fn test_sentence_case_basic() {
1955        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1956        let content = "# The Quick Brown Fox\n";
1957        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1958        let result = rule.check(&ctx).unwrap();
1959        assert_eq!(result.len(), 1);
1960        assert!(result[0].message.contains("The quick brown fox"));
1961    }
1962
1963    #[test]
1964    fn test_sentence_case_already_correct() {
1965        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1966        let content = "# The quick brown fox\n";
1967        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1968        let result = rule.check(&ctx).unwrap();
1969        assert!(result.is_empty());
1970    }
1971
1972    #[test]
1973    fn test_sentence_case_preserves_first_person_pronoun() {
1974        // Regression test for issue #845.
1975        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1976        let content = "# How do I debug playbooks?\n";
1977        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1978
1979        assert!(rule.check(&ctx).unwrap().is_empty());
1980        assert_eq!(rule.fix(&ctx).unwrap(), content);
1981    }
1982
1983    #[test]
1984    fn test_sentence_case_preserves_first_person_contractions() {
1985        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1986
1987        for content in [
1988            "# What I'd change\n",
1989            "# Where I'll look\n",
1990            "# Why I'm here\n",
1991            "# What I've learned\n",
1992            "# What I’d change\n",
1993            "# Where I’ll look\n",
1994            "# Why I’m here\n",
1995            "# What I’ve learned\n",
1996        ] {
1997            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1998            assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1999            assert_eq!(rule.fix(&ctx).unwrap(), content);
2000        }
2001    }
2002
2003    #[test]
2004    fn test_sentence_case_pronoun_handles_markup_punctuation_and_suffix_case() {
2005        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2006        let cases = [
2007            ("# What (**I**) would change\n", "# What (**I**) would change\n"),
2008            ("# Why **I**'M changing it\n", "# Why **I**'m changing it\n"),
2009            ("# What I’LL change\n", "# What I’ll change\n"),
2010            ("# What I—really want\n", "# What I—really want\n"),
2011            ("# What I’d—reluctantly change\n", "# What I’d—reluctantly change\n"),
2012            ("# What I—yes—I—would do\n", "# What I—yes—I—would do\n"),
2013            ("# What [I'll change](plan.md)\n", "# What [I'll change](plan.md)\n"),
2014            ("# [What I—really want](plan.md)\n", "# [What I—really want](plan.md)\n"),
2015        ];
2016
2017        for (content, expected) in cases {
2018            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2019            assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
2020        }
2021    }
2022
2023    #[test]
2024    fn test_sentence_case_pronoun_is_independent_of_cased_word_preservation() {
2025        let config = MD063Config {
2026            enabled: true,
2027            style: HeadingCapStyle::SentenceCase,
2028            preserve_cased_words: false,
2029            ..Default::default()
2030        };
2031        let rule = MD063HeadingCapitalization::from_config_struct(config);
2032        let content = "# How do I debug what I’LL change?\n";
2033        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2034
2035        assert_eq!(rule.fix(&ctx).unwrap(), "# How do I debug what I’ll change?\n");
2036    }
2037
2038    #[test]
2039    fn test_sentence_case_explicit_ignore_wins_over_pronoun_normalization() {
2040        let config = MD063Config {
2041            enabled: true,
2042            style: HeadingCapStyle::SentenceCase,
2043            ignore_words: vec!["I'LL".to_string(), "I’LL".to_string()],
2044            ..Default::default()
2045        };
2046        let rule = MD063HeadingCapitalization::from_config_struct(config);
2047        let content = "# Why I'LL stay and why I’LL leave\n";
2048        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2049
2050        assert!(rule.check(&ctx).unwrap().is_empty());
2051        assert_eq!(rule.fix(&ctx).unwrap(), content);
2052    }
2053
2054    #[test]
2055    fn test_sentence_case_pronoun_does_not_preserve_other_single_letters_or_compounds() {
2056        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2057        let content = "# Compare i, A, I/O, and A.I. values\n";
2058        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2059
2060        assert_eq!(rule.fix(&ctx).unwrap(), "# Compare i, a, i/o, and a.i. values\n");
2061    }
2062
2063    // All caps tests
2064    #[test]
2065    fn test_all_caps_basic() {
2066        let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
2067        let content = "# hello world\n";
2068        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2069        let result = rule.check(&ctx).unwrap();
2070        assert_eq!(result.len(), 1);
2071        assert!(result[0].message.contains("HELLO WORLD"));
2072    }
2073
2074    // Preserve tests
2075    #[test]
2076    fn test_preserve_ignore_words() {
2077        let config = MD063Config {
2078            enabled: true,
2079            ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
2080            ..Default::default()
2081        };
2082        let rule = MD063HeadingCapitalization::from_config_struct(config);
2083
2084        let content = "# using iPhone on macOS\n";
2085        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2086        let result = rule.check(&ctx).unwrap();
2087        assert_eq!(result.len(), 1);
2088        // iPhone and macOS should be preserved
2089        assert!(result[0].message.contains("iPhone"));
2090        assert!(result[0].message.contains("macOS"));
2091    }
2092
2093    #[test]
2094    fn test_preserve_cased_words() {
2095        let rule = create_rule();
2096        let content = "# using GitHub actions\n";
2097        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2098        let result = rule.check(&ctx).unwrap();
2099        assert_eq!(result.len(), 1);
2100        // GitHub should be preserved (has internal capital)
2101        assert!(result[0].message.contains("GitHub"));
2102    }
2103
2104    // Inline code tests
2105    #[test]
2106    fn test_inline_code_preserved() {
2107        let rule = create_rule();
2108        let content = "# using `const` in javascript\n";
2109        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2110        let result = rule.check(&ctx).unwrap();
2111        assert_eq!(result.len(), 1);
2112        // `const` should be preserved, rest capitalized
2113        assert!(result[0].message.contains("`const`"));
2114        assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
2115    }
2116
2117    // Level filter tests
2118    #[test]
2119    fn test_level_filter() {
2120        let config = MD063Config {
2121            enabled: true,
2122            min_level: 2,
2123            max_level: 4,
2124            ..Default::default()
2125        };
2126        let rule = MD063HeadingCapitalization::from_config_struct(config);
2127
2128        let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
2129        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2130        let result = rule.check(&ctx).unwrap();
2131
2132        // Only h2 and h3 should be flagged (h1 < min_level, h5 > max_level)
2133        assert_eq!(result.len(), 2);
2134        assert_eq!(result[0].line, 2); // h2
2135        assert_eq!(result[1].line, 3); // h3
2136    }
2137
2138    // Fix tests
2139    #[test]
2140    fn test_fix_atx_heading() {
2141        let rule = create_rule();
2142        let content = "# hello world\n";
2143        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2144        let fixed = rule.fix(&ctx).unwrap();
2145        assert_eq!(fixed, "# Hello World\n");
2146    }
2147
2148    #[test]
2149    fn test_fix_multiple_headings() {
2150        let rule = create_rule();
2151        let content = "# first heading\n\n## second heading\n";
2152        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2153        let fixed = rule.fix(&ctx).unwrap();
2154        assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
2155    }
2156
2157    // Setext heading tests
2158    #[test]
2159    fn test_setext_heading() {
2160        let rule = create_rule();
2161        let content = "hello world\n============\n";
2162        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2163        let result = rule.check(&ctx).unwrap();
2164        assert_eq!(result.len(), 1);
2165        assert!(result[0].message.contains("Hello World"));
2166    }
2167
2168    #[test]
2169    fn test_multi_line_setext_heading_is_capitalized_in_place() {
2170        // A setext heading's text is the whole paragraph its underline ends. The
2171        // capitalization runs on that joined text so the position rules see the
2172        // whole heading, and the result goes back onto the author's own lines.
2173        let rule = create_rule();
2174        let content = "hello world\nand more words\n==============\n";
2175        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2176        let fixed = rule.fix(&ctx).unwrap();
2177        assert_eq!(fixed, "Hello World\nand More Words\n==============\n");
2178
2179        let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2180        assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent");
2181    }
2182
2183    #[test]
2184    fn test_multi_line_setext_heading_keeps_a_hard_break_backslash() {
2185        // A hard line break's backslash goes with its line ending and is left out
2186        // of the joined text, so writing the text back puts it back.
2187        let rule = create_rule();
2188        let content = "foo bar\\\nbaz qux\n=======\n";
2189        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2190        let fixed = rule.fix(&ctx).unwrap();
2191        assert_eq!(fixed, "Foo Bar\\\nBaz Qux\n=======\n");
2192
2193        let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2194        assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent");
2195    }
2196
2197    #[test]
2198    fn test_multi_line_setext_heading_keeps_a_backslash_that_is_no_hard_break() {
2199        // A backslash inside a code span is code, and one ending the last line
2200        // is text, so both are in the joined text already and are written back
2201        // once.
2202        let rule = create_rule();
2203        for (content, expected) in [
2204            ("foo `a\\\nb` tail\n===\n", "Foo `a\\\nb` Tail\n===\n"),
2205            ("foo bar\nbaz\\\n===\n", "Foo Bar\nBaz\\\n===\n"),
2206        ] {
2207            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2208            let fixed = rule.fix(&ctx).unwrap();
2209            assert_eq!(fixed, expected, "{content:?}");
2210
2211            let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2212            assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent: {content:?}");
2213        }
2214    }
2215
2216    #[test]
2217    fn test_multi_line_setext_heading_warning_covers_the_whole_span() {
2218        // The warning starts on the heading's first text line and runs to the end
2219        // of the text on its last.
2220        let rule = create_rule();
2221        let content = "hello world\nand more\n=====\n";
2222        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2223        let result = rule.check(&ctx).unwrap();
2224        assert_eq!(result.len(), 1, "got: {result:?}");
2225        assert_eq!(result[0].line, 1);
2226        assert_eq!(result[0].column, 1);
2227        assert_eq!(result[0].end_line, 2);
2228        assert_eq!(result[0].end_column, 9);
2229    }
2230
2231    #[test]
2232    fn test_multi_line_setext_heading_with_a_multi_byte_word_is_idempotent() {
2233        // The shrunk input from the idempotency proptest: three text lines under
2234        // one dash underline, with a multi-byte word in the middle line. The fix
2235        // may only recase the words where they stand.
2236        let rule = create_rule();
2237        let content = "`A`\n| à |  |\n| --- | --- |\n---";
2238        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2239        let fixed = rule.fix(&ctx).unwrap();
2240        assert_eq!(fixed, "`A`\n| À |  |\n| --- | --- |\n---");
2241
2242        let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2243        assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent");
2244    }
2245
2246    #[test]
2247    fn test_multi_line_setext_heading_whose_words_cannot_be_mapped_back_is_reported_without_a_fix() {
2248        // Link text is rebuilt from its words, so the padding inside this label
2249        // is gone from the capitalized text and the words no longer map back
2250        // one to one onto the author's lines. The heading is still reported;
2251        // only the rewrite is withheld, and the document is left as written.
2252        let rule = create_rule();
2253        let content = "see [ the guide ](guide.md) first\nand then more\n=====\n";
2254        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2255        let result = rule.check(&ctx).unwrap();
2256        assert_eq!(result.len(), 1, "got: {result:?}");
2257        assert_eq!(result[0].line, 1);
2258        assert_eq!(result[0].end_line, 2);
2259        assert!(
2260            result[0].fix.is_none(),
2261            "no rewrite is offered, got: {:?}",
2262            result[0].fix
2263        );
2264        assert_eq!(rule.fix(&ctx).unwrap(), content, "the heading is left as written");
2265
2266        // Control: the same heading with an unpadded label is rewritten in place.
2267        let content = "see [the guide](guide.md) first\nand then more\n=====\n";
2268        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2269        let result = rule.check(&ctx).unwrap();
2270        assert_eq!(result.len(), 1, "got: {result:?}");
2271        assert!(result[0].fix.is_some(), "the control heading is fixable");
2272        assert_eq!(
2273            rule.fix(&ctx).unwrap(),
2274            "See [The Guide](guide.md) First\nand Then More\n=====\n"
2275        );
2276    }
2277
2278    #[test]
2279    fn test_fix_honours_a_suppression_on_any_line_of_a_multi_line_setext_heading() {
2280        // The warning is dropped when any of the heading's lines is disabled,
2281        // and the rewrite goes with it: the comment disables the first text
2282        // line, and the heading is recorded on the last.
2283        let rule = create_rule();
2284        let content = "<!-- rumdl-disable-next-line MD063 -->\nhello\nworld\n===\n";
2285        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2286        assert_eq!(
2287            rule.fix(&ctx).unwrap(),
2288            content,
2289            "the suppressed heading is left as written"
2290        );
2291
2292        // Control: without the comment the same heading is rewritten in place.
2293        let content = "hello\nworld\n===\n";
2294        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2295        assert_eq!(rule.fix(&ctx).unwrap(), "Hello\nWorld\n===\n");
2296    }
2297
2298    // Custom ID tests
2299    #[test]
2300    fn test_custom_id_preserved() {
2301        let rule = create_rule();
2302        let content = "# getting started {#intro}\n";
2303        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2304        let result = rule.check(&ctx).unwrap();
2305        assert_eq!(result.len(), 1);
2306        // Custom ID should be preserved
2307        assert!(result[0].message.contains("{#intro}"));
2308    }
2309
2310    // Acronym preservation tests
2311    #[test]
2312    fn test_skip_obsidian_tags_not_headings() {
2313        let rule = create_rule();
2314
2315        // #tag (no space after #) is an Obsidian tag, not a heading
2316        let content = "# H1\n\n#tag\n";
2317        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2318        let result = rule.check(&ctx).unwrap();
2319        assert!(
2320            result.is_empty() || result.iter().all(|w| w.line != 3),
2321            "Obsidian tag #tag should not be treated as a heading: {result:?}"
2322        );
2323    }
2324
2325    #[test]
2326    fn test_skip_invalid_atx_headings_no_space() {
2327        let rule = create_rule();
2328
2329        // #NoSpace is not a valid ATX heading (requires space after #)
2330        let content = "#notaheading\n";
2331        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2332        let result = rule.check(&ctx).unwrap();
2333        assert!(
2334            result.is_empty(),
2335            "Invalid ATX heading without space should not be flagged: {result:?}"
2336        );
2337    }
2338
2339    #[test]
2340    fn test_fix_skips_obsidian_tags() {
2341        let rule = create_rule();
2342
2343        let content = "# hello world\n\n#tag\n";
2344        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2345        let fixed = rule.fix(&ctx).unwrap();
2346        // Should fix the real heading but leave the tag alone
2347        assert!(fixed.contains("#tag"), "Fix should not modify Obsidian tag #tag");
2348        assert!(fixed.contains("# Hello World"), "Fix should still fix real headings");
2349    }
2350
2351    #[test]
2352    fn test_preserve_all_caps_acronyms() {
2353        let rule = create_rule();
2354        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2355
2356        // Basic acronyms should be preserved
2357        let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
2358        assert_eq!(fixed, "# Using API in Production\n");
2359
2360        // Multiple acronyms
2361        let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
2362        assert_eq!(fixed, "# API and GPU Integration\n");
2363
2364        // Two-letter acronyms
2365        let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
2366        assert_eq!(fixed, "# IO Performance Guide\n");
2367
2368        // Acronyms with numbers
2369        let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
2370        assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
2371    }
2372
2373    #[test]
2374    fn test_preserve_acronyms_in_hyphenated_words() {
2375        let rule = create_rule();
2376        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2377
2378        // Acronyms at start of hyphenated word
2379        let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
2380        assert_eq!(fixed, "# API-Driven Architecture\n");
2381
2382        // Multiple acronyms with hyphens
2383        let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
2384        assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
2385    }
2386
2387    #[test]
2388    fn test_single_letters_not_treated_as_acronyms() {
2389        let rule = create_rule();
2390        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2391
2392        // Single uppercase letters should follow title case rules, not be preserved
2393        let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
2394        assert_eq!(fixed, "# I Am a Heading\n");
2395    }
2396
2397    #[test]
2398    fn test_lowercase_terms_need_ignore_words() {
2399        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2400
2401        // Without ignore_words: npm gets capitalized
2402        let rule = create_rule();
2403        let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
2404        assert_eq!(fixed, "# Using Npm Packages\n");
2405
2406        // With ignore_words: npm preserved
2407        let config = MD063Config {
2408            enabled: true,
2409            ignore_words: vec!["npm".to_string()],
2410            ..Default::default()
2411        };
2412        let rule = MD063HeadingCapitalization::from_config_struct(config);
2413        let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
2414        assert_eq!(fixed, "# Using npm Packages\n");
2415    }
2416
2417    #[test]
2418    fn test_acronyms_with_mixed_case_preserved() {
2419        let rule = create_rule();
2420        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2421
2422        // Both acronyms (API, GPU) and mixed-case (GitHub) should be preserved
2423        let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
2424        assert_eq!(fixed, "# Using API with GitHub\n");
2425    }
2426
2427    #[test]
2428    fn test_real_world_acronyms() {
2429        let rule = create_rule();
2430        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2431
2432        // Common technical acronyms from tested repositories
2433        let content = "# FFI bindings for CPU optimization\n";
2434        let fixed = rule.fix(&ctx(content)).unwrap();
2435        assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
2436
2437        let content = "# DOM manipulation and SSR rendering\n";
2438        let fixed = rule.fix(&ctx(content)).unwrap();
2439        assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
2440
2441        let content = "# CVE security and RNN models\n";
2442        let fixed = rule.fix(&ctx(content)).unwrap();
2443        assert_eq!(fixed, "# CVE Security and RNN Models\n");
2444    }
2445
2446    #[test]
2447    fn test_is_all_caps_acronym() {
2448        let rule = create_rule();
2449
2450        // Should return true for all-caps with 2+ letters
2451        assert!(rule.is_all_caps_acronym("API"));
2452        assert!(rule.is_all_caps_acronym("IO"));
2453        assert!(rule.is_all_caps_acronym("GPU"));
2454        assert!(rule.is_all_caps_acronym("HTTP2")); // Numbers don't break it
2455
2456        // Should return false for single letters
2457        assert!(!rule.is_all_caps_acronym("A"));
2458        assert!(!rule.is_all_caps_acronym("I"));
2459
2460        // Should return false for words with lowercase
2461        assert!(!rule.is_all_caps_acronym("Api"));
2462        assert!(!rule.is_all_caps_acronym("npm"));
2463        assert!(!rule.is_all_caps_acronym("iPhone"));
2464    }
2465
2466    #[test]
2467    fn test_sentence_case_starts_after_a_leading_empty_anchor() {
2468        // The anchor renders nothing, so the sentence still starts at `the`.
2469        let config = MD063Config {
2470            enabled: true,
2471            style: HeadingCapStyle::SentenceCase,
2472            ..Default::default()
2473        };
2474        let rule = MD063HeadingCapitalization::from_config_struct(config);
2475
2476        let content = "# <a id=\"top\"></a>the beginning\n";
2477        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2478        assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2479        assert_eq!(rule.fix(&ctx).unwrap(), "# <a id=\"top\"></a>The beginning\n");
2480
2481        // Visible HTML is an element of its own, so the prose after it is mid-sentence.
2482        let content = "# <kbd>ctrl</kbd> the key\n";
2483        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2484        assert!(rule.check(&ctx).unwrap().is_empty());
2485
2486        // An image paints something without holding text, so it is visible too,
2487        // whether written as HTML or as Markdown.
2488        for content in ["# <img src=\"x.png\"> the picture\n", "# ![x](x.png) the picture\n"] {
2489            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2490            assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
2491        }
2492    }
2493
2494    #[test]
2495    fn test_sentence_case_ignore_words_first_word() {
2496        let config = MD063Config {
2497            enabled: true,
2498            style: HeadingCapStyle::SentenceCase,
2499            ignore_words: vec!["nvim".to_string()],
2500            ..Default::default()
2501        };
2502        let rule = MD063HeadingCapitalization::from_config_struct(config);
2503
2504        // "nvim" as first word should be preserved exactly
2505        let content = "# nvim config\n";
2506        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2507        let result = rule.check(&ctx).unwrap();
2508        assert!(
2509            result.is_empty(),
2510            "nvim in ignore-words should not be flagged. Got: {result:?}"
2511        );
2512
2513        // Verify fix also preserves it
2514        let fixed = rule.fix(&ctx).unwrap();
2515        assert_eq!(fixed, "# nvim config\n");
2516    }
2517
2518    #[test]
2519    fn test_sentence_case_ignore_words_not_first() {
2520        let config = MD063Config {
2521            enabled: true,
2522            style: HeadingCapStyle::SentenceCase,
2523            ignore_words: vec!["nvim".to_string()],
2524            ..Default::default()
2525        };
2526        let rule = MD063HeadingCapitalization::from_config_struct(config);
2527
2528        // "nvim" in middle should also be preserved
2529        let content = "# Using nvim editor\n";
2530        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2531        let result = rule.check(&ctx).unwrap();
2532        assert!(
2533            result.is_empty(),
2534            "nvim in ignore-words should be preserved. Got: {result:?}"
2535        );
2536    }
2537
2538    #[test]
2539    fn test_preserve_cased_words_ios() {
2540        let config = MD063Config {
2541            enabled: true,
2542            style: HeadingCapStyle::SentenceCase,
2543            preserve_cased_words: true,
2544            ..Default::default()
2545        };
2546        let rule = MD063HeadingCapitalization::from_config_struct(config);
2547
2548        // "iOS" should be preserved (has mixed case: lowercase 'i' + uppercase 'OS')
2549        let content = "## This is iOS\n";
2550        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2551        let result = rule.check(&ctx).unwrap();
2552        assert!(
2553            result.is_empty(),
2554            "iOS should be preserved with preserve-cased-words. Got: {result:?}"
2555        );
2556
2557        // Verify fix also preserves it
2558        let fixed = rule.fix(&ctx).unwrap();
2559        assert_eq!(fixed, "## This is iOS\n");
2560    }
2561
2562    #[test]
2563    fn test_preserve_cased_words_ios_title_case() {
2564        let config = MD063Config {
2565            enabled: true,
2566            style: HeadingCapStyle::TitleCase,
2567            preserve_cased_words: true,
2568            ..Default::default()
2569        };
2570        let rule = MD063HeadingCapitalization::from_config_struct(config);
2571
2572        // "iOS" should be preserved in title case too
2573        let content = "# developing for iOS\n";
2574        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2575        let fixed = rule.fix(&ctx).unwrap();
2576        assert_eq!(fixed, "# Developing for iOS\n");
2577    }
2578
2579    #[test]
2580    fn test_has_internal_capitals_ios() {
2581        let rule = create_rule();
2582
2583        // iOS should be detected as having internal capitals
2584        assert!(
2585            rule.has_internal_capitals("iOS"),
2586            "iOS has mixed case (lowercase i, uppercase OS)"
2587        );
2588
2589        // Other mixed-case words
2590        assert!(rule.has_internal_capitals("iPhone"));
2591        assert!(rule.has_internal_capitals("macOS"));
2592        assert!(rule.has_internal_capitals("GitHub"));
2593        assert!(rule.has_internal_capitals("JavaScript"));
2594        assert!(rule.has_internal_capitals("eBay"));
2595
2596        // All-caps should NOT be detected (handled by is_all_caps_acronym)
2597        assert!(!rule.has_internal_capitals("API"));
2598        assert!(!rule.has_internal_capitals("GPU"));
2599
2600        // All-lowercase should NOT be detected
2601        assert!(!rule.has_internal_capitals("npm"));
2602        assert!(!rule.has_internal_capitals("config"));
2603
2604        // Regular capitalized words should NOT be detected
2605        assert!(!rule.has_internal_capitals("The"));
2606        assert!(!rule.has_internal_capitals("Hello"));
2607    }
2608
2609    #[test]
2610    fn test_lowercase_words_before_trailing_code() {
2611        let config = MD063Config {
2612            enabled: true,
2613            style: HeadingCapStyle::TitleCase,
2614            lowercase_words: vec![
2615                "a".to_string(),
2616                "an".to_string(),
2617                "and".to_string(),
2618                "at".to_string(),
2619                "but".to_string(),
2620                "by".to_string(),
2621                "for".to_string(),
2622                "from".to_string(),
2623                "into".to_string(),
2624                "nor".to_string(),
2625                "on".to_string(),
2626                "onto".to_string(),
2627                "or".to_string(),
2628                "the".to_string(),
2629                "to".to_string(),
2630                "upon".to_string(),
2631                "via".to_string(),
2632                "vs".to_string(),
2633                "with".to_string(),
2634                "without".to_string(),
2635            ],
2636            preserve_cased_words: true,
2637            ..Default::default()
2638        };
2639        let rule = MD063HeadingCapitalization::from_config_struct(config);
2640
2641        // Test: "subtitle with a `app`" (all lowercase input)
2642        // Expected fix: "Subtitle With a `app`" - capitalize "Subtitle" and "With",
2643        // but keep "a" lowercase (it's in lowercase-words and not the last word)
2644        // Incorrect: "Subtitle with A `app`" (would incorrectly capitalize "a")
2645        let content = "## subtitle with a `app`\n";
2646        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2647        let result = rule.check(&ctx).unwrap();
2648
2649        // Should flag it
2650        assert!(!result.is_empty(), "Should flag incorrect capitalization");
2651        let fixed = rule.fix(&ctx).unwrap();
2652        // "a" should remain lowercase (not "A") because inline code at end doesn't change lowercase-words behavior
2653        assert!(
2654            fixed.contains("with a `app`"),
2655            "Expected 'with a `app`' but got: {fixed:?}"
2656        );
2657        assert!(
2658            !fixed.contains("with A `app`"),
2659            "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
2660        );
2661        // "Subtitle" should be capitalized, "with" and "a" should remain lowercase (they're in lowercase-words)
2662        assert!(
2663            fixed.contains("Subtitle with a `app`"),
2664            "Expected 'Subtitle with a `app`' but got: {fixed:?}"
2665        );
2666    }
2667
2668    #[test]
2669    fn test_lowercase_words_preserved_before_trailing_code_variant() {
2670        let config = MD063Config {
2671            enabled: true,
2672            style: HeadingCapStyle::TitleCase,
2673            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2674            ..Default::default()
2675        };
2676        let rule = MD063HeadingCapitalization::from_config_struct(config);
2677
2678        // Another variant: "Title with the `code`"
2679        let content = "## Title with the `code`\n";
2680        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2681        let fixed = rule.fix(&ctx).unwrap();
2682        // "the" should remain lowercase
2683        assert!(
2684            fixed.contains("with the `code`"),
2685            "Expected 'with the `code`' but got: {fixed:?}"
2686        );
2687        assert!(
2688            !fixed.contains("with The `code`"),
2689            "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
2690        );
2691    }
2692
2693    #[test]
2694    fn test_last_word_capitalized_when_no_trailing_code() {
2695        // Verify that when there's NO trailing code, the last word IS capitalized
2696        // (even if it's in lowercase-words) - this is the normal title case behavior
2697        let config = MD063Config {
2698            enabled: true,
2699            style: HeadingCapStyle::TitleCase,
2700            lowercase_words: vec!["a".to_string(), "the".to_string()],
2701            ..Default::default()
2702        };
2703        let rule = MD063HeadingCapitalization::from_config_struct(config);
2704
2705        // "title with a word" - "word" is last, should be capitalized
2706        // "a" is in lowercase-words and not last, so should be lowercase
2707        let content = "## title with a word\n";
2708        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2709        let fixed = rule.fix(&ctx).unwrap();
2710        // "a" should be lowercase, "word" should be capitalized (it's last)
2711        assert!(
2712            fixed.contains("With a Word"),
2713            "Expected 'With a Word' but got: {fixed:?}"
2714        );
2715    }
2716
2717    #[test]
2718    fn test_multiple_lowercase_words_before_code() {
2719        let config = MD063Config {
2720            enabled: true,
2721            style: HeadingCapStyle::TitleCase,
2722            lowercase_words: vec![
2723                "a".to_string(),
2724                "the".to_string(),
2725                "with".to_string(),
2726                "for".to_string(),
2727            ],
2728            ..Default::default()
2729        };
2730        let rule = MD063HeadingCapitalization::from_config_struct(config);
2731
2732        // Multiple lowercase words before code - all should remain lowercase
2733        let content = "## Guide for the `user`\n";
2734        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2735        let fixed = rule.fix(&ctx).unwrap();
2736        assert!(
2737            fixed.contains("for the `user`"),
2738            "Expected 'for the `user`' but got: {fixed:?}"
2739        );
2740        assert!(
2741            !fixed.contains("For The `user`"),
2742            "Should not capitalize lowercase words before code. Got: {fixed:?}"
2743        );
2744    }
2745
2746    #[test]
2747    fn test_code_in_middle_normal_rules_apply() {
2748        let config = MD063Config {
2749            enabled: true,
2750            style: HeadingCapStyle::TitleCase,
2751            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2752            ..Default::default()
2753        };
2754        let rule = MD063HeadingCapitalization::from_config_struct(config);
2755
2756        // Code in the middle - normal title case rules apply (last word capitalized)
2757        let content = "## Using `const` for the code\n";
2758        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2759        let fixed = rule.fix(&ctx).unwrap();
2760        // "for" and "the" should be lowercase (middle), "code" should be capitalized (last)
2761        assert!(
2762            fixed.contains("for the Code"),
2763            "Expected 'for the Code' but got: {fixed:?}"
2764        );
2765    }
2766
2767    #[test]
2768    fn test_link_at_end_same_as_code() {
2769        let config = MD063Config {
2770            enabled: true,
2771            style: HeadingCapStyle::TitleCase,
2772            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2773            ..Default::default()
2774        };
2775        let rule = MD063HeadingCapitalization::from_config_struct(config);
2776
2777        // Link at the end - same behavior as code (lowercase words before should remain lowercase)
2778        let content = "## Guide for the [link](./page.md)\n";
2779        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2780        let fixed = rule.fix(&ctx).unwrap();
2781        // "for" and "the" should remain lowercase (not last word because link follows)
2782        assert!(
2783            fixed.contains("for the [Link]"),
2784            "Expected 'for the [Link]' but got: {fixed:?}"
2785        );
2786        assert!(
2787            !fixed.contains("for The [Link]"),
2788            "Should not capitalize 'the' before link. Got: {fixed:?}"
2789        );
2790    }
2791
2792    #[test]
2793    fn test_multiple_code_segments() {
2794        let config = MD063Config {
2795            enabled: true,
2796            style: HeadingCapStyle::TitleCase,
2797            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2798            ..Default::default()
2799        };
2800        let rule = MD063HeadingCapitalization::from_config_struct(config);
2801
2802        // Multiple code segments - last segment is code, so lowercase words before should remain lowercase
2803        let content = "## Using `const` with a `variable`\n";
2804        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2805        let fixed = rule.fix(&ctx).unwrap();
2806        // "a" should remain lowercase (not last word because code follows)
2807        assert!(
2808            fixed.contains("with a `variable`"),
2809            "Expected 'with a `variable`' but got: {fixed:?}"
2810        );
2811        assert!(
2812            !fixed.contains("with A `variable`"),
2813            "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2814        );
2815    }
2816
2817    #[test]
2818    fn test_code_and_link_combination() {
2819        let config = MD063Config {
2820            enabled: true,
2821            style: HeadingCapStyle::TitleCase,
2822            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2823            ..Default::default()
2824        };
2825        let rule = MD063HeadingCapitalization::from_config_struct(config);
2826
2827        // Code then link - last segment is link, so lowercase words before code should remain lowercase
2828        let content = "## Guide for the `code` [link](./page.md)\n";
2829        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2830        let fixed = rule.fix(&ctx).unwrap();
2831        // "for" and "the" should remain lowercase (not last word because link follows)
2832        assert!(
2833            fixed.contains("for the `code`"),
2834            "Expected 'for the `code`' but got: {fixed:?}"
2835        );
2836    }
2837
2838    #[test]
2839    fn test_text_after_code_capitalizes_last() {
2840        let config = MD063Config {
2841            enabled: true,
2842            style: HeadingCapStyle::TitleCase,
2843            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2844            ..Default::default()
2845        };
2846        let rule = MD063HeadingCapitalization::from_config_struct(config);
2847
2848        // Code in middle, text after - last word should be capitalized
2849        let content = "## Using `const` for the code\n";
2850        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2851        let fixed = rule.fix(&ctx).unwrap();
2852        // "for" and "the" should be lowercase, "code" is last word, should be capitalized
2853        assert!(
2854            fixed.contains("for the Code"),
2855            "Expected 'for the Code' but got: {fixed:?}"
2856        );
2857    }
2858
2859    #[test]
2860    fn test_preserve_cased_words_with_trailing_code() {
2861        let config = MD063Config {
2862            enabled: true,
2863            style: HeadingCapStyle::TitleCase,
2864            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2865            preserve_cased_words: true,
2866            ..Default::default()
2867        };
2868        let rule = MD063HeadingCapitalization::from_config_struct(config);
2869
2870        // Preserve-cased words should still work with trailing code
2871        let content = "## Guide for iOS `app`\n";
2872        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2873        let fixed = rule.fix(&ctx).unwrap();
2874        // "iOS" should be preserved, "for" should be lowercase
2875        assert!(
2876            fixed.contains("for iOS `app`"),
2877            "Expected 'for iOS `app`' but got: {fixed:?}"
2878        );
2879        assert!(
2880            !fixed.contains("For iOS `app`"),
2881            "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
2882        );
2883    }
2884
2885    #[test]
2886    fn test_ignore_words_with_trailing_code() {
2887        let config = MD063Config {
2888            enabled: true,
2889            style: HeadingCapStyle::TitleCase,
2890            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2891            ignore_words: vec!["npm".to_string()],
2892            ..Default::default()
2893        };
2894        let rule = MD063HeadingCapitalization::from_config_struct(config);
2895
2896        // Ignore-words should still work with trailing code
2897        let content = "## Using npm with a `script`\n";
2898        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2899        let fixed = rule.fix(&ctx).unwrap();
2900        // "npm" should be preserved, "with" and "a" should be lowercase
2901        assert!(
2902            fixed.contains("npm with a `script`"),
2903            "Expected 'npm with a `script`' but got: {fixed:?}"
2904        );
2905        assert!(
2906            !fixed.contains("with A `script`"),
2907            "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2908        );
2909    }
2910
2911    #[test]
2912    fn test_empty_text_segment_edge_case() {
2913        let config = MD063Config {
2914            enabled: true,
2915            style: HeadingCapStyle::TitleCase,
2916            lowercase_words: vec!["a".to_string(), "with".to_string()],
2917            ..Default::default()
2918        };
2919        let rule = MD063HeadingCapitalization::from_config_struct(config);
2920
2921        // Edge case: code at start, then text with lowercase word, then code at end
2922        let content = "## `start` with a `end`\n";
2923        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2924        let fixed = rule.fix(&ctx).unwrap();
2925        // "with" is first word in text segment, so capitalized (correct)
2926        // "a" should remain lowercase (not last word because code follows) - this is the key test
2927        assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
2928        assert!(
2929            !fixed.contains("A `end`"),
2930            "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2931        );
2932    }
2933
2934    #[test]
2935    fn test_sentence_case_with_trailing_code() {
2936        let config = MD063Config {
2937            enabled: true,
2938            style: HeadingCapStyle::SentenceCase,
2939            lowercase_words: vec!["a".to_string(), "the".to_string()],
2940            ..Default::default()
2941        };
2942        let rule = MD063HeadingCapitalization::from_config_struct(config);
2943
2944        // Sentence case should also respect lowercase words before code
2945        let content = "## guide for the `user`\n";
2946        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2947        let fixed = rule.fix(&ctx).unwrap();
2948        // First word capitalized, rest lowercase including "the" before code
2949        assert!(
2950            fixed.contains("Guide for the `user`"),
2951            "Expected 'Guide for the `user`' but got: {fixed:?}"
2952        );
2953    }
2954
2955    #[test]
2956    fn test_hyphenated_word_before_code() {
2957        let config = MD063Config {
2958            enabled: true,
2959            style: HeadingCapStyle::TitleCase,
2960            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2961            ..Default::default()
2962        };
2963        let rule = MD063HeadingCapitalization::from_config_struct(config);
2964
2965        // Hyphenated word before code - last part should respect lowercase-words
2966        let content = "## Self-contained with a `feature`\n";
2967        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2968        let fixed = rule.fix(&ctx).unwrap();
2969        // "with" and "a" should remain lowercase (not last word because code follows)
2970        assert!(
2971            fixed.contains("with a `feature`"),
2972            "Expected 'with a `feature`' but got: {fixed:?}"
2973        );
2974    }
2975
2976    // Issue #228: Sentence case with inline code at heading start
2977    // When a heading starts with inline code, the first word after the code
2978    // should NOT be capitalized because the heading already has a "first element"
2979
2980    #[test]
2981    fn test_sentence_case_code_at_start_basic() {
2982        // The exact case from issue #228
2983        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2984        let content = "# `rumdl` is a linter\n";
2985        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2986        let result = rule.check(&ctx).unwrap();
2987        // Should be correct as-is: code is first, "is" stays lowercase
2988        assert!(
2989            result.is_empty(),
2990            "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
2991            result.iter().map(|w| &w.message).collect::<Vec<_>>()
2992        );
2993    }
2994
2995    #[test]
2996    fn test_sentence_case_code_at_start_incorrect_capitalization() {
2997        // Verify we detect incorrect capitalization after code at start
2998        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2999        let content = "# `rumdl` Is a Linter\n";
3000        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3001        let result = rule.check(&ctx).unwrap();
3002        // Should flag: "Is" and "Linter" should be lowercase
3003        assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
3004        assert!(
3005            result[0].message.contains("`rumdl` is a linter"),
3006            "Should suggest lowercase after code. Got: {:?}",
3007            result[0].message
3008        );
3009    }
3010
3011    #[test]
3012    fn test_sentence_case_code_at_start_fix() {
3013        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3014        let content = "# `rumdl` Is A Linter\n";
3015        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3016        let fixed = rule.fix(&ctx).unwrap();
3017        assert!(
3018            fixed.contains("# `rumdl` is a linter"),
3019            "Should fix to lowercase after code. Got: {fixed:?}"
3020        );
3021    }
3022
3023    #[test]
3024    fn test_sentence_case_text_at_start_still_capitalizes() {
3025        // Ensure normal headings still capitalize first word
3026        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3027        let content = "# the quick brown fox\n";
3028        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3029        let result = rule.check(&ctx).unwrap();
3030        assert_eq!(result.len(), 1);
3031        assert!(
3032            result[0].message.contains("The quick brown fox"),
3033            "Text-first heading should capitalize first word. Got: {:?}",
3034            result[0].message
3035        );
3036    }
3037
3038    #[test]
3039    fn test_sentence_case_link_at_start() {
3040        // Link labels are visible prose, so an opening label starts the sentence.
3041        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3042        let content = "# [api](api.md) reference guide\n";
3043        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3044        let result = rule.check(&ctx).unwrap();
3045        assert_eq!(result.len(), 1);
3046        assert!(result[0].message.contains("[Api](api.md) reference guide"));
3047    }
3048
3049    #[test]
3050    fn test_sentence_case_link_preserves_acronyms() {
3051        // Acronyms in link text should be preserved (API, HTTP, etc.)
3052        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3053        let content = "# [API](api.md) Reference Guide\n";
3054        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3055        let result = rule.check(&ctx).unwrap();
3056        assert_eq!(result.len(), 1);
3057        // "API" should be preserved (acronym), "Reference Guide" should be lowercased
3058        assert!(
3059            result[0].message.contains("[API](api.md) reference guide"),
3060            "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
3061            result[0].message
3062        );
3063    }
3064
3065    #[test]
3066    fn test_sentence_case_link_preserves_brand_names() {
3067        // Brand names with internal capitals should be preserved
3068        let config = MD063Config {
3069            enabled: true,
3070            style: HeadingCapStyle::SentenceCase,
3071            preserve_cased_words: true,
3072            ..Default::default()
3073        };
3074        let rule = MD063HeadingCapitalization::from_config_struct(config);
3075        let content = "# [iPhone](iphone.md) Features Guide\n";
3076        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3077        let result = rule.check(&ctx).unwrap();
3078        assert_eq!(result.len(), 1);
3079        // "iPhone" should be preserved, "Features Guide" should be lowercased
3080        assert!(
3081            result[0].message.contains("[iPhone](iphone.md) features guide"),
3082            "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
3083            result[0].message
3084        );
3085    }
3086
3087    #[test]
3088    fn test_sentence_case_link_lowercases_regular_words() {
3089        // The first link word is capitalized and later words are lowercased.
3090        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3091        let content = "# [Documentation](docs.md) Reference\n";
3092        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3093        let result = rule.check(&ctx).unwrap();
3094        assert_eq!(result.len(), 1);
3095        assert!(
3096            result[0].message.contains("[Documentation](docs.md) reference"),
3097            "Should preserve the sentence-initial capital. Got: {:?}",
3098            result[0].message
3099        );
3100    }
3101
3102    #[test]
3103    fn test_sentence_case_opening_link_label_is_sentence_initial() {
3104        // Regression test for issue #844.
3105        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3106        let content = "# [Foo bar](https://example.com)\n";
3107        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3108
3109        assert!(rule.check(&ctx).unwrap().is_empty());
3110        assert_eq!(rule.fix(&ctx).unwrap(), content);
3111    }
3112
3113    #[test]
3114    fn test_sentence_case_link_at_start_correct_already() {
3115        // Link with correct casing should not be flagged
3116        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3117        let content = "# [API](api.md) reference guide\n";
3118        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3119        let result = rule.check(&ctx).unwrap();
3120        assert!(
3121            result.is_empty(),
3122            "Correctly cased heading with link should not be flagged. Got: {:?}",
3123            result.iter().map(|w| &w.message).collect::<Vec<_>>()
3124        );
3125    }
3126
3127    #[test]
3128    fn test_sentence_case_link_github_preserved() {
3129        // GitHub should be preserved (internal capitals)
3130        let config = MD063Config {
3131            enabled: true,
3132            style: HeadingCapStyle::SentenceCase,
3133            preserve_cased_words: true,
3134            ..Default::default()
3135        };
3136        let rule = MD063HeadingCapitalization::from_config_struct(config);
3137        let content = "# [GitHub](gh.md) Repository Setup\n";
3138        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3139        let result = rule.check(&ctx).unwrap();
3140        assert_eq!(result.len(), 1);
3141        assert!(
3142            result[0].message.contains("[GitHub](gh.md) repository setup"),
3143            "Should preserve 'GitHub'. Got: {:?}",
3144            result[0].message
3145        );
3146    }
3147
3148    #[test]
3149    fn test_sentence_case_multiple_code_spans() {
3150        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3151        let content = "# `foo` and `bar` are methods\n";
3152        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3153        let result = rule.check(&ctx).unwrap();
3154        // All text after first code should be lowercase
3155        assert!(
3156            result.is_empty(),
3157            "Should not capitalize words between/after code spans. Got: {:?}",
3158            result.iter().map(|w| &w.message).collect::<Vec<_>>()
3159        );
3160    }
3161
3162    #[test]
3163    fn test_sentence_case_code_only_heading() {
3164        // Heading with only code, no text
3165        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3166        let content = "# `rumdl`\n";
3167        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3168        let result = rule.check(&ctx).unwrap();
3169        assert!(
3170            result.is_empty(),
3171            "Code-only heading should be fine. Got: {:?}",
3172            result.iter().map(|w| &w.message).collect::<Vec<_>>()
3173        );
3174    }
3175
3176    #[test]
3177    fn test_sentence_case_code_at_end() {
3178        // Heading ending with code, text before should still capitalize first word
3179        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3180        let content = "# install the `rumdl` tool\n";
3181        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3182        let result = rule.check(&ctx).unwrap();
3183        // "install" should be capitalized (first word), rest lowercase
3184        assert_eq!(result.len(), 1);
3185        assert!(
3186            result[0].message.contains("Install the `rumdl` tool"),
3187            "First word should still be capitalized when text comes first. Got: {:?}",
3188            result[0].message
3189        );
3190    }
3191
3192    #[test]
3193    fn test_sentence_case_code_in_middle() {
3194        // Code in middle, text at start should capitalize first word
3195        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3196        let content = "# using the `rumdl` linter for markdown\n";
3197        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3198        let result = rule.check(&ctx).unwrap();
3199        // "using" should be capitalized, rest lowercase
3200        assert_eq!(result.len(), 1);
3201        assert!(
3202            result[0].message.contains("Using the `rumdl` linter for markdown"),
3203            "First word should be capitalized. Got: {:?}",
3204            result[0].message
3205        );
3206    }
3207
3208    #[test]
3209    fn test_sentence_case_preserved_word_after_code() {
3210        // Preserved words (like iPhone) should stay preserved even after code
3211        let config = MD063Config {
3212            enabled: true,
3213            style: HeadingCapStyle::SentenceCase,
3214            preserve_cased_words: true,
3215            ..Default::default()
3216        };
3217        let rule = MD063HeadingCapitalization::from_config_struct(config);
3218        let content = "# `swift` iPhone development\n";
3219        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3220        let result = rule.check(&ctx).unwrap();
3221        // "iPhone" should be preserved, "development" lowercase
3222        assert!(
3223            result.is_empty(),
3224            "Preserved words after code should stay. Got: {:?}",
3225            result.iter().map(|w| &w.message).collect::<Vec<_>>()
3226        );
3227    }
3228
3229    #[test]
3230    fn test_title_case_code_at_start_still_capitalizes() {
3231        // Title case should still capitalize words even after code at start
3232        let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3233        let content = "# `api` quick start guide\n";
3234        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3235        let result = rule.check(&ctx).unwrap();
3236        // Title case: all major words capitalized
3237        assert_eq!(result.len(), 1);
3238        assert!(
3239            result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
3240            "Title case should capitalize major words after code. Got: {:?}",
3241            result[0].message
3242        );
3243    }
3244
3245    // ======== HTML TAG TESTS ========
3246
3247    #[test]
3248    fn test_sentence_case_html_tag_at_start() {
3249        // HTML tag at start: text after should NOT capitalize first word
3250        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3251        let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
3252        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3253        let result = rule.check(&ctx).unwrap();
3254        // "is", "a", "Modifier", "Key" should all be lowercase (except preserved words)
3255        assert_eq!(result.len(), 1);
3256        let fixed = rule.fix(&ctx).unwrap();
3257        assert_eq!(
3258            fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
3259            "Text after HTML at start should be lowercase"
3260        );
3261    }
3262
3263    #[test]
3264    fn test_sentence_case_html_tag_preserves_content() {
3265        // Content inside HTML tags should be preserved as-is
3266        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3267        let content = "# The <abbr>API</abbr> documentation guide\n";
3268        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3269        let result = rule.check(&ctx).unwrap();
3270        // "The" is first, "API" inside tag preserved, rest lowercase
3271        assert!(
3272            result.is_empty(),
3273            "HTML tag content should be preserved. Got: {:?}",
3274            result.iter().map(|w| &w.message).collect::<Vec<_>>()
3275        );
3276    }
3277
3278    #[test]
3279    fn test_sentence_case_html_tag_at_start_with_acronym() {
3280        // HTML tag at start with acronym content
3281        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3282        let content = "# <abbr>API</abbr> Documentation Guide\n";
3283        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3284        let result = rule.check(&ctx).unwrap();
3285        assert_eq!(result.len(), 1);
3286        let fixed = rule.fix(&ctx).unwrap();
3287        assert_eq!(
3288            fixed, "# <abbr>API</abbr> documentation guide\n",
3289            "Text after HTML at start should be lowercase, HTML content preserved"
3290        );
3291    }
3292
3293    #[test]
3294    fn test_sentence_case_html_tag_in_middle() {
3295        // HTML tag in middle: first word still capitalized
3296        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3297        let content = "# using the <code>config</code> File\n";
3298        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3299        let result = rule.check(&ctx).unwrap();
3300        assert_eq!(result.len(), 1);
3301        let fixed = rule.fix(&ctx).unwrap();
3302        assert_eq!(
3303            fixed, "# Using the <code>config</code> file\n",
3304            "First word capitalized, HTML preserved, rest lowercase"
3305        );
3306    }
3307
3308    #[test]
3309    fn test_html_tag_strong_emphasis() {
3310        // <strong> tag handling
3311        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3312        let content = "# The <strong>Bold</strong> Way\n";
3313        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3314        let result = rule.check(&ctx).unwrap();
3315        assert_eq!(result.len(), 1);
3316        let fixed = rule.fix(&ctx).unwrap();
3317        assert_eq!(
3318            fixed, "# The <strong>Bold</strong> way\n",
3319            "<strong> tag content should be preserved"
3320        );
3321    }
3322
3323    #[test]
3324    fn test_html_tag_with_attributes() {
3325        // HTML tags with attributes should still be detected
3326        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3327        let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
3328        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3329        let result = rule.check(&ctx).unwrap();
3330        assert_eq!(result.len(), 1);
3331        let fixed = rule.fix(&ctx).unwrap();
3332        assert_eq!(
3333            fixed, "# <span class=\"highlight\">Important</span> notice here\n",
3334            "HTML tag with attributes should be preserved"
3335        );
3336    }
3337
3338    #[test]
3339    fn test_multiple_html_tags() {
3340        // Multiple HTML tags in heading
3341        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3342        let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
3343        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3344        let result = rule.check(&ctx).unwrap();
3345        assert_eq!(result.len(), 1);
3346        let fixed = rule.fix(&ctx).unwrap();
3347        assert_eq!(
3348            fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
3349            "Multiple HTML tags should all be preserved"
3350        );
3351    }
3352
3353    #[test]
3354    fn test_html_and_code_mixed() {
3355        // Mix of HTML tags and inline code
3356        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3357        let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
3358        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3359        let result = rule.check(&ctx).unwrap();
3360        assert_eq!(result.len(), 1);
3361        let fixed = rule.fix(&ctx).unwrap();
3362        assert_eq!(
3363            fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
3364            "HTML and code should both be preserved"
3365        );
3366    }
3367
3368    #[test]
3369    fn test_self_closing_html_tag() {
3370        // Self-closing tags like <br/>
3371        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3372        let content = "# Line one<br/>Line Two Here\n";
3373        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3374        let result = rule.check(&ctx).unwrap();
3375        assert_eq!(result.len(), 1);
3376        let fixed = rule.fix(&ctx).unwrap();
3377        assert_eq!(
3378            fixed, "# Line one<br/>line two here\n",
3379            "Self-closing HTML tags should be preserved"
3380        );
3381    }
3382
3383    #[test]
3384    fn test_title_case_with_html_tags() {
3385        // Title case with HTML tags
3386        let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3387        let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
3388        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3389        let result = rule.check(&ctx).unwrap();
3390        assert_eq!(result.len(), 1);
3391        let fixed = rule.fix(&ctx).unwrap();
3392        // "the" as first word should be "The", content inside <kbd> preserved
3393        assert!(
3394            fixed.contains("<kbd>ctrl</kbd>"),
3395            "HTML tag content should be preserved in title case. Got: {fixed}"
3396        );
3397        assert!(
3398            fixed.starts_with("# The ") || fixed.starts_with("# the "),
3399            "Title case should work with HTML. Got: {fixed}"
3400        );
3401    }
3402
3403    // ======== CARET NOTATION TESTS ========
3404
3405    #[test]
3406    fn test_sentence_case_preserves_caret_notation() {
3407        // Caret notation for control characters should be preserved
3408        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3409        let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
3410        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3411        let result = rule.check(&ctx).unwrap();
3412        // Should not flag - ^A and ^R are preserved
3413        assert!(
3414            result.is_empty(),
3415            "Caret notation should be preserved. Got: {:?}",
3416            result.iter().map(|w| &w.message).collect::<Vec<_>>()
3417        );
3418    }
3419
3420    #[test]
3421    fn test_sentence_case_caret_notation_various() {
3422        // Various caret notation patterns
3423        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3424
3425        // ^C for interrupt
3426        let content = "## Press ^C to cancel\n";
3427        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3428        let result = rule.check(&ctx).unwrap();
3429        assert!(
3430            result.is_empty(),
3431            "^C should be preserved. Got: {:?}",
3432            result.iter().map(|w| &w.message).collect::<Vec<_>>()
3433        );
3434
3435        // ^Z for suspend
3436        let content = "## Use ^Z for background\n";
3437        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3438        let result = rule.check(&ctx).unwrap();
3439        assert!(
3440            result.is_empty(),
3441            "^Z should be preserved. Got: {:?}",
3442            result.iter().map(|w| &w.message).collect::<Vec<_>>()
3443        );
3444
3445        // ^[ for escape
3446        let content = "## Press ^[ for escape\n";
3447        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3448        let result = rule.check(&ctx).unwrap();
3449        assert!(
3450            result.is_empty(),
3451            "^[ should be preserved. Got: {:?}",
3452            result.iter().map(|w| &w.message).collect::<Vec<_>>()
3453        );
3454    }
3455
3456    #[test]
3457    fn test_caret_notation_detection() {
3458        let rule = create_rule();
3459
3460        // Valid caret notation
3461        assert!(rule.is_caret_notation("^A"));
3462        assert!(rule.is_caret_notation("^Z"));
3463        assert!(rule.is_caret_notation("^C"));
3464        assert!(rule.is_caret_notation("^@")); // NUL
3465        assert!(rule.is_caret_notation("^[")); // ESC
3466        assert!(rule.is_caret_notation("^]")); // GS
3467        assert!(rule.is_caret_notation("^^")); // RS
3468        assert!(rule.is_caret_notation("^_")); // US
3469
3470        // Not caret notation
3471        assert!(!rule.is_caret_notation("^a")); // lowercase
3472        assert!(!rule.is_caret_notation("A")); // no caret
3473        assert!(!rule.is_caret_notation("^")); // caret alone
3474        assert!(!rule.is_caret_notation("^1")); // digit
3475    }
3476
3477    // MD044 proper names integration tests
3478    //
3479    // When MD063 (sentence case) and MD044 (proper names) are both active, MD063 must
3480    // preserve the exact capitalization of MD044 proper names rather than lowercasing them.
3481    // Without this, the two rules oscillate: MD044 re-capitalizes what MD063 lowercases.
3482
3483    fn create_sentence_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
3484        let config = MD063Config {
3485            enabled: true,
3486            style: HeadingCapStyle::SentenceCase,
3487            ..Default::default()
3488        };
3489        let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3490        rule.proper_names = names;
3491        rule
3492    }
3493
3494    #[test]
3495    fn test_sentence_case_preserves_single_word_proper_name() {
3496        let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
3497        // "javascript" in non-first position should become "JavaScript", not "javascript"
3498        let content = "# installing javascript\n";
3499        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3500        let result = rule.check(&ctx).unwrap();
3501        assert_eq!(result.len(), 1, "Should flag the heading");
3502        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3503        assert!(
3504            fix_text.contains("JavaScript"),
3505            "Fix should preserve proper name 'JavaScript', got: {fix_text:?}"
3506        );
3507        assert!(
3508            !fix_text.contains("javascript"),
3509            "Fix should not have lowercase 'javascript', got: {fix_text:?}"
3510        );
3511    }
3512
3513    #[test]
3514    fn test_sentence_case_preserves_multi_word_proper_name() {
3515        let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3516        // "Good Application" is a proper name; sentence case must not lowercase "Application"
3517        let content = "# using good application features\n";
3518        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3519        let result = rule.check(&ctx).unwrap();
3520        assert_eq!(result.len(), 1, "Should flag the heading");
3521        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3522        assert!(
3523            fix_text.contains("Good Application"),
3524            "Fix should preserve 'Good Application' as a phrase, got: {fix_text:?}"
3525        );
3526    }
3527
3528    #[test]
3529    fn test_sentence_case_proper_name_at_start_of_heading() {
3530        let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3531        // The proper name "Good Application" starts the heading; both words must be canonical
3532        let content = "# good application overview\n";
3533        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3534        let result = rule.check(&ctx).unwrap();
3535        assert_eq!(result.len(), 1, "Should flag the heading");
3536        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3537        assert!(
3538            fix_text.contains("Good Application"),
3539            "Fix should produce 'Good Application' at start of heading, got: {fix_text:?}"
3540        );
3541        assert!(
3542            fix_text.contains("overview"),
3543            "Non-proper-name word 'overview' should be lowercase, got: {fix_text:?}"
3544        );
3545    }
3546
3547    #[test]
3548    fn test_sentence_case_with_proper_names_no_oscillation() {
3549        // This is the core convergence test: applying the fix once must produce
3550        // output that is already correct (no further changes needed).
3551        let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3552
3553        // First application of fix
3554        let content = "# installing good application on your system\n";
3555        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3556        let result = rule.check(&ctx).unwrap();
3557        assert_eq!(result.len(), 1);
3558        let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
3559
3560        // The fixed heading should contain the proper name preserved
3561        assert!(
3562            fixed_heading.contains("Good Application"),
3563            "After fix, proper name must be preserved: {fixed_heading:?}"
3564        );
3565
3566        // Second application: must produce no further warnings (convergence)
3567        let fixed_line = format!("{fixed_heading}\n");
3568        let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
3569        let result2 = rule.check(&ctx2).unwrap();
3570        assert!(
3571            result2.is_empty(),
3572            "After one fix, heading must already satisfy both MD063 and MD044 - no oscillation. \
3573             Second pass warnings: {result2:?}"
3574        );
3575    }
3576
3577    #[test]
3578    fn test_sentence_case_proper_names_already_correct() {
3579        let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3580        // Heading already has correct sentence case with proper name preserved
3581        let content = "# Installing Good Application\n";
3582        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3583        let result = rule.check(&ctx).unwrap();
3584        assert!(
3585            result.is_empty(),
3586            "Correct sentence-case heading with proper name should not be flagged, got: {result:?}"
3587        );
3588    }
3589
3590    #[test]
3591    fn test_sentence_case_multiple_proper_names_in_heading() {
3592        let rule = create_sentence_case_rule_with_proper_names(vec!["TypeScript".to_string(), "React".to_string()]);
3593        let content = "# using typescript with react\n";
3594        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3595        let result = rule.check(&ctx).unwrap();
3596        assert_eq!(result.len(), 1);
3597        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3598        assert!(
3599            fix_text.contains("TypeScript"),
3600            "Fix should preserve 'TypeScript', got: {fix_text:?}"
3601        );
3602        assert!(
3603            fix_text.contains("React"),
3604            "Fix should preserve 'React', got: {fix_text:?}"
3605        );
3606    }
3607
3608    #[test]
3609    fn test_sentence_case_unicode_casefold_expansion_before_proper_name() {
3610        // Regression for Unicode case-fold expansion: `İ` lowercases to `i̇` (2 code points),
3611        // so matching offsets must be computed from the original text, not from a lowercased copy.
3612        let rule = create_sentence_case_rule_with_proper_names(vec!["Österreich".to_string()]);
3613        let content = "# İ österreich guide\n";
3614        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3615
3616        // Should not panic and should preserve canonical proper-name casing.
3617        let result = rule.check(&ctx).unwrap();
3618        assert_eq!(result.len(), 1, "Should flag heading for canonical proper-name casing");
3619        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3620        assert!(
3621            fix_text.contains("Österreich"),
3622            "Fix should preserve canonical 'Österreich', got: {fix_text:?}"
3623        );
3624    }
3625
3626    #[test]
3627    fn test_sentence_case_preserves_trailing_punctuation_on_proper_name() {
3628        let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
3629        let content = "# using javascript, today\n";
3630        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3631        let result = rule.check(&ctx).unwrap();
3632        assert_eq!(result.len(), 1, "Should flag heading");
3633        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3634        assert!(
3635            fix_text.contains("JavaScript,"),
3636            "Fix should preserve trailing punctuation, got: {fix_text:?}"
3637        );
3638    }
3639
3640    // Title case + MD044 conflict tests
3641    //
3642    // In title case, short words like "the", "a", "of" are kept lowercase by MD063.
3643    // If those words are part of an MD044 proper name (e.g. "The Rolling Stones"),
3644    // the same oscillation problem occurs.  The fix must extend to title case too.
3645
3646    fn create_title_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
3647        let config = MD063Config {
3648            enabled: true,
3649            style: HeadingCapStyle::TitleCase,
3650            ..Default::default()
3651        };
3652        let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3653        rule.proper_names = names;
3654        rule
3655    }
3656
3657    #[test]
3658    fn test_title_case_preserves_proper_name_with_lowercase_article() {
3659        // "The" is in the lowercase_words list for title case, so "the" in the middle
3660        // of a heading would normally stay lowercase.  But "The Rolling Stones" is a
3661        // proper name that must be capitalised exactly.
3662        let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
3663        let content = "# listening to the rolling stones today\n";
3664        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3665        let result = rule.check(&ctx).unwrap();
3666        assert_eq!(result.len(), 1, "Should flag the heading");
3667        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3668        assert!(
3669            fix_text.contains("The Rolling Stones"),
3670            "Fix should preserve proper name 'The Rolling Stones', got: {fix_text:?}"
3671        );
3672    }
3673
3674    #[test]
3675    fn test_title_case_proper_name_no_oscillation() {
3676        // One fix pass must produce output that title case already accepts.
3677        let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
3678        let content = "# listening to the rolling stones today\n";
3679        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3680        let result = rule.check(&ctx).unwrap();
3681        assert_eq!(result.len(), 1);
3682        let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
3683
3684        let fixed_line = format!("{fixed_heading}\n");
3685        let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
3686        let result2 = rule.check(&ctx2).unwrap();
3687        assert!(
3688            result2.is_empty(),
3689            "After one title-case fix, heading must already satisfy both rules. \
3690             Second pass warnings: {result2:?}"
3691        );
3692    }
3693
3694    #[test]
3695    fn test_title_case_unicode_casefold_expansion_before_proper_name() {
3696        let rule = create_title_case_rule_with_proper_names(vec!["Österreich".to_string()]);
3697        let content = "# İ österreich guide\n";
3698        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3699        let result = rule.check(&ctx).unwrap();
3700        assert_eq!(result.len(), 1, "Should flag the heading");
3701        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3702        assert!(
3703            fix_text.contains("Österreich"),
3704            "Fix should preserve canonical proper-name casing, got: {fix_text:?}"
3705        );
3706    }
3707
3708    // End-to-end integration test: from_config wires MD044 names into MD063
3709    //
3710    // This tests the actual code path used in production, where both rules are
3711    // configured in a rumdl.toml and the rule registry calls from_config.
3712
3713    #[test]
3714    fn test_from_config_loads_md044_names_into_md063() {
3715        use crate::config::{Config, RuleConfig};
3716        use crate::rule::Rule;
3717        use std::collections::BTreeMap;
3718
3719        let mut config = Config::default();
3720
3721        // Configure MD063 with sentence_case
3722        let mut md063_values = BTreeMap::new();
3723        md063_values.insert("style".to_string(), toml::Value::String("sentence_case".to_string()));
3724        md063_values.insert("enabled".to_string(), toml::Value::Boolean(true));
3725        config.rules.insert(
3726            "MD063".to_string(),
3727            RuleConfig {
3728                values: md063_values,
3729                severity: None,
3730            },
3731        );
3732
3733        // Configure MD044 with a proper name
3734        let mut md044_values = BTreeMap::new();
3735        md044_values.insert(
3736            "names".to_string(),
3737            toml::Value::Array(vec![toml::Value::String("Good Application".to_string())]),
3738        );
3739        config.rules.insert(
3740            "MD044".to_string(),
3741            RuleConfig {
3742                values: md044_values,
3743                severity: None,
3744            },
3745        );
3746
3747        // Build MD063 via the production code path
3748        let rule = MD063HeadingCapitalization::from_config(&config);
3749
3750        // Verify MD044 names were loaded: the fix must preserve "Good Application"
3751        let content = "# using good application features\n";
3752        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3753        let result = rule.check(&ctx).unwrap();
3754        assert_eq!(result.len(), 1, "Should flag the heading");
3755        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3756        assert!(
3757            fix_text.contains("Good Application"),
3758            "from_config should wire MD044 names into MD063; fix should preserve \
3759             'Good Application', got: {fix_text:?}"
3760        );
3761    }
3762
3763    #[test]
3764    fn test_title_case_short_word_not_confused_with_substring() {
3765        // Verify that short preposition matching ("in") does not trigger on
3766        // substrings of longer words ("insert"). Title case must capitalize
3767        // "insert" while keeping "in" lowercase.
3768        let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3769
3770        // "in" is a short preposition (should be lowercase in title case)
3771        // "insert" contains "in" as substring but is a regular word (should be capitalized)
3772        let content = "# in the insert\n";
3773        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3774        let result = rule.check(&ctx).unwrap();
3775        assert_eq!(result.len(), 1, "Should flag the heading");
3776        let fix = result[0].fix.as_ref().expect("Fix should be present");
3777        // "In" capitalized as first word, "the" lowercase as article, "Insert" capitalized
3778        assert!(
3779            fix.replacement.contains("In the Insert"),
3780            "Expected 'In the Insert', got: {:?}",
3781            fix.replacement
3782        );
3783    }
3784
3785    #[test]
3786    fn test_title_case_or_not_confused_with_orchestra() {
3787        let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3788
3789        // "or" is a conjunction (should be lowercase in title case)
3790        // "orchestra" contains "or" as substring but is a regular word
3791        let content = "# or the orchestra\n";
3792        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3793        let result = rule.check(&ctx).unwrap();
3794        assert_eq!(result.len(), 1, "Should flag the heading");
3795        let fix = result[0].fix.as_ref().expect("Fix should be present");
3796        // "Or" capitalized as first word, "the" lowercase, "Orchestra" capitalized
3797        assert!(
3798            fix.replacement.contains("Or the Orchestra"),
3799            "Expected 'Or the Orchestra', got: {:?}",
3800            fix.replacement
3801        );
3802    }
3803
3804    #[test]
3805    fn test_all_caps_preserves_all_words() {
3806        let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
3807
3808        let content = "# in the insert\n";
3809        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3810        let result = rule.check(&ctx).unwrap();
3811        assert_eq!(result.len(), 1, "Should flag the heading");
3812        let fix = result[0].fix.as_ref().expect("Fix should be present");
3813        assert!(
3814            fix.replacement.contains("IN THE INSERT"),
3815            "All caps should uppercase all words, got: {:?}",
3816            fix.replacement
3817        );
3818    }
3819
3820    // Numbered prefix tests — words following a period-terminated token must be capitalized
3821    #[test]
3822    fn test_title_case_numbered_prefix_lowercase_word() {
3823        // "to" follows "1." and must be treated as the start of a new phrase
3824        let rule = create_rule();
3825        let content = "## 1. To Be a Thing\n";
3826        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3827        let result = rule.check(&ctx).unwrap();
3828        assert!(
3829            result.is_empty(),
3830            "Should not flag '## 1. To Be a Thing', got: {result:?}"
3831        );
3832
3833        let content_lower = "## 1. to be a thing\n";
3834        let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3835        let result2 = rule.check(&ctx2).unwrap();
3836        assert!(!result2.is_empty(), "Should flag '## 1. to be a thing'");
3837        let fix = result2[0].fix.as_ref().expect("Should have a fix");
3838        assert!(
3839            fix.replacement.contains("1. To Be a Thing"),
3840            "Fix should capitalize 'To', got: {:?}",
3841            fix.replacement
3842        );
3843    }
3844
3845    #[test]
3846    fn test_title_case_numbered_prefix_article() {
3847        // "a" follows "2." and must be capitalized as the first word of the phrase
3848        let rule = create_rule();
3849        let content = "## 2. A Guide to the Galaxy\n";
3850        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3851        let result = rule.check(&ctx).unwrap();
3852        assert!(
3853            result.is_empty(),
3854            "Should not flag '## 2. A Guide to the Galaxy', got: {result:?}"
3855        );
3856
3857        let content_lower = "## 2. a guide to the galaxy\n";
3858        let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3859        let result2 = rule.check(&ctx2).unwrap();
3860        assert!(!result2.is_empty(), "Should flag '## 2. a guide to the galaxy'");
3861        let fix = result2[0].fix.as_ref().expect("Should have a fix");
3862        assert!(
3863            fix.replacement.contains("2. A Guide to the Galaxy"),
3864            "Fix should capitalize 'A', got: {:?}",
3865            fix.replacement
3866        );
3867    }
3868
3869    #[test]
3870    fn test_title_case_mid_sentence_period_word() {
3871        // "introduction" follows "1." embedded in a phrase — must be capitalized
3872        let rule = create_rule();
3873        let content = "## Step 1. Introduction to the Problem\n";
3874        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3875        let result = rule.check(&ctx).unwrap();
3876        assert!(
3877            result.is_empty(),
3878            "Should not flag '## Step 1. Introduction to the Problem', got: {result:?}"
3879        );
3880
3881        let content_lower = "## Step 1. introduction to the problem\n";
3882        let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3883        let result2 = rule.check(&ctx2).unwrap();
3884        assert!(
3885            !result2.is_empty(),
3886            "Should flag '## Step 1. introduction to the problem'"
3887        );
3888        let fix = result2[0].fix.as_ref().expect("Should have a fix");
3889        assert!(
3890            fix.replacement.contains("Step 1. Introduction to the Problem"),
3891            "Fix should capitalize 'Introduction', got: {:?}",
3892            fix.replacement
3893        );
3894    }
3895
3896    #[test]
3897    fn test_title_case_numbered_prefix_in_link_text() {
3898        // apply_title_case (link text path) must also respect after_period.
3899        // A heading whose only content is a link: ## [1. to be a thing](url)
3900        let config = MD063Config {
3901            enabled: true,
3902            style: HeadingCapStyle::TitleCase,
3903            ..Default::default()
3904        };
3905        let rule = MD063HeadingCapitalization::from_config_struct(config);
3906
3907        // Correct heading — link text already title-cased after numbered prefix
3908        let content = "## [1. To Be a Thing](https://example.com)\n";
3909        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3910        let result = rule.check(&ctx).unwrap();
3911        assert!(
3912            result.is_empty(),
3913            "Should not flag '## [1. To Be a Thing](url)', got: {result:?}"
3914        );
3915
3916        // Incorrect heading — "to" in link text must be capitalized after "1."
3917        let content_lower = "## [1. to be a thing](https://example.com)\n";
3918        let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3919        let result2 = rule.check(&ctx2).unwrap();
3920        assert!(!result2.is_empty(), "Should flag '## [1. to be a thing](url)'");
3921        let fix = result2[0].fix.as_ref().expect("Should have a fix");
3922        assert!(
3923            fix.replacement.contains("1. To Be a Thing"),
3924            "Fix should capitalize 'To' in link text, got: {:?}",
3925            fix.replacement
3926        );
3927    }
3928
3929    // Numeric-ordinal tests (issue #608): "1st", "2nd", "3rd", "4th", "21st"
3930    // and so on must keep their alphabetic suffix lower-cased in title case
3931    // and must be normalised back from mis-cased forms like "5Th".
3932
3933    #[test]
3934    fn test_is_numeric_ordinal_recognises_canonical_forms() {
3935        for word in &[
3936            "1st", "2nd", "3rd", "4th", "5th", "11th", "21st", "22nd", "23rd", "100th", "1ST", "5Th", "21St", "21sT",
3937        ] {
3938            assert!(
3939                MD063HeadingCapitalization::is_numeric_ordinal(word),
3940                "expected `{word}` to be detected as a numeric ordinal"
3941            );
3942        }
3943    }
3944
3945    #[test]
3946    fn test_is_numeric_ordinal_rejects_non_ordinals() {
3947        // Words without a digit prefix, an unrecognised alphabetic suffix,
3948        // or a non-ordinal alpha tail are all rejected. Compound forms with
3949        // hyphens are handled by `handle_hyphenated_word` so the helper's
3950        // behaviour on them is intentionally unconstrained.
3951        for word in &[
3952            "first", "1stop", "ist", "5", "th", "abc", "4G", "4K", "30s", "100k", "5x", "1.5", "iPhone6S",
3953        ] {
3954            assert!(
3955                !MD063HeadingCapitalization::is_numeric_ordinal(word),
3956                "expected `{word}` NOT to be detected as a numeric ordinal"
3957            );
3958        }
3959    }
3960
3961    #[test]
3962    fn test_is_numeric_ordinal_strips_trailing_punctuation() {
3963        for word in &["5th.", "1st,", "21st!", "3rd:", "4th)", "5th's"] {
3964            assert!(
3965                MD063HeadingCapitalization::is_numeric_ordinal(word),
3966                "expected `{word}` to be detected as a numeric ordinal (with punctuation)"
3967            );
3968        }
3969    }
3970
3971    #[test]
3972    fn test_is_numeric_ordinal_ignores_wrapping_punctuation() {
3973        // A word reaches this check as a whitespace-split token, so it still
3974        // carries whatever punctuation wraps it. A leading wrapper hid the
3975        // digits, and the capitaliser then uppercased the first letter it could
3976        // find, turning `(2nd` into `(2Nd`.
3977        for word in &[
3978            "(2nd", "[2nd", "\"2nd", "'2nd", "*2nd", "(21st)", "\"3rd\"", "**5th**", "_1st_",
3979        ] {
3980            assert!(
3981                MD063HeadingCapitalization::is_numeric_ordinal(word),
3982                "expected `{word}` to be detected as a numeric ordinal"
3983            );
3984        }
3985
3986        // Only the wrapping comes off. An interior separator still makes the
3987        // token something other than an ordinal, and a token with no core at
3988        // all must be rejected rather than read as an empty ordinal.
3989        for word in &["2-nd", "2 nd", "(", "\"\"", "()", "(nd", "(2"] {
3990            assert!(
3991                !MD063HeadingCapitalization::is_numeric_ordinal(word),
3992                "expected `{word}` NOT to be detected as a numeric ordinal"
3993            );
3994        }
3995    }
3996
3997    #[test]
3998    fn test_ordinal_wrapped_in_punctuation_survives_a_fix() {
3999        // Already correct in each style: the wrapped ordinal is preserved, so
4000        // nothing is reported and the fix leaves the heading byte-identical.
4001        for (style, content) in [
4002            (HeadingCapStyle::SentenceCase, "# The second (2nd) attempt\n"),
4003            (HeadingCapStyle::SentenceCase, "# Ranked \"3rd\" overall\n"),
4004            (HeadingCapStyle::SentenceCase, "# Plain 2nd place\n"),
4005            (HeadingCapStyle::TitleCase, "# The Second (2nd) Attempt\n"),
4006            (HeadingCapStyle::TitleCase, "# Ranked \"3rd\" Overall\n"),
4007        ] {
4008            let rule = create_rule_with_style(style);
4009            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4010            let result = rule.check(&ctx).unwrap();
4011            assert!(
4012                result.is_empty(),
4013                "{style:?} should not flag {content:?}, got: {result:?}"
4014            );
4015            assert_eq!(
4016                rule.fix(&ctx).unwrap(),
4017                content,
4018                "{style:?} must leave {content:?} alone"
4019            );
4020        }
4021
4022        // Positive control: on a heading that does need recasing the fix runs,
4023        // recases the prose around the ordinal and still leaves the ordinal
4024        // itself untouched. A second pass changes nothing more.
4025        for (style, content, expected) in [
4026            (
4027                HeadingCapStyle::SentenceCase,
4028                "# The Second (2nd) Attempt\n",
4029                "# The second (2nd) attempt\n",
4030            ),
4031            (
4032                HeadingCapStyle::TitleCase,
4033                "# the second (2nd) attempt\n",
4034                "# The Second (2nd) Attempt\n",
4035            ),
4036        ] {
4037            let rule = create_rule_with_style(style);
4038            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4039            assert!(
4040                !rule.check(&ctx).unwrap().is_empty(),
4041                "{style:?} should flag {content:?}"
4042            );
4043            let fixed = rule.fix(&ctx).unwrap();
4044            assert_eq!(fixed, expected, "{style:?} fix of {content:?}");
4045
4046            let refixed = rule
4047                .fix(&LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None))
4048                .unwrap();
4049            assert_eq!(refixed, expected, "{style:?} second pass over {fixed:?}");
4050        }
4051    }
4052
4053    #[test]
4054    fn test_wrapped_ordinal_corrupted_by_the_old_fix_is_repaired() {
4055        // A document already rewritten by the buggy capitaliser has to come back,
4056        // not stay wrong: `(2Nd)` was not recognised as an ordinal either, so the
4057        // old behaviour was stable and `check` reported nothing about it.
4058        for (style, content, expected) in [
4059            (
4060                HeadingCapStyle::SentenceCase,
4061                "# The second (2Nd) attempt\n",
4062                "# The second (2nd) attempt\n",
4063            ),
4064            (
4065                HeadingCapStyle::TitleCase,
4066                "# The Second (2Nd) Attempt\n",
4067                "# The Second (2nd) Attempt\n",
4068            ),
4069            (
4070                HeadingCapStyle::SentenceCase,
4071                "# Ranked \"3Rd\" overall\n",
4072                "# Ranked \"3rd\" overall\n",
4073            ),
4074        ] {
4075            let rule = create_rule_with_style(style);
4076            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4077            assert!(
4078                !rule.check(&ctx).unwrap().is_empty(),
4079                "{style:?} should flag {content:?}"
4080            );
4081            assert_eq!(rule.fix(&ctx).unwrap(), expected, "{style:?} fix of {content:?}");
4082        }
4083    }
4084
4085    #[test]
4086    fn test_title_case_ordinal_first_word_not_flagged() {
4087        let rule = create_rule();
4088        for content in &[
4089            "# 1st Place\n",
4090            "# 2nd Edition\n",
4091            "# 3rd Time\n",
4092            "# 5th Avenue\n",
4093            "# 21st Century Skills\n",
4094            "# 100th Customer\n",
4095        ] {
4096            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4097            let result = rule.check(&ctx).unwrap();
4098            assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4099        }
4100    }
4101
4102    #[test]
4103    fn test_title_case_ordinal_mid_heading_not_flagged() {
4104        let rule = create_rule();
4105        for content in &[
4106            "# May 3rd Notes\n",
4107            "# Top 100th Customer\n",
4108            "# Notes for the 5th of May\n",
4109        ] {
4110            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4111            let result = rule.check(&ctx).unwrap();
4112            assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4113        }
4114    }
4115
4116    #[test]
4117    fn test_title_case_ordinal_corrupted_form_is_fixed() {
4118        // The "sticky" case: a heading already mangled by the buggy
4119        // capitaliser must be flagged and corrected back, not left alone.
4120        let rule = create_rule();
4121        for (input, expected) in &[
4122            ("# 1St Place\n", "1st Place"),
4123            ("# 5Th Avenue\n", "5th Avenue"),
4124            ("# 21St Century Skills\n", "21st Century Skills"),
4125            ("# May 3Rd Notes\n", "May 3rd Notes"),
4126            ("# 22Nd Edition\n", "22nd Edition"),
4127        ] {
4128            let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
4129            let result = rule.check(&ctx).unwrap();
4130            assert!(!result.is_empty(), "Should flag {input:?}");
4131            let fix = result[0].fix.as_ref().expect("should have a fix");
4132            assert!(
4133                fix.replacement.contains(expected),
4134                "Fix for {input:?} should contain {expected:?}, got: {:?}",
4135                fix.replacement
4136            );
4137        }
4138    }
4139
4140    #[test]
4141    fn test_title_case_ordinal_lowercase_other_words_capitalised() {
4142        // Non-ordinal words around an ordinal still need title-casing.
4143        let rule = create_rule();
4144        let content = "# 5th avenue\n";
4145        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4146        let result = rule.check(&ctx).unwrap();
4147        assert_eq!(result.len(), 1);
4148        let fix = result[0].fix.as_ref().expect("should have a fix");
4149        assert!(
4150            fix.replacement.contains("5th Avenue"),
4151            "Fix should produce '5th Avenue', got: {:?}",
4152            fix.replacement
4153        );
4154    }
4155
4156    #[test]
4157    fn test_title_case_ordinal_with_trailing_punctuation() {
4158        let rule = create_rule();
4159        let content = "# Released on the 5th.\n";
4160        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4161        let result = rule.check(&ctx).unwrap();
4162        assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4163    }
4164
4165    #[test]
4166    fn test_title_case_ordinal_hyphenated() {
4167        let rule = create_rule();
4168        for content in &["# 21st-Century Skills\n", "# A 19th-Century Novel\n"] {
4169            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4170            let result = rule.check(&ctx).unwrap();
4171            assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4172        }
4173    }
4174
4175    #[test]
4176    fn test_sentence_case_ordinal_corrupted_form_is_fixed() {
4177        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
4178        let content = "# 5Th avenue\n";
4179        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4180        let result = rule.check(&ctx).unwrap();
4181        assert_eq!(result.len(), 1);
4182        let fix = result[0].fix.as_ref().expect("should have a fix");
4183        assert!(
4184            fix.replacement.contains("5th avenue"),
4185            "Fix should produce '5th avenue', got: {:?}",
4186            fix.replacement
4187        );
4188    }
4189
4190    #[test]
4191    fn test_title_case_digit_acronym_unchanged() {
4192        // Non-ordinal digit-prefixed tokens (4G, 4K) must still be preserved
4193        // as all-caps acronyms — the ordinal carve-out must not catch them.
4194        let rule = create_rule();
4195        for content in &["# 4G Networks\n", "# 4K Streaming\n"] {
4196            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4197            let result = rule.check(&ctx).unwrap();
4198            assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4199        }
4200    }
4201
4202    // --- sentence-case-restart-after ---
4203
4204    fn restart_rule(boundaries: &[&str]) -> MD063HeadingCapitalization {
4205        let config = MD063Config {
4206            enabled: true,
4207            style: HeadingCapStyle::SentenceCase,
4208            sentence_case_restart_after: boundaries.iter().copied().map(String::from).collect(),
4209            ..Default::default()
4210        };
4211        MD063HeadingCapitalization::from_config_struct(config)
4212    }
4213
4214    /// The heading text MD063 would rewrite this content to, or `None` when it is
4215    /// already compliant.
4216    fn suggested(rule: &MD063HeadingCapitalization, content: &str) -> Option<String> {
4217        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4218        let warnings = rule.check(&ctx).unwrap();
4219        let fixed = rule.fix(&ctx).unwrap();
4220        assert_eq!(
4221            warnings.is_empty(),
4222            fixed == content,
4223            "a warning and a rewrite must agree for {content:?}"
4224        );
4225        (!warnings.is_empty()).then(|| fixed.trim_start_matches('#').trim().to_string())
4226    }
4227
4228    #[test]
4229    fn test_restart_after_capitalizes_the_word_following_a_boundary() {
4230        let rule = restart_rule(&[":"]);
4231        assert_eq!(
4232            suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
4233            Some("Requirement 1: Struct to logger slice conversion")
4234        );
4235    }
4236
4237    #[test]
4238    fn test_restart_after_defaults_to_no_boundaries() {
4239        // The empty default must leave sentence case as it was: first word only.
4240        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
4241        assert_eq!(
4242            suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
4243            Some("Requirement 1: struct to logger slice conversion")
4244        );
4245    }
4246
4247    #[test]
4248    fn test_restart_after_only_honors_configured_punctuation() {
4249        // A colon-only configuration must not drag in the dash and semicolon cases.
4250        let rule = restart_rule(&[":"]);
4251        assert_eq!(
4252            suggested(&rule, "# Design - Data Model Overview\n").as_deref(),
4253            Some("Design - data model overview")
4254        );
4255        assert_eq!(
4256            suggested(&rule, "# Setup; Then Run\n").as_deref(),
4257            Some("Setup; then run")
4258        );
4259
4260        let rule = restart_rule(&[";", "\u{2014}"]);
4261        assert_eq!(
4262            suggested(&rule, "# Setup; Then Run\n").as_deref(),
4263            Some("Setup; Then run")
4264        );
4265        assert_eq!(
4266            suggested(&rule, "# Part One \u{2014} The Big Idea\n").as_deref(),
4267            Some("Part one \u{2014} The big idea")
4268        );
4269    }
4270
4271    #[test]
4272    fn test_restart_after_matches_only_at_the_end_of_a_word() {
4273        // An intra-word hyphen is not a sentence boundary, so a configured dash must
4274        // not restart inside `Well-Known`, and a URL's punctuation must not either.
4275        let rule = restart_rule(&["-", ":"]);
4276        assert_eq!(
4277            suggested(&rule, "# Ports: Well-Known Ports Explained\n").as_deref(),
4278            Some("Ports: Well-Known ports explained")
4279        );
4280        assert_eq!(
4281            suggested(&rule, "# See https://example.com/A/B For Details\n").as_deref(),
4282            Some("See https://example.com/A/B for details")
4283        );
4284    }
4285
4286    #[test]
4287    fn test_restart_after_a_trailing_boundary_is_a_no_op() {
4288        let rule = restart_rule(&[":"]);
4289        assert_eq!(suggested(&rule, "# Setup:\n"), None);
4290    }
4291
4292    #[test]
4293    fn test_restart_after_does_not_override_preserved_words() {
4294        // The likeliest regression: a preserved brand name landing right after a
4295        // boundary must not be re-capitalized into `IPhone`.
4296        let rule = restart_rule(&[":"]);
4297        assert_eq!(
4298            suggested(&rule, "# Devices: iPhone And Android\n").as_deref(),
4299            Some("Devices: iPhone and android")
4300        );
4301
4302        let config = MD063Config {
4303            enabled: true,
4304            style: HeadingCapStyle::SentenceCase,
4305            sentence_case_restart_after: vec![":".to_string()],
4306            ignore_words: vec!["kubectl".to_string()],
4307            preserve_cased_words: false,
4308            ..Default::default()
4309        };
4310        let rule = MD063HeadingCapitalization::from_config_struct(config);
4311        assert_eq!(
4312            suggested(&rule, "# Tools: kubectl And Helm\n").as_deref(),
4313            Some("Tools: kubectl and helm")
4314        );
4315    }
4316
4317    #[test]
4318    fn test_restart_after_keeps_md044_canonical_forms() {
4319        let config = MD063Config {
4320            enabled: true,
4321            style: HeadingCapStyle::SentenceCase,
4322            sentence_case_restart_after: vec![":".to_string()],
4323            ..Default::default()
4324        };
4325        let mut rule = MD063HeadingCapitalization::from_config_struct(config);
4326        rule.proper_names = vec!["GitHub".to_string()];
4327
4328        // The canonical form wins over the restart, so this is `GitHub`, not `Github`.
4329        assert_eq!(
4330            suggested(&rule, "# Docs: github Actions Guide\n").as_deref(),
4331            Some("Docs: GitHub actions guide")
4332        );
4333        assert_eq!(suggested(&rule, "# Docs: GitHub actions guide\n"), None);
4334    }
4335
4336    #[test]
4337    fn test_restart_after_carries_across_segments() {
4338        // A boundary in one segment governs the next, so a heading with a link behaves
4339        // the same as one without.
4340        let rule = restart_rule(&[":"]);
4341        assert_eq!(
4342            suggested(
4343                &rule,
4344                "# Overview: [Some Link Here](https://example.com) Trailing Words\n"
4345            )
4346            .as_deref(),
4347            Some("Overview: [Some link here](https://example.com) trailing words")
4348        );
4349        assert_eq!(
4350            suggested(&rule, "# Overview: `code` Then More Words\n").as_deref(),
4351            Some("Overview: `code` then more words")
4352        );
4353    }
4354
4355    #[test]
4356    fn test_restart_after_ends_a_sentence_at_the_end_of_link_text() {
4357        // A reader sees `[see:](url)` as `see:`, so the boundary is where they read it,
4358        // not at the closing paren of the destination. This is the complement of a
4359        // boundary before a link carrying into its text.
4360        let rule = restart_rule(&[":"]);
4361        assert_eq!(
4362            suggested(&rule, "# Topic [See:](https://example.com) More Words\n").as_deref(),
4363            Some("Topic [see:](https://example.com) More words")
4364        );
4365
4366        // A boundary that only appears in the destination is not visible prose.
4367        assert_eq!(
4368            suggested(&rule, "# Topic [See](https://example.com) More Words\n").as_deref(),
4369            Some("Topic [see](https://example.com) more words")
4370        );
4371    }
4372
4373    #[test]
4374    fn test_restart_after_ignores_boundaries_inside_opaque_segments() {
4375        // Code, HTML and image alt text are preserved verbatim rather than capitalized,
4376        // so a boundary inside them is not one this rule offers the reader.
4377        let rule = restart_rule(&[":"]);
4378        for content in [
4379            "# Topic `see:` More Words\n",
4380            "# Topic ![alt:](image.png) More Words\n",
4381            "# Topic <span title=\"x:\">y</span> More Words\n",
4382        ] {
4383            let fixed = suggested(&rule, content).expect("heading should be rewritten");
4384            assert!(
4385                fixed.ends_with("more words"),
4386                "opaque segment restarted the sentence in {content:?}: {fixed}"
4387            );
4388        }
4389    }
4390
4391    #[test]
4392    fn test_restart_after_treats_a_leading_link_as_sentence_initial() {
4393        // A heading opening with visible link text starts the sentence, whether or not
4394        // sentence restarts are configured.
4395        for rule in [restart_rule(&[]), restart_rule(&[":"])] {
4396            assert_eq!(
4397                suggested(&rule, "# [Some Link Here](https://example.com) Trailing Words\n").as_deref(),
4398                Some("[Some link here](https://example.com) trailing words")
4399            );
4400        }
4401    }
4402
4403    #[test]
4404    fn test_restart_after_fix_is_idempotent() {
4405        let rule = restart_rule(&[":", ";", "-", "\u{2014}"]);
4406        for content in [
4407            "# Requirement 1: Struct to Logger Slice Conversion\n",
4408            "# Ports: Well-Known Ports Explained\n",
4409            "# Devices: iPhone And Android\n",
4410            "# Overview: [Some Link Here](https://example.com) Trailing Words\n",
4411            "# Setup:\n",
4412        ] {
4413            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4414            let once = rule.fix(&ctx).unwrap();
4415            let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
4416            assert_eq!(rule.fix(&ctx).unwrap(), once, "fix is not idempotent for {content:?}");
4417        }
4418    }
4419
4420    #[test]
4421    fn test_restart_after_ignores_empty_boundary_entries() {
4422        // An empty string would otherwise end every word, capitalizing the whole heading.
4423        let rule = restart_rule(&[""]);
4424        assert_eq!(
4425            suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
4426            Some("Requirement 1: struct to logger slice conversion")
4427        );
4428    }
4429
4430    // Markdown with Gherkin
4431
4432    const STYLES: [HeadingCapStyle; 3] = [
4433        HeadingCapStyle::TitleCase,
4434        HeadingCapStyle::SentenceCase,
4435        HeadingCapStyle::AllCaps,
4436    ];
4437
4438    /// Every Gherkin structure keyword, each with a name all three styles rewrite:
4439    /// (heading, title case, sentence case, all caps).
4440    const GHERKIN_STRUCTURES: [(&str, &str, &str, &str); 6] = [
4441        (
4442            "# Feature: the system under test",
4443            "# Feature: The System Under Test",
4444            "# Feature: The system under test",
4445            "# Feature: THE SYSTEM UNDER TEST",
4446        ),
4447        (
4448            "## Background: a shared setup",
4449            "## Background: A Shared Setup",
4450            "## Background: A shared setup",
4451            "## Background: A SHARED SETUP",
4452        ),
4453        (
4454            "## Rule: money is never lost",
4455            "## Rule: Money Is Never Lost",
4456            "## Rule: Money is never lost",
4457            "## Rule: MONEY IS NEVER LOST",
4458        ),
4459        (
4460            "### Scenario: add two numbers",
4461            "### Scenario: Add Two Numbers",
4462            "### Scenario: Add two numbers",
4463            "### Scenario: ADD TWO NUMBERS",
4464        ),
4465        (
4466            "### Scenario Outline: add two numbers",
4467            "### Scenario Outline: Add Two Numbers",
4468            "### Scenario Outline: Add two numbers",
4469            "### Scenario Outline: ADD TWO NUMBERS",
4470        ),
4471        (
4472            "#### Examples: happy path",
4473            "#### Examples: Happy Path",
4474            "#### Examples: Happy path",
4475            "#### Examples: HAPPY PATH",
4476        ),
4477    ];
4478
4479    /// The heading MD063 leaves behind under `flavor`, rewritten or not.
4480    fn recased(style: HeadingCapStyle, heading: &str, flavor: crate::config::MarkdownFlavor) -> String {
4481        let rule = create_rule_with_style(style);
4482        let content = format!("{heading}\n");
4483        let ctx = LintContext::new(&content, flavor, None);
4484        let warnings = rule.check(&ctx).unwrap();
4485        let fixed = rule.fix(&ctx).unwrap();
4486        assert_eq!(
4487            warnings.is_empty(),
4488            fixed == content,
4489            "a warning and a rewrite must agree for {content:?} under {flavor:?}"
4490        );
4491        fixed.trim_end().to_string()
4492    }
4493
4494    #[test]
4495    fn test_mdg_keeps_the_keyword_of_every_structure() {
4496        // A keyword only names a structure when spelled exactly, so a recased one
4497        // silently turns the structure into prose.
4498        for (heading, ..) in GHERKIN_STRUCTURES {
4499            let keyword = &heading[..=heading.find(':').unwrap()];
4500            for style in STYLES {
4501                let fixed = recased(style, heading, crate::config::MarkdownFlavor::MDG);
4502                assert!(
4503                    fixed.starts_with(keyword),
4504                    "{style:?} lost the keyword of {heading:?}: {fixed}"
4505                );
4506            }
4507        }
4508    }
4509
4510    #[test]
4511    fn test_mdg_recases_only_the_name_of_a_structure() {
4512        for (heading, title, sentence, caps) in GHERKIN_STRUCTURES {
4513            let mdg = crate::config::MarkdownFlavor::MDG;
4514            assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), title);
4515            assert_eq!(recased(HeadingCapStyle::SentenceCase, heading, mdg), sentence);
4516            assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), caps);
4517        }
4518    }
4519
4520    #[test]
4521    fn test_standard_flavor_recases_a_keyword_like_any_other_word() {
4522        // The exemption belongs to the flavor, not to the rule.
4523        let standard = crate::config::MarkdownFlavor::Standard;
4524        assert_eq!(
4525            recased(HeadingCapStyle::TitleCase, "# Feature: the system under test", standard),
4526            "# Feature: the System Under Test"
4527        );
4528        assert_eq!(
4529            recased(
4530                HeadingCapStyle::SentenceCase,
4531                "### Scenario Outline: add two numbers",
4532                standard
4533            ),
4534            "### Scenario outline: add two numbers"
4535        );
4536        assert_eq!(
4537            recased(HeadingCapStyle::AllCaps, "# Feature: the system under test", standard),
4538            "# FEATURE: THE SYSTEM UNDER TEST"
4539        );
4540    }
4541
4542    #[test]
4543    fn test_mdg_leaves_a_heading_without_a_colon_to_the_normal_rule() {
4544        for heading in ["## notes about the system", "## Notes", "# THE SYSTEM"] {
4545            for style in STYLES {
4546                assert_eq!(
4547                    recased(style, heading, crate::config::MarkdownFlavor::MDG),
4548                    recased(style, heading, crate::config::MarkdownFlavor::Standard),
4549                    "{style:?} treated {heading:?} as a Gherkin structure"
4550                );
4551            }
4552        }
4553    }
4554
4555    #[test]
4556    fn test_mdg_splits_at_the_first_colon_only() {
4557        // A later colon belongs to the name, which is prose this rule still owns.
4558        let mdg = crate::config::MarkdownFlavor::MDG;
4559        let heading = "## Scenario: ratio: two to one";
4560        assert_eq!(
4561            recased(HeadingCapStyle::TitleCase, heading, mdg),
4562            "## Scenario: Ratio: Two to One"
4563        );
4564        assert_eq!(
4565            recased(HeadingCapStyle::SentenceCase, heading, mdg),
4566            "## Scenario: Ratio: two to one"
4567        );
4568        assert_eq!(
4569            recased(HeadingCapStyle::AllCaps, heading, mdg),
4570            "## Scenario: RATIO: TWO TO ONE"
4571        );
4572    }
4573
4574    #[test]
4575    fn test_mdg_leaves_a_colon_behind_a_backtick_to_the_normal_rule() {
4576        // Dialect keywords are plain words, so such a colon is inside a code span rather
4577        // than after a keyword. Splitting there would hide the span from the segment
4578        // parser and recase what a reader sees as code.
4579        for heading in [
4580            "# See `x: y` Notes",
4581            "# `a: b`",
4582            "# `code` Feature: a name",
4583            "# `x: y` Feature: a name",
4584        ] {
4585            for style in STYLES {
4586                assert_eq!(
4587                    recased(style, heading, crate::config::MarkdownFlavor::MDG),
4588                    recased(style, heading, crate::config::MarkdownFlavor::Standard),
4589                    "{style:?} split {heading:?} at a colon inside a code span"
4590                );
4591            }
4592        }
4593    }
4594
4595    #[test]
4596    fn test_mdg_splits_at_a_keyword_colon_that_precedes_a_code_span() {
4597        // The backtick is in the name, so the keyword colon still governs.
4598        let mdg = crate::config::MarkdownFlavor::MDG;
4599        let heading = "# Scenario: use `a: b` here";
4600        assert_eq!(
4601            recased(HeadingCapStyle::TitleCase, heading, mdg),
4602            "# Scenario: Use `a: b` Here"
4603        );
4604        assert_eq!(
4605            recased(HeadingCapStyle::SentenceCase, heading, mdg),
4606            "# Scenario: Use `a: b` here"
4607        );
4608        assert_eq!(
4609            recased(HeadingCapStyle::AllCaps, heading, mdg),
4610            "# Scenario: USE `a: b` HERE"
4611        );
4612    }
4613
4614    #[test]
4615    fn test_mdg_splits_at_a_keyword_colon_before_an_unbalanced_backtick() {
4616        // An unclosed backtick opens no code span for either flavor, so the tail stays
4617        // prose and only the keyword is held back.
4618        let mdg = crate::config::MarkdownFlavor::MDG;
4619        let heading = "# Scenario: a ` b";
4620        assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), "# Scenario: A ` B");
4621        assert_eq!(
4622            recased(HeadingCapStyle::SentenceCase, heading, mdg),
4623            "# Scenario: A ` b"
4624        );
4625        assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), "# Scenario: A ` B");
4626    }
4627
4628    #[test]
4629    fn test_mdg_keeps_a_keyword_with_nothing_left_to_recase() {
4630        for style in STYLES {
4631            assert_eq!(
4632                recased(style, "# Feature:", crate::config::MarkdownFlavor::MDG),
4633                "# Feature:"
4634            );
4635        }
4636    }
4637
4638    #[test]
4639    fn test_mdg_keeps_a_custom_id_after_the_name() {
4640        assert_eq!(
4641            recased(
4642                HeadingCapStyle::TitleCase,
4643                "# Feature: the system {#overview}",
4644                crate::config::MarkdownFlavor::MDG
4645            ),
4646            "# Feature: The System {#overview}"
4647        );
4648    }
4649
4650    #[test]
4651    fn test_mdg_fix_is_idempotent() {
4652        for (heading, ..) in GHERKIN_STRUCTURES {
4653            for style in STYLES {
4654                let rule = create_rule_with_style(style);
4655                let content = format!("{heading}\n");
4656                let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
4657                let once = rule.fix(&ctx).unwrap();
4658                let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::MDG, None);
4659                assert_eq!(
4660                    rule.fix(&ctx).unwrap(),
4661                    once,
4662                    "fix is not idempotent for {heading:?} ({style:?})"
4663                );
4664            }
4665        }
4666    }
4667}
4668
4669#[cfg(test)]
4670mod shortcode_delimiter_tests {
4671    use super::*;
4672
4673    #[test]
4674    fn heading_capitalization_preserves_shortcode_raw_arguments() {
4675        let rule = MD063HeadingCapitalization::from_config_struct(MD063Config {
4676            enabled: true,
4677            style: HeadingCapStyle::TitleCase,
4678            ..Default::default()
4679        });
4680        for (open, close) in [("{{<", ">}}"), ("{{%", "%}}")] {
4681            let tag = format!("{open} note title=`literal {close} friend` {close}");
4682            assert_eq!(
4683                rule.apply_capitalization(&format!("hello {tag} world"), crate::config::MarkdownFlavor::Hugo),
4684                format!("Hello {tag} World")
4685            );
4686        }
4687    }
4688}
4689
4690#[cfg(test)]
4691mod mdx_tag_capitalization_tests {
4692    use super::*;
4693    use crate::rule::Rule;
4694
4695    fn fixed(style: HeadingCapStyle, source: &str) -> String {
4696        let rule = MD063HeadingCapitalization::from_config_struct(MD063Config {
4697            enabled: true,
4698            style,
4699            ..Default::default()
4700        });
4701        let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
4702        rule.fix(&ctx).unwrap()
4703    }
4704
4705    #[test]
4706    fn mdx_heading_capitalization_preserves_native_jsx_elements() {
4707        for name in ["span", "Card", "ui.Card", "svg:path"] {
4708            let element = format!("<{name} onClick={{() => null}} title=\"literal friend\">world</{name}>");
4709            let source = format!("# hello {element} after\n");
4710            for (style, before, after) in [
4711                (HeadingCapStyle::TitleCase, "Hello", "After"),
4712                (HeadingCapStyle::SentenceCase, "Hello", "after"),
4713                (HeadingCapStyle::AllCaps, "HELLO", "AFTER"),
4714            ] {
4715                assert_eq!(fixed(style, &source), format!("# {before} {element} {after}\n"));
4716            }
4717        }
4718    }
4719
4720    #[test]
4721    fn mdx_heading_capitalization_preserves_nested_member_elements() {
4722        let element = "<ui.Card title='literal friend'>world <span>inner</span></ui.Card>";
4723        let source = format!("# hello {element} after\n");
4724        assert_eq!(
4725            fixed(HeadingCapStyle::TitleCase, &source),
4726            format!("# Hello {element} After\n")
4727        );
4728    }
4729
4730    #[test]
4731    fn mdx_heading_tag_protection_preserves_existing_visible_word_context() {
4732        assert_eq!(
4733            fixed(HeadingCapStyle::SentenceCase, "# <span></span> hello world\n"),
4734            "# <span></span> Hello world\n"
4735        );
4736        assert_eq!(
4737            fixed(HeadingCapStyle::SentenceCase, "# <img alt='friend'/> hello world\n"),
4738            "# <img alt='friend'/> hello world\n"
4739        );
4740        assert_eq!(
4741            fixed(HeadingCapStyle::TitleCase, "# <span>visible</span> and the world\n"),
4742            "# <span>visible</span> And the World\n"
4743        );
4744    }
4745
4746    #[test]
4747    fn mdx_setext_member_attributes_preserve_their_source_map() {
4748        let member = "hello <ui.Card title=\"literal\nfriend\">world</ui.Card> after\n===\n";
4749        assert_eq!(fixed(HeadingCapStyle::TitleCase, member), member);
4750        let html = "hello <span title=\"literal\nfriend\">world</span> after\n===\n";
4751        assert_eq!(
4752            fixed(HeadingCapStyle::TitleCase, html),
4753            "Hello <span title=\"literal\nfriend\">world</span> After\n===\n"
4754        );
4755    }
4756}