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