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