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