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