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