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