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