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::{HashMap, HashSet};
21use std::hash::Hash;
22use std::sync::LazyLock;
23
24mod md063_config;
25pub(super) use md063_config::{HeadingCapStyle, MD063Config};
26
27/// Pair closing tags with the nearest matching opener. Successful searches
28/// discard the entries they visit; a failed search leaves them all open. Build
29/// a name index after the first miss so repeated unmatched tags stay linear,
30/// while ordinary paired markup uses only the stack.
31struct OpenElements<N> {
32    elements: Vec<(N, usize)>,
33    counts: Option<HashMap<N, usize>>,
34}
35
36impl<N: Clone + Eq + Hash> OpenElements<N> {
37    fn new() -> Self {
38        Self {
39            elements: Vec::new(),
40            counts: None,
41        }
42    }
43
44    fn push(&mut self, name: N, start: usize) {
45        if let Some(counts) = &mut self.counts {
46            *counts.entry(name.clone()).or_default() += 1;
47        }
48        self.elements.push((name, start));
49    }
50
51    fn close(&mut self, name: &N) -> Option<usize> {
52        if let Some(counts) = &self.counts
53            && !counts.contains_key(name)
54        {
55            return None;
56        }
57        if let Some(depth) = self.elements.iter().rposition(|(open_name, _)| open_name == name) {
58            let start = self.elements[depth].1;
59            if let Some(counts) = &mut self.counts {
60                for (name, _) in self.elements.drain(depth..) {
61                    let count = counts.get_mut(&name).unwrap();
62                    *count -= 1;
63                    if *count == 0 {
64                        counts.remove(&name);
65                    }
66                }
67                // A recovered stack starts fresh: later paired markup should
68                // not pay for an earlier unmatched tag.
69                if self.elements.is_empty() {
70                    self.counts = None;
71                }
72            } else {
73                self.elements.truncate(depth);
74            }
75            Some(start)
76        } else {
77            if self.counts.is_none() && !self.elements.is_empty() {
78                let mut counts = HashMap::new();
79                for (name, _) in &self.elements {
80                    *counts.entry(name.clone()).or_default() += 1;
81                }
82                self.counts = Some(counts);
83            }
84            None
85        }
86    }
87}
88
89// Regex to match inline code spans (backticks)
90static INLINE_CODE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`+[^`]+`+").unwrap());
91
92// Regex to match markdown links [text](url) or [text][ref].
93// The inline-URL part allows one level of nested parentheses so URLs like
94// `https://example.com/docs/v(2)beta` are matched in full; otherwise the URL
95// would be truncated at the first ')' and its tail title-cased as prose.
96static LINK_REGEX: LazyLock<Regex> =
97    LazyLock::new(|| Regex::new(r"\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)|\[([^\]]*)\]\[[^\]]*\]").unwrap());
98
99// One inline HTML token: a comment, a closing tag (name in group 1), or an open
100// tag (name in group 2). `<!-->` and `<!--->` are complete comments, so a later
101// `-->` is text. Attributes follow the CommonMark grammar, so a quoted value may
102// contain `>`. Elements are paired by name in `html_regions`, since the regex
103// engine has no backreferences.
104static HTML_TOKEN_REGEX: LazyLock<Regex> = LazyLock::new(|| {
105    let pattern = format!(
106        r"<!-->|<!--->|<!--.*?-->|</({HTML_TAG_NAME_PATTERN})\s*>|<({HTML_TAG_NAME_PATTERN}){HTML_TAG_ATTRIBUTES_PATTERN}\s*/?>"
107    );
108    Regex::new(&pattern).unwrap()
109});
110
111// Elements that paint content of their own without holding text: the replaced
112// elements (images, media, frames, canvases, embedded SVG and MathML) and the
113// form controls. An empty one is still visible, so it counts as a word of the
114// heading, as a Markdown image does.
115const SELF_RENDERING_ELEMENTS: &[&str] = &[
116    "img", "video", "audio", "iframe", "embed", "object", "canvas", "svg", "math", "input", "select", "textarea",
117    "button", "meter", "progress",
118];
119
120// Regex to match custom header IDs {#id}
121static CUSTOM_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*\{#[^}]+\}\s*$").unwrap());
122
123/// Represents a segment of heading text
124#[derive(Debug, Clone)]
125enum HeadingSegment {
126    /// Regular text that should be capitalized
127    Text(String),
128    /// Inline code that should be preserved as-is
129    Code(String),
130    /// Link with text that may be capitalized and URL that's preserved
131    Link {
132        full: String,
133        text_start: usize,
134        text_end: usize,
135    },
136    /// Inline HTML tag that should be preserved as-is
137    Html(String),
138    /// Image `![alt](url)` preserved as-is, including alt text. Unlike link
139    /// text, image alt text is not recased.
140    Image(String),
141}
142
143impl HeadingSegment {
144    /// Whether the segment shows a reader nothing: an empty element such as an
145    /// anchor, or a comment. Such a segment does not move the heading's first or
146    /// last word. An element that paints content of its own, such as an image or
147    /// a form control, is visible even without text.
148    fn renders_nothing(&self) -> bool {
149        match self {
150            HeadingSegment::Html(html) => {
151                let paints_something = HTML_TOKEN_REGEX.captures_iter(html).any(|token| {
152                    token.get(2).is_some_and(|name| {
153                        let name = name.as_str().to_ascii_lowercase();
154                        SELF_RENDERING_ELEMENTS.contains(&name.as_str())
155                    })
156                });
157                !paints_something && HTML_TOKEN_REGEX.replace_all(html, "").trim().is_empty()
158            }
159            _ => false,
160        }
161    }
162}
163
164/// Rule MD063: Heading capitalization
165#[derive(Clone)]
166pub struct MD063HeadingCapitalization {
167    config: MD063Config,
168    lowercase_set: HashSet<String>,
169    /// Multi-word proper names from MD044 that must survive sentence-case transformation.
170    /// Populated via `from_config` when both rules are active.
171    proper_names: Vec<String>,
172}
173
174/// A concrete transformation, resolved once for the document before recasing.
175#[derive(Clone, Copy)]
176enum ResolvedStyle {
177    TitleCase,
178    SentenceCase,
179    AllCaps,
180}
181
182impl ResolvedStyle {
183    const ALL: [Self; 3] = [Self::TitleCase, Self::SentenceCase, Self::AllCaps];
184
185    fn name(self) -> &'static str {
186        match self {
187            Self::TitleCase => "title case",
188            Self::SentenceCase => "sentence case",
189            Self::AllCaps => "ALL CAPS",
190        }
191    }
192}
193
194impl Default for MD063HeadingCapitalization {
195    fn default() -> Self {
196        Self::new()
197    }
198}
199
200impl MD063HeadingCapitalization {
201    fn eligible_headings<'a>(
202        &'a self,
203        ctx: &'a crate::lint_context::LintContext<'_>,
204    ) -> impl Iterator<Item = crate::lint_context::ParsedHeading<'a>> + 'a {
205        let edges = crate::utils::blockquote::container_edges(ctx);
206        ctx.headings().filter(move |parsed| {
207            let heading = parsed.heading;
208            let line_index = parsed.line_num - 1;
209            heading.level >= self.config.min_level
210                && heading.level <= self.config.max_level
211                && !(!parsed.is_blockquote()
212                    && matches!(heading.style, crate::lint_context::HeadingStyle::ATX)
213                    && crate::utils::blockquote::is_indented_past_edge(
214                        &edges,
215                        line_index,
216                        parsed.line_info.content(ctx.content),
217                    ))
218        })
219    }
220
221    /// Disabled headings neither choose the document style nor get rewritten.
222    /// Raw checks still visit them so the lint driver can account for suppressions.
223    fn enabled_headings<'a>(
224        &'a self,
225        ctx: &'a crate::lint_context::LintContext<'_>,
226    ) -> impl Iterator<Item = crate::lint_context::ParsedHeading<'a>> + 'a {
227        self.eligible_headings(ctx).filter(|parsed| {
228            // ParsedHeading records a Setext heading on its last text line.
229            let first_line = parsed.line_num + 1 - parsed.heading.text_lines;
230            !(first_line..=parsed.line_num).any(|line| ctx.is_rule_disabled(self.name(), line))
231        })
232    }
233
234    fn resolve_style(&self, ctx: &crate::lint_context::LintContext) -> ResolvedStyle {
235        match self.config.style {
236            HeadingCapStyle::TitleCase => return ResolvedStyle::TitleCase,
237            HeadingCapStyle::SentenceCase => return ResolvedStyle::SentenceCase,
238            HeadingCapStyle::AllCaps => return ResolvedStyle::AllCaps,
239            HeadingCapStyle::Consistent => {}
240        }
241
242        let mut candidates = 0b111_u8;
243        for parsed in self.enabled_headings(ctx) {
244            let mut valid = 0_u8;
245            for (index, style) in ResolvedStyle::ALL.into_iter().enumerate() {
246                if self.apply_heading_capitalization(ctx, parsed.line_num - 1, parsed.heading, style)
247                    == parsed.heading.raw_text
248                {
249                    valid |= 1 << index;
250                }
251            }
252            // A heading invalid in every style offers no usable evidence.
253            if valid == 0 {
254                continue;
255            }
256            let remaining = candidates & valid;
257            if remaining == 0 {
258                break;
259            }
260            candidates = remaining;
261            if candidates.is_power_of_two() {
262                break;
263            }
264        }
265        // Preserve the earliest evidence; prefer title, sentence, then ALL CAPS
266        // when more than one style still fits. Every evidence heading is valid
267        // under the chosen style and stays unchanged by fixes. Later fixed
268        // headings can only narrow the set while retaining that preferred style,
269        // so formatting again makes the same choice.
270        ResolvedStyle::ALL[candidates.trailing_zeros() as usize]
271    }
272
273    pub fn new() -> Self {
274        let config = MD063Config::default();
275        let lowercase_set = config.lowercase_words.iter().cloned().collect();
276        Self {
277            config,
278            lowercase_set,
279            proper_names: Vec::new(),
280        }
281    }
282
283    pub fn from_config_struct(config: MD063Config) -> Self {
284        let lowercase_set = config.lowercase_words.iter().cloned().collect();
285        Self {
286            config,
287            lowercase_set,
288            proper_names: Vec::new(),
289        }
290    }
291
292    /// Match `pattern_lower` at `start` in `text` using Unicode-aware lowercasing.
293    /// Returns the end byte offset in `text` when the match succeeds.
294    ///
295    /// This avoids converting the full `text` to lowercase and then reusing those
296    /// offsets on the original string, which can panic for case-fold expansions
297    /// (e.g. `İ` -> `i̇`).
298    fn match_case_insensitive_at(text: &str, start: usize, pattern_lower: &str) -> Option<usize> {
299        if start > text.len() || !text.is_char_boundary(start) || pattern_lower.is_empty() {
300            return None;
301        }
302
303        let mut matched_bytes = 0;
304
305        for (offset, ch) in text[start..].char_indices() {
306            if matched_bytes >= pattern_lower.len() {
307                break;
308            }
309
310            let lowered: String = ch.to_lowercase().collect();
311            if !pattern_lower[matched_bytes..].starts_with(&lowered) {
312                return None;
313            }
314
315            matched_bytes += lowered.len();
316
317            if matched_bytes == pattern_lower.len() {
318                return Some(start + offset + ch.len_utf8());
319            }
320        }
321
322        None
323    }
324
325    /// Find the next case-insensitive match of `pattern_lower` in `text`,
326    /// returning byte offsets in the ORIGINAL string.
327    fn find_case_insensitive_match(text: &str, pattern_lower: &str, search_start: usize) -> Option<(usize, usize)> {
328        if pattern_lower.is_empty() || search_start >= text.len() || !text.is_char_boundary(search_start) {
329            return None;
330        }
331
332        for (offset, _) in text[search_start..].char_indices() {
333            let start = search_start + offset;
334            if let Some(end) = Self::match_case_insensitive_at(text, start, pattern_lower) {
335                return Some((start, end));
336            }
337        }
338
339        None
340    }
341
342    /// Build a map from word byte-position → canonical form for all proper names
343    /// that appear in the heading text (case-insensitive phrase match).
344    ///
345    /// This is used in `apply_sentence_case_from` so that words belonging to a proper
346    /// name phrase are never lowercased to begin with.
347    fn proper_name_canonical_forms(&self, text: &str) -> std::collections::HashMap<usize, &str> {
348        let mut map = std::collections::HashMap::new();
349
350        for name in &self.proper_names {
351            if name.is_empty() {
352                continue;
353            }
354            let name_lower = name.to_lowercase();
355            let canonical_words: Vec<&str> = name.split_whitespace().collect();
356            if canonical_words.is_empty() {
357                continue;
358            }
359            let mut search_start = 0;
360
361            while search_start < text.len() {
362                let Some((abs_pos, end_pos)) = Self::find_case_insensitive_match(text, &name_lower, search_start)
363                else {
364                    break;
365                };
366
367                // Require word boundaries
368                let before_ok = abs_pos == 0 || !text[..abs_pos].chars().last().is_some_and(char::is_alphanumeric);
369                let after_ok =
370                    end_pos >= text.len() || !text[end_pos..].chars().next().is_some_and(char::is_alphanumeric);
371
372                if before_ok && after_ok {
373                    // Map each word in the matched region to its canonical form.
374                    // We zip the words found in the text slice with the words of the
375                    // canonical name so that every word gets the right casing.
376                    let text_slice = &text[abs_pos..end_pos];
377                    let mut word_idx = 0;
378                    let mut slice_offset = 0;
379
380                    for text_word in text_slice.split_whitespace() {
381                        if let Some(w_rel) = text_slice[slice_offset..].find(text_word) {
382                            let word_abs = abs_pos + slice_offset + w_rel;
383                            if let Some(&canonical_word) = canonical_words.get(word_idx) {
384                                map.insert(word_abs, canonical_word);
385                            }
386                            slice_offset += w_rel + text_word.len();
387                            word_idx += 1;
388                        }
389                    }
390                }
391
392                // Advance by one Unicode scalar value to allow overlapping matches
393                // while staying on a UTF-8 char boundary.
394                search_start = abs_pos + text[abs_pos..].chars().next().map_or(1, char::len_utf8);
395            }
396        }
397
398        map
399    }
400
401    /// Check if a word has internal capitals (like "iPhone", "macOS", "GitHub", "iOS")
402    fn has_internal_capitals(&self, word: &str) -> bool {
403        let chars: Vec<char> = word.chars().collect();
404        if chars.len() < 2 {
405            return false;
406        }
407
408        let first = chars[0];
409        let rest = &chars[1..];
410        let has_upper_in_rest = rest.iter().any(|c| c.is_uppercase());
411        let has_lower_in_rest = rest.iter().any(|c| c.is_lowercase());
412
413        // Case 1: Mixed case after first character (like "iPhone", "macOS", "GitHub", "JavaScript")
414        if has_upper_in_rest && has_lower_in_rest {
415            return true;
416        }
417
418        // Case 2: Lowercase first + uppercase in rest (like "iOS", "eBay")
419        if first.is_lowercase() && has_upper_in_rest {
420            return true;
421        }
422
423        false
424    }
425
426    /// Check if a word is an all-caps acronym (2+ consecutive uppercase letters)
427    /// Examples: "API", "GPU", "HTTP2", "IO" return true
428    /// Examples: "A", "iPhone", "npm" return false
429    fn is_all_caps_acronym(&self, word: &str) -> bool {
430        // Skip single-letter words (handled by title case rules)
431        if word.len() < 2 {
432            return false;
433        }
434
435        let mut consecutive_upper = 0;
436        let mut max_consecutive = 0;
437
438        for c in word.chars() {
439            if c.is_uppercase() {
440                consecutive_upper += 1;
441                max_consecutive = max_consecutive.max(consecutive_upper);
442            } else if c.is_lowercase() {
443                // Any lowercase letter means not all-caps
444                return false;
445            } else {
446                // Non-letter (number, punctuation) - reset counter but don't fail
447                consecutive_upper = 0;
448            }
449        }
450
451        // Must have at least 2 consecutive uppercase letters
452        max_consecutive >= 2
453    }
454
455    /// Check if a word should be preserved as-is
456    fn should_preserve_word(&self, word: &str) -> bool {
457        // Check ignore_words list (case-sensitive exact match)
458        if self.is_ignored_word(word) {
459            return true;
460        }
461
462        // Numeric ordinals ("1st", "5th", "21st", ...) must always flow
463        // through the normal title-case path so mis-cased forms like
464        // "5Th" get normalised back to "5th". Skip the preserve_cased_words
465        // heuristics, which would otherwise treat "5Th" as intentionally
466        // mixed-case and leave it untouched.
467        let is_ordinal = Self::is_numeric_ordinal(word);
468
469        if !is_ordinal {
470            // Check if word has internal capitals and preserve_cased_words is enabled
471            if self.config.preserve_cased_words && self.has_internal_capitals(word) {
472                return true;
473            }
474
475            // Check if word is an all-caps acronym (2+ consecutive uppercase)
476            if self.config.preserve_cased_words && self.is_all_caps_acronym(word) {
477                return true;
478            }
479        }
480
481        // Preserve caret notation for control characters (^A, ^Z, ^@, etc.)
482        if self.is_caret_notation(word) {
483            return true;
484        }
485
486        false
487    }
488
489    /// Whether `word` is an `ignore-words` entry, case-sensitively. A token is
490    /// matched as written first (entries like `C++` or `.NET` carry their own
491    /// punctuation), then again with trailing punctuation peeled off one character
492    /// at a time, so `Kubernetes?`, `Kubernetes),` and `C++,` match the entries
493    /// `Kubernetes` and `C++`.
494    fn is_ignored_word(&self, word: &str) -> bool {
495        let mut candidate = word;
496        loop {
497            if self.config.ignore_words.iter().any(|ignored| ignored == candidate) {
498                return true;
499            }
500            match candidate.char_indices().next_back() {
501                Some((i, c)) if Self::is_trailing_punctuation(c) => candidate = &candidate[..i],
502                _ => return false,
503            }
504        }
505    }
506
507    /// Punctuation that can follow a word without becoming part of it: sentence
508    /// punctuation and closing brackets or quotes, in ASCII, Latin, CJK and fullwidth
509    /// forms. Symbols such as `+` or `#` and combining marks are not in the set, so
510    /// `c++` stays distinct from `c` and a decomposed accent stays with its letter.
511    fn is_trailing_punctuation(c: char) -> bool {
512        matches!(
513            c,
514            '.' | ','
515                | ';'
516                | ':'
517                | '!'
518                | '?'
519                | ')'
520                | ']'
521                | '}'
522                | '"'
523                | '\''
524                | '\u{2026}'
525                | '\u{2019}'
526                | '\u{201d}'
527                | '\u{203a}'
528                | '\u{bb}'
529                | '\u{3001}'
530                | '\u{3002}'
531                | '\u{3009}'
532                | '\u{300b}'
533                | '\u{300d}'
534                | '\u{300f}'
535                | '\u{3011}'
536                | '\u{ff01}'
537                | '\u{ff09}'
538                | '\u{ff0c}'
539                | '\u{ff0e}'
540                | '\u{ff1a}'
541                | '\u{ff1b}'
542                | '\u{ff1f}'
543        )
544    }
545
546    /// Detect numeric ordinals like `1st`, `2nd`, `3rd`, `4th`, `21st`,
547    /// `100th`, ignoring the case of the suffix and any punctuation wrapping
548    /// the token (e.g. `5th.`, `1st,`, `(2nd)`, `"3rd"`).
549    ///
550    /// Such tokens have a fixed lower-case alphabetic suffix in title case
551    /// — `21st Century`, never `21St Century` — and must be detected
552    /// before applying the generic "capitalise first letter" rule.
553    fn is_numeric_ordinal(word: &str) -> bool {
554        // The word arrives as a whitespace-split token, punctuation included, so
555        // both wrappers come off before the digits are read. Only the wrapping:
556        // an interior separator (`2-nd`) leaves a token that is not an ordinal,
557        // and a token with no alphanumeric core is rejected by the digit scan.
558        let core = word.trim_matches(|c: char| !c.is_alphanumeric());
559        let bytes = core.as_bytes();
560
561        // Require at least one leading ASCII digit followed by a letter.
562        let alpha_start = match bytes.iter().position(|&b| !b.is_ascii_digit()) {
563            Some(pos) if pos > 0 => pos,
564            _ => return false,
565        };
566
567        // Find where the alphabetic suffix ends (a possessive `'s`, and so on).
568        let alpha_end = bytes[alpha_start..]
569            .iter()
570            .position(|b| !b.is_ascii_alphabetic())
571            .map_or(bytes.len(), |p| alpha_start + p);
572
573        let suffix = &core[alpha_start..alpha_end];
574        matches!(suffix.to_ascii_lowercase().as_str(), "st" | "nd" | "rd" | "th")
575    }
576
577    /// Check if a word is caret notation for control characters (e.g., ^A, ^C, ^Z)
578    fn is_caret_notation(&self, word: &str) -> bool {
579        let chars: Vec<char> = word.chars().collect();
580        // Pattern: ^ followed by uppercase letter or @[\]^_
581        if chars.len() >= 2 && chars[0] == '^' {
582            let second = chars[1];
583            // Control characters: ^@ (NUL) through ^_ (US), which includes ^A-^Z
584            if second.is_ascii_uppercase() || "@[\\]^_".contains(second) {
585                return true;
586            }
587        }
588        false
589    }
590
591    /// Check if a word is a "lowercase word" (articles, prepositions, etc.)
592    fn is_lowercase_word(&self, word: &str) -> bool {
593        self.lowercase_set.contains(&word.to_lowercase())
594    }
595
596    /// Apply title case to a single word
597    fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
598        if word.is_empty() {
599            return word.to_string();
600        }
601
602        // Preserve words in ignore list or with internal capitals
603        if self.should_preserve_word(word) {
604            return word.to_string();
605        }
606
607        // First and last words are always capitalized
608        if is_first || is_last {
609            return self.capitalize_first(word);
610        }
611
612        // Check if it's a lowercase word (articles, prepositions, etc.)
613        if self.is_lowercase_word(word) {
614            return Self::lowercase_preserving_composition(word);
615        }
616
617        // Regular word - capitalize first letter
618        self.capitalize_first(word)
619    }
620
621    /// Apply canonical proper-name casing while preserving any trailing punctuation
622    /// attached to the original whitespace token (e.g. `javascript,` -> `JavaScript,`).
623    fn apply_canonical_form_to_word(word: &str, canonical: &str) -> String {
624        let canonical_lower = canonical.to_lowercase();
625        if canonical_lower.is_empty() {
626            return canonical.to_string();
627        }
628
629        if let Some(end_pos) = Self::match_case_insensitive_at(word, 0, &canonical_lower) {
630            let mut out = String::with_capacity(canonical.len() + word.len().saturating_sub(end_pos));
631            out.push_str(canonical);
632            out.push_str(&word[end_pos..]);
633            out
634        } else {
635            canonical.to_string()
636        }
637    }
638
639    /// Capitalize the first letter of a word, handling Unicode properly
640    fn capitalize_first(&self, word: &str) -> String {
641        if word.is_empty() {
642            return String::new();
643        }
644
645        // Find the first alphabetic character to capitalize
646        let first_alpha_pos = word.find(|c: char| c.is_alphabetic());
647        let Some(pos) = first_alpha_pos else {
648            return word.to_string();
649        };
650
651        let prefix = &word[..pos];
652        let suffix = &word[pos..];
653
654        // Numeric ordinals ("1st", "21st", "5th", ...) keep their
655        // alphabetic suffix lower-cased even at title-case positions.
656        if Self::is_numeric_ordinal(word) {
657            let suffix_lower = Self::lowercase_preserving_composition(suffix);
658            return format!("{prefix}{suffix_lower}");
659        }
660
661        let mut chars = suffix.chars();
662        let first = chars.next().unwrap();
663        // Use composition-preserving uppercase to avoid decomposing
664        // precomposed characters (e.g., ῷ → Ω + combining marks + Ι)
665        let first_upper = Self::uppercase_preserving_composition(&first.to_string());
666        let rest: String = chars.collect();
667        let rest_lower = Self::lowercase_preserving_composition(&rest);
668        format!("{prefix}{first_upper}{rest_lower}")
669    }
670
671    /// Lowercase a string character-by-character, preserving precomposed
672    /// characters that would decompose during case conversion.
673    fn lowercase_preserving_composition(s: &str) -> String {
674        let mut result = String::with_capacity(s.len());
675        for c in s.chars() {
676            let lower: String = c.to_lowercase().collect();
677            if lower.chars().count() == 1 {
678                result.push_str(&lower);
679            } else {
680                // Lowercasing would decompose this character; keep original
681                result.push(c);
682            }
683        }
684        result
685    }
686
687    /// Return the sentence-case spelling of English first-person pronouns found
688    /// in `word`, when at least one uses its required uppercase `I`.
689    ///
690    /// Sentence case normally lowercases every mid-sentence token, but `I` is
691    /// uppercase wherever it appears. Recognize its standard contractions as
692    /// well, including a typographic apostrophe and adjacent punctuation or
693    /// Markdown emphasis. Only already-uppercase instances are recognized: a
694    /// lowercase `i` may intentionally be a variable and is outside MD063's
695    /// responsibility to reinterpret.
696    fn sentence_case_first_person_pronouns(word: &str) -> Option<String> {
697        fn is_emphasis_marker(c: char) -> bool {
698            matches!(c, '*' | '_' | '~')
699        }
700
701        // These characters commonly join identifiers or abbreviations. Treating
702        // them as word boundaries would misread technical forms such as `I/O`,
703        // `A.I.` or `I-am` as the pronoun.
704        fn is_word_connector(c: char) -> bool {
705            matches!(c, '/' | '\\' | '-' | '.' | '+' | '&')
706        }
707
708        fn is_word_boundary(c: char) -> bool {
709            !c.is_alphanumeric() && !is_word_connector(c)
710        }
711
712        fn is_pronoun_at(word: &str, pos: usize) -> bool {
713            let left = word[..pos].trim_end_matches(is_emphasis_marker);
714            if !left.chars().next_back().is_none_or(is_word_boundary) {
715                return false;
716            }
717
718            let after_i = word[pos + 1..].trim_start_matches(is_emphasis_marker);
719            let Some(apostrophe) = after_i.chars().next() else {
720                return true;
721            };
722            if !matches!(apostrophe, '\'' | '’') {
723                return is_word_boundary(apostrophe);
724            }
725
726            let after_apostrophe = after_i[apostrophe.len_utf8()..].trim_start_matches(is_emphasis_marker);
727            let suffix_end = after_apostrophe
728                .find(|c: char| !c.is_ascii_alphabetic())
729                .unwrap_or(after_apostrophe.len());
730            let suffix = &after_apostrophe[..suffix_end];
731            if suffix.is_empty() {
732                // A trailing apostrophe can be a closing quotation mark around `I`.
733                return after_apostrophe.chars().next().is_none_or(is_word_boundary);
734            }
735            if !matches!(suffix.to_ascii_lowercase().as_str(), "d" | "ll" | "m" | "ve") {
736                return false;
737            }
738
739            let after_suffix = after_apostrophe[suffix_end..].trim_start_matches(is_emphasis_marker);
740            after_suffix.chars().next().is_none_or(is_word_boundary)
741        }
742
743        let pronoun_positions: Vec<usize> = word
744            .char_indices()
745            .filter_map(|(pos, c)| (c == 'I' && is_pronoun_at(word, pos)).then_some(pos))
746            .collect();
747        if pronoun_positions.is_empty() {
748            return None;
749        }
750
751        let mut result = String::with_capacity(word.len());
752        let mut copied_through = 0;
753        for pos in pronoun_positions {
754            result.push_str(&Self::lowercase_preserving_composition(&word[copied_through..pos]));
755            result.push('I');
756            copied_through = pos + 1;
757        }
758        result.push_str(&Self::lowercase_preserving_composition(&word[copied_through..]));
759        Some(result)
760    }
761
762    /// Uppercase a string character-by-character, preserving precomposed
763    /// characters that would decompose during case conversion.
764    /// For example, ῷ (U+1FF7) would decompose into Ω + combining marks + Ι
765    /// via to_uppercase(); this function keeps ῷ unchanged instead.
766    fn uppercase_preserving_composition(s: &str) -> String {
767        let mut result = String::with_capacity(s.len());
768        for c in s.chars() {
769            let upper: String = c.to_uppercase().collect();
770            if upper.chars().count() == 1 {
771                result.push_str(&upper);
772            } else {
773                // Uppercasing would decompose this character; keep original
774                result.push(c);
775            }
776        }
777        result
778    }
779
780    /// Apply title case to text, using our own title-case logic.
781    /// We avoid the external titlecase crate because it decomposes
782    /// precomposed Unicode characters during case conversion.
783    fn apply_title_case(&self, text: &str) -> String {
784        let canonical_forms = self.proper_name_canonical_forms(text);
785
786        let original_words: Vec<&str> = text.split_whitespace().collect();
787        let total_words = original_words.len();
788
789        // Pre-compute byte position of each word for canonical form lookup.
790        // Use usize::MAX as sentinel for unfound words so canonical_forms.get() returns None.
791        let mut word_positions: Vec<usize> = Vec::with_capacity(original_words.len());
792        let mut pos = 0;
793        for word in &original_words {
794            if let Some(rel) = text[pos..].find(word) {
795                word_positions.push(pos + rel);
796                pos = pos + rel + word.len();
797            } else {
798                word_positions.push(usize::MAX);
799            }
800        }
801
802        let result_words: Vec<String> = original_words
803            .iter()
804            .enumerate()
805            .map(|(i, word)| {
806                let after_period = i > 0 && original_words[i - 1].ends_with('.');
807                let is_first = i == 0 || after_period;
808                let is_last = i == total_words - 1;
809
810                // Words that are part of an MD044 proper name use the canonical form directly.
811                if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
812                    return Self::apply_canonical_form_to_word(word, canonical);
813                }
814
815                // Preserve words in ignore list or with internal capitals
816                if self.should_preserve_word(word) {
817                    return (*word).to_string();
818                }
819
820                // An ignore-words entry is kept whole, hyphens included
821                if self.is_ignored_word(word) {
822                    return (*word).to_string();
823                }
824
825                // Handle hyphenated words
826                if word.contains('-') {
827                    return self.handle_hyphenated_word(word, is_first, is_last);
828                }
829
830                self.title_case_word(word, is_first, is_last)
831            })
832            .collect();
833
834        result_words.join(" ")
835    }
836
837    /// Handle hyphenated words like "self-documenting"
838    fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
839        let parts: Vec<&str> = word.split('-').collect();
840        let total_parts = parts.len();
841
842        let result_parts: Vec<String> = parts
843            .iter()
844            .enumerate()
845            .map(|(i, part)| {
846                // First part of first word and last part of last word get special treatment
847                let part_is_first = is_first && i == 0;
848                let part_is_last = is_last && i == total_parts - 1;
849                self.title_case_word(part, part_is_first, part_is_last)
850            })
851            .collect();
852
853        result_parts.join("-")
854    }
855
856    /// True when a word ends a sentence, so the next word is capitalized.
857    ///
858    /// The comparison is against the end of the whole word rather than a scan through
859    /// it, so `Overview: details` restarts and `a:b` does not. That keeps the boundary
860    /// where a reader sees one and leaves `https://example.com` alone.
861    fn ends_sentence(&self, word: &str) -> bool {
862        self.config
863            .sentence_case_restart_after
864            .iter()
865            .any(|boundary| !boundary.is_empty() && word.ends_with(boundary.as_str()))
866    }
867
868    /// Apply sentence case to text, capitalizing the leading word only when it opens
869    /// the heading. A segment that follows a code span or link continues the sentence
870    /// the earlier segment started, so it begins mid-sentence.
871    fn apply_sentence_case_from(&self, text: &str, starts_sentence: bool) -> String {
872        if text.is_empty() {
873            return text.to_string();
874        }
875
876        let canonical_forms = self.proper_name_canonical_forms(text);
877        let mut result = String::new();
878        let mut current_pos = 0;
879        let mut at_sentence_start = starts_sentence;
880
881        // Use original text positions to preserve whitespace correctly
882        for word in text.split_whitespace() {
883            if let Some(pos) = text[current_pos..].find(word) {
884                let abs_pos = current_pos + pos;
885
886                // Preserve whitespace before this word
887                result.push_str(&text[current_pos..abs_pos]);
888
889                // Words that are part of an MD044 proper name use the canonical form
890                // directly, bypassing sentence-case lowercasing entirely.
891                if let Some(&canonical) = canonical_forms.get(&abs_pos) {
892                    result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
893                } else if self.is_ignored_word(word) {
894                    // Explicit ignore-words promise exact byte-for-byte preservation.
895                    result.push_str(word);
896                } else if let Some(pronoun) = Self::sentence_case_first_person_pronouns(word) {
897                    // The English pronoun `I` remains uppercase in every sentence
898                    // position; normalize only the contraction suffix.
899                    result.push_str(&pronoun);
900                } else if at_sentence_start {
901                    // Check if word should be preserved BEFORE any capitalization
902                    if self.should_preserve_word(word) {
903                        // Preserve ignore-words exactly as-is, even at start
904                        result.push_str(word);
905                    } else {
906                        // Sentence-initial word: capitalize first letter, lowercase rest
907                        let mut chars = word.chars();
908                        if let Some(first) = chars.next() {
909                            result.push_str(&Self::uppercase_preserving_composition(&first.to_string()));
910                            let rest: String = chars.collect();
911                            result.push_str(&Self::lowercase_preserving_composition(&rest));
912                        }
913                    }
914                } else {
915                    // Mid-sentence words: preserve if needed, otherwise lowercase
916                    if self.should_preserve_word(word) {
917                        result.push_str(word);
918                    } else {
919                        result.push_str(&Self::lowercase_preserving_composition(word));
920                    }
921                }
922
923                at_sentence_start = self.ends_sentence(word);
924                current_pos = abs_pos + word.len();
925            }
926        }
927
928        // Preserve any trailing whitespace
929        if current_pos < text.len() {
930            result.push_str(&text[current_pos..]);
931        }
932
933        result
934    }
935
936    /// Apply all caps to text (preserve whitespace)
937    fn apply_all_caps(&self, text: &str) -> String {
938        if text.is_empty() {
939            return text.to_string();
940        }
941
942        let canonical_forms = self.proper_name_canonical_forms(text);
943        let mut result = String::new();
944        let mut current_pos = 0;
945
946        // Use original text positions to preserve whitespace correctly
947        for word in text.split_whitespace() {
948            if let Some(pos) = text[current_pos..].find(word) {
949                let abs_pos = current_pos + pos;
950
951                // Preserve whitespace before this word
952                result.push_str(&text[current_pos..abs_pos]);
953
954                // Words that are part of an MD044 proper name use the canonical form directly.
955                // This prevents oscillation with MD044 when all-caps style is active.
956                if let Some(&canonical) = canonical_forms.get(&abs_pos) {
957                    result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
958                } else if self.should_preserve_word(word) {
959                    result.push_str(word);
960                } else {
961                    result.push_str(&Self::uppercase_preserving_composition(word));
962                }
963
964                current_pos = abs_pos + word.len();
965            }
966        }
967
968        // Preserve any trailing whitespace
969        if current_pos < text.len() {
970            result.push_str(&text[current_pos..]);
971        }
972
973        result
974    }
975
976    /// Parse heading text into segments
977    fn parse_segments(&self, text: &str) -> Vec<HeadingSegment> {
978        let mut segments = Vec::new();
979        let mut last_end = 0;
980
981        // Collect all special regions (code and links)
982        let mut special_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
983
984        // Find inline code spans
985        for mat in INLINE_CODE_REGEX.find_iter(text) {
986            special_regions.push((mat.start(), mat.end(), HeadingSegment::Code(mat.as_str().to_string())));
987        }
988
989        // Find links
990        for caps in LINK_REGEX.captures_iter(text) {
991            let full_match = caps.get(0).unwrap();
992
993            // A '!' immediately before the match makes this an image. Preserve
994            // the whole image (including the leading '!' and its alt text)
995            // rather than recasing the alt text as if it were link text.
996            if full_match.start() >= 1 && text.as_bytes()[full_match.start() - 1] == b'!' {
997                let region_start = full_match.start() - 1;
998                special_regions.push((
999                    region_start,
1000                    full_match.end(),
1001                    HeadingSegment::Image(text[region_start..full_match.end()].to_string()),
1002                ));
1003                continue;
1004            }
1005
1006            let text_match = caps.get(1).or_else(|| caps.get(2));
1007
1008            if let Some(text_m) = text_match {
1009                special_regions.push((
1010                    full_match.start(),
1011                    full_match.end(),
1012                    HeadingSegment::Link {
1013                        full: full_match.as_str().to_string(),
1014                        text_start: text_m.start() - full_match.start(),
1015                        text_end: text_m.end() - full_match.start(),
1016                    },
1017                ));
1018            }
1019        }
1020
1021        // Find inline HTML: a tag token is never prose, and neither is the content
1022        // of an element closed on the same line. A tag inside a code span is code.
1023        let code_ranges: Vec<(usize, usize)> = special_regions
1024            .iter()
1025            .filter(|(_, _, segment)| matches!(segment, HeadingSegment::Code(_)))
1026            .map(|(start, end, _)| (*start, *end))
1027            .collect();
1028        for (start, end) in Self::html_regions(text, &code_ranges) {
1029            special_regions.push((start, end, HeadingSegment::Html(text[start..end].to_string())));
1030        }
1031
1032        // Sort by start position
1033        special_regions.sort_by_key(|(start, _, _)| *start);
1034
1035        // Drop regions that overlap one already kept. After sorting by start
1036        // position, the earliest-starting region wins a conflict. Kept regions
1037        // are nonempty and disjoint, so only the last one can overlap the next.
1038        let mut filtered_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
1039        for region in special_regions {
1040            let overlaps = filtered_regions.last().is_some_and(|(_, end, _)| region.0 < *end);
1041            if !overlaps {
1042                filtered_regions.push(region);
1043            }
1044        }
1045
1046        // Build segments
1047        for (start, end, segment) in filtered_regions {
1048            // Add text before this special region
1049            if start > last_end {
1050                let text_segment = &text[last_end..start];
1051                if !text_segment.is_empty() {
1052                    segments.push(HeadingSegment::Text(text_segment.to_string()));
1053                }
1054            }
1055            segments.push(segment);
1056            last_end = end;
1057        }
1058
1059        // Add remaining text
1060        if last_end < text.len() {
1061            let remaining = &text[last_end..];
1062            if !remaining.is_empty() {
1063                segments.push(HeadingSegment::Text(remaining.to_string()));
1064            }
1065        }
1066
1067        // If no segments were found, treat the whole thing as text
1068        if segments.is_empty() && !text.is_empty() {
1069            segments.push(HeadingSegment::Text(text.to_string()));
1070        }
1071
1072        segments
1073    }
1074
1075    /// Byte ranges of `text` that are HTML and therefore never recased.
1076    ///
1077    /// Every tag token and comment is one region. When a closing tag pairs with
1078    /// the nearest earlier open tag of the same name, the whole element becomes
1079    /// one region, so `<b>bold <i>inner</i> more</b>` is preserved verbatim
1080    /// while the prose after an unpaired tag (`<br>`) stays prose. A self-closing
1081    /// token (`<span/>`) and a void element (`<br>`) hold no content, so neither
1082    /// opens an element and a later closing tag of the same name belongs to the
1083    /// enclosing one. A token that starts inside a code span is code, and one
1084    /// whose `<` is backslash-escaped is text, so neither is markup; the scan
1085    /// resumes just past its `<`, since that text may hold a real tag of its
1086    /// own. Tokens are read one after another as a browser tokenizes them, so a
1087    /// tag written inside another tag's attribute value is part of that value.
1088    fn html_regions(text: &str, code_ranges: &[(usize, usize)]) -> Vec<(usize, usize)> {
1089        let mut regions: Vec<(usize, usize)> = Vec::new();
1090        let mut open_elements = OpenElements::new();
1091
1092        // Both regex streams advance in source order; code ranges are disjoint.
1093        let mut code_index = 0;
1094        let mut pos = 0;
1095        while let Some(token) = HTML_TOKEN_REGEX.captures_at(text, pos) {
1096            let whole = token.get(0).unwrap();
1097            while code_ranges
1098                .get(code_index)
1099                .is_some_and(|&(_, end)| end <= whole.start())
1100            {
1101                code_index += 1;
1102            }
1103            if code_ranges
1104                .get(code_index)
1105                .is_some_and(|&(start, _)| start <= whole.start())
1106                || is_backslash_escaped(text, whole.start())
1107            {
1108                // The token is text, and text may hold a tag of its own past its `<`.
1109                pos = whole.start() + 1;
1110                continue;
1111            }
1112            pos = whole.end();
1113
1114            if let Some(closing) = token.get(1) {
1115                let name = closing.as_str().to_ascii_lowercase();
1116                if let Some(element_start) = open_elements.close(&name) {
1117                    // Regions stay ordered by start. A completed element
1118                    // replaces only the suffix beginning at its opening tag.
1119                    while regions.last().is_some_and(|&(start, _)| start >= element_start) {
1120                        regions.pop();
1121                    }
1122                    regions.push((element_start, whole.end()));
1123                    continue;
1124                }
1125            } else if let Some(opening) = token.get(2) {
1126                let name = opening.as_str().to_ascii_lowercase();
1127                if !whole.as_str().ends_with("/>") && !is_void_element(&name) {
1128                    open_elements.push(name, whole.start());
1129                }
1130            }
1131
1132            regions.push((whole.start(), whole.end()));
1133        }
1134
1135        regions
1136    }
1137
1138    /// Preserve paired JSX elements, like the existing inline HTML regions.
1139    /// Only examine tags intersecting this heading, rather than scanning every
1140    /// document tag again for each heading.
1141    fn mdx_html_regions(
1142        ctx: &crate::lint_context::LintContext,
1143        start: usize,
1144        end: usize,
1145    ) -> (Vec<(usize, usize)>, bool) {
1146        let html = ctx.html_tags();
1147        let components = ctx.jsx_component_tags();
1148        let mut tags = Vec::new();
1149        for cached in [html.as_slice(), components.as_slice()] {
1150            let first = cached.partition_point(|tag| tag.byte_end <= start);
1151            tags.extend(cached[first..].iter().take_while(|tag| tag.byte_offset < end));
1152        }
1153        tags.sort_unstable_by_key(|tag| tag.byte_offset);
1154        let mut regions = Vec::new();
1155        let mut open = OpenElements::new();
1156        let mut requires_source_map = false;
1157        for tag in tags {
1158            let source = &ctx.content[tag.byte_offset..tag.byte_end];
1159            requires_source_map |= !HTML_TOKEN_REGEX
1160                .find(source)
1161                .is_some_and(|token| token.start() == 0 && token.end() == source.len());
1162            if tag.is_closing {
1163                if let Some(element_start) = open.close(&tag.tag_name.as_str()) {
1164                    regions.push((element_start, tag.byte_end));
1165                }
1166            } else if !tag.is_self_closing && !is_void_element(&tag.tag_name) {
1167                open.push(tag.tag_name.as_str(), tag.byte_offset);
1168            }
1169            regions.push((tag.byte_offset, tag.byte_end));
1170        }
1171        (regions, requires_source_map)
1172    }
1173
1174    /// Preserve cached MDX code when heading text maps directly to its source.
1175    fn apply_heading_capitalization(
1176        &self,
1177        ctx: &crate::lint_context::LintContext,
1178        line_index: usize,
1179        heading: &crate::lint_context::HeadingInfo,
1180        style: ResolvedStyle,
1181    ) -> String {
1182        if ctx.flavor != crate::config::MarkdownFlavor::MDX {
1183            return self.apply_capitalization(&heading.raw_text, ctx.flavor, style);
1184        }
1185        let line_info = &ctx.lines[line_index];
1186        if heading.text_lines > 1 {
1187            // Joined Setext text has a different byte map. Keep code-bearing
1188            // headings intact rather than applying source offsets to that map.
1189            let first = line_index.saturating_sub(heading.text_lines - 1);
1190            let start = ctx.lines[first].byte_offset;
1191            let end = line_info.byte_offset + line_info.byte_len;
1192            let (_, requires_source_map) = Self::mdx_html_regions(ctx, start, end);
1193            if ctx.overlaps_mdx_inline_code(start, end) || requires_source_map {
1194                return heading.raw_text.clone();
1195            }
1196            return self.apply_capitalization(&heading.raw_text, ctx.flavor, style);
1197        }
1198        let line = line_info.content(ctx.content);
1199        let Some(relative_start) = line.find(&heading.raw_text) else {
1200            return heading.raw_text.clone();
1201        };
1202        let start = line_info.byte_offset + relative_start;
1203        let end = start + heading.raw_text.len();
1204        let (mut ranges, _) = Self::mdx_html_regions(ctx, start, end);
1205        ranges.extend(ctx.mdx_inline_code_ranges(start, end));
1206        let ranges = ranges
1207            .into_iter()
1208            .map(|(range_start, range_end)| (range_start.max(start) - start, range_end.min(end) - start))
1209            .collect();
1210        self.apply_capitalization_with_ranges(&heading.raw_text, ctx.flavor, ranges, style)
1211    }
1212
1213    /// Preserve template identifiers and string values while recasing prose.
1214    fn apply_capitalization(&self, text: &str, flavor: crate::config::MarkdownFlavor, style: ResolvedStyle) -> String {
1215        self.apply_capitalization_with_ranges(text, flavor, Vec::new(), style)
1216    }
1217
1218    fn apply_capitalization_with_ranges(
1219        &self,
1220        text: &str,
1221        flavor: crate::config::MarkdownFlavor,
1222        mut ranges: Vec<(usize, usize)>,
1223        style: ResolvedStyle,
1224    ) -> String {
1225        ranges.extend(crate::utils::jinja_utils::find_jinja_ranges(text));
1226        ranges.extend(crate::utils::shortcode_utils::shortcode_ranges(text));
1227        ranges.sort_unstable();
1228        if ranges.is_empty() {
1229            return self.apply_capitalization_to_segments(text, flavor, style);
1230        }
1231
1232        // Recovery ranges for malformed markup can overlap. Protect their union
1233        // so every slice below remains ordered and each tag is copied intact.
1234        let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
1235        for (start, end) in ranges {
1236            if let Some(last) = merged.last_mut()
1237                && start <= last.1
1238            {
1239                last.1 = last.1.max(end);
1240            } else {
1241                merged.push((start, end));
1242            }
1243        }
1244
1245        // Template tokens contain no letters, so every style preserves them.
1246        // JSX tokens remain HTML segments so their visible-word context stays
1247        // the same as the original element. A space inside each template token keeps
1248        // words on opposite sides of a tag separate during casing. Choose a
1249        // delimiter absent from the input so literal private-use characters
1250        // cannot collide with them.
1251        let mut delimiter = String::from("\u{e000}");
1252        while text.contains(&delimiter) {
1253            delimiter.push('\u{e000}');
1254        }
1255        let mut masked = String::with_capacity(text.len());
1256        let mut templates = Vec::with_capacity(merged.len());
1257        let mut last_end = 0;
1258        for (index, (start, end)) in merged.into_iter().enumerate() {
1259            let source = &text[start..end];
1260            let token = if flavor == crate::config::MarkdownFlavor::MDX && source.starts_with('<') {
1261                let body = if HeadingSegment::Html(source.to_string()).renders_nothing() {
1262                    ""
1263                } else {
1264                    "0"
1265                };
1266                format!("<span data-md063=\"{delimiter}{index}{delimiter}\">{body}</span>")
1267            } else if flavor == crate::config::MarkdownFlavor::MDX
1268                && crate::utils::mdx_comments::is_comment_only_expression(&text[start..end])
1269            {
1270                // A comment renders nothing, so it must not consume the first
1271                // visible word when applying sentence case.
1272                format!("<!--{delimiter}{index}{delimiter}-->")
1273            } else {
1274                format!("{delimiter}{index}{delimiter} {delimiter}{index}{delimiter}")
1275            };
1276            masked.push_str(&text[last_end..start]);
1277            masked.push_str(&token);
1278            templates.push((token, &text[start..end]));
1279            last_end = end;
1280        }
1281        masked.push_str(&text[last_end..]);
1282
1283        let capitalized = self.apply_capitalization_to_segments(&masked, flavor, style);
1284        let mut result = String::with_capacity(capitalized.len());
1285        let mut cursor = 0;
1286        // Casing preserves token order. Walk forward so restoration does not
1287        // repeatedly scan a heading containing many template tags.
1288        for (token, template) in templates {
1289            let Some(offset) = capitalized[cursor..].find(&token) else {
1290                // Preserve the source if a configured transformation consumed a
1291                // token; an internal placeholder must never become an autofix.
1292                return text.to_string();
1293            };
1294            let start = cursor + offset;
1295            result.push_str(&capitalized[cursor..start]);
1296            result.push_str(template);
1297            cursor = start + token.len();
1298        }
1299        result.push_str(&capitalized[cursor..]);
1300        result
1301    }
1302
1303    /// Apply capitalization to heading text after protecting template tags.
1304    fn apply_capitalization_to_segments(
1305        &self,
1306        text: &str,
1307        flavor: crate::config::MarkdownFlavor,
1308        style: ResolvedStyle,
1309    ) -> String {
1310        // Strip custom ID if present and re-add later
1311        let (main_text, custom_id) = if let Some(mat) = CUSTOM_ID_REGEX.find(text) {
1312            (&text[..mat.start()], Some(mat.as_str()))
1313        } else {
1314            (text, None)
1315        };
1316
1317        // Markdown with Gherkin spells every structure as a `Keyword: name` heading, and
1318        // a keyword names a structure only when spelled exactly, so recasing starts after
1319        // the colon and the keyword is copied through verbatim. The split precedes segment
1320        // parsing so the keyword keeps its own spacing and never counts as the heading's
1321        // first or last word, and so a code span the split declines stays visible to the
1322        // parser below instead of having its contents recased.
1323        let (keyword, main_text) = if flavor == crate::config::MarkdownFlavor::MDG {
1324            mdg::keyword_split(main_text).unwrap_or(("", main_text))
1325        } else {
1326            ("", main_text)
1327        };
1328
1329        // Parse into segments
1330        let segments = self.parse_segments(main_text);
1331
1332        // Count text segments to determine first/last word context
1333        let text_segments: Vec<usize> = segments
1334            .iter()
1335            .enumerate()
1336            .filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
1337            .collect();
1338
1339        // Sentence case starts with visible prose. A link label is prose too, even
1340        // though its Markdown destination is kept opaque. Invisible HTML, such as
1341        // an empty anchor or a comment, is looked past.
1342        let first_segment_starts_sentence = segments
1343            .iter()
1344            .find(|s| !s.renders_nothing())
1345            .is_some_and(|s| matches!(s, HeadingSegment::Text(_) | HeadingSegment::Link { .. }));
1346
1347        // If the last visible segment is Code or Link, then the last text segment should
1348        // NOT treat its last word as the heading's last word (for lowercase-words respect)
1349        let last_segment_is_text = segments
1350            .iter()
1351            .rev()
1352            .find(|s| !s.renders_nothing())
1353            .is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
1354
1355        // Apply capitalization to each segment
1356        let mut result_parts: Vec<String> = Vec::new();
1357
1358        // Where the heading's sentence currently stands, carried across segments so a
1359        // boundary in one segment governs the next.
1360        let mut at_sentence_start = first_segment_starts_sentence;
1361
1362        for (i, segment) in segments.iter().enumerate() {
1363            // Whether this segment closes a sentence, which decides how the next one
1364            // starts. Only prose can, and the prose of a heading is what this rule
1365            // capitalizes: plain text and link text. Code, HTML and images are opaque,
1366            // so a boundary inside them is not one a reader is offered.
1367            at_sentence_start = match segment {
1368                HeadingSegment::Text(t) => {
1369                    let is_first_text = text_segments.first() == Some(&i);
1370                    // A text segment is "last" only if it's the last text segment AND
1371                    // the last segment overall is also text. If there's Code/Link after,
1372                    // the last word should respect lowercase-words.
1373                    let is_last_text = text_segments.last() == Some(&i) && last_segment_is_text;
1374
1375                    let capitalized = match style {
1376                        ResolvedStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
1377                        ResolvedStyle::SentenceCase => self.apply_sentence_case_from(t, at_sentence_start),
1378                        ResolvedStyle::AllCaps => self.apply_all_caps(t),
1379                    };
1380                    let ends_sentence = self.ends_sentence(capitalized.trim_end());
1381                    result_parts.push(capitalized);
1382                    ends_sentence
1383                }
1384                HeadingSegment::Code(c) => {
1385                    result_parts.push(c.clone());
1386                    false
1387                }
1388                HeadingSegment::Link {
1389                    full,
1390                    text_start,
1391                    text_end,
1392                } => {
1393                    // Apply capitalization to link text only
1394                    let link_text = &full[*text_start..*text_end];
1395                    let capitalized_text = match style {
1396                        ResolvedStyle::TitleCase => self.apply_title_case(link_text),
1397                        // For sentence case, apply same preservation logic as text
1398                        // This preserves acronyms (API), brand names (iPhone), etc.
1399                        ResolvedStyle::SentenceCase => self.apply_sentence_case_from(link_text, at_sentence_start),
1400                        ResolvedStyle::AllCaps => self.apply_all_caps(link_text),
1401                    };
1402                    // The link's own text ends the sentence, not its destination: a reader
1403                    // sees `[see:](url)` as `see:`, so the boundary is where they read it.
1404                    let ends_sentence = self.ends_sentence(capitalized_text.trim_end());
1405
1406                    let mut new_link = String::new();
1407                    new_link.push_str(&full[..*text_start]);
1408                    new_link.push_str(&capitalized_text);
1409                    new_link.push_str(&full[*text_end..]);
1410                    result_parts.push(new_link);
1411                    ends_sentence
1412                }
1413                HeadingSegment::Html(h) => {
1414                    // Preserve HTML tags as-is (like code). Markup that renders
1415                    // nothing is invisible to the reader, so the sentence stands
1416                    // where it stood before it.
1417                    result_parts.push(h.clone());
1418                    segment.renders_nothing() && at_sentence_start
1419                }
1420                HeadingSegment::Image(img) => {
1421                    // Preserve images as-is, including alt text.
1422                    result_parts.push(img.clone());
1423                    false
1424                }
1425            };
1426        }
1427
1428        let mut result = String::with_capacity(text.len());
1429        result.push_str(keyword);
1430        result.push_str(&result_parts.join(""));
1431
1432        // Re-add custom ID if present
1433        if let Some(id) = custom_id {
1434            result.push_str(id);
1435        }
1436
1437        result
1438    }
1439
1440    /// Apply title case to a text segment with first/last awareness
1441    fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
1442        let canonical_forms = self.proper_name_canonical_forms(text);
1443        let words: Vec<&str> = text.split_whitespace().collect();
1444        let total_words = words.len();
1445
1446        if total_words == 0 {
1447            return text.to_string();
1448        }
1449
1450        // Pre-compute byte position of each word so we can look up canonical forms.
1451        // Use usize::MAX as sentinel for unfound words so canonical_forms.get() returns None.
1452        let mut word_positions: Vec<usize> = Vec::with_capacity(words.len());
1453        let mut pos = 0;
1454        for word in &words {
1455            if let Some(rel) = text[pos..].find(word) {
1456                word_positions.push(pos + rel);
1457                pos = pos + rel + word.len();
1458            } else {
1459                word_positions.push(usize::MAX);
1460            }
1461        }
1462
1463        let result_words: Vec<String> = words
1464            .iter()
1465            .enumerate()
1466            .map(|(i, word)| {
1467                let after_period = i > 0 && words[i - 1].ends_with('.');
1468                let is_first = (is_first_segment && i == 0) || after_period;
1469                let is_last = is_last_segment && i == total_words - 1;
1470
1471                // Words that are part of an MD044 proper name use the canonical form directly.
1472                if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
1473                    return Self::apply_canonical_form_to_word(word, canonical);
1474                }
1475
1476                // An ignore-words entry is kept whole, hyphens included
1477                if self.is_ignored_word(word) {
1478                    return (*word).to_string();
1479                }
1480
1481                // Handle hyphenated words
1482                if word.contains('-') {
1483                    return self.handle_hyphenated_word(word, is_first, is_last);
1484                }
1485
1486                self.title_case_word(word, is_first, is_last)
1487            })
1488            .collect();
1489
1490        // Preserve original spacing
1491        let mut result = String::new();
1492        let mut word_iter = result_words.iter();
1493        let mut in_word = false;
1494
1495        for c in text.chars() {
1496            if c.is_whitespace() {
1497                if in_word {
1498                    in_word = false;
1499                }
1500                result.push(c);
1501            } else if !in_word {
1502                if let Some(word) = word_iter.next() {
1503                    result.push_str(word);
1504                }
1505                in_word = true;
1506            }
1507        }
1508
1509        result
1510    }
1511
1512    /// Fix an ATX heading line
1513    fn fix_atx_heading(&self, line: &str, heading: &crate::lint_context::HeadingInfo, fixed_text: &str) -> String {
1514        // Everything before the `#`s stays: the indentation and, for a heading
1515        // that opens a list item, the list marker
1516        let indent = &line[..heading.marker_column];
1517        let hashes = "#".repeat(heading.level as usize);
1518
1519        // Reconstruct with closing sequence if present
1520        let closing = &heading.closing_sequence;
1521        if heading.has_closing_sequence {
1522            format!("{indent}{hashes} {fixed_text} {closing}")
1523        } else {
1524            format!("{indent}{hashes} {fixed_text}")
1525        }
1526    }
1527
1528    /// Fix a Setext heading line
1529    fn fix_setext_heading(&self, line: &str, heading: &crate::lint_context::HeadingInfo, fixed_text: &str) -> String {
1530        // Everything before the text stays: the indentation and, for a heading
1531        // that opens a list item, the list marker
1532        let leading_ws = &line[..heading.content_column];
1533
1534        format!("{leading_ws}{fixed_text}")
1535    }
1536
1537    /// Rewrite `line` word by word, taking each word's replacement from `words`
1538    /// and keeping the line's own whitespace runs.
1539    fn rewrite_words<'w>(line: &str, words: &mut impl Iterator<Item = &'w str>) -> String {
1540        let mut result = String::with_capacity(line.len());
1541        let mut in_word = false;
1542        for c in line.chars() {
1543            if c.is_whitespace() {
1544                in_word = false;
1545                result.push(c);
1546            } else if !in_word {
1547                if let Some(word) = words.next() {
1548                    result.push_str(word);
1549                }
1550                in_word = true;
1551            }
1552        }
1553        result
1554    }
1555
1556    /// Capitalize a setext heading whose text spans several source lines.
1557    ///
1558    /// The heading's text is the whole paragraph its underline ends, so the
1559    /// capitalization runs over the joined text and the result is written back
1560    /// word by word onto the original lines, keeping their line breaks and
1561    /// their indentation. A hard break's backslash goes with the line ending
1562    /// it marks and is left out of the joined text, so it is put back after
1563    /// the words; the last line ends with no break, and a backslash inside a
1564    /// code span is code, so those are in the text and come back with it.
1565    ///
1566    /// Returns `None` when the capitalized text does not hold one word per
1567    /// original word. Prose keeps its whitespace through the capitalization,
1568    /// but link text is rebuilt from its words alone, so padding inside a link
1569    /// label changes the count. The heading is then reported without a fix
1570    /// rather than rewritten from a mapping whose words do not line up.
1571    fn fix_setext_heading_span(
1572        &self,
1573        ctx: &crate::lint_context::LintContext,
1574        first_idx: usize,
1575        last_idx: usize,
1576        heading: &crate::lint_context::HeadingInfo,
1577        quoted: bool,
1578        fixed_text: &str,
1579    ) -> Option<Vec<String>> {
1580        let quote_depth = if quoted {
1581            ctx.lines
1582                .get(last_idx + 1)
1583                .and_then(|underline| crate::utils::blockquote::parse_blockquote_prefix(underline.content(ctx.content)))
1584                .map_or(0, |prefix| prefix.nesting_level)
1585        } else {
1586            0
1587        };
1588        let bodies: Vec<(&str, &str, &str)> = (first_idx..=last_idx)
1589            .map(|idx| {
1590                let line = ctx.lines[idx].content(ctx.content);
1591                // The first line may open with a list marker, which is not
1592                // one of the heading's words; a line inside a blockquote
1593                // carries the quote markers, which are not either
1594                let (prefix, line) = if idx == first_idx {
1595                    line.split_at(heading.content_column)
1596                } else if quoted {
1597                    // Only the heading's own quote markers are the quote's; a
1598                    // `>` further in is text of the heading
1599                    let margin = crate::utils::blockquote::quote_margin_len(line, quote_depth).unwrap_or(0);
1600                    line.split_at(margin)
1601                } else {
1602                    ("", line)
1603                };
1604                if idx < last_idx && ctx.line_ends_with_hard_break(idx + 1) {
1605                    let (body, hard_break) = line.split_at(line.len() - 1);
1606                    (prefix, body, hard_break)
1607                } else {
1608                    (prefix, line, "")
1609                }
1610            })
1611            .collect();
1612        let fixed_words: Vec<&str> = fixed_text.split_whitespace().collect();
1613        let original_words: usize = bodies.iter().map(|(_, body, _)| body.split_whitespace().count()).sum();
1614        if fixed_words.len() != original_words {
1615            return None;
1616        }
1617
1618        let mut words = fixed_words.into_iter();
1619        Some(
1620            bodies
1621                .iter()
1622                .map(|(prefix, body, hard_break)| {
1623                    format!("{prefix}{}{hard_break}", Self::rewrite_words(body, &mut words))
1624                })
1625                .collect(),
1626        )
1627    }
1628}
1629
1630impl Rule for MD063HeadingCapitalization {
1631    fn name(&self) -> &'static str {
1632        "MD063"
1633    }
1634
1635    fn description(&self) -> &'static str {
1636        "Heading capitalization"
1637    }
1638
1639    fn category(&self) -> RuleCategory {
1640        RuleCategory::Heading
1641    }
1642
1643    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1644        !ctx.likely_has_headings() || ctx.headings().next().is_none()
1645    }
1646
1647    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1648        let content = ctx.content;
1649
1650        if content.is_empty() {
1651            return Ok(Vec::new());
1652        }
1653
1654        let mut warnings = Vec::new();
1655
1656        let style = self.resolve_style(ctx);
1657        for parsed in self.eligible_headings(ctx) {
1658            let line_num = parsed.line_num - 1;
1659            let line_info = parsed.line_info;
1660            let heading = parsed.heading;
1661            // Apply capitalization and compare
1662            let original_text = &heading.raw_text;
1663            let fixed_text = self.apply_heading_capitalization(ctx, line_num, heading, style);
1664
1665            if original_text != &fixed_text {
1666                let line = line_info.content(ctx.content);
1667                let style_name = style.name();
1668
1669                // A setext heading's text is the whole paragraph its
1670                // underline ends, so the warning covers every one of those
1671                // lines and the fix rewrites them where they stand.
1672                if heading.text_lines > 1 {
1673                    let first_idx = line_num + 1 - heading.text_lines;
1674                    let first_line = ctx.lines[first_idx].content(ctx.content);
1675                    let fix = self
1676                        .fix_setext_heading_span(ctx, first_idx, line_num, heading, parsed.is_blockquote(), &fixed_text)
1677                        .map(|rewritten| {
1678                            let range = ctx.line_content_byte_range(first_idx + 1).start
1679                                ..ctx.line_content_byte_range(line_num + 1).end;
1680                            Fix::new(range, rewritten.join("\n"))
1681                        });
1682                    warnings.push(LintWarning {
1683                        rule_name: Some(self.name().to_string()),
1684                        line: first_idx + 1,
1685                        column: byte_to_char_count(first_line, heading.content_column),
1686                        end_line: line_num + 1,
1687                        end_column: line.trim_end().chars().count() + 1,
1688                        message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1689                        severity: Severity::Warning,
1690                        fix,
1691                    });
1692                    continue;
1693                }
1694
1695                warnings.push(LintWarning {
1696                    rule_name: Some(self.name().to_string()),
1697                    line: line_num + 1,
1698                    column: byte_to_char_count(line, heading.content_column),
1699                    end_line: line_num + 1,
1700                    end_column: byte_to_char_count(line, heading.content_column) + original_text.chars().count(),
1701                    message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1702                    severity: Severity::Warning,
1703                    fix: Some(Fix::new(
1704                        ctx.line_content_byte_range(line_num + 1),
1705                        match heading.style {
1706                            crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading, &fixed_text),
1707                            _ => self.fix_setext_heading(line, heading, &fixed_text),
1708                        },
1709                    )),
1710                });
1711            }
1712        }
1713
1714        Ok(warnings)
1715    }
1716
1717    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1718        let content = ctx.content;
1719
1720        if content.is_empty() {
1721            return Ok(content.to_string());
1722        }
1723
1724        let lines = ctx.raw_lines();
1725        let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1726
1727        let style = self.resolve_style(ctx);
1728        for parsed in self.enabled_headings(ctx) {
1729            let line_num = parsed.line_num - 1;
1730            let line_info = parsed.line_info;
1731            let heading = parsed.heading;
1732            let original_text = &heading.raw_text;
1733            let fixed_text = self.apply_heading_capitalization(ctx, line_num, heading, style);
1734
1735            if original_text != &fixed_text {
1736                let line = line_info.content(ctx.content);
1737                // A setext heading's text is the whole paragraph its
1738                // underline ends, so each of those lines keeps its own words.
1739                if heading.text_lines > 1 {
1740                    let first_idx = line_num + 1 - heading.text_lines;
1741                    if let Some(rewritten) = self.fix_setext_heading_span(
1742                        ctx,
1743                        first_idx,
1744                        line_num,
1745                        heading,
1746                        parsed.is_blockquote(),
1747                        &fixed_text,
1748                    ) {
1749                        fixed_lines[first_idx..=line_num].clone_from_slice(&rewritten);
1750                    }
1751                    continue;
1752                }
1753                fixed_lines[line_num] = match heading.style {
1754                    crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading, &fixed_text),
1755                    _ => self.fix_setext_heading(line, heading, &fixed_text),
1756                };
1757            }
1758        }
1759
1760        // Reconstruct content preserving line endings
1761        let mut result = String::with_capacity(content.len());
1762        for (i, line) in fixed_lines.iter().enumerate() {
1763            result.push_str(line);
1764            if i < fixed_lines.len() - 1 || content.ends_with('\n') {
1765                result.push('\n');
1766            }
1767        }
1768
1769        Ok(result)
1770    }
1771
1772    fn as_any(&self) -> &dyn std::any::Any {
1773        self
1774    }
1775
1776    crate::impl_rule_config_sections!(MD063Config);
1777
1778    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1779    where
1780        Self: Sized,
1781    {
1782        let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
1783        let md044_config =
1784            crate::rule_config_serde::load_rule_config::<crate::rules::md044_proper_names::MD044Config>(config);
1785        let mut rule = Self::from_config_struct(rule_config);
1786        rule.proper_names = md044_config.names;
1787        Box::new(rule)
1788    }
1789}
1790
1791#[cfg(test)]
1792mod tests {
1793    use super::*;
1794    use crate::lint_context::LintContext;
1795
1796    #[test]
1797    fn test_open_elements_match_stack_pairing() {
1798        // Include duplicate opens, missing closes, and case-distinct names.
1799        // Sequences exercise both the stack path and the lazily indexed path.
1800        let operations = [(true, "a"), (true, "A"), (false, "a"), (false, "A"), (false, "missing")];
1801        for mut sequence in 0..operations.len().pow(6) {
1802            let mut indexed = OpenElements::new();
1803            let mut original = Vec::new();
1804            for start in 0..6 {
1805                let (opening, name) = operations[sequence % operations.len()];
1806                sequence /= operations.len();
1807                if opening {
1808                    indexed.push(name, start);
1809                    original.push((name, start));
1810                } else {
1811                    let expected = original
1812                        .iter()
1813                        .rposition(|(open_name, _)| *open_name == name)
1814                        .map(|depth| {
1815                            let start = original[depth].1;
1816                            original.truncate(depth);
1817                            start
1818                        });
1819                    assert_eq!(indexed.close(&name), expected);
1820                }
1821                assert_eq!(indexed.elements, original);
1822                if let Some(counts) = &indexed.counts {
1823                    let mut expected = HashMap::new();
1824                    for (name, _) in &original {
1825                        *expected.entry(*name).or_default() += 1;
1826                    }
1827                    assert_eq!(*counts, expected);
1828                }
1829            }
1830        }
1831    }
1832
1833    #[test]
1834    fn test_paired_elements_do_not_build_a_name_index() {
1835        let mut elements = OpenElements::new();
1836        assert_eq!(elements.close(&"missing"), None);
1837        elements.push("a", 0);
1838        elements.push("b", 3);
1839        elements.push("a", 6);
1840        assert_eq!(elements.close(&"a"), Some(6));
1841        assert_eq!(elements.close(&"a"), Some(0));
1842        assert!(elements.elements.is_empty());
1843        assert!(elements.counts.is_none());
1844
1845        elements.push("a", 10);
1846        assert_eq!(elements.close(&"missing"), None);
1847        assert!(elements.counts.is_some());
1848        assert_eq!(elements.close(&"a"), Some(10));
1849        assert!(elements.counts.is_none());
1850        elements.push("b", 20);
1851        assert_eq!(elements.close(&"b"), Some(20));
1852        assert!(elements.counts.is_none());
1853    }
1854
1855    fn create_rule() -> MD063HeadingCapitalization {
1856        let config = MD063Config {
1857            enabled: true,
1858            ..Default::default()
1859        };
1860        MD063HeadingCapitalization::from_config_struct(config)
1861    }
1862
1863    fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
1864        let config = MD063Config {
1865            enabled: true,
1866            style,
1867            ..Default::default()
1868        };
1869        MD063HeadingCapitalization::from_config_struct(config)
1870    }
1871
1872    #[test]
1873    fn test_heading_indent_counts_from_the_list_item_content_column() {
1874        let rule = create_rule();
1875        for (source, expected) in [
1876            ("- x\n\n    ## hello world\n", "- x\n\n    ## Hello World\n"),
1877            ("- x\n\n     ## hello world\n", "- x\n\n     ## Hello World\n"),
1878            ("1. x\n\n    ## hello world\n", "1. x\n\n    ## Hello World\n"),
1879            ("10. x\n\n    ## hello world\n", "10. x\n\n    ## Hello World\n"),
1880            ("10. x\n\n     ## hello world\n", "10. x\n\n     ## Hello World\n"),
1881            ("- x\n\n\t## hello world\n", "- x\n\n\t## Hello World\n"),
1882        ] {
1883            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1884            assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source:?}");
1885            assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source:?}");
1886        }
1887    }
1888
1889    #[test]
1890    fn test_heading_four_columns_past_the_list_item_content_column_is_code() {
1891        let rule = create_rule();
1892        for source in [
1893            "- x\n\n      ## hello world\n",
1894            "1. x\n\n       ## hello world\n",
1895            "10. x\n\n        ## hello world\n",
1896            "- x\n\n\t\t## hello world\n",
1897            "    ## hello world\n",
1898        ] {
1899            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1900            assert!(rule.check(&ctx).unwrap().is_empty(), "{source:?}");
1901            assert_eq!(rule.fix(&ctx).unwrap(), source, "{source:?}");
1902        }
1903    }
1904
1905    // Title case tests
1906
1907    #[test]
1908    fn test_mdx_sentence_case_distinguishes_comments_from_comments_plus_code() {
1909        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1910        for (source, expected) in [
1911            ("# { /* hidden */ } hello world\n", "# { /* hidden */ } Hello world\n"),
1912            (
1913                "export const name = \"Visible\"\n\n# {/* c */ name} hello world\n",
1914                "export const name = \"Visible\"\n\n# {/* c */ name} hello world\n",
1915            ),
1916        ] {
1917            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1918            assert_eq!(rule.fix(&ctx).unwrap(), expected);
1919        }
1920    }
1921
1922    #[test]
1923    fn test_mdx_leading_comment_does_not_consume_sentence_start() {
1924        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1925        for (source, expected) in [
1926            (
1927                "# {/* literal comment */} hello world\n",
1928                "# {/* literal comment */} Hello world\n",
1929            ),
1930            (
1931                "{/* literal comment */} hello world\n===\n",
1932                "{/* literal comment */} Hello world\n===\n",
1933            ),
1934        ] {
1935            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1936            assert_eq!(rule.fix(&ctx).unwrap(), expected);
1937        }
1938    }
1939
1940    #[test]
1941    fn test_mdx_heading_expression_code_is_preserved() {
1942        for (style, before, after) in [
1943            (HeadingCapStyle::TitleCase, "Hello", "World"),
1944            (HeadingCapStyle::SentenceCase, "Hello", "world"),
1945            (HeadingCapStyle::AllCaps, "HELLO", "WORLD"),
1946        ] {
1947            let rule = create_rule_with_style(style);
1948            for expression in ["name", "data.text", r#""literal value""#, "name.toLowerCase()"] {
1949                let source = format!("# hello {{{expression}}} world\n");
1950                let expected = format!("# {before} {{{expression}}} {after}\n");
1951                let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
1952                assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1953                assert_eq!(
1954                    rule.fix(&ctx).unwrap(),
1955                    expected,
1956                    "expression code is literal: {source}"
1957                );
1958                let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::MDX, None);
1959                assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1960                assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1961            }
1962        }
1963    }
1964
1965    #[test]
1966    fn test_mdx_heading_link_labels_and_adjacent_expressions_preserve_code() {
1967        let rule = create_rule();
1968        for (source, expected) in [
1969            (
1970                "# hello [{name} world](target) friend\n",
1971                "# Hello [{name} World](target) Friend\n",
1972            ),
1973            ("# hello {name}{data.text} world\n", "# Hello {name}{data.text} World\n"),
1974            (
1975                "# hello {/* literal comment */} world\n",
1976                "# Hello {/* literal comment */} World\n",
1977            ),
1978            ("# 日本語 {name} world\n", "# 日本語 {name} World\n"),
1979        ] {
1980            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1981            assert_eq!(rule.fix(&ctx).unwrap(), expected);
1982        }
1983    }
1984
1985    #[test]
1986    fn test_mdx_expression_protection_is_flavor_specific() {
1987        let rule = create_rule();
1988        let source = "# hello {name} world\n";
1989        let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1990        assert_eq!(rule.fix(&ctx).unwrap(), "# Hello {Name} World\n");
1991    }
1992
1993    #[test]
1994    fn test_mdx_heading_attribute_expressions_keep_existing_html_protection() {
1995        let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1996        let source = "# hello <span title={name}>world</span> friend\n";
1997        let expected = "# HELLO <span title={name}>world</span> FRIEND\n";
1998        let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1999        assert_eq!(rule.fix(&ctx).unwrap(), expected);
2000    }
2001
2002    #[test]
2003    fn test_heading_template_tags_are_preserved() {
2004        for (style, before, after) in [
2005            (HeadingCapStyle::TitleCase, "Hello", "World"),
2006            (HeadingCapStyle::SentenceCase, "Hello", "world"),
2007            (HeadingCapStyle::AllCaps, "HELLO", "WORLD"),
2008        ] {
2009            let rule = create_rule_with_style(style);
2010            for template in [
2011                "{{ name }}",
2012                "{{ data['display_name'] }}",
2013                "{{< note title=\"literal value\" >}}",
2014                "{{% note title=\"literal value\" %}}",
2015            ] {
2016                let source = format!("# hello {template} world\n");
2017                let expected = format!("# {before} {template} {after}\n");
2018                let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
2019                assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2020                assert_eq!(rule.fix(&ctx).unwrap(), expected, "template code is literal: {source}");
2021                let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
2022                assert!(rule.check(&fixed_ctx).unwrap().is_empty());
2023                assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
2024            }
2025        }
2026    }
2027
2028    #[test]
2029    fn test_heading_template_control_tags_and_link_labels_are_preserved() {
2030        let rule = create_rule();
2031        for (source, expected) in [
2032            (
2033                "# hello {% if enabled %}yes{% else %}no{% endif %} world\n",
2034                "# Hello {% if enabled %}Yes{% else %}No{% endif %} World\n",
2035            ),
2036            (
2037                "# hello [{{ name }} world](target) friend\n",
2038                "# Hello [{{ name }} World](target) Friend\n",
2039            ),
2040            (
2041                "# hello {{ name }} and {{ data['display_name'] }} world\n",
2042                "# Hello {{ name }} and {{ data['display_name'] }} World\n",
2043            ),
2044        ] {
2045            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2046            assert_eq!(rule.fix(&ctx).unwrap(), expected);
2047        }
2048    }
2049
2050    #[test]
2051    fn test_heading_template_tokens_do_not_collide_with_literal_text() {
2052        let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
2053        let source = "# 日本語 \u{e000}0\u{e000} {{ name }} world\n";
2054        let expected = "# 日本語 \u{e000}0\u{e000} {{ name }} WORLD\n";
2055        let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2056        assert_eq!(rule.fix(&ctx).unwrap(), expected);
2057    }
2058
2059    #[test]
2060    fn test_an_escaped_tag_does_not_hide_the_element_written_inside_it() {
2061        // `\<span` is text, so the `<a>` where its attribute value would be is a
2062        // real element and only its bytes are HTML.
2063        let text = r#"\<span title='<a id="x"></a>'>foo"#;
2064        assert_eq!(MD063HeadingCapitalization::html_regions(text, &[]), vec![(14, 28)]);
2065    }
2066
2067    #[test]
2068    fn test_title_case_basic() {
2069        let rule = create_rule();
2070        let content = "# hello world\n";
2071        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2072        let result = rule.check(&ctx).unwrap();
2073        assert_eq!(result.len(), 1);
2074        assert!(result[0].message.contains("Hello World"));
2075    }
2076
2077    #[test]
2078    fn test_title_case_lowercase_words() {
2079        let rule = create_rule();
2080        let content = "# the quick brown fox\n";
2081        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2082        let result = rule.check(&ctx).unwrap();
2083        assert_eq!(result.len(), 1);
2084        // "The" should be capitalized (first word), "quick", "brown", "fox" should be capitalized
2085        assert!(result[0].message.contains("The Quick Brown Fox"));
2086    }
2087
2088    #[test]
2089    fn test_title_case_already_correct() {
2090        let rule = create_rule();
2091        let content = "# The Quick Brown Fox\n";
2092        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2093        let result = rule.check(&ctx).unwrap();
2094        assert!(result.is_empty(), "Already correct heading should not be flagged");
2095    }
2096
2097    #[test]
2098    fn test_title_case_hyphenated() {
2099        let rule = create_rule();
2100        let content = "# self-documenting code\n";
2101        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2102        let result = rule.check(&ctx).unwrap();
2103        assert_eq!(result.len(), 1);
2104        assert!(result[0].message.contains("Self-Documenting Code"));
2105    }
2106
2107    #[test]
2108    fn test_title_case_preserves_url_with_nested_parens() {
2109        let rule = create_rule();
2110        // The URL contains a parenthesised segment followed by more URL text.
2111        let content = "# guide for [the api](https://example.com/docs/v(2)beta)\n";
2112        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2113        let fixed = rule.fix(&ctx).unwrap();
2114        // The whole URL, including the lowercase "beta" after the nested
2115        // parens, must be preserved exactly and never title-cased.
2116        assert!(
2117            fixed.contains("https://example.com/docs/v(2)beta"),
2118            "URL with nested parens was corrupted: {fixed:?}"
2119        );
2120    }
2121
2122    #[test]
2123    fn test_title_case_does_not_recase_image_alt() {
2124        let rule = create_rule();
2125        let content = "# overview ![a small icon](icon.png)\n";
2126        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2127        let fixed = rule.fix(&ctx).unwrap();
2128        // Image (alt text and all) is preserved as-is; only prose is recased.
2129        assert!(
2130            fixed.contains("![a small icon](icon.png)"),
2131            "image alt text was modified: {fixed:?}"
2132        );
2133        assert!(
2134            fixed.contains("# Overview"),
2135            "surrounding prose should still be title-cased: {fixed:?}"
2136        );
2137    }
2138
2139    // Sentence case tests
2140    #[test]
2141    fn test_sentence_case_basic() {
2142        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2143        let content = "# The Quick Brown Fox\n";
2144        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2145        let result = rule.check(&ctx).unwrap();
2146        assert_eq!(result.len(), 1);
2147        assert!(result[0].message.contains("The quick brown fox"));
2148    }
2149
2150    #[test]
2151    fn test_sentence_case_already_correct() {
2152        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2153        let content = "# The quick brown fox\n";
2154        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2155        let result = rule.check(&ctx).unwrap();
2156        assert!(result.is_empty());
2157    }
2158
2159    #[test]
2160    fn test_sentence_case_preserves_first_person_pronoun() {
2161        // Regression test for issue #845.
2162        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2163        let content = "# How do I debug playbooks?\n";
2164        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2165
2166        assert!(rule.check(&ctx).unwrap().is_empty());
2167        assert_eq!(rule.fix(&ctx).unwrap(), content);
2168    }
2169
2170    #[test]
2171    fn test_sentence_case_preserves_first_person_contractions() {
2172        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2173
2174        for content in [
2175            "# What I'd change\n",
2176            "# Where I'll look\n",
2177            "# Why I'm here\n",
2178            "# What I've learned\n",
2179            "# What I’d change\n",
2180            "# Where I’ll look\n",
2181            "# Why I’m here\n",
2182            "# What I’ve learned\n",
2183        ] {
2184            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2185            assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
2186            assert_eq!(rule.fix(&ctx).unwrap(), content);
2187        }
2188    }
2189
2190    #[test]
2191    fn test_sentence_case_pronoun_handles_markup_punctuation_and_suffix_case() {
2192        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2193        let cases = [
2194            ("# What (**I**) would change\n", "# What (**I**) would change\n"),
2195            ("# Why **I**'M changing it\n", "# Why **I**'m changing it\n"),
2196            ("# What I’LL change\n", "# What I’ll change\n"),
2197            ("# What I—really want\n", "# What I—really want\n"),
2198            ("# What I’d—reluctantly change\n", "# What I’d—reluctantly change\n"),
2199            ("# What I—yes—I—would do\n", "# What I—yes—I—would do\n"),
2200            ("# What [I'll change](plan.md)\n", "# What [I'll change](plan.md)\n"),
2201            ("# [What I—really want](plan.md)\n", "# [What I—really want](plan.md)\n"),
2202        ];
2203
2204        for (content, expected) in cases {
2205            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2206            assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
2207        }
2208    }
2209
2210    #[test]
2211    fn test_sentence_case_pronoun_is_independent_of_cased_word_preservation() {
2212        let config = MD063Config {
2213            enabled: true,
2214            style: HeadingCapStyle::SentenceCase,
2215            preserve_cased_words: false,
2216            ..Default::default()
2217        };
2218        let rule = MD063HeadingCapitalization::from_config_struct(config);
2219        let content = "# How do I debug what I’LL change?\n";
2220        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2221
2222        assert_eq!(rule.fix(&ctx).unwrap(), "# How do I debug what I’ll change?\n");
2223    }
2224
2225    #[test]
2226    fn test_sentence_case_explicit_ignore_wins_over_pronoun_normalization() {
2227        let config = MD063Config {
2228            enabled: true,
2229            style: HeadingCapStyle::SentenceCase,
2230            ignore_words: vec!["I'LL".to_string(), "I’LL".to_string()],
2231            ..Default::default()
2232        };
2233        let rule = MD063HeadingCapitalization::from_config_struct(config);
2234        let content = "# Why I'LL stay and why I’LL leave\n";
2235        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2236
2237        assert!(rule.check(&ctx).unwrap().is_empty());
2238        assert_eq!(rule.fix(&ctx).unwrap(), content);
2239    }
2240
2241    #[test]
2242    fn test_sentence_case_pronoun_does_not_preserve_other_single_letters_or_compounds() {
2243        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2244        let content = "# Compare i, A, I/O, and A.I. values\n";
2245        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2246
2247        assert_eq!(rule.fix(&ctx).unwrap(), "# Compare i, a, i/o, and a.i. values\n");
2248    }
2249
2250    // All caps tests
2251    #[test]
2252    fn test_all_caps_basic() {
2253        let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
2254        let content = "# hello world\n";
2255        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2256        let result = rule.check(&ctx).unwrap();
2257        assert_eq!(result.len(), 1);
2258        assert!(result[0].message.contains("HELLO WORLD"));
2259    }
2260
2261    // Preserve tests
2262    #[test]
2263    fn test_preserve_ignore_words() {
2264        let config = MD063Config {
2265            enabled: true,
2266            ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
2267            ..Default::default()
2268        };
2269        let rule = MD063HeadingCapitalization::from_config_struct(config);
2270
2271        let content = "# using iPhone on macOS\n";
2272        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2273        let result = rule.check(&ctx).unwrap();
2274        assert_eq!(result.len(), 1);
2275        // iPhone and macOS should be preserved
2276        assert!(result[0].message.contains("iPhone"));
2277        assert!(result[0].message.contains("macOS"));
2278    }
2279
2280    #[test]
2281    fn test_preserve_cased_words() {
2282        let rule = create_rule();
2283        let content = "# using GitHub actions\n";
2284        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2285        let result = rule.check(&ctx).unwrap();
2286        assert_eq!(result.len(), 1);
2287        // GitHub should be preserved (has internal capital)
2288        assert!(result[0].message.contains("GitHub"));
2289    }
2290
2291    // Inline code tests
2292    #[test]
2293    fn test_inline_code_preserved() {
2294        let rule = create_rule();
2295        let content = "# using `const` in javascript\n";
2296        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2297        let result = rule.check(&ctx).unwrap();
2298        assert_eq!(result.len(), 1);
2299        // `const` should be preserved, rest capitalized
2300        assert!(result[0].message.contains("`const`"));
2301        assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
2302    }
2303
2304    // Level filter tests
2305    #[test]
2306    fn test_level_filter() {
2307        let config = MD063Config {
2308            enabled: true,
2309            min_level: 2,
2310            max_level: 4,
2311            ..Default::default()
2312        };
2313        let rule = MD063HeadingCapitalization::from_config_struct(config);
2314
2315        let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
2316        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2317        let result = rule.check(&ctx).unwrap();
2318
2319        // Only h2 and h3 should be flagged (h1 < min_level, h5 > max_level)
2320        assert_eq!(result.len(), 2);
2321        assert_eq!(result[0].line, 2); // h2
2322        assert_eq!(result[1].line, 3); // h3
2323    }
2324
2325    // Fix tests
2326    #[test]
2327    fn test_fix_atx_heading() {
2328        let rule = create_rule();
2329        let content = "# hello world\n";
2330        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2331        let fixed = rule.fix(&ctx).unwrap();
2332        assert_eq!(fixed, "# Hello World\n");
2333    }
2334
2335    #[test]
2336    fn test_fix_multiple_headings() {
2337        let rule = create_rule();
2338        let content = "# first heading\n\n## second heading\n";
2339        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2340        let fixed = rule.fix(&ctx).unwrap();
2341        assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
2342    }
2343
2344    // Setext heading tests
2345    #[test]
2346    fn test_setext_heading() {
2347        let rule = create_rule();
2348        let content = "hello world\n============\n";
2349        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2350        let result = rule.check(&ctx).unwrap();
2351        assert_eq!(result.len(), 1);
2352        assert!(result[0].message.contains("Hello World"));
2353    }
2354
2355    #[test]
2356    fn test_multi_line_setext_heading_is_capitalized_in_place() {
2357        // A setext heading's text is the whole paragraph its underline ends. The
2358        // capitalization runs on that joined text so the position rules see the
2359        // whole heading, and the result goes back onto the author's own lines.
2360        let rule = create_rule();
2361        let content = "hello world\nand more words\n==============\n";
2362        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2363        let fixed = rule.fix(&ctx).unwrap();
2364        assert_eq!(fixed, "Hello World\nand More Words\n==============\n");
2365
2366        let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2367        assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent");
2368    }
2369
2370    #[test]
2371    fn test_multi_line_setext_heading_keeps_a_hard_break_backslash() {
2372        // A hard line break's backslash goes with its line ending and is left out
2373        // of the joined text, so writing the text back puts it back.
2374        let rule = create_rule();
2375        let content = "foo bar\\\nbaz qux\n=======\n";
2376        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2377        let fixed = rule.fix(&ctx).unwrap();
2378        assert_eq!(fixed, "Foo Bar\\\nBaz Qux\n=======\n");
2379
2380        let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2381        assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent");
2382    }
2383
2384    #[test]
2385    fn test_multi_line_setext_heading_keeps_a_backslash_that_is_no_hard_break() {
2386        // A backslash inside a code span is code, and one ending the last line
2387        // is text, so both are in the joined text already and are written back
2388        // once.
2389        let rule = create_rule();
2390        for (content, expected) in [
2391            ("foo `a\\\nb` tail\n===\n", "Foo `a\\\nb` Tail\n===\n"),
2392            ("foo bar\nbaz\\\n===\n", "Foo Bar\nBaz\\\n===\n"),
2393        ] {
2394            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2395            let fixed = rule.fix(&ctx).unwrap();
2396            assert_eq!(fixed, expected, "{content:?}");
2397
2398            let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2399            assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent: {content:?}");
2400        }
2401    }
2402
2403    #[test]
2404    fn test_multi_line_setext_heading_warning_covers_the_whole_span() {
2405        // The warning starts on the heading's first text line and runs to the end
2406        // of the text on its last.
2407        let rule = create_rule();
2408        let content = "hello world\nand more\n=====\n";
2409        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2410        let result = rule.check(&ctx).unwrap();
2411        assert_eq!(result.len(), 1, "got: {result:?}");
2412        assert_eq!(result[0].line, 1);
2413        assert_eq!(result[0].column, 1);
2414        assert_eq!(result[0].end_line, 2);
2415        assert_eq!(result[0].end_column, 9);
2416    }
2417
2418    #[test]
2419    fn test_multi_line_setext_heading_with_a_multi_byte_word_is_idempotent() {
2420        // The shrunk input from the idempotency proptest: three text lines under
2421        // one dash underline, with a multi-byte word in the middle line. The fix
2422        // may only recase the words where they stand.
2423        let rule = create_rule();
2424        let content = "`A`\n| à |  |\n| --- | --- |\n---";
2425        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2426        let fixed = rule.fix(&ctx).unwrap();
2427        assert_eq!(fixed, "`A`\n| À |  |\n| --- | --- |\n---");
2428
2429        let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2430        assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent");
2431    }
2432
2433    #[test]
2434    fn test_multi_line_setext_heading_whose_words_cannot_be_mapped_back_is_reported_without_a_fix() {
2435        // Link text is rebuilt from its words, so the padding inside this label
2436        // is gone from the capitalized text and the words no longer map back
2437        // one to one onto the author's lines. The heading is still reported;
2438        // only the rewrite is withheld, and the document is left as written.
2439        let rule = create_rule();
2440        let content = "see [ the guide ](guide.md) first\nand then more\n=====\n";
2441        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2442        let result = rule.check(&ctx).unwrap();
2443        assert_eq!(result.len(), 1, "got: {result:?}");
2444        assert_eq!(result[0].line, 1);
2445        assert_eq!(result[0].end_line, 2);
2446        assert!(
2447            result[0].fix.is_none(),
2448            "no rewrite is offered, got: {:?}",
2449            result[0].fix
2450        );
2451        assert_eq!(rule.fix(&ctx).unwrap(), content, "the heading is left as written");
2452
2453        // Control: the same heading with an unpadded label is rewritten in place.
2454        let content = "see [the guide](guide.md) first\nand then more\n=====\n";
2455        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2456        let result = rule.check(&ctx).unwrap();
2457        assert_eq!(result.len(), 1, "got: {result:?}");
2458        assert!(result[0].fix.is_some(), "the control heading is fixable");
2459        assert_eq!(
2460            rule.fix(&ctx).unwrap(),
2461            "See [The Guide](guide.md) First\nand Then More\n=====\n"
2462        );
2463    }
2464
2465    #[test]
2466    fn test_fix_honours_a_suppression_on_any_line_of_a_multi_line_setext_heading() {
2467        // The warning is dropped when any of the heading's lines is disabled,
2468        // and the rewrite goes with it: the comment disables the first text
2469        // line, and the heading is recorded on the last.
2470        let rule = create_rule();
2471        let content = "<!-- rumdl-disable-next-line MD063 -->\nhello\nworld\n===\n";
2472        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2473        assert_eq!(
2474            rule.fix(&ctx).unwrap(),
2475            content,
2476            "the suppressed heading is left as written"
2477        );
2478
2479        // Control: without the comment the same heading is rewritten in place.
2480        let content = "hello\nworld\n===\n";
2481        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2482        assert_eq!(rule.fix(&ctx).unwrap(), "Hello\nWorld\n===\n");
2483    }
2484
2485    #[test]
2486    fn md087_tracks_heading_suppressions_across_styles_and_scopes() {
2487        use crate::config::MarkdownFlavor;
2488        use crate::rules::MD087UnusedDisableComment;
2489
2490        for style in [
2491            HeadingCapStyle::TitleCase,
2492            HeadingCapStyle::SentenceCase,
2493            HeadingCapStyle::AllCaps,
2494            HeadingCapStyle::Consistent,
2495        ] {
2496            let rule = create_rule_with_style(style);
2497            let rules: Vec<Box<dyn Rule>> = vec![Box::new(rule.clone()), Box::new(MD087UnusedDisableComment)];
2498            let lint =
2499                |content: &str| crate::lint(content, &rules, false, MarkdownFlavor::Standard, None, None).unwrap();
2500            for name in ["MD063", "heading-capitalization"] {
2501                for (directive, source) in [
2502                    ("disable-line", format!("# asdf <!-- rumdl-disable-line {name} -->\n")),
2503                    (
2504                        "disable-next-line",
2505                        format!("<!-- rumdl-disable-next-line {name} -->\n# asdf\n"),
2506                    ),
2507                    ("disable", format!("<!-- rumdl-disable {name} -->\n# asdf\n")),
2508                    (
2509                        "disable-file",
2510                        format!("# asdf\n\n<!-- rumdl-disable-file {name} -->\n"),
2511                    ),
2512                    // Suppression on either text line must cover the whole Setext heading.
2513                    (
2514                        "disable-next-line",
2515                        format!("<!-- rumdl-disable-next-line {name} -->\nasdf\nasdf\n===\n"),
2516                    ),
2517                    (
2518                        "disable-line",
2519                        format!("asdf\nasdf <!-- rumdl-disable-line {name} -->\n===\n"),
2520                    ),
2521                ] {
2522                    let warnings = lint(&source);
2523                    assert!(warnings.is_empty(), "{style:?}: {source:?}: {warnings:?}");
2524                    let ctx = LintContext::new(&source, MarkdownFlavor::Standard, None);
2525                    assert_eq!(rule.fix(&ctx).unwrap(), source, "suppressed heading must not change");
2526
2527                    // Same comment, now genuinely unnecessary: MD087 must still report it.
2528                    let valid = if style == HeadingCapStyle::AllCaps {
2529                        "ASDF"
2530                    } else {
2531                        "Asdf"
2532                    };
2533                    let unused = if style == HeadingCapStyle::SentenceCase {
2534                        source.replacen("asdf", valid, 1)
2535                    } else {
2536                        source.replace("asdf", valid)
2537                    };
2538                    let warnings = lint(&unused);
2539                    assert_eq!(warnings.len(), 1, "{style:?}: {unused:?}: {warnings:?}");
2540                    assert_eq!(warnings[0].rule_name.as_deref(), Some("MD087"));
2541                    assert_eq!(warnings[0].message, format!("Unused {directive} comment: {name}"));
2542                }
2543            }
2544            let warnings = lint("# asdf\n");
2545            assert_eq!(warnings.len(), 1);
2546            assert_eq!(warnings[0].rule_name.as_deref(), Some("MD063"));
2547        }
2548    }
2549
2550    #[test]
2551    fn md087_accounting_does_not_let_disabled_headings_choose_consistent_style() {
2552        use crate::config::MarkdownFlavor;
2553        use crate::rules::MD087UnusedDisableComment;
2554
2555        let rule = create_rule_with_style(HeadingCapStyle::Consistent);
2556        let source =
2557            "# Title Case Heading <!-- rumdl-disable-line MD063 -->\n\n## Sentence case heading\n\n### More Details\n";
2558        let rules: Vec<Box<dyn Rule>> = vec![Box::new(rule.clone()), Box::new(MD087UnusedDisableComment)];
2559        let warnings = crate::lint(source, &rules, false, MarkdownFlavor::Standard, None, None).unwrap();
2560        assert_eq!(warnings.len(), 1, "{warnings:?}");
2561        assert_eq!(warnings[0].rule_name.as_deref(), Some("MD063"));
2562        assert_eq!(warnings[0].line, 5);
2563        assert!(warnings[0].message.contains("'More Details' -> 'More details'"));
2564        let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
2565        assert_eq!(rule.fix(&ctx).unwrap(), source.replace("More Details", "More details"));
2566    }
2567
2568    // Custom ID tests
2569    #[test]
2570    fn test_custom_id_preserved() {
2571        let rule = create_rule();
2572        let content = "# getting started {#intro}\n";
2573        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2574        let result = rule.check(&ctx).unwrap();
2575        assert_eq!(result.len(), 1);
2576        // Custom ID should be preserved
2577        assert!(result[0].message.contains("{#intro}"));
2578    }
2579
2580    // Acronym preservation tests
2581    #[test]
2582    fn test_skip_obsidian_tags_not_headings() {
2583        let rule = create_rule();
2584
2585        // #tag (no space after #) is an Obsidian tag, not a heading
2586        let content = "# H1\n\n#tag\n";
2587        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2588        let result = rule.check(&ctx).unwrap();
2589        assert!(
2590            result.is_empty() || result.iter().all(|w| w.line != 3),
2591            "Obsidian tag #tag should not be treated as a heading: {result:?}"
2592        );
2593    }
2594
2595    #[test]
2596    fn test_skip_invalid_atx_headings_no_space() {
2597        let rule = create_rule();
2598
2599        // #NoSpace is not a valid ATX heading (requires space after #)
2600        let content = "#notaheading\n";
2601        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2602        let result = rule.check(&ctx).unwrap();
2603        assert!(
2604            result.is_empty(),
2605            "Invalid ATX heading without space should not be flagged: {result:?}"
2606        );
2607    }
2608
2609    #[test]
2610    fn test_fix_skips_obsidian_tags() {
2611        let rule = create_rule();
2612
2613        let content = "# hello world\n\n#tag\n";
2614        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2615        let fixed = rule.fix(&ctx).unwrap();
2616        // Should fix the real heading but leave the tag alone
2617        assert!(fixed.contains("#tag"), "Fix should not modify Obsidian tag #tag");
2618        assert!(fixed.contains("# Hello World"), "Fix should still fix real headings");
2619    }
2620
2621    #[test]
2622    fn test_preserve_all_caps_acronyms() {
2623        let rule = create_rule();
2624        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2625
2626        // Basic acronyms should be preserved
2627        let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
2628        assert_eq!(fixed, "# Using API in Production\n");
2629
2630        // Multiple acronyms
2631        let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
2632        assert_eq!(fixed, "# API and GPU Integration\n");
2633
2634        // Two-letter acronyms
2635        let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
2636        assert_eq!(fixed, "# IO Performance Guide\n");
2637
2638        // Acronyms with numbers
2639        let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
2640        assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
2641    }
2642
2643    #[test]
2644    fn test_preserve_acronyms_in_hyphenated_words() {
2645        let rule = create_rule();
2646        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2647
2648        // Acronyms at start of hyphenated word
2649        let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
2650        assert_eq!(fixed, "# API-Driven Architecture\n");
2651
2652        // Multiple acronyms with hyphens
2653        let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
2654        assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
2655    }
2656
2657    #[test]
2658    fn test_single_letters_not_treated_as_acronyms() {
2659        let rule = create_rule();
2660        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2661
2662        // Single uppercase letters should follow title case rules, not be preserved
2663        let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
2664        assert_eq!(fixed, "# I Am a Heading\n");
2665    }
2666
2667    #[test]
2668    fn test_lowercase_terms_need_ignore_words() {
2669        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2670
2671        // Without ignore_words: npm gets capitalized
2672        let rule = create_rule();
2673        let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
2674        assert_eq!(fixed, "# Using Npm Packages\n");
2675
2676        // With ignore_words: npm preserved
2677        let config = MD063Config {
2678            enabled: true,
2679            ignore_words: vec!["npm".to_string()],
2680            ..Default::default()
2681        };
2682        let rule = MD063HeadingCapitalization::from_config_struct(config);
2683        let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
2684        assert_eq!(fixed, "# Using npm Packages\n");
2685    }
2686
2687    #[test]
2688    fn test_acronyms_with_mixed_case_preserved() {
2689        let rule = create_rule();
2690        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2691
2692        // Both acronyms (API, GPU) and mixed-case (GitHub) should be preserved
2693        let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
2694        assert_eq!(fixed, "# Using API with GitHub\n");
2695    }
2696
2697    #[test]
2698    fn test_real_world_acronyms() {
2699        let rule = create_rule();
2700        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2701
2702        // Common technical acronyms from tested repositories
2703        let content = "# FFI bindings for CPU optimization\n";
2704        let fixed = rule.fix(&ctx(content)).unwrap();
2705        assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
2706
2707        let content = "# DOM manipulation and SSR rendering\n";
2708        let fixed = rule.fix(&ctx(content)).unwrap();
2709        assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
2710
2711        let content = "# CVE security and RNN models\n";
2712        let fixed = rule.fix(&ctx(content)).unwrap();
2713        assert_eq!(fixed, "# CVE Security and RNN Models\n");
2714    }
2715
2716    #[test]
2717    fn test_is_all_caps_acronym() {
2718        let rule = create_rule();
2719
2720        // Should return true for all-caps with 2+ letters
2721        assert!(rule.is_all_caps_acronym("API"));
2722        assert!(rule.is_all_caps_acronym("IO"));
2723        assert!(rule.is_all_caps_acronym("GPU"));
2724        assert!(rule.is_all_caps_acronym("HTTP2")); // Numbers don't break it
2725
2726        // Should return false for single letters
2727        assert!(!rule.is_all_caps_acronym("A"));
2728        assert!(!rule.is_all_caps_acronym("I"));
2729
2730        // Should return false for words with lowercase
2731        assert!(!rule.is_all_caps_acronym("Api"));
2732        assert!(!rule.is_all_caps_acronym("npm"));
2733        assert!(!rule.is_all_caps_acronym("iPhone"));
2734    }
2735
2736    #[test]
2737    fn test_sentence_case_starts_after_a_leading_empty_anchor() {
2738        // The anchor renders nothing, so the sentence still starts at `the`.
2739        let config = MD063Config {
2740            enabled: true,
2741            style: HeadingCapStyle::SentenceCase,
2742            ..Default::default()
2743        };
2744        let rule = MD063HeadingCapitalization::from_config_struct(config);
2745
2746        let content = "# <a id=\"top\"></a>the beginning\n";
2747        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2748        assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2749        assert_eq!(rule.fix(&ctx).unwrap(), "# <a id=\"top\"></a>The beginning\n");
2750
2751        // Visible HTML is an element of its own, so the prose after it is mid-sentence.
2752        let content = "# <kbd>ctrl</kbd> the key\n";
2753        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2754        assert!(rule.check(&ctx).unwrap().is_empty());
2755
2756        // An image paints something without holding text, so it is visible too,
2757        // whether written as HTML or as Markdown.
2758        for content in ["# <img src=\"x.png\"> the picture\n", "# ![x](x.png) the picture\n"] {
2759            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2760            assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
2761        }
2762    }
2763
2764    #[test]
2765    fn test_sentence_case_ignore_words_first_word() {
2766        let config = MD063Config {
2767            enabled: true,
2768            style: HeadingCapStyle::SentenceCase,
2769            ignore_words: vec!["nvim".to_string()],
2770            ..Default::default()
2771        };
2772        let rule = MD063HeadingCapitalization::from_config_struct(config);
2773
2774        // "nvim" as first word should be preserved exactly
2775        let content = "# nvim config\n";
2776        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2777        let result = rule.check(&ctx).unwrap();
2778        assert!(
2779            result.is_empty(),
2780            "nvim in ignore-words should not be flagged. Got: {result:?}"
2781        );
2782
2783        // Verify fix also preserves it
2784        let fixed = rule.fix(&ctx).unwrap();
2785        assert_eq!(fixed, "# nvim config\n");
2786    }
2787
2788    #[test]
2789    fn test_sentence_case_ignore_words_not_first() {
2790        let config = MD063Config {
2791            enabled: true,
2792            style: HeadingCapStyle::SentenceCase,
2793            ignore_words: vec!["nvim".to_string()],
2794            ..Default::default()
2795        };
2796        let rule = MD063HeadingCapitalization::from_config_struct(config);
2797
2798        // "nvim" in middle should also be preserved
2799        let content = "# Using nvim editor\n";
2800        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2801        let result = rule.check(&ctx).unwrap();
2802        assert!(
2803            result.is_empty(),
2804            "nvim in ignore-words should be preserved. Got: {result:?}"
2805        );
2806    }
2807
2808    #[test]
2809    fn test_preserve_cased_words_ios() {
2810        let config = MD063Config {
2811            enabled: true,
2812            style: HeadingCapStyle::SentenceCase,
2813            preserve_cased_words: true,
2814            ..Default::default()
2815        };
2816        let rule = MD063HeadingCapitalization::from_config_struct(config);
2817
2818        // "iOS" should be preserved (has mixed case: lowercase 'i' + uppercase 'OS')
2819        let content = "## This is iOS\n";
2820        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2821        let result = rule.check(&ctx).unwrap();
2822        assert!(
2823            result.is_empty(),
2824            "iOS should be preserved with preserve-cased-words. Got: {result:?}"
2825        );
2826
2827        // Verify fix also preserves it
2828        let fixed = rule.fix(&ctx).unwrap();
2829        assert_eq!(fixed, "## This is iOS\n");
2830    }
2831
2832    #[test]
2833    fn test_preserve_cased_words_ios_title_case() {
2834        let config = MD063Config {
2835            enabled: true,
2836            style: HeadingCapStyle::TitleCase,
2837            preserve_cased_words: true,
2838            ..Default::default()
2839        };
2840        let rule = MD063HeadingCapitalization::from_config_struct(config);
2841
2842        // "iOS" should be preserved in title case too
2843        let content = "# developing for iOS\n";
2844        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2845        let fixed = rule.fix(&ctx).unwrap();
2846        assert_eq!(fixed, "# Developing for iOS\n");
2847    }
2848
2849    #[test]
2850    fn test_has_internal_capitals_ios() {
2851        let rule = create_rule();
2852
2853        // iOS should be detected as having internal capitals
2854        assert!(
2855            rule.has_internal_capitals("iOS"),
2856            "iOS has mixed case (lowercase i, uppercase OS)"
2857        );
2858
2859        // Other mixed-case words
2860        assert!(rule.has_internal_capitals("iPhone"));
2861        assert!(rule.has_internal_capitals("macOS"));
2862        assert!(rule.has_internal_capitals("GitHub"));
2863        assert!(rule.has_internal_capitals("JavaScript"));
2864        assert!(rule.has_internal_capitals("eBay"));
2865
2866        // All-caps should NOT be detected (handled by is_all_caps_acronym)
2867        assert!(!rule.has_internal_capitals("API"));
2868        assert!(!rule.has_internal_capitals("GPU"));
2869
2870        // All-lowercase should NOT be detected
2871        assert!(!rule.has_internal_capitals("npm"));
2872        assert!(!rule.has_internal_capitals("config"));
2873
2874        // Regular capitalized words should NOT be detected
2875        assert!(!rule.has_internal_capitals("The"));
2876        assert!(!rule.has_internal_capitals("Hello"));
2877    }
2878
2879    #[test]
2880    fn test_lowercase_words_before_trailing_code() {
2881        let config = MD063Config {
2882            enabled: true,
2883            style: HeadingCapStyle::TitleCase,
2884            lowercase_words: vec![
2885                "a".to_string(),
2886                "an".to_string(),
2887                "and".to_string(),
2888                "at".to_string(),
2889                "but".to_string(),
2890                "by".to_string(),
2891                "for".to_string(),
2892                "from".to_string(),
2893                "into".to_string(),
2894                "nor".to_string(),
2895                "on".to_string(),
2896                "onto".to_string(),
2897                "or".to_string(),
2898                "the".to_string(),
2899                "to".to_string(),
2900                "upon".to_string(),
2901                "via".to_string(),
2902                "vs".to_string(),
2903                "with".to_string(),
2904                "without".to_string(),
2905            ],
2906            preserve_cased_words: true,
2907            ..Default::default()
2908        };
2909        let rule = MD063HeadingCapitalization::from_config_struct(config);
2910
2911        // Test: "subtitle with a `app`" (all lowercase input)
2912        // Expected fix: "Subtitle With a `app`" - capitalize "Subtitle" and "With",
2913        // but keep "a" lowercase (it's in lowercase-words and not the last word)
2914        // Incorrect: "Subtitle with A `app`" (would incorrectly capitalize "a")
2915        let content = "## subtitle with a `app`\n";
2916        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2917        let result = rule.check(&ctx).unwrap();
2918
2919        // Should flag it
2920        assert!(!result.is_empty(), "Should flag incorrect capitalization");
2921        let fixed = rule.fix(&ctx).unwrap();
2922        // "a" should remain lowercase (not "A") because inline code at end doesn't change lowercase-words behavior
2923        assert!(
2924            fixed.contains("with a `app`"),
2925            "Expected 'with a `app`' but got: {fixed:?}"
2926        );
2927        assert!(
2928            !fixed.contains("with A `app`"),
2929            "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
2930        );
2931        // "Subtitle" should be capitalized, "with" and "a" should remain lowercase (they're in lowercase-words)
2932        assert!(
2933            fixed.contains("Subtitle with a `app`"),
2934            "Expected 'Subtitle with a `app`' but got: {fixed:?}"
2935        );
2936    }
2937
2938    #[test]
2939    fn test_lowercase_words_preserved_before_trailing_code_variant() {
2940        let config = MD063Config {
2941            enabled: true,
2942            style: HeadingCapStyle::TitleCase,
2943            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2944            ..Default::default()
2945        };
2946        let rule = MD063HeadingCapitalization::from_config_struct(config);
2947
2948        // Another variant: "Title with the `code`"
2949        let content = "## Title with the `code`\n";
2950        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2951        let fixed = rule.fix(&ctx).unwrap();
2952        // "the" should remain lowercase
2953        assert!(
2954            fixed.contains("with the `code`"),
2955            "Expected 'with the `code`' but got: {fixed:?}"
2956        );
2957        assert!(
2958            !fixed.contains("with The `code`"),
2959            "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
2960        );
2961    }
2962
2963    #[test]
2964    fn test_last_word_capitalized_when_no_trailing_code() {
2965        // Verify that when there's NO trailing code, the last word IS capitalized
2966        // (even if it's in lowercase-words) - this is the normal title case behavior
2967        let config = MD063Config {
2968            enabled: true,
2969            style: HeadingCapStyle::TitleCase,
2970            lowercase_words: vec!["a".to_string(), "the".to_string()],
2971            ..Default::default()
2972        };
2973        let rule = MD063HeadingCapitalization::from_config_struct(config);
2974
2975        // "title with a word" - "word" is last, should be capitalized
2976        // "a" is in lowercase-words and not last, so should be lowercase
2977        let content = "## title with a word\n";
2978        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2979        let fixed = rule.fix(&ctx).unwrap();
2980        // "a" should be lowercase, "word" should be capitalized (it's last)
2981        assert!(
2982            fixed.contains("With a Word"),
2983            "Expected 'With a Word' but got: {fixed:?}"
2984        );
2985    }
2986
2987    #[test]
2988    fn test_multiple_lowercase_words_before_code() {
2989        let config = MD063Config {
2990            enabled: true,
2991            style: HeadingCapStyle::TitleCase,
2992            lowercase_words: vec![
2993                "a".to_string(),
2994                "the".to_string(),
2995                "with".to_string(),
2996                "for".to_string(),
2997            ],
2998            ..Default::default()
2999        };
3000        let rule = MD063HeadingCapitalization::from_config_struct(config);
3001
3002        // Multiple lowercase words before code - all should remain lowercase
3003        let content = "## Guide for the `user`\n";
3004        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3005        let fixed = rule.fix(&ctx).unwrap();
3006        assert!(
3007            fixed.contains("for the `user`"),
3008            "Expected 'for the `user`' but got: {fixed:?}"
3009        );
3010        assert!(
3011            !fixed.contains("For The `user`"),
3012            "Should not capitalize lowercase words before code. Got: {fixed:?}"
3013        );
3014    }
3015
3016    #[test]
3017    fn test_code_in_middle_normal_rules_apply() {
3018        let config = MD063Config {
3019            enabled: true,
3020            style: HeadingCapStyle::TitleCase,
3021            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
3022            ..Default::default()
3023        };
3024        let rule = MD063HeadingCapitalization::from_config_struct(config);
3025
3026        // Code in the middle - normal title case rules apply (last word capitalized)
3027        let content = "## Using `const` for the code\n";
3028        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3029        let fixed = rule.fix(&ctx).unwrap();
3030        // "for" and "the" should be lowercase (middle), "code" should be capitalized (last)
3031        assert!(
3032            fixed.contains("for the Code"),
3033            "Expected 'for the Code' but got: {fixed:?}"
3034        );
3035    }
3036
3037    #[test]
3038    fn test_link_at_end_same_as_code() {
3039        let config = MD063Config {
3040            enabled: true,
3041            style: HeadingCapStyle::TitleCase,
3042            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
3043            ..Default::default()
3044        };
3045        let rule = MD063HeadingCapitalization::from_config_struct(config);
3046
3047        // Link at the end - same behavior as code (lowercase words before should remain lowercase)
3048        let content = "## Guide for the [link](./page.md)\n";
3049        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3050        let fixed = rule.fix(&ctx).unwrap();
3051        // "for" and "the" should remain lowercase (not last word because link follows)
3052        assert!(
3053            fixed.contains("for the [Link]"),
3054            "Expected 'for the [Link]' but got: {fixed:?}"
3055        );
3056        assert!(
3057            !fixed.contains("for The [Link]"),
3058            "Should not capitalize 'the' before link. Got: {fixed:?}"
3059        );
3060    }
3061
3062    #[test]
3063    fn test_multiple_code_segments() {
3064        let config = MD063Config {
3065            enabled: true,
3066            style: HeadingCapStyle::TitleCase,
3067            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
3068            ..Default::default()
3069        };
3070        let rule = MD063HeadingCapitalization::from_config_struct(config);
3071
3072        // Multiple code segments - last segment is code, so lowercase words before should remain lowercase
3073        let content = "## Using `const` with a `variable`\n";
3074        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3075        let fixed = rule.fix(&ctx).unwrap();
3076        // "a" should remain lowercase (not last word because code follows)
3077        assert!(
3078            fixed.contains("with a `variable`"),
3079            "Expected 'with a `variable`' but got: {fixed:?}"
3080        );
3081        assert!(
3082            !fixed.contains("with A `variable`"),
3083            "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
3084        );
3085    }
3086
3087    #[test]
3088    fn test_code_and_link_combination() {
3089        let config = MD063Config {
3090            enabled: true,
3091            style: HeadingCapStyle::TitleCase,
3092            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
3093            ..Default::default()
3094        };
3095        let rule = MD063HeadingCapitalization::from_config_struct(config);
3096
3097        // Code then link - last segment is link, so lowercase words before code should remain lowercase
3098        let content = "## Guide for the `code` [link](./page.md)\n";
3099        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3100        let fixed = rule.fix(&ctx).unwrap();
3101        // "for" and "the" should remain lowercase (not last word because link follows)
3102        assert!(
3103            fixed.contains("for the `code`"),
3104            "Expected 'for the `code`' but got: {fixed:?}"
3105        );
3106    }
3107
3108    #[test]
3109    fn test_text_after_code_capitalizes_last() {
3110        let config = MD063Config {
3111            enabled: true,
3112            style: HeadingCapStyle::TitleCase,
3113            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
3114            ..Default::default()
3115        };
3116        let rule = MD063HeadingCapitalization::from_config_struct(config);
3117
3118        // Code in middle, text after - last word should be capitalized
3119        let content = "## Using `const` for the code\n";
3120        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3121        let fixed = rule.fix(&ctx).unwrap();
3122        // "for" and "the" should be lowercase, "code" is last word, should be capitalized
3123        assert!(
3124            fixed.contains("for the Code"),
3125            "Expected 'for the Code' but got: {fixed:?}"
3126        );
3127    }
3128
3129    #[test]
3130    fn test_preserve_cased_words_with_trailing_code() {
3131        let config = MD063Config {
3132            enabled: true,
3133            style: HeadingCapStyle::TitleCase,
3134            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
3135            preserve_cased_words: true,
3136            ..Default::default()
3137        };
3138        let rule = MD063HeadingCapitalization::from_config_struct(config);
3139
3140        // Preserve-cased words should still work with trailing code
3141        let content = "## Guide for iOS `app`\n";
3142        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3143        let fixed = rule.fix(&ctx).unwrap();
3144        // "iOS" should be preserved, "for" should be lowercase
3145        assert!(
3146            fixed.contains("for iOS `app`"),
3147            "Expected 'for iOS `app`' but got: {fixed:?}"
3148        );
3149        assert!(
3150            !fixed.contains("For iOS `app`"),
3151            "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
3152        );
3153    }
3154
3155    #[test]
3156    fn test_ignore_words_with_trailing_code() {
3157        let config = MD063Config {
3158            enabled: true,
3159            style: HeadingCapStyle::TitleCase,
3160            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
3161            ignore_words: vec!["npm".to_string()],
3162            ..Default::default()
3163        };
3164        let rule = MD063HeadingCapitalization::from_config_struct(config);
3165
3166        // Ignore-words should still work with trailing code
3167        let content = "## Using npm with a `script`\n";
3168        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3169        let fixed = rule.fix(&ctx).unwrap();
3170        // "npm" should be preserved, "with" and "a" should be lowercase
3171        assert!(
3172            fixed.contains("npm with a `script`"),
3173            "Expected 'npm with a `script`' but got: {fixed:?}"
3174        );
3175        assert!(
3176            !fixed.contains("with A `script`"),
3177            "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
3178        );
3179    }
3180
3181    #[test]
3182    fn test_empty_text_segment_edge_case() {
3183        let config = MD063Config {
3184            enabled: true,
3185            style: HeadingCapStyle::TitleCase,
3186            lowercase_words: vec!["a".to_string(), "with".to_string()],
3187            ..Default::default()
3188        };
3189        let rule = MD063HeadingCapitalization::from_config_struct(config);
3190
3191        // Edge case: code at start, then text with lowercase word, then code at end
3192        let content = "## `start` with a `end`\n";
3193        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3194        let fixed = rule.fix(&ctx).unwrap();
3195        // "with" is first word in text segment, so capitalized (correct)
3196        // "a" should remain lowercase (not last word because code follows) - this is the key test
3197        assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
3198        assert!(
3199            !fixed.contains("A `end`"),
3200            "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
3201        );
3202    }
3203
3204    #[test]
3205    fn test_sentence_case_with_trailing_code() {
3206        let config = MD063Config {
3207            enabled: true,
3208            style: HeadingCapStyle::SentenceCase,
3209            lowercase_words: vec!["a".to_string(), "the".to_string()],
3210            ..Default::default()
3211        };
3212        let rule = MD063HeadingCapitalization::from_config_struct(config);
3213
3214        // Sentence case should also respect lowercase words before code
3215        let content = "## guide for the `user`\n";
3216        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3217        let fixed = rule.fix(&ctx).unwrap();
3218        // First word capitalized, rest lowercase including "the" before code
3219        assert!(
3220            fixed.contains("Guide for the `user`"),
3221            "Expected 'Guide for the `user`' but got: {fixed:?}"
3222        );
3223    }
3224
3225    #[test]
3226    fn test_hyphenated_word_before_code() {
3227        let config = MD063Config {
3228            enabled: true,
3229            style: HeadingCapStyle::TitleCase,
3230            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
3231            ..Default::default()
3232        };
3233        let rule = MD063HeadingCapitalization::from_config_struct(config);
3234
3235        // Hyphenated word before code - last part should respect lowercase-words
3236        let content = "## Self-contained with a `feature`\n";
3237        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3238        let fixed = rule.fix(&ctx).unwrap();
3239        // "with" and "a" should remain lowercase (not last word because code follows)
3240        assert!(
3241            fixed.contains("with a `feature`"),
3242            "Expected 'with a `feature`' but got: {fixed:?}"
3243        );
3244    }
3245
3246    // Issue #228: Sentence case with inline code at heading start
3247    // When a heading starts with inline code, the first word after the code
3248    // should NOT be capitalized because the heading already has a "first element"
3249
3250    #[test]
3251    fn test_sentence_case_code_at_start_basic() {
3252        // The exact case from issue #228
3253        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3254        let content = "# `rumdl` is a linter\n";
3255        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3256        let result = rule.check(&ctx).unwrap();
3257        // Should be correct as-is: code is first, "is" stays lowercase
3258        assert!(
3259            result.is_empty(),
3260            "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
3261            result.iter().map(|w| &w.message).collect::<Vec<_>>()
3262        );
3263    }
3264
3265    #[test]
3266    fn test_sentence_case_code_at_start_incorrect_capitalization() {
3267        // Verify we detect incorrect capitalization after code at start
3268        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3269        let content = "# `rumdl` Is a Linter\n";
3270        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3271        let result = rule.check(&ctx).unwrap();
3272        // Should flag: "Is" and "Linter" should be lowercase
3273        assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
3274        assert!(
3275            result[0].message.contains("`rumdl` is a linter"),
3276            "Should suggest lowercase after code. Got: {:?}",
3277            result[0].message
3278        );
3279    }
3280
3281    #[test]
3282    fn test_sentence_case_code_at_start_fix() {
3283        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3284        let content = "# `rumdl` Is A Linter\n";
3285        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3286        let fixed = rule.fix(&ctx).unwrap();
3287        assert!(
3288            fixed.contains("# `rumdl` is a linter"),
3289            "Should fix to lowercase after code. Got: {fixed:?}"
3290        );
3291    }
3292
3293    #[test]
3294    fn test_sentence_case_text_at_start_still_capitalizes() {
3295        // Ensure normal headings still capitalize first word
3296        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3297        let content = "# the quick brown fox\n";
3298        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3299        let result = rule.check(&ctx).unwrap();
3300        assert_eq!(result.len(), 1);
3301        assert!(
3302            result[0].message.contains("The quick brown fox"),
3303            "Text-first heading should capitalize first word. Got: {:?}",
3304            result[0].message
3305        );
3306    }
3307
3308    #[test]
3309    fn test_sentence_case_link_at_start() {
3310        // Link labels are visible prose, so an opening label starts the sentence.
3311        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3312        let content = "# [api](api.md) reference guide\n";
3313        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3314        let result = rule.check(&ctx).unwrap();
3315        assert_eq!(result.len(), 1);
3316        assert!(result[0].message.contains("[Api](api.md) reference guide"));
3317    }
3318
3319    #[test]
3320    fn test_sentence_case_link_preserves_acronyms() {
3321        // Acronyms in link text should be preserved (API, HTTP, etc.)
3322        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3323        let content = "# [API](api.md) Reference Guide\n";
3324        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3325        let result = rule.check(&ctx).unwrap();
3326        assert_eq!(result.len(), 1);
3327        // "API" should be preserved (acronym), "Reference Guide" should be lowercased
3328        assert!(
3329            result[0].message.contains("[API](api.md) reference guide"),
3330            "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
3331            result[0].message
3332        );
3333    }
3334
3335    #[test]
3336    fn test_sentence_case_link_preserves_brand_names() {
3337        // Brand names with internal capitals should be preserved
3338        let config = MD063Config {
3339            enabled: true,
3340            style: HeadingCapStyle::SentenceCase,
3341            preserve_cased_words: true,
3342            ..Default::default()
3343        };
3344        let rule = MD063HeadingCapitalization::from_config_struct(config);
3345        let content = "# [iPhone](iphone.md) Features Guide\n";
3346        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3347        let result = rule.check(&ctx).unwrap();
3348        assert_eq!(result.len(), 1);
3349        // "iPhone" should be preserved, "Features Guide" should be lowercased
3350        assert!(
3351            result[0].message.contains("[iPhone](iphone.md) features guide"),
3352            "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
3353            result[0].message
3354        );
3355    }
3356
3357    #[test]
3358    fn test_sentence_case_link_lowercases_regular_words() {
3359        // The first link word is capitalized and later words are lowercased.
3360        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3361        let content = "# [Documentation](docs.md) Reference\n";
3362        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3363        let result = rule.check(&ctx).unwrap();
3364        assert_eq!(result.len(), 1);
3365        assert!(
3366            result[0].message.contains("[Documentation](docs.md) reference"),
3367            "Should preserve the sentence-initial capital. Got: {:?}",
3368            result[0].message
3369        );
3370    }
3371
3372    #[test]
3373    fn test_sentence_case_opening_link_label_is_sentence_initial() {
3374        // Regression test for issue #844.
3375        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3376        let content = "# [Foo bar](https://example.com)\n";
3377        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3378
3379        assert!(rule.check(&ctx).unwrap().is_empty());
3380        assert_eq!(rule.fix(&ctx).unwrap(), content);
3381    }
3382
3383    #[test]
3384    fn test_sentence_case_link_at_start_correct_already() {
3385        // Link with correct casing should not be flagged
3386        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3387        let content = "# [API](api.md) reference guide\n";
3388        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3389        let result = rule.check(&ctx).unwrap();
3390        assert!(
3391            result.is_empty(),
3392            "Correctly cased heading with link should not be flagged. Got: {:?}",
3393            result.iter().map(|w| &w.message).collect::<Vec<_>>()
3394        );
3395    }
3396
3397    #[test]
3398    fn test_sentence_case_link_github_preserved() {
3399        // GitHub should be preserved (internal capitals)
3400        let config = MD063Config {
3401            enabled: true,
3402            style: HeadingCapStyle::SentenceCase,
3403            preserve_cased_words: true,
3404            ..Default::default()
3405        };
3406        let rule = MD063HeadingCapitalization::from_config_struct(config);
3407        let content = "# [GitHub](gh.md) Repository Setup\n";
3408        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3409        let result = rule.check(&ctx).unwrap();
3410        assert_eq!(result.len(), 1);
3411        assert!(
3412            result[0].message.contains("[GitHub](gh.md) repository setup"),
3413            "Should preserve 'GitHub'. Got: {:?}",
3414            result[0].message
3415        );
3416    }
3417
3418    #[test]
3419    fn test_sentence_case_multiple_code_spans() {
3420        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3421        let content = "# `foo` and `bar` are methods\n";
3422        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3423        let result = rule.check(&ctx).unwrap();
3424        // All text after first code should be lowercase
3425        assert!(
3426            result.is_empty(),
3427            "Should not capitalize words between/after code spans. Got: {:?}",
3428            result.iter().map(|w| &w.message).collect::<Vec<_>>()
3429        );
3430    }
3431
3432    #[test]
3433    fn test_sentence_case_code_only_heading() {
3434        // Heading with only code, no text
3435        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3436        let content = "# `rumdl`\n";
3437        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3438        let result = rule.check(&ctx).unwrap();
3439        assert!(
3440            result.is_empty(),
3441            "Code-only heading should be fine. Got: {:?}",
3442            result.iter().map(|w| &w.message).collect::<Vec<_>>()
3443        );
3444    }
3445
3446    #[test]
3447    fn test_sentence_case_code_at_end() {
3448        // Heading ending with code, text before should still capitalize first word
3449        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3450        let content = "# install the `rumdl` tool\n";
3451        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3452        let result = rule.check(&ctx).unwrap();
3453        // "install" should be capitalized (first word), rest lowercase
3454        assert_eq!(result.len(), 1);
3455        assert!(
3456            result[0].message.contains("Install the `rumdl` tool"),
3457            "First word should still be capitalized when text comes first. Got: {:?}",
3458            result[0].message
3459        );
3460    }
3461
3462    #[test]
3463    fn test_sentence_case_code_in_middle() {
3464        // Code in middle, text at start should capitalize first word
3465        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3466        let content = "# using the `rumdl` linter for markdown\n";
3467        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3468        let result = rule.check(&ctx).unwrap();
3469        // "using" should be capitalized, rest lowercase
3470        assert_eq!(result.len(), 1);
3471        assert!(
3472            result[0].message.contains("Using the `rumdl` linter for markdown"),
3473            "First word should be capitalized. Got: {:?}",
3474            result[0].message
3475        );
3476    }
3477
3478    #[test]
3479    fn test_sentence_case_preserved_word_after_code() {
3480        // Preserved words (like iPhone) should stay preserved even after code
3481        let config = MD063Config {
3482            enabled: true,
3483            style: HeadingCapStyle::SentenceCase,
3484            preserve_cased_words: true,
3485            ..Default::default()
3486        };
3487        let rule = MD063HeadingCapitalization::from_config_struct(config);
3488        let content = "# `swift` iPhone development\n";
3489        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3490        let result = rule.check(&ctx).unwrap();
3491        // "iPhone" should be preserved, "development" lowercase
3492        assert!(
3493            result.is_empty(),
3494            "Preserved words after code should stay. Got: {:?}",
3495            result.iter().map(|w| &w.message).collect::<Vec<_>>()
3496        );
3497    }
3498
3499    #[test]
3500    fn test_title_case_code_at_start_still_capitalizes() {
3501        // Title case should still capitalize words even after code at start
3502        let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3503        let content = "# `api` quick start guide\n";
3504        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3505        let result = rule.check(&ctx).unwrap();
3506        // Title case: all major words capitalized
3507        assert_eq!(result.len(), 1);
3508        assert!(
3509            result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
3510            "Title case should capitalize major words after code. Got: {:?}",
3511            result[0].message
3512        );
3513    }
3514
3515    // ======== HTML TAG TESTS ========
3516
3517    #[test]
3518    fn test_sentence_case_html_tag_at_start() {
3519        // HTML tag at start: text after should NOT capitalize first word
3520        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3521        let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
3522        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3523        let result = rule.check(&ctx).unwrap();
3524        // "is", "a", "Modifier", "Key" should all be lowercase (except preserved words)
3525        assert_eq!(result.len(), 1);
3526        let fixed = rule.fix(&ctx).unwrap();
3527        assert_eq!(
3528            fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
3529            "Text after HTML at start should be lowercase"
3530        );
3531    }
3532
3533    #[test]
3534    fn test_sentence_case_html_tag_preserves_content() {
3535        // Content inside HTML tags should be preserved as-is
3536        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3537        let content = "# The <abbr>API</abbr> documentation guide\n";
3538        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3539        let result = rule.check(&ctx).unwrap();
3540        // "The" is first, "API" inside tag preserved, rest lowercase
3541        assert!(
3542            result.is_empty(),
3543            "HTML tag content should be preserved. Got: {:?}",
3544            result.iter().map(|w| &w.message).collect::<Vec<_>>()
3545        );
3546    }
3547
3548    #[test]
3549    fn test_sentence_case_html_tag_at_start_with_acronym() {
3550        // HTML tag at start with acronym content
3551        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3552        let content = "# <abbr>API</abbr> Documentation Guide\n";
3553        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3554        let result = rule.check(&ctx).unwrap();
3555        assert_eq!(result.len(), 1);
3556        let fixed = rule.fix(&ctx).unwrap();
3557        assert_eq!(
3558            fixed, "# <abbr>API</abbr> documentation guide\n",
3559            "Text after HTML at start should be lowercase, HTML content preserved"
3560        );
3561    }
3562
3563    #[test]
3564    fn test_sentence_case_html_tag_in_middle() {
3565        // HTML tag in middle: first word still capitalized
3566        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3567        let content = "# using the <code>config</code> File\n";
3568        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3569        let result = rule.check(&ctx).unwrap();
3570        assert_eq!(result.len(), 1);
3571        let fixed = rule.fix(&ctx).unwrap();
3572        assert_eq!(
3573            fixed, "# Using the <code>config</code> file\n",
3574            "First word capitalized, HTML preserved, rest lowercase"
3575        );
3576    }
3577
3578    #[test]
3579    fn test_html_tag_strong_emphasis() {
3580        // <strong> tag handling
3581        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3582        let content = "# The <strong>Bold</strong> Way\n";
3583        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3584        let result = rule.check(&ctx).unwrap();
3585        assert_eq!(result.len(), 1);
3586        let fixed = rule.fix(&ctx).unwrap();
3587        assert_eq!(
3588            fixed, "# The <strong>Bold</strong> way\n",
3589            "<strong> tag content should be preserved"
3590        );
3591    }
3592
3593    #[test]
3594    fn test_html_tag_with_attributes() {
3595        // HTML tags with attributes should still be detected
3596        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3597        let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
3598        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3599        let result = rule.check(&ctx).unwrap();
3600        assert_eq!(result.len(), 1);
3601        let fixed = rule.fix(&ctx).unwrap();
3602        assert_eq!(
3603            fixed, "# <span class=\"highlight\">Important</span> notice here\n",
3604            "HTML tag with attributes should be preserved"
3605        );
3606    }
3607
3608    #[test]
3609    fn test_multiple_html_tags() {
3610        // Multiple HTML tags in heading
3611        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3612        let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
3613        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3614        let result = rule.check(&ctx).unwrap();
3615        assert_eq!(result.len(), 1);
3616        let fixed = rule.fix(&ctx).unwrap();
3617        assert_eq!(
3618            fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
3619            "Multiple HTML tags should all be preserved"
3620        );
3621    }
3622
3623    #[test]
3624    fn test_html_and_code_mixed() {
3625        // Mix of HTML tags and inline code
3626        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3627        let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
3628        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3629        let result = rule.check(&ctx).unwrap();
3630        assert_eq!(result.len(), 1);
3631        let fixed = rule.fix(&ctx).unwrap();
3632        assert_eq!(
3633            fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
3634            "HTML and code should both be preserved"
3635        );
3636    }
3637
3638    #[test]
3639    fn test_self_closing_html_tag() {
3640        // Self-closing tags like <br/>
3641        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3642        let content = "# Line one<br/>Line Two Here\n";
3643        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3644        let result = rule.check(&ctx).unwrap();
3645        assert_eq!(result.len(), 1);
3646        let fixed = rule.fix(&ctx).unwrap();
3647        assert_eq!(
3648            fixed, "# Line one<br/>line two here\n",
3649            "Self-closing HTML tags should be preserved"
3650        );
3651    }
3652
3653    #[test]
3654    fn test_title_case_with_html_tags() {
3655        // Title case with HTML tags
3656        let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3657        let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
3658        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3659        let result = rule.check(&ctx).unwrap();
3660        assert_eq!(result.len(), 1);
3661        let fixed = rule.fix(&ctx).unwrap();
3662        // "the" as first word should be "The", content inside <kbd> preserved
3663        assert!(
3664            fixed.contains("<kbd>ctrl</kbd>"),
3665            "HTML tag content should be preserved in title case. Got: {fixed}"
3666        );
3667        assert!(
3668            fixed.starts_with("# The ") || fixed.starts_with("# the "),
3669            "Title case should work with HTML. Got: {fixed}"
3670        );
3671    }
3672
3673    // ======== CARET NOTATION TESTS ========
3674
3675    #[test]
3676    fn test_sentence_case_preserves_caret_notation() {
3677        // Caret notation for control characters should be preserved
3678        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3679        let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
3680        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3681        let result = rule.check(&ctx).unwrap();
3682        // Should not flag - ^A and ^R are preserved
3683        assert!(
3684            result.is_empty(),
3685            "Caret notation should be preserved. Got: {:?}",
3686            result.iter().map(|w| &w.message).collect::<Vec<_>>()
3687        );
3688    }
3689
3690    #[test]
3691    fn test_sentence_case_caret_notation_various() {
3692        // Various caret notation patterns
3693        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3694
3695        // ^C for interrupt
3696        let content = "## Press ^C to cancel\n";
3697        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3698        let result = rule.check(&ctx).unwrap();
3699        assert!(
3700            result.is_empty(),
3701            "^C should be preserved. Got: {:?}",
3702            result.iter().map(|w| &w.message).collect::<Vec<_>>()
3703        );
3704
3705        // ^Z for suspend
3706        let content = "## Use ^Z for background\n";
3707        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3708        let result = rule.check(&ctx).unwrap();
3709        assert!(
3710            result.is_empty(),
3711            "^Z should be preserved. Got: {:?}",
3712            result.iter().map(|w| &w.message).collect::<Vec<_>>()
3713        );
3714
3715        // ^[ for escape
3716        let content = "## Press ^[ for escape\n";
3717        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3718        let result = rule.check(&ctx).unwrap();
3719        assert!(
3720            result.is_empty(),
3721            "^[ should be preserved. Got: {:?}",
3722            result.iter().map(|w| &w.message).collect::<Vec<_>>()
3723        );
3724    }
3725
3726    #[test]
3727    fn test_caret_notation_detection() {
3728        let rule = create_rule();
3729
3730        // Valid caret notation
3731        assert!(rule.is_caret_notation("^A"));
3732        assert!(rule.is_caret_notation("^Z"));
3733        assert!(rule.is_caret_notation("^C"));
3734        assert!(rule.is_caret_notation("^@")); // NUL
3735        assert!(rule.is_caret_notation("^[")); // ESC
3736        assert!(rule.is_caret_notation("^]")); // GS
3737        assert!(rule.is_caret_notation("^^")); // RS
3738        assert!(rule.is_caret_notation("^_")); // US
3739
3740        // Not caret notation
3741        assert!(!rule.is_caret_notation("^a")); // lowercase
3742        assert!(!rule.is_caret_notation("A")); // no caret
3743        assert!(!rule.is_caret_notation("^")); // caret alone
3744        assert!(!rule.is_caret_notation("^1")); // digit
3745    }
3746
3747    // MD044 proper names integration tests
3748    //
3749    // When MD063 (sentence case) and MD044 (proper names) are both active, MD063 must
3750    // preserve the exact capitalization of MD044 proper names rather than lowercasing them.
3751    // Without this, the two rules oscillate: MD044 re-capitalizes what MD063 lowercases.
3752
3753    fn create_sentence_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
3754        let config = MD063Config {
3755            enabled: true,
3756            style: HeadingCapStyle::SentenceCase,
3757            ..Default::default()
3758        };
3759        let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3760        rule.proper_names = names;
3761        rule
3762    }
3763
3764    #[test]
3765    fn test_sentence_case_preserves_single_word_proper_name() {
3766        let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
3767        // "javascript" in non-first position should become "JavaScript", not "javascript"
3768        let content = "# installing javascript\n";
3769        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3770        let result = rule.check(&ctx).unwrap();
3771        assert_eq!(result.len(), 1, "Should flag the heading");
3772        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3773        assert!(
3774            fix_text.contains("JavaScript"),
3775            "Fix should preserve proper name 'JavaScript', got: {fix_text:?}"
3776        );
3777        assert!(
3778            !fix_text.contains("javascript"),
3779            "Fix should not have lowercase 'javascript', got: {fix_text:?}"
3780        );
3781    }
3782
3783    #[test]
3784    fn test_sentence_case_preserves_multi_word_proper_name() {
3785        let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3786        // "Good Application" is a proper name; sentence case must not lowercase "Application"
3787        let content = "# using good application features\n";
3788        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3789        let result = rule.check(&ctx).unwrap();
3790        assert_eq!(result.len(), 1, "Should flag the heading");
3791        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3792        assert!(
3793            fix_text.contains("Good Application"),
3794            "Fix should preserve 'Good Application' as a phrase, got: {fix_text:?}"
3795        );
3796    }
3797
3798    #[test]
3799    fn test_sentence_case_proper_name_at_start_of_heading() {
3800        let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3801        // The proper name "Good Application" starts the heading; both words must be canonical
3802        let content = "# good application overview\n";
3803        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3804        let result = rule.check(&ctx).unwrap();
3805        assert_eq!(result.len(), 1, "Should flag the heading");
3806        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3807        assert!(
3808            fix_text.contains("Good Application"),
3809            "Fix should produce 'Good Application' at start of heading, got: {fix_text:?}"
3810        );
3811        assert!(
3812            fix_text.contains("overview"),
3813            "Non-proper-name word 'overview' should be lowercase, got: {fix_text:?}"
3814        );
3815    }
3816
3817    #[test]
3818    fn test_sentence_case_with_proper_names_no_oscillation() {
3819        // This is the core convergence test: applying the fix once must produce
3820        // output that is already correct (no further changes needed).
3821        let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3822
3823        // First application of fix
3824        let content = "# installing good application on your system\n";
3825        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3826        let result = rule.check(&ctx).unwrap();
3827        assert_eq!(result.len(), 1);
3828        let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
3829
3830        // The fixed heading should contain the proper name preserved
3831        assert!(
3832            fixed_heading.contains("Good Application"),
3833            "After fix, proper name must be preserved: {fixed_heading:?}"
3834        );
3835
3836        // Second application: must produce no further warnings (convergence)
3837        let fixed_line = format!("{fixed_heading}\n");
3838        let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
3839        let result2 = rule.check(&ctx2).unwrap();
3840        assert!(
3841            result2.is_empty(),
3842            "After one fix, heading must already satisfy both MD063 and MD044 - no oscillation. \
3843             Second pass warnings: {result2:?}"
3844        );
3845    }
3846
3847    #[test]
3848    fn test_sentence_case_proper_names_already_correct() {
3849        let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3850        // Heading already has correct sentence case with proper name preserved
3851        let content = "# Installing Good Application\n";
3852        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3853        let result = rule.check(&ctx).unwrap();
3854        assert!(
3855            result.is_empty(),
3856            "Correct sentence-case heading with proper name should not be flagged, got: {result:?}"
3857        );
3858    }
3859
3860    #[test]
3861    fn test_sentence_case_multiple_proper_names_in_heading() {
3862        let rule = create_sentence_case_rule_with_proper_names(vec!["TypeScript".to_string(), "React".to_string()]);
3863        let content = "# using typescript with react\n";
3864        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3865        let result = rule.check(&ctx).unwrap();
3866        assert_eq!(result.len(), 1);
3867        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3868        assert!(
3869            fix_text.contains("TypeScript"),
3870            "Fix should preserve 'TypeScript', got: {fix_text:?}"
3871        );
3872        assert!(
3873            fix_text.contains("React"),
3874            "Fix should preserve 'React', got: {fix_text:?}"
3875        );
3876    }
3877
3878    #[test]
3879    fn test_sentence_case_unicode_casefold_expansion_before_proper_name() {
3880        // Regression for Unicode case-fold expansion: `İ` lowercases to `i̇` (2 code points),
3881        // so matching offsets must be computed from the original text, not from a lowercased copy.
3882        let rule = create_sentence_case_rule_with_proper_names(vec!["Österreich".to_string()]);
3883        let content = "# İ österreich guide\n";
3884        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3885
3886        // Should not panic and should preserve canonical proper-name casing.
3887        let result = rule.check(&ctx).unwrap();
3888        assert_eq!(result.len(), 1, "Should flag heading for canonical proper-name casing");
3889        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3890        assert!(
3891            fix_text.contains("Österreich"),
3892            "Fix should preserve canonical 'Österreich', got: {fix_text:?}"
3893        );
3894    }
3895
3896    #[test]
3897    fn test_sentence_case_preserves_trailing_punctuation_on_proper_name() {
3898        let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
3899        let content = "# using javascript, today\n";
3900        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3901        let result = rule.check(&ctx).unwrap();
3902        assert_eq!(result.len(), 1, "Should flag heading");
3903        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3904        assert!(
3905            fix_text.contains("JavaScript,"),
3906            "Fix should preserve trailing punctuation, got: {fix_text:?}"
3907        );
3908    }
3909
3910    // Title case + MD044 conflict tests
3911    //
3912    // In title case, short words like "the", "a", "of" are kept lowercase by MD063.
3913    // If those words are part of an MD044 proper name (e.g. "The Rolling Stones"),
3914    // the same oscillation problem occurs.  The fix must extend to title case too.
3915
3916    fn create_title_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
3917        let config = MD063Config {
3918            enabled: true,
3919            style: HeadingCapStyle::TitleCase,
3920            ..Default::default()
3921        };
3922        let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3923        rule.proper_names = names;
3924        rule
3925    }
3926
3927    #[test]
3928    fn test_title_case_preserves_proper_name_with_lowercase_article() {
3929        // "The" is in the lowercase_words list for title case, so "the" in the middle
3930        // of a heading would normally stay lowercase.  But "The Rolling Stones" is a
3931        // proper name that must be capitalised exactly.
3932        let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
3933        let content = "# listening to the rolling stones today\n";
3934        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3935        let result = rule.check(&ctx).unwrap();
3936        assert_eq!(result.len(), 1, "Should flag the heading");
3937        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3938        assert!(
3939            fix_text.contains("The Rolling Stones"),
3940            "Fix should preserve proper name 'The Rolling Stones', got: {fix_text:?}"
3941        );
3942    }
3943
3944    #[test]
3945    fn test_title_case_proper_name_no_oscillation() {
3946        // One fix pass must produce output that title case already accepts.
3947        let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
3948        let content = "# listening to the rolling stones today\n";
3949        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3950        let result = rule.check(&ctx).unwrap();
3951        assert_eq!(result.len(), 1);
3952        let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
3953
3954        let fixed_line = format!("{fixed_heading}\n");
3955        let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
3956        let result2 = rule.check(&ctx2).unwrap();
3957        assert!(
3958            result2.is_empty(),
3959            "After one title-case fix, heading must already satisfy both rules. \
3960             Second pass warnings: {result2:?}"
3961        );
3962    }
3963
3964    #[test]
3965    fn test_title_case_unicode_casefold_expansion_before_proper_name() {
3966        let rule = create_title_case_rule_with_proper_names(vec!["Österreich".to_string()]);
3967        let content = "# İ österreich guide\n";
3968        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3969        let result = rule.check(&ctx).unwrap();
3970        assert_eq!(result.len(), 1, "Should flag the heading");
3971        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3972        assert!(
3973            fix_text.contains("Österreich"),
3974            "Fix should preserve canonical proper-name casing, got: {fix_text:?}"
3975        );
3976    }
3977
3978    // End-to-end integration test: from_config wires MD044 names into MD063
3979    //
3980    // This tests the actual code path used in production, where both rules are
3981    // configured in a rumdl.toml and the rule registry calls from_config.
3982
3983    #[test]
3984    fn test_from_config_loads_md044_names_into_md063() {
3985        use crate::config::{Config, RuleConfig};
3986        use crate::rule::Rule;
3987        use std::collections::BTreeMap;
3988
3989        let mut config = Config::default();
3990
3991        // Configure MD063 with sentence_case
3992        let mut md063_values = BTreeMap::new();
3993        md063_values.insert("style".to_string(), toml::Value::String("sentence_case".to_string()));
3994        md063_values.insert("enabled".to_string(), toml::Value::Boolean(true));
3995        config.rules.insert(
3996            "MD063".to_string(),
3997            RuleConfig {
3998                values: md063_values,
3999                severity: None,
4000            },
4001        );
4002
4003        // Configure MD044 with a proper name
4004        let mut md044_values = BTreeMap::new();
4005        md044_values.insert(
4006            "names".to_string(),
4007            toml::Value::Array(vec![toml::Value::String("Good Application".to_string())]),
4008        );
4009        config.rules.insert(
4010            "MD044".to_string(),
4011            RuleConfig {
4012                values: md044_values,
4013                severity: None,
4014            },
4015        );
4016
4017        // Build MD063 via the production code path
4018        let rule = MD063HeadingCapitalization::from_config(&config);
4019
4020        // Verify MD044 names were loaded: the fix must preserve "Good Application"
4021        let content = "# using good application features\n";
4022        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4023        let result = rule.check(&ctx).unwrap();
4024        assert_eq!(result.len(), 1, "Should flag the heading");
4025        let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
4026        assert!(
4027            fix_text.contains("Good Application"),
4028            "from_config should wire MD044 names into MD063; fix should preserve \
4029             'Good Application', got: {fix_text:?}"
4030        );
4031    }
4032
4033    #[test]
4034    fn test_title_case_short_word_not_confused_with_substring() {
4035        // Verify that short preposition matching ("in") does not trigger on
4036        // substrings of longer words ("insert"). Title case must capitalize
4037        // "insert" while keeping "in" lowercase.
4038        let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
4039
4040        // "in" is a short preposition (should be lowercase in title case)
4041        // "insert" contains "in" as substring but is a regular word (should be capitalized)
4042        let content = "# in the insert\n";
4043        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4044        let result = rule.check(&ctx).unwrap();
4045        assert_eq!(result.len(), 1, "Should flag the heading");
4046        let fix = result[0].fix.as_ref().expect("Fix should be present");
4047        // "In" capitalized as first word, "the" lowercase as article, "Insert" capitalized
4048        assert!(
4049            fix.replacement.contains("In the Insert"),
4050            "Expected 'In the Insert', got: {:?}",
4051            fix.replacement
4052        );
4053    }
4054
4055    #[test]
4056    fn test_title_case_or_not_confused_with_orchestra() {
4057        let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
4058
4059        // "or" is a conjunction (should be lowercase in title case)
4060        // "orchestra" contains "or" as substring but is a regular word
4061        let content = "# or the orchestra\n";
4062        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4063        let result = rule.check(&ctx).unwrap();
4064        assert_eq!(result.len(), 1, "Should flag the heading");
4065        let fix = result[0].fix.as_ref().expect("Fix should be present");
4066        // "Or" capitalized as first word, "the" lowercase, "Orchestra" capitalized
4067        assert!(
4068            fix.replacement.contains("Or the Orchestra"),
4069            "Expected 'Or the Orchestra', got: {:?}",
4070            fix.replacement
4071        );
4072    }
4073
4074    #[test]
4075    fn test_all_caps_preserves_all_words() {
4076        let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
4077
4078        let content = "# in the insert\n";
4079        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4080        let result = rule.check(&ctx).unwrap();
4081        assert_eq!(result.len(), 1, "Should flag the heading");
4082        let fix = result[0].fix.as_ref().expect("Fix should be present");
4083        assert!(
4084            fix.replacement.contains("IN THE INSERT"),
4085            "All caps should uppercase all words, got: {:?}",
4086            fix.replacement
4087        );
4088    }
4089
4090    // Numbered prefix tests — words following a period-terminated token must be capitalized
4091    #[test]
4092    fn test_title_case_numbered_prefix_lowercase_word() {
4093        // "to" follows "1." and must be treated as the start of a new phrase
4094        let rule = create_rule();
4095        let content = "## 1. To Be a Thing\n";
4096        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4097        let result = rule.check(&ctx).unwrap();
4098        assert!(
4099            result.is_empty(),
4100            "Should not flag '## 1. To Be a Thing', got: {result:?}"
4101        );
4102
4103        let content_lower = "## 1. to be a thing\n";
4104        let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
4105        let result2 = rule.check(&ctx2).unwrap();
4106        assert!(!result2.is_empty(), "Should flag '## 1. to be a thing'");
4107        let fix = result2[0].fix.as_ref().expect("Should have a fix");
4108        assert!(
4109            fix.replacement.contains("1. To Be a Thing"),
4110            "Fix should capitalize 'To', got: {:?}",
4111            fix.replacement
4112        );
4113    }
4114
4115    #[test]
4116    fn test_title_case_numbered_prefix_article() {
4117        // "a" follows "2." and must be capitalized as the first word of the phrase
4118        let rule = create_rule();
4119        let content = "## 2. A Guide to the Galaxy\n";
4120        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4121        let result = rule.check(&ctx).unwrap();
4122        assert!(
4123            result.is_empty(),
4124            "Should not flag '## 2. A Guide to the Galaxy', got: {result:?}"
4125        );
4126
4127        let content_lower = "## 2. a guide to the galaxy\n";
4128        let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
4129        let result2 = rule.check(&ctx2).unwrap();
4130        assert!(!result2.is_empty(), "Should flag '## 2. a guide to the galaxy'");
4131        let fix = result2[0].fix.as_ref().expect("Should have a fix");
4132        assert!(
4133            fix.replacement.contains("2. A Guide to the Galaxy"),
4134            "Fix should capitalize 'A', got: {:?}",
4135            fix.replacement
4136        );
4137    }
4138
4139    #[test]
4140    fn test_title_case_mid_sentence_period_word() {
4141        // "introduction" follows "1." embedded in a phrase — must be capitalized
4142        let rule = create_rule();
4143        let content = "## Step 1. Introduction to the Problem\n";
4144        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4145        let result = rule.check(&ctx).unwrap();
4146        assert!(
4147            result.is_empty(),
4148            "Should not flag '## Step 1. Introduction to the Problem', got: {result:?}"
4149        );
4150
4151        let content_lower = "## Step 1. introduction to the problem\n";
4152        let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
4153        let result2 = rule.check(&ctx2).unwrap();
4154        assert!(
4155            !result2.is_empty(),
4156            "Should flag '## Step 1. introduction to the problem'"
4157        );
4158        let fix = result2[0].fix.as_ref().expect("Should have a fix");
4159        assert!(
4160            fix.replacement.contains("Step 1. Introduction to the Problem"),
4161            "Fix should capitalize 'Introduction', got: {:?}",
4162            fix.replacement
4163        );
4164    }
4165
4166    #[test]
4167    fn test_title_case_numbered_prefix_in_link_text() {
4168        // apply_title_case (link text path) must also respect after_period.
4169        // A heading whose only content is a link: ## [1. to be a thing](url)
4170        let config = MD063Config {
4171            enabled: true,
4172            style: HeadingCapStyle::TitleCase,
4173            ..Default::default()
4174        };
4175        let rule = MD063HeadingCapitalization::from_config_struct(config);
4176
4177        // Correct heading — link text already title-cased after numbered prefix
4178        let content = "## [1. To Be a Thing](https://example.com)\n";
4179        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4180        let result = rule.check(&ctx).unwrap();
4181        assert!(
4182            result.is_empty(),
4183            "Should not flag '## [1. To Be a Thing](url)', got: {result:?}"
4184        );
4185
4186        // Incorrect heading — "to" in link text must be capitalized after "1."
4187        let content_lower = "## [1. to be a thing](https://example.com)\n";
4188        let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
4189        let result2 = rule.check(&ctx2).unwrap();
4190        assert!(!result2.is_empty(), "Should flag '## [1. to be a thing](url)'");
4191        let fix = result2[0].fix.as_ref().expect("Should have a fix");
4192        assert!(
4193            fix.replacement.contains("1. To Be a Thing"),
4194            "Fix should capitalize 'To' in link text, got: {:?}",
4195            fix.replacement
4196        );
4197    }
4198
4199    // Numeric-ordinal tests (issue #608): "1st", "2nd", "3rd", "4th", "21st"
4200    // and so on must keep their alphabetic suffix lower-cased in title case
4201    // and must be normalised back from mis-cased forms like "5Th".
4202
4203    #[test]
4204    fn test_is_numeric_ordinal_recognises_canonical_forms() {
4205        for word in &[
4206            "1st", "2nd", "3rd", "4th", "5th", "11th", "21st", "22nd", "23rd", "100th", "1ST", "5Th", "21St", "21sT",
4207        ] {
4208            assert!(
4209                MD063HeadingCapitalization::is_numeric_ordinal(word),
4210                "expected `{word}` to be detected as a numeric ordinal"
4211            );
4212        }
4213    }
4214
4215    #[test]
4216    fn test_is_numeric_ordinal_rejects_non_ordinals() {
4217        // Words without a digit prefix, an unrecognised alphabetic suffix,
4218        // or a non-ordinal alpha tail are all rejected. Compound forms with
4219        // hyphens are handled by `handle_hyphenated_word` so the helper's
4220        // behaviour on them is intentionally unconstrained.
4221        for word in &[
4222            "first", "1stop", "ist", "5", "th", "abc", "4G", "4K", "30s", "100k", "5x", "1.5", "iPhone6S",
4223        ] {
4224            assert!(
4225                !MD063HeadingCapitalization::is_numeric_ordinal(word),
4226                "expected `{word}` NOT to be detected as a numeric ordinal"
4227            );
4228        }
4229    }
4230
4231    #[test]
4232    fn test_is_numeric_ordinal_strips_trailing_punctuation() {
4233        for word in &["5th.", "1st,", "21st!", "3rd:", "4th)", "5th's"] {
4234            assert!(
4235                MD063HeadingCapitalization::is_numeric_ordinal(word),
4236                "expected `{word}` to be detected as a numeric ordinal (with punctuation)"
4237            );
4238        }
4239    }
4240
4241    #[test]
4242    fn test_is_numeric_ordinal_ignores_wrapping_punctuation() {
4243        // A word reaches this check as a whitespace-split token, so it still
4244        // carries whatever punctuation wraps it. A leading wrapper hid the
4245        // digits, and the capitaliser then uppercased the first letter it could
4246        // find, turning `(2nd` into `(2Nd`.
4247        for word in &[
4248            "(2nd", "[2nd", "\"2nd", "'2nd", "*2nd", "(21st)", "\"3rd\"", "**5th**", "_1st_",
4249        ] {
4250            assert!(
4251                MD063HeadingCapitalization::is_numeric_ordinal(word),
4252                "expected `{word}` to be detected as a numeric ordinal"
4253            );
4254        }
4255
4256        // Only the wrapping comes off. An interior separator still makes the
4257        // token something other than an ordinal, and a token with no core at
4258        // all must be rejected rather than read as an empty ordinal.
4259        for word in &["2-nd", "2 nd", "(", "\"\"", "()", "(nd", "(2"] {
4260            assert!(
4261                !MD063HeadingCapitalization::is_numeric_ordinal(word),
4262                "expected `{word}` NOT to be detected as a numeric ordinal"
4263            );
4264        }
4265    }
4266
4267    #[test]
4268    fn test_ordinal_wrapped_in_punctuation_survives_a_fix() {
4269        // Already correct in each style: the wrapped ordinal is preserved, so
4270        // nothing is reported and the fix leaves the heading byte-identical.
4271        for (style, content) in [
4272            (HeadingCapStyle::SentenceCase, "# The second (2nd) attempt\n"),
4273            (HeadingCapStyle::SentenceCase, "# Ranked \"3rd\" overall\n"),
4274            (HeadingCapStyle::SentenceCase, "# Plain 2nd place\n"),
4275            (HeadingCapStyle::TitleCase, "# The Second (2nd) Attempt\n"),
4276            (HeadingCapStyle::TitleCase, "# Ranked \"3rd\" Overall\n"),
4277        ] {
4278            let rule = create_rule_with_style(style);
4279            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4280            let result = rule.check(&ctx).unwrap();
4281            assert!(
4282                result.is_empty(),
4283                "{style:?} should not flag {content:?}, got: {result:?}"
4284            );
4285            assert_eq!(
4286                rule.fix(&ctx).unwrap(),
4287                content,
4288                "{style:?} must leave {content:?} alone"
4289            );
4290        }
4291
4292        // Positive control: on a heading that does need recasing the fix runs,
4293        // recases the prose around the ordinal and still leaves the ordinal
4294        // itself untouched. A second pass changes nothing more.
4295        for (style, content, expected) in [
4296            (
4297                HeadingCapStyle::SentenceCase,
4298                "# The Second (2nd) Attempt\n",
4299                "# The second (2nd) attempt\n",
4300            ),
4301            (
4302                HeadingCapStyle::TitleCase,
4303                "# the second (2nd) attempt\n",
4304                "# The Second (2nd) Attempt\n",
4305            ),
4306        ] {
4307            let rule = create_rule_with_style(style);
4308            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4309            assert!(
4310                !rule.check(&ctx).unwrap().is_empty(),
4311                "{style:?} should flag {content:?}"
4312            );
4313            let fixed = rule.fix(&ctx).unwrap();
4314            assert_eq!(fixed, expected, "{style:?} fix of {content:?}");
4315
4316            let refixed = rule
4317                .fix(&LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None))
4318                .unwrap();
4319            assert_eq!(refixed, expected, "{style:?} second pass over {fixed:?}");
4320        }
4321    }
4322
4323    #[test]
4324    fn test_wrapped_ordinal_corrupted_by_the_old_fix_is_repaired() {
4325        // A document already rewritten by the buggy capitaliser has to come back,
4326        // not stay wrong: `(2Nd)` was not recognised as an ordinal either, so the
4327        // old behaviour was stable and `check` reported nothing about it.
4328        for (style, content, expected) in [
4329            (
4330                HeadingCapStyle::SentenceCase,
4331                "# The second (2Nd) attempt\n",
4332                "# The second (2nd) attempt\n",
4333            ),
4334            (
4335                HeadingCapStyle::TitleCase,
4336                "# The Second (2Nd) Attempt\n",
4337                "# The Second (2nd) Attempt\n",
4338            ),
4339            (
4340                HeadingCapStyle::SentenceCase,
4341                "# Ranked \"3Rd\" overall\n",
4342                "# Ranked \"3rd\" overall\n",
4343            ),
4344        ] {
4345            let rule = create_rule_with_style(style);
4346            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4347            assert!(
4348                !rule.check(&ctx).unwrap().is_empty(),
4349                "{style:?} should flag {content:?}"
4350            );
4351            assert_eq!(rule.fix(&ctx).unwrap(), expected, "{style:?} fix of {content:?}");
4352        }
4353    }
4354
4355    #[test]
4356    fn test_title_case_ordinal_first_word_not_flagged() {
4357        let rule = create_rule();
4358        for content in &[
4359            "# 1st Place\n",
4360            "# 2nd Edition\n",
4361            "# 3rd Time\n",
4362            "# 5th Avenue\n",
4363            "# 21st Century Skills\n",
4364            "# 100th Customer\n",
4365        ] {
4366            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4367            let result = rule.check(&ctx).unwrap();
4368            assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4369        }
4370    }
4371
4372    #[test]
4373    fn test_title_case_ordinal_mid_heading_not_flagged() {
4374        let rule = create_rule();
4375        for content in &[
4376            "# May 3rd Notes\n",
4377            "# Top 100th Customer\n",
4378            "# Notes for the 5th of May\n",
4379        ] {
4380            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4381            let result = rule.check(&ctx).unwrap();
4382            assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4383        }
4384    }
4385
4386    #[test]
4387    fn test_title_case_ordinal_corrupted_form_is_fixed() {
4388        // The "sticky" case: a heading already mangled by the buggy
4389        // capitaliser must be flagged and corrected back, not left alone.
4390        let rule = create_rule();
4391        for (input, expected) in &[
4392            ("# 1St Place\n", "1st Place"),
4393            ("# 5Th Avenue\n", "5th Avenue"),
4394            ("# 21St Century Skills\n", "21st Century Skills"),
4395            ("# May 3Rd Notes\n", "May 3rd Notes"),
4396            ("# 22Nd Edition\n", "22nd Edition"),
4397        ] {
4398            let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
4399            let result = rule.check(&ctx).unwrap();
4400            assert!(!result.is_empty(), "Should flag {input:?}");
4401            let fix = result[0].fix.as_ref().expect("should have a fix");
4402            assert!(
4403                fix.replacement.contains(expected),
4404                "Fix for {input:?} should contain {expected:?}, got: {:?}",
4405                fix.replacement
4406            );
4407        }
4408    }
4409
4410    #[test]
4411    fn test_title_case_ordinal_lowercase_other_words_capitalised() {
4412        // Non-ordinal words around an ordinal still need title-casing.
4413        let rule = create_rule();
4414        let content = "# 5th avenue\n";
4415        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4416        let result = rule.check(&ctx).unwrap();
4417        assert_eq!(result.len(), 1);
4418        let fix = result[0].fix.as_ref().expect("should have a fix");
4419        assert!(
4420            fix.replacement.contains("5th Avenue"),
4421            "Fix should produce '5th Avenue', got: {:?}",
4422            fix.replacement
4423        );
4424    }
4425
4426    #[test]
4427    fn test_title_case_ordinal_with_trailing_punctuation() {
4428        let rule = create_rule();
4429        let content = "# Released on the 5th.\n";
4430        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4431        let result = rule.check(&ctx).unwrap();
4432        assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4433    }
4434
4435    #[test]
4436    fn test_title_case_ordinal_hyphenated() {
4437        let rule = create_rule();
4438        for content in &["# 21st-Century Skills\n", "# A 19th-Century Novel\n"] {
4439            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4440            let result = rule.check(&ctx).unwrap();
4441            assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4442        }
4443    }
4444
4445    #[test]
4446    fn test_sentence_case_ordinal_corrupted_form_is_fixed() {
4447        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
4448        let content = "# 5Th avenue\n";
4449        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4450        let result = rule.check(&ctx).unwrap();
4451        assert_eq!(result.len(), 1);
4452        let fix = result[0].fix.as_ref().expect("should have a fix");
4453        assert!(
4454            fix.replacement.contains("5th avenue"),
4455            "Fix should produce '5th avenue', got: {:?}",
4456            fix.replacement
4457        );
4458    }
4459
4460    #[test]
4461    fn test_title_case_digit_acronym_unchanged() {
4462        // Non-ordinal digit-prefixed tokens (4G, 4K) must still be preserved
4463        // as all-caps acronyms — the ordinal carve-out must not catch them.
4464        let rule = create_rule();
4465        for content in &["# 4G Networks\n", "# 4K Streaming\n"] {
4466            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4467            let result = rule.check(&ctx).unwrap();
4468            assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4469        }
4470    }
4471
4472    // --- sentence-case-restart-after ---
4473
4474    fn restart_rule(boundaries: &[&str]) -> MD063HeadingCapitalization {
4475        let config = MD063Config {
4476            enabled: true,
4477            style: HeadingCapStyle::SentenceCase,
4478            sentence_case_restart_after: boundaries.iter().copied().map(String::from).collect(),
4479            ..Default::default()
4480        };
4481        MD063HeadingCapitalization::from_config_struct(config)
4482    }
4483
4484    /// The heading text MD063 would rewrite this content to, or `None` when it is
4485    /// already compliant.
4486    fn suggested(rule: &MD063HeadingCapitalization, content: &str) -> Option<String> {
4487        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4488        let warnings = rule.check(&ctx).unwrap();
4489        let fixed = rule.fix(&ctx).unwrap();
4490        assert_eq!(
4491            warnings.is_empty(),
4492            fixed == content,
4493            "a warning and a rewrite must agree for {content:?}"
4494        );
4495        (!warnings.is_empty()).then(|| fixed.trim_start_matches('#').trim().to_string())
4496    }
4497
4498    #[test]
4499    fn test_restart_after_capitalizes_the_word_following_a_boundary() {
4500        let rule = restart_rule(&[":"]);
4501        assert_eq!(
4502            suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
4503            Some("Requirement 1: Struct to logger slice conversion")
4504        );
4505    }
4506
4507    #[test]
4508    fn test_restart_after_defaults_to_no_boundaries() {
4509        // The empty default must leave sentence case as it was: first word only.
4510        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
4511        assert_eq!(
4512            suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
4513            Some("Requirement 1: struct to logger slice conversion")
4514        );
4515    }
4516
4517    #[test]
4518    fn test_restart_after_only_honors_configured_punctuation() {
4519        // A colon-only configuration must not drag in the dash and semicolon cases.
4520        let rule = restart_rule(&[":"]);
4521        assert_eq!(
4522            suggested(&rule, "# Design - Data Model Overview\n").as_deref(),
4523            Some("Design - data model overview")
4524        );
4525        assert_eq!(
4526            suggested(&rule, "# Setup; Then Run\n").as_deref(),
4527            Some("Setup; then run")
4528        );
4529
4530        let rule = restart_rule(&[";", "\u{2014}"]);
4531        assert_eq!(
4532            suggested(&rule, "# Setup; Then Run\n").as_deref(),
4533            Some("Setup; Then run")
4534        );
4535        assert_eq!(
4536            suggested(&rule, "# Part One \u{2014} The Big Idea\n").as_deref(),
4537            Some("Part one \u{2014} The big idea")
4538        );
4539    }
4540
4541    #[test]
4542    fn test_restart_after_matches_only_at_the_end_of_a_word() {
4543        // An intra-word hyphen is not a sentence boundary, so a configured dash must
4544        // not restart inside `Well-Known`, and a URL's punctuation must not either.
4545        let rule = restart_rule(&["-", ":"]);
4546        assert_eq!(
4547            suggested(&rule, "# Ports: Well-Known Ports Explained\n").as_deref(),
4548            Some("Ports: Well-Known ports explained")
4549        );
4550        assert_eq!(
4551            suggested(&rule, "# See https://example.com/A/B For Details\n").as_deref(),
4552            Some("See https://example.com/A/B for details")
4553        );
4554    }
4555
4556    #[test]
4557    fn test_restart_after_a_trailing_boundary_is_a_no_op() {
4558        let rule = restart_rule(&[":"]);
4559        assert_eq!(suggested(&rule, "# Setup:\n"), None);
4560    }
4561
4562    #[test]
4563    fn test_restart_after_does_not_override_preserved_words() {
4564        // The likeliest regression: a preserved brand name landing right after a
4565        // boundary must not be re-capitalized into `IPhone`.
4566        let rule = restart_rule(&[":"]);
4567        assert_eq!(
4568            suggested(&rule, "# Devices: iPhone And Android\n").as_deref(),
4569            Some("Devices: iPhone and android")
4570        );
4571
4572        let config = MD063Config {
4573            enabled: true,
4574            style: HeadingCapStyle::SentenceCase,
4575            sentence_case_restart_after: vec![":".to_string()],
4576            ignore_words: vec!["kubectl".to_string()],
4577            preserve_cased_words: false,
4578            ..Default::default()
4579        };
4580        let rule = MD063HeadingCapitalization::from_config_struct(config);
4581        assert_eq!(
4582            suggested(&rule, "# Tools: kubectl And Helm\n").as_deref(),
4583            Some("Tools: kubectl and helm")
4584        );
4585    }
4586
4587    #[test]
4588    fn test_restart_after_keeps_md044_canonical_forms() {
4589        let config = MD063Config {
4590            enabled: true,
4591            style: HeadingCapStyle::SentenceCase,
4592            sentence_case_restart_after: vec![":".to_string()],
4593            ..Default::default()
4594        };
4595        let mut rule = MD063HeadingCapitalization::from_config_struct(config);
4596        rule.proper_names = vec!["GitHub".to_string()];
4597
4598        // The canonical form wins over the restart, so this is `GitHub`, not `Github`.
4599        assert_eq!(
4600            suggested(&rule, "# Docs: github Actions Guide\n").as_deref(),
4601            Some("Docs: GitHub actions guide")
4602        );
4603        assert_eq!(suggested(&rule, "# Docs: GitHub actions guide\n"), None);
4604    }
4605
4606    #[test]
4607    fn test_restart_after_carries_across_segments() {
4608        // A boundary in one segment governs the next, so a heading with a link behaves
4609        // the same as one without.
4610        let rule = restart_rule(&[":"]);
4611        assert_eq!(
4612            suggested(
4613                &rule,
4614                "# Overview: [Some Link Here](https://example.com) Trailing Words\n"
4615            )
4616            .as_deref(),
4617            Some("Overview: [Some link here](https://example.com) trailing words")
4618        );
4619        assert_eq!(
4620            suggested(&rule, "# Overview: `code` Then More Words\n").as_deref(),
4621            Some("Overview: `code` then more words")
4622        );
4623    }
4624
4625    #[test]
4626    fn test_restart_after_ends_a_sentence_at_the_end_of_link_text() {
4627        // A reader sees `[see:](url)` as `see:`, so the boundary is where they read it,
4628        // not at the closing paren of the destination. This is the complement of a
4629        // boundary before a link carrying into its text.
4630        let rule = restart_rule(&[":"]);
4631        assert_eq!(
4632            suggested(&rule, "# Topic [See:](https://example.com) More Words\n").as_deref(),
4633            Some("Topic [see:](https://example.com) More words")
4634        );
4635
4636        // A boundary that only appears in the destination is not visible prose.
4637        assert_eq!(
4638            suggested(&rule, "# Topic [See](https://example.com) More Words\n").as_deref(),
4639            Some("Topic [see](https://example.com) more words")
4640        );
4641    }
4642
4643    #[test]
4644    fn test_restart_after_ignores_boundaries_inside_opaque_segments() {
4645        // Code, HTML and image alt text are preserved verbatim rather than capitalized,
4646        // so a boundary inside them is not one this rule offers the reader.
4647        let rule = restart_rule(&[":"]);
4648        for content in [
4649            "# Topic `see:` More Words\n",
4650            "# Topic ![alt:](image.png) More Words\n",
4651            "# Topic <span title=\"x:\">y</span> More Words\n",
4652        ] {
4653            let fixed = suggested(&rule, content).expect("heading should be rewritten");
4654            assert!(
4655                fixed.ends_with("more words"),
4656                "opaque segment restarted the sentence in {content:?}: {fixed}"
4657            );
4658        }
4659    }
4660
4661    #[test]
4662    fn test_restart_after_treats_a_leading_link_as_sentence_initial() {
4663        // A heading opening with visible link text starts the sentence, whether or not
4664        // sentence restarts are configured.
4665        for rule in [restart_rule(&[]), restart_rule(&[":"])] {
4666            assert_eq!(
4667                suggested(&rule, "# [Some Link Here](https://example.com) Trailing Words\n").as_deref(),
4668                Some("[Some link here](https://example.com) trailing words")
4669            );
4670        }
4671    }
4672
4673    #[test]
4674    fn test_restart_after_fix_is_idempotent() {
4675        let rule = restart_rule(&[":", ";", "-", "\u{2014}"]);
4676        for content in [
4677            "# Requirement 1: Struct to Logger Slice Conversion\n",
4678            "# Ports: Well-Known Ports Explained\n",
4679            "# Devices: iPhone And Android\n",
4680            "# Overview: [Some Link Here](https://example.com) Trailing Words\n",
4681            "# Setup:\n",
4682        ] {
4683            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4684            let once = rule.fix(&ctx).unwrap();
4685            let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
4686            assert_eq!(rule.fix(&ctx).unwrap(), once, "fix is not idempotent for {content:?}");
4687        }
4688    }
4689
4690    #[test]
4691    fn test_restart_after_ignores_empty_boundary_entries() {
4692        // An empty string would otherwise end every word, capitalizing the whole heading.
4693        let rule = restart_rule(&[""]);
4694        assert_eq!(
4695            suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
4696            Some("Requirement 1: struct to logger slice conversion")
4697        );
4698    }
4699
4700    // Markdown with Gherkin
4701
4702    const STYLES: [HeadingCapStyle; 3] = [
4703        HeadingCapStyle::TitleCase,
4704        HeadingCapStyle::SentenceCase,
4705        HeadingCapStyle::AllCaps,
4706    ];
4707
4708    /// Every Gherkin structure keyword, each with a name all three styles rewrite:
4709    /// (heading, title case, sentence case, all caps).
4710    const GHERKIN_STRUCTURES: [(&str, &str, &str, &str); 6] = [
4711        (
4712            "# Feature: the system under test",
4713            "# Feature: The System Under Test",
4714            "# Feature: The system under test",
4715            "# Feature: THE SYSTEM UNDER TEST",
4716        ),
4717        (
4718            "## Background: a shared setup",
4719            "## Background: A Shared Setup",
4720            "## Background: A shared setup",
4721            "## Background: A SHARED SETUP",
4722        ),
4723        (
4724            "## Rule: money is never lost",
4725            "## Rule: Money Is Never Lost",
4726            "## Rule: Money is never lost",
4727            "## Rule: MONEY IS NEVER LOST",
4728        ),
4729        (
4730            "### Scenario: add two numbers",
4731            "### Scenario: Add Two Numbers",
4732            "### Scenario: Add two numbers",
4733            "### Scenario: ADD TWO NUMBERS",
4734        ),
4735        (
4736            "### Scenario Outline: add two numbers",
4737            "### Scenario Outline: Add Two Numbers",
4738            "### Scenario Outline: Add two numbers",
4739            "### Scenario Outline: ADD TWO NUMBERS",
4740        ),
4741        (
4742            "#### Examples: happy path",
4743            "#### Examples: Happy Path",
4744            "#### Examples: Happy path",
4745            "#### Examples: HAPPY PATH",
4746        ),
4747    ];
4748
4749    /// The heading MD063 leaves behind under `flavor`, rewritten or not.
4750    fn recased(style: HeadingCapStyle, heading: &str, flavor: crate::config::MarkdownFlavor) -> String {
4751        let rule = create_rule_with_style(style);
4752        let content = format!("{heading}\n");
4753        let ctx = LintContext::new(&content, flavor, None);
4754        let warnings = rule.check(&ctx).unwrap();
4755        let fixed = rule.fix(&ctx).unwrap();
4756        assert_eq!(
4757            warnings.is_empty(),
4758            fixed == content,
4759            "a warning and a rewrite must agree for {content:?} under {flavor:?}"
4760        );
4761        fixed.trim_end().to_string()
4762    }
4763
4764    #[test]
4765    fn test_mdg_keeps_the_keyword_of_every_structure() {
4766        // A keyword only names a structure when spelled exactly, so a recased one
4767        // silently turns the structure into prose.
4768        for (heading, ..) in GHERKIN_STRUCTURES {
4769            let keyword = &heading[..=heading.find(':').unwrap()];
4770            for style in STYLES {
4771                let fixed = recased(style, heading, crate::config::MarkdownFlavor::MDG);
4772                assert!(
4773                    fixed.starts_with(keyword),
4774                    "{style:?} lost the keyword of {heading:?}: {fixed}"
4775                );
4776            }
4777        }
4778    }
4779
4780    #[test]
4781    fn test_mdg_recases_only_the_name_of_a_structure() {
4782        for (heading, title, sentence, caps) in GHERKIN_STRUCTURES {
4783            let mdg = crate::config::MarkdownFlavor::MDG;
4784            assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), title);
4785            assert_eq!(recased(HeadingCapStyle::SentenceCase, heading, mdg), sentence);
4786            assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), caps);
4787        }
4788    }
4789
4790    #[test]
4791    fn test_standard_flavor_recases_a_keyword_like_any_other_word() {
4792        // The exemption belongs to the flavor, not to the rule.
4793        let standard = crate::config::MarkdownFlavor::Standard;
4794        assert_eq!(
4795            recased(HeadingCapStyle::TitleCase, "# Feature: the system under test", standard),
4796            "# Feature: the System Under Test"
4797        );
4798        assert_eq!(
4799            recased(
4800                HeadingCapStyle::SentenceCase,
4801                "### Scenario Outline: add two numbers",
4802                standard
4803            ),
4804            "### Scenario outline: add two numbers"
4805        );
4806        assert_eq!(
4807            recased(HeadingCapStyle::AllCaps, "# Feature: the system under test", standard),
4808            "# FEATURE: THE SYSTEM UNDER TEST"
4809        );
4810    }
4811
4812    #[test]
4813    fn test_mdg_leaves_a_heading_without_a_colon_to_the_normal_rule() {
4814        for heading in ["## notes about the system", "## Notes", "# THE SYSTEM"] {
4815            for style in STYLES {
4816                assert_eq!(
4817                    recased(style, heading, crate::config::MarkdownFlavor::MDG),
4818                    recased(style, heading, crate::config::MarkdownFlavor::Standard),
4819                    "{style:?} treated {heading:?} as a Gherkin structure"
4820                );
4821            }
4822        }
4823    }
4824
4825    #[test]
4826    fn test_mdg_splits_at_the_first_colon_only() {
4827        // A later colon belongs to the name, which is prose this rule still owns.
4828        let mdg = crate::config::MarkdownFlavor::MDG;
4829        let heading = "## Scenario: ratio: two to one";
4830        assert_eq!(
4831            recased(HeadingCapStyle::TitleCase, heading, mdg),
4832            "## Scenario: Ratio: Two to One"
4833        );
4834        assert_eq!(
4835            recased(HeadingCapStyle::SentenceCase, heading, mdg),
4836            "## Scenario: Ratio: two to one"
4837        );
4838        assert_eq!(
4839            recased(HeadingCapStyle::AllCaps, heading, mdg),
4840            "## Scenario: RATIO: TWO TO ONE"
4841        );
4842    }
4843
4844    #[test]
4845    fn test_mdg_leaves_a_colon_behind_a_backtick_to_the_normal_rule() {
4846        // Dialect keywords are plain words, so such a colon is inside a code span rather
4847        // than after a keyword. Splitting there would hide the span from the segment
4848        // parser and recase what a reader sees as code.
4849        for heading in [
4850            "# See `x: y` Notes",
4851            "# `a: b`",
4852            "# `code` Feature: a name",
4853            "# `x: y` Feature: a name",
4854        ] {
4855            for style in STYLES {
4856                assert_eq!(
4857                    recased(style, heading, crate::config::MarkdownFlavor::MDG),
4858                    recased(style, heading, crate::config::MarkdownFlavor::Standard),
4859                    "{style:?} split {heading:?} at a colon inside a code span"
4860                );
4861            }
4862        }
4863    }
4864
4865    #[test]
4866    fn test_mdg_splits_at_a_keyword_colon_that_precedes_a_code_span() {
4867        // The backtick is in the name, so the keyword colon still governs.
4868        let mdg = crate::config::MarkdownFlavor::MDG;
4869        let heading = "# Scenario: use `a: b` here";
4870        assert_eq!(
4871            recased(HeadingCapStyle::TitleCase, heading, mdg),
4872            "# Scenario: Use `a: b` Here"
4873        );
4874        assert_eq!(
4875            recased(HeadingCapStyle::SentenceCase, heading, mdg),
4876            "# Scenario: Use `a: b` here"
4877        );
4878        assert_eq!(
4879            recased(HeadingCapStyle::AllCaps, heading, mdg),
4880            "# Scenario: USE `a: b` HERE"
4881        );
4882    }
4883
4884    #[test]
4885    fn test_mdg_splits_at_a_keyword_colon_before_an_unbalanced_backtick() {
4886        // An unclosed backtick opens no code span for either flavor, so the tail stays
4887        // prose and only the keyword is held back.
4888        let mdg = crate::config::MarkdownFlavor::MDG;
4889        let heading = "# Scenario: a ` b";
4890        assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), "# Scenario: A ` B");
4891        assert_eq!(
4892            recased(HeadingCapStyle::SentenceCase, heading, mdg),
4893            "# Scenario: A ` b"
4894        );
4895        assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), "# Scenario: A ` B");
4896    }
4897
4898    #[test]
4899    fn test_mdg_keeps_a_keyword_with_nothing_left_to_recase() {
4900        for style in STYLES {
4901            assert_eq!(
4902                recased(style, "# Feature:", crate::config::MarkdownFlavor::MDG),
4903                "# Feature:"
4904            );
4905        }
4906    }
4907
4908    #[test]
4909    fn test_mdg_keeps_a_custom_id_after_the_name() {
4910        assert_eq!(
4911            recased(
4912                HeadingCapStyle::TitleCase,
4913                "# Feature: the system {#overview}",
4914                crate::config::MarkdownFlavor::MDG
4915            ),
4916            "# Feature: The System {#overview}"
4917        );
4918    }
4919
4920    #[test]
4921    fn test_mdg_fix_is_idempotent() {
4922        for (heading, ..) in GHERKIN_STRUCTURES {
4923            for style in STYLES {
4924                let rule = create_rule_with_style(style);
4925                let content = format!("{heading}\n");
4926                let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
4927                let once = rule.fix(&ctx).unwrap();
4928                let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::MDG, None);
4929                assert_eq!(
4930                    rule.fix(&ctx).unwrap(),
4931                    once,
4932                    "fix is not idempotent for {heading:?} ({style:?})"
4933                );
4934            }
4935        }
4936    }
4937}
4938
4939#[cfg(test)]
4940mod shortcode_delimiter_tests {
4941    use super::*;
4942
4943    #[test]
4944    fn heading_capitalization_preserves_shortcode_raw_arguments() {
4945        let rule = MD063HeadingCapitalization::from_config_struct(MD063Config {
4946            enabled: true,
4947            style: HeadingCapStyle::TitleCase,
4948            ..Default::default()
4949        });
4950        for (open, close) in [("{{<", ">}}"), ("{{%", "%}}")] {
4951            let tag = format!("{open} note title=`literal {close} friend` {close}");
4952            assert_eq!(
4953                rule.apply_capitalization(
4954                    &format!("hello {tag} world"),
4955                    crate::config::MarkdownFlavor::Hugo,
4956                    ResolvedStyle::TitleCase
4957                ),
4958                format!("Hello {tag} World")
4959            );
4960        }
4961    }
4962}
4963
4964#[cfg(test)]
4965mod mdx_tag_capitalization_tests {
4966    use super::*;
4967    use crate::rule::Rule;
4968
4969    fn fixed(style: HeadingCapStyle, source: &str) -> String {
4970        let rule = MD063HeadingCapitalization::from_config_struct(MD063Config {
4971            enabled: true,
4972            style,
4973            ..Default::default()
4974        });
4975        let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
4976        rule.fix(&ctx).unwrap()
4977    }
4978
4979    #[test]
4980    fn mdx_heading_capitalization_preserves_native_jsx_elements() {
4981        for name in ["span", "Card", "ui.Card", "svg:path"] {
4982            let element = format!("<{name} onClick={{() => null}} title=\"literal friend\">world</{name}>");
4983            let source = format!("# hello {element} after\n");
4984            for (style, before, after) in [
4985                (HeadingCapStyle::TitleCase, "Hello", "After"),
4986                (HeadingCapStyle::SentenceCase, "Hello", "after"),
4987                (HeadingCapStyle::AllCaps, "HELLO", "AFTER"),
4988            ] {
4989                assert_eq!(fixed(style, &source), format!("# {before} {element} {after}\n"));
4990            }
4991        }
4992    }
4993
4994    #[test]
4995    fn mdx_heading_capitalization_preserves_nested_member_elements() {
4996        let element = "<ui.Card title='literal friend'>world <span>inner</span></ui.Card>";
4997        let source = format!("# hello {element} after\n");
4998        assert_eq!(
4999            fixed(HeadingCapStyle::TitleCase, &source),
5000            format!("# Hello {element} After\n")
5001        );
5002    }
5003
5004    #[test]
5005    fn mdx_heading_tag_protection_preserves_existing_visible_word_context() {
5006        assert_eq!(
5007            fixed(HeadingCapStyle::SentenceCase, "# <span></span> hello world\n"),
5008            "# <span></span> Hello world\n"
5009        );
5010        assert_eq!(
5011            fixed(HeadingCapStyle::SentenceCase, "# <img alt='friend'/> hello world\n"),
5012            "# <img alt='friend'/> hello world\n"
5013        );
5014        assert_eq!(
5015            fixed(HeadingCapStyle::TitleCase, "# <span>visible</span> and the world\n"),
5016            "# <span>visible</span> And the World\n"
5017        );
5018    }
5019
5020    #[test]
5021    fn mdx_setext_member_attributes_preserve_their_source_map() {
5022        let member = "hello <ui.Card title=\"literal\nfriend\">world</ui.Card> after\n===\n";
5023        assert_eq!(fixed(HeadingCapStyle::TitleCase, member), member);
5024        let html = "hello <span title=\"literal\nfriend\">world</span> after\n===\n";
5025        assert_eq!(
5026            fixed(HeadingCapStyle::TitleCase, html),
5027            "Hello <span title=\"literal\nfriend\">world</span> After\n===\n"
5028        );
5029    }
5030}