Skip to main content

rumdl_lib/rules/md063_heading_capitalization/
mod.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, Severity};
15use crate::utils::range_utils::LineIndex;
16use regex::Regex;
17use std::collections::HashSet;
18use std::ops::Range;
19use std::sync::LazyLock;
20
21mod md063_config;
22pub use md063_config::{HeadingCapStyle, MD063Config};
23
24// Regex to match inline code spans (backticks)
25static INLINE_CODE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`+[^`]+`+").unwrap());
26
27// Regex to match markdown links [text](url) or [text][ref]
28static LINK_REGEX: LazyLock<Regex> =
29    LazyLock::new(|| Regex::new(r"\[([^\]]*)\]\([^)]*\)|\[([^\]]*)\]\[[^\]]*\]").unwrap());
30
31// Regex to match inline HTML tags commonly used in headings
32// Matches paired tags: <tag>content</tag>, <tag attr="val">content</tag>
33// Matches self-closing: <tag/>, <tag />
34// Uses explicit list of common inline tags to avoid backreference (not supported in Rust regex)
35static HTML_TAG_REGEX: LazyLock<Regex> = LazyLock::new(|| {
36    // Common inline HTML tags used in documentation headings
37    let tags = "kbd|abbr|code|span|sub|sup|mark|cite|dfn|var|samp|small|strong|em|b|i|u|s|q|br|wbr";
38    let pattern = format!(r"<({tags})(?:\s[^>]*)?>.*?</({tags})>|<({tags})(?:\s[^>]*)?\s*/?>");
39    Regex::new(&pattern).unwrap()
40});
41
42// Regex to match custom header IDs {#id}
43static CUSTOM_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*\{#[^}]+\}\s*$").unwrap());
44
45/// Represents a segment of heading text
46#[derive(Debug, Clone)]
47enum HeadingSegment {
48    /// Regular text that should be capitalized
49    Text(String),
50    /// Inline code that should be preserved as-is
51    Code(String),
52    /// Link with text that may be capitalized and URL that's preserved
53    Link {
54        full: String,
55        text_start: usize,
56        text_end: usize,
57    },
58    /// Inline HTML tag that should be preserved as-is
59    Html(String),
60}
61
62/// Rule MD063: Heading capitalization
63#[derive(Clone)]
64pub struct MD063HeadingCapitalization {
65    config: MD063Config,
66    lowercase_set: HashSet<String>,
67}
68
69impl Default for MD063HeadingCapitalization {
70    fn default() -> Self {
71        Self::new()
72    }
73}
74
75impl MD063HeadingCapitalization {
76    pub fn new() -> Self {
77        let config = MD063Config::default();
78        let lowercase_set = config.lowercase_words.iter().cloned().collect();
79        Self { config, lowercase_set }
80    }
81
82    pub fn from_config_struct(config: MD063Config) -> Self {
83        let lowercase_set = config.lowercase_words.iter().cloned().collect();
84        Self { config, lowercase_set }
85    }
86
87    /// Check if a word has internal capitals (like "iPhone", "macOS", "GitHub", "iOS")
88    fn has_internal_capitals(&self, word: &str) -> bool {
89        let chars: Vec<char> = word.chars().collect();
90        if chars.len() < 2 {
91            return false;
92        }
93
94        let first = chars[0];
95        let rest = &chars[1..];
96        let has_upper_in_rest = rest.iter().any(|c| c.is_uppercase());
97        let has_lower_in_rest = rest.iter().any(|c| c.is_lowercase());
98
99        // Case 1: Mixed case after first character (like "iPhone", "macOS", "GitHub", "JavaScript")
100        if has_upper_in_rest && has_lower_in_rest {
101            return true;
102        }
103
104        // Case 2: Lowercase first + uppercase in rest (like "iOS", "eBay")
105        if first.is_lowercase() && has_upper_in_rest {
106            return true;
107        }
108
109        false
110    }
111
112    /// Check if a word is an all-caps acronym (2+ consecutive uppercase letters)
113    /// Examples: "API", "GPU", "HTTP2", "IO" return true
114    /// Examples: "A", "iPhone", "npm" return false
115    fn is_all_caps_acronym(&self, word: &str) -> bool {
116        // Skip single-letter words (handled by title case rules)
117        if word.len() < 2 {
118            return false;
119        }
120
121        let mut consecutive_upper = 0;
122        let mut max_consecutive = 0;
123
124        for c in word.chars() {
125            if c.is_uppercase() {
126                consecutive_upper += 1;
127                max_consecutive = max_consecutive.max(consecutive_upper);
128            } else if c.is_lowercase() {
129                // Any lowercase letter means not all-caps
130                return false;
131            } else {
132                // Non-letter (number, punctuation) - reset counter but don't fail
133                consecutive_upper = 0;
134            }
135        }
136
137        // Must have at least 2 consecutive uppercase letters
138        max_consecutive >= 2
139    }
140
141    /// Check if a word should be preserved as-is
142    fn should_preserve_word(&self, word: &str) -> bool {
143        // Check ignore_words list (case-sensitive exact match)
144        if self.config.ignore_words.iter().any(|w| w == word) {
145            return true;
146        }
147
148        // Check if word has internal capitals and preserve_cased_words is enabled
149        if self.config.preserve_cased_words && self.has_internal_capitals(word) {
150            return true;
151        }
152
153        // Check if word is an all-caps acronym (2+ consecutive uppercase)
154        if self.config.preserve_cased_words && self.is_all_caps_acronym(word) {
155            return true;
156        }
157
158        // Preserve caret notation for control characters (^A, ^Z, ^@, etc.)
159        if self.is_caret_notation(word) {
160            return true;
161        }
162
163        false
164    }
165
166    /// Check if a word is caret notation for control characters (e.g., ^A, ^C, ^Z)
167    fn is_caret_notation(&self, word: &str) -> bool {
168        let chars: Vec<char> = word.chars().collect();
169        // Pattern: ^ followed by uppercase letter or @[\]^_
170        if chars.len() >= 2 && chars[0] == '^' {
171            let second = chars[1];
172            // Control characters: ^@ (NUL) through ^_ (US), which includes ^A-^Z
173            if second.is_ascii_uppercase() || "@[\\]^_".contains(second) {
174                return true;
175            }
176        }
177        false
178    }
179
180    /// Check if a word is a "lowercase word" (articles, prepositions, etc.)
181    fn is_lowercase_word(&self, word: &str) -> bool {
182        self.lowercase_set.contains(&word.to_lowercase())
183    }
184
185    /// Apply title case to a single word
186    fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
187        if word.is_empty() {
188            return word.to_string();
189        }
190
191        // Preserve words in ignore list or with internal capitals
192        if self.should_preserve_word(word) {
193            return word.to_string();
194        }
195
196        // First and last words are always capitalized
197        if is_first || is_last {
198            return self.capitalize_first(word);
199        }
200
201        // Check if it's a lowercase word (articles, prepositions, etc.)
202        if self.is_lowercase_word(word) {
203            return word.to_lowercase();
204        }
205
206        // Regular word - capitalize first letter
207        self.capitalize_first(word)
208    }
209
210    /// Capitalize the first letter of a word, handling Unicode properly
211    fn capitalize_first(&self, word: &str) -> String {
212        let mut chars = word.chars();
213        match chars.next() {
214            None => String::new(),
215            Some(first) => {
216                let first_upper: String = first.to_uppercase().collect();
217                let rest: String = chars.collect();
218                format!("{}{}", first_upper, rest.to_lowercase())
219            }
220        }
221    }
222
223    /// Apply title case to text (using titlecase crate as base, then our customizations)
224    fn apply_title_case(&self, text: &str) -> String {
225        // Use the titlecase crate for the base transformation
226        let base_result = titlecase::titlecase(text);
227
228        // Get words from both original and transformed text to compare
229        let original_words: Vec<&str> = text.split_whitespace().collect();
230        let transformed_words: Vec<&str> = base_result.split_whitespace().collect();
231        let total_words = transformed_words.len();
232
233        let result_words: Vec<String> = transformed_words
234            .iter()
235            .enumerate()
236            .map(|(i, word)| {
237                let is_first = i == 0;
238                let is_last = i == total_words - 1;
239
240                // Check if the ORIGINAL word should be preserved (for acronyms like "API")
241                if let Some(original_word) = original_words.get(i)
242                    && self.should_preserve_word(original_word)
243                {
244                    return (*original_word).to_string();
245                }
246
247                // Handle hyphenated words
248                if word.contains('-') {
249                    // Also check original for hyphenated preservation
250                    if let Some(original_word) = original_words.get(i) {
251                        return self.handle_hyphenated_word_with_original(word, original_word, is_first, is_last);
252                    }
253                    return self.handle_hyphenated_word(word, is_first, is_last);
254                }
255
256                self.title_case_word(word, is_first, is_last)
257            })
258            .collect();
259
260        result_words.join(" ")
261    }
262
263    /// Handle hyphenated words like "self-documenting"
264    fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
265        let parts: Vec<&str> = word.split('-').collect();
266        let total_parts = parts.len();
267
268        let result_parts: Vec<String> = parts
269            .iter()
270            .enumerate()
271            .map(|(i, part)| {
272                // First part of first word and last part of last word get special treatment
273                let part_is_first = is_first && i == 0;
274                let part_is_last = is_last && i == total_parts - 1;
275                self.title_case_word(part, part_is_first, part_is_last)
276            })
277            .collect();
278
279        result_parts.join("-")
280    }
281
282    /// Handle hyphenated words with original text for acronym preservation
283    fn handle_hyphenated_word_with_original(
284        &self,
285        word: &str,
286        original: &str,
287        is_first: bool,
288        is_last: bool,
289    ) -> String {
290        let parts: Vec<&str> = word.split('-').collect();
291        let original_parts: Vec<&str> = original.split('-').collect();
292        let total_parts = parts.len();
293
294        let result_parts: Vec<String> = parts
295            .iter()
296            .enumerate()
297            .map(|(i, part)| {
298                // Check if the original part should be preserved (for acronyms)
299                if let Some(original_part) = original_parts.get(i)
300                    && self.should_preserve_word(original_part)
301                {
302                    return (*original_part).to_string();
303                }
304
305                // First part of first word and last part of last word get special treatment
306                let part_is_first = is_first && i == 0;
307                let part_is_last = is_last && i == total_parts - 1;
308                self.title_case_word(part, part_is_first, part_is_last)
309            })
310            .collect();
311
312        result_parts.join("-")
313    }
314
315    /// Apply sentence case to text
316    fn apply_sentence_case(&self, text: &str) -> String {
317        if text.is_empty() {
318            return text.to_string();
319        }
320
321        let mut result = String::new();
322        let mut current_pos = 0;
323        let mut is_first_word = true;
324
325        // Use original text positions to preserve whitespace correctly
326        for word in text.split_whitespace() {
327            if let Some(pos) = text[current_pos..].find(word) {
328                let abs_pos = current_pos + pos;
329
330                // Preserve whitespace before this word
331                result.push_str(&text[current_pos..abs_pos]);
332
333                // Process the word
334                if is_first_word {
335                    // Check if word should be preserved BEFORE any capitalization
336                    if self.should_preserve_word(word) {
337                        // Preserve ignore-words exactly as-is, even at start
338                        result.push_str(word);
339                    } else {
340                        // First word: capitalize first letter, lowercase rest
341                        let mut chars = word.chars();
342                        if let Some(first) = chars.next() {
343                            let first_upper: String = first.to_uppercase().collect();
344                            result.push_str(&first_upper);
345                            let rest: String = chars.collect();
346                            result.push_str(&rest.to_lowercase());
347                        }
348                    }
349                    is_first_word = false;
350                } else {
351                    // Non-first words: preserve if needed, otherwise lowercase
352                    if self.should_preserve_word(word) {
353                        result.push_str(word);
354                    } else {
355                        result.push_str(&word.to_lowercase());
356                    }
357                }
358
359                current_pos = abs_pos + word.len();
360            }
361        }
362
363        // Preserve any trailing whitespace
364        if current_pos < text.len() {
365            result.push_str(&text[current_pos..]);
366        }
367
368        result
369    }
370
371    /// Apply all caps to text (preserve whitespace)
372    fn apply_all_caps(&self, text: &str) -> String {
373        if text.is_empty() {
374            return text.to_string();
375        }
376
377        let mut result = String::new();
378        let mut current_pos = 0;
379
380        // Use original text positions to preserve whitespace correctly
381        for word in text.split_whitespace() {
382            if let Some(pos) = text[current_pos..].find(word) {
383                let abs_pos = current_pos + pos;
384
385                // Preserve whitespace before this word
386                result.push_str(&text[current_pos..abs_pos]);
387
388                // Check if this word should be preserved
389                if self.should_preserve_word(word) {
390                    result.push_str(word);
391                } else {
392                    result.push_str(&word.to_uppercase());
393                }
394
395                current_pos = abs_pos + word.len();
396            }
397        }
398
399        // Preserve any trailing whitespace
400        if current_pos < text.len() {
401            result.push_str(&text[current_pos..]);
402        }
403
404        result
405    }
406
407    /// Parse heading text into segments
408    fn parse_segments(&self, text: &str) -> Vec<HeadingSegment> {
409        let mut segments = Vec::new();
410        let mut last_end = 0;
411
412        // Collect all special regions (code and links)
413        let mut special_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
414
415        // Find inline code spans
416        for mat in INLINE_CODE_REGEX.find_iter(text) {
417            special_regions.push((mat.start(), mat.end(), HeadingSegment::Code(mat.as_str().to_string())));
418        }
419
420        // Find links
421        for caps in LINK_REGEX.captures_iter(text) {
422            let full_match = caps.get(0).unwrap();
423            let text_match = caps.get(1).or_else(|| caps.get(2));
424
425            if let Some(text_m) = text_match {
426                special_regions.push((
427                    full_match.start(),
428                    full_match.end(),
429                    HeadingSegment::Link {
430                        full: full_match.as_str().to_string(),
431                        text_start: text_m.start() - full_match.start(),
432                        text_end: text_m.end() - full_match.start(),
433                    },
434                ));
435            }
436        }
437
438        // Find inline HTML tags
439        for mat in HTML_TAG_REGEX.find_iter(text) {
440            special_regions.push((mat.start(), mat.end(), HeadingSegment::Html(mat.as_str().to_string())));
441        }
442
443        // Sort by start position
444        special_regions.sort_by_key(|(start, _, _)| *start);
445
446        // Remove overlapping regions (code takes precedence)
447        let mut filtered_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
448        for region in special_regions {
449            let overlaps = filtered_regions.iter().any(|(s, e, _)| region.0 < *e && region.1 > *s);
450            if !overlaps {
451                filtered_regions.push(region);
452            }
453        }
454
455        // Build segments
456        for (start, end, segment) in filtered_regions {
457            // Add text before this special region
458            if start > last_end {
459                let text_segment = &text[last_end..start];
460                if !text_segment.is_empty() {
461                    segments.push(HeadingSegment::Text(text_segment.to_string()));
462                }
463            }
464            segments.push(segment);
465            last_end = end;
466        }
467
468        // Add remaining text
469        if last_end < text.len() {
470            let remaining = &text[last_end..];
471            if !remaining.is_empty() {
472                segments.push(HeadingSegment::Text(remaining.to_string()));
473            }
474        }
475
476        // If no segments were found, treat the whole thing as text
477        if segments.is_empty() && !text.is_empty() {
478            segments.push(HeadingSegment::Text(text.to_string()));
479        }
480
481        segments
482    }
483
484    /// Apply capitalization to heading text
485    fn apply_capitalization(&self, text: &str) -> String {
486        // Strip custom ID if present and re-add later
487        let (main_text, custom_id) = if let Some(mat) = CUSTOM_ID_REGEX.find(text) {
488            (&text[..mat.start()], Some(mat.as_str()))
489        } else {
490            (text, None)
491        };
492
493        // Parse into segments
494        let segments = self.parse_segments(main_text);
495
496        // Count text segments to determine first/last word context
497        let text_segments: Vec<usize> = segments
498            .iter()
499            .enumerate()
500            .filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
501            .collect();
502
503        // Determine if the first segment overall is a text segment
504        // For sentence case: if heading starts with code/link, the first text segment
505        // should NOT capitalize its first word (the heading already has a "first element")
506        let first_segment_is_text = segments
507            .first()
508            .map(|s| matches!(s, HeadingSegment::Text(_)))
509            .unwrap_or(false);
510
511        // Determine if the last segment overall is a text segment
512        // If the last segment is Code or Link, then the last text segment should NOT
513        // treat its last word as the heading's last word (for lowercase-words respect)
514        let last_segment_is_text = segments
515            .last()
516            .map(|s| matches!(s, HeadingSegment::Text(_)))
517            .unwrap_or(false);
518
519        // Apply capitalization to each segment
520        let mut result_parts: Vec<String> = Vec::new();
521
522        for (i, segment) in segments.iter().enumerate() {
523            match segment {
524                HeadingSegment::Text(t) => {
525                    let is_first_text = text_segments.first() == Some(&i);
526                    // A text segment is "last" only if it's the last text segment AND
527                    // the last segment overall is also text. If there's Code/Link after,
528                    // the last word should respect lowercase-words.
529                    let is_last_text = text_segments.last() == Some(&i) && last_segment_is_text;
530
531                    let capitalized = match self.config.style {
532                        HeadingCapStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
533                        HeadingCapStyle::SentenceCase => {
534                            // For sentence case, only capitalize first word if:
535                            // 1. This is the first text segment, AND
536                            // 2. The heading actually starts with text (not code/link)
537                            if is_first_text && first_segment_is_text {
538                                self.apply_sentence_case(t)
539                            } else {
540                                // Non-first segments OR heading starts with code/link
541                                self.apply_sentence_case_non_first(t)
542                            }
543                        }
544                        HeadingCapStyle::AllCaps => self.apply_all_caps(t),
545                    };
546                    result_parts.push(capitalized);
547                }
548                HeadingSegment::Code(c) => {
549                    result_parts.push(c.clone());
550                }
551                HeadingSegment::Link {
552                    full,
553                    text_start,
554                    text_end,
555                } => {
556                    // Apply capitalization to link text only
557                    let link_text = &full[*text_start..*text_end];
558                    let capitalized_text = match self.config.style {
559                        HeadingCapStyle::TitleCase => self.apply_title_case(link_text),
560                        // For sentence case, apply same preservation logic as non-first text
561                        // This preserves acronyms (API), brand names (iPhone), etc.
562                        HeadingCapStyle::SentenceCase => self.apply_sentence_case_non_first(link_text),
563                        HeadingCapStyle::AllCaps => self.apply_all_caps(link_text),
564                    };
565
566                    let mut new_link = String::new();
567                    new_link.push_str(&full[..*text_start]);
568                    new_link.push_str(&capitalized_text);
569                    new_link.push_str(&full[*text_end..]);
570                    result_parts.push(new_link);
571                }
572                HeadingSegment::Html(h) => {
573                    // Preserve HTML tags as-is (like code)
574                    result_parts.push(h.clone());
575                }
576            }
577        }
578
579        let mut result = result_parts.join("");
580
581        // Re-add custom ID if present
582        if let Some(id) = custom_id {
583            result.push_str(id);
584        }
585
586        result
587    }
588
589    /// Apply title case to a text segment with first/last awareness
590    fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
591        let words: Vec<&str> = text.split_whitespace().collect();
592        let total_words = words.len();
593
594        if total_words == 0 {
595            return text.to_string();
596        }
597
598        let result_words: Vec<String> = words
599            .iter()
600            .enumerate()
601            .map(|(i, word)| {
602                let is_first = is_first_segment && i == 0;
603                let is_last = is_last_segment && i == total_words - 1;
604
605                // Handle hyphenated words
606                if word.contains('-') {
607                    return self.handle_hyphenated_word(word, is_first, is_last);
608                }
609
610                self.title_case_word(word, is_first, is_last)
611            })
612            .collect();
613
614        // Preserve original spacing
615        let mut result = String::new();
616        let mut word_iter = result_words.iter();
617        let mut in_word = false;
618
619        for c in text.chars() {
620            if c.is_whitespace() {
621                if in_word {
622                    in_word = false;
623                }
624                result.push(c);
625            } else if !in_word {
626                if let Some(word) = word_iter.next() {
627                    result.push_str(word);
628                }
629                in_word = true;
630            }
631        }
632
633        result
634    }
635
636    /// Apply sentence case to non-first segments (just lowercase, preserve whitespace)
637    fn apply_sentence_case_non_first(&self, text: &str) -> String {
638        if text.is_empty() {
639            return text.to_string();
640        }
641
642        let lower = text.to_lowercase();
643        let mut result = String::new();
644        let mut current_pos = 0;
645
646        for word in lower.split_whitespace() {
647            if let Some(pos) = lower[current_pos..].find(word) {
648                let abs_pos = current_pos + pos;
649
650                // Preserve whitespace before this word
651                result.push_str(&lower[current_pos..abs_pos]);
652
653                // Check if this word should be preserved
654                let original_word = &text[abs_pos..abs_pos + word.len()];
655                if self.should_preserve_word(original_word) {
656                    result.push_str(original_word);
657                } else {
658                    result.push_str(word);
659                }
660
661                current_pos = abs_pos + word.len();
662            }
663        }
664
665        // Preserve any trailing whitespace
666        if current_pos < lower.len() {
667            result.push_str(&lower[current_pos..]);
668        }
669
670        result
671    }
672
673    /// Get byte range for a line
674    fn get_line_byte_range(&self, content: &str, line_num: usize, line_index: &LineIndex) -> Range<usize> {
675        let start_pos = line_index.get_line_start_byte(line_num).unwrap_or(content.len());
676        let line = content.lines().nth(line_num - 1).unwrap_or("");
677        Range {
678            start: start_pos,
679            end: start_pos + line.len(),
680        }
681    }
682
683    /// Fix an ATX heading line
684    fn fix_atx_heading(&self, _line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
685        // Parse the line to preserve structure
686        let indent = " ".repeat(heading.marker_column);
687        let hashes = "#".repeat(heading.level as usize);
688
689        // Apply capitalization to the text
690        let fixed_text = self.apply_capitalization(&heading.raw_text);
691
692        // Reconstruct with closing sequence if present
693        let closing = &heading.closing_sequence;
694        if heading.has_closing_sequence {
695            format!("{indent}{hashes} {fixed_text} {closing}")
696        } else {
697            format!("{indent}{hashes} {fixed_text}")
698        }
699    }
700
701    /// Fix a Setext heading line
702    fn fix_setext_heading(&self, line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
703        // Apply capitalization to the text
704        let fixed_text = self.apply_capitalization(&heading.raw_text);
705
706        // Preserve leading whitespace from original line
707        let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
708
709        format!("{leading_ws}{fixed_text}")
710    }
711}
712
713impl Rule for MD063HeadingCapitalization {
714    fn name(&self) -> &'static str {
715        "MD063"
716    }
717
718    fn description(&self) -> &'static str {
719        "Heading capitalization"
720    }
721
722    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
723        !ctx.likely_has_headings() || !ctx.lines.iter().any(|line| line.heading.is_some())
724    }
725
726    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
727        let content = ctx.content;
728
729        if content.is_empty() {
730            return Ok(Vec::new());
731        }
732
733        let mut warnings = Vec::new();
734        let line_index = &ctx.line_index;
735
736        for (line_num, line_info) in ctx.lines.iter().enumerate() {
737            if let Some(heading) = &line_info.heading {
738                // Check level filter
739                if heading.level < self.config.min_level || heading.level > self.config.max_level {
740                    continue;
741                }
742
743                // Skip headings in code blocks (indented headings)
744                if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
745                    continue;
746                }
747
748                // Apply capitalization and compare
749                let original_text = &heading.raw_text;
750                let fixed_text = self.apply_capitalization(original_text);
751
752                if original_text != &fixed_text {
753                    let line = line_info.content(ctx.content);
754                    let style_name = match self.config.style {
755                        HeadingCapStyle::TitleCase => "title case",
756                        HeadingCapStyle::SentenceCase => "sentence case",
757                        HeadingCapStyle::AllCaps => "ALL CAPS",
758                    };
759
760                    warnings.push(LintWarning {
761                        rule_name: Some(self.name().to_string()),
762                        line: line_num + 1,
763                        column: heading.content_column + 1,
764                        end_line: line_num + 1,
765                        end_column: heading.content_column + 1 + original_text.len(),
766                        message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
767                        severity: Severity::Warning,
768                        fix: Some(Fix {
769                            range: self.get_line_byte_range(content, line_num + 1, line_index),
770                            replacement: match heading.style {
771                                crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
772                                _ => self.fix_setext_heading(line, heading),
773                            },
774                        }),
775                    });
776                }
777            }
778        }
779
780        Ok(warnings)
781    }
782
783    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
784        let content = ctx.content;
785
786        if content.is_empty() {
787            return Ok(content.to_string());
788        }
789
790        let lines = ctx.raw_lines();
791        let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
792
793        for (line_num, line_info) in ctx.lines.iter().enumerate() {
794            if let Some(heading) = &line_info.heading {
795                // Check level filter
796                if heading.level < self.config.min_level || heading.level > self.config.max_level {
797                    continue;
798                }
799
800                // Skip headings in code blocks
801                if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
802                    continue;
803                }
804
805                let original_text = &heading.raw_text;
806                let fixed_text = self.apply_capitalization(original_text);
807
808                if original_text != &fixed_text {
809                    let line = line_info.content(ctx.content);
810                    fixed_lines[line_num] = match heading.style {
811                        crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
812                        _ => self.fix_setext_heading(line, heading),
813                    };
814                }
815            }
816        }
817
818        // Reconstruct content preserving line endings
819        let mut result = String::with_capacity(content.len());
820        for (i, line) in fixed_lines.iter().enumerate() {
821            result.push_str(line);
822            if i < fixed_lines.len() - 1 || content.ends_with('\n') {
823                result.push('\n');
824            }
825        }
826
827        Ok(result)
828    }
829
830    fn as_any(&self) -> &dyn std::any::Any {
831        self
832    }
833
834    fn default_config_section(&self) -> Option<(String, toml::Value)> {
835        let json_value = serde_json::to_value(&self.config).ok()?;
836        Some((
837            self.name().to_string(),
838            crate::rule_config_serde::json_to_toml_value(&json_value)?,
839        ))
840    }
841
842    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
843    where
844        Self: Sized,
845    {
846        let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
847        Box::new(Self::from_config_struct(rule_config))
848    }
849}
850
851#[cfg(test)]
852mod tests {
853    use super::*;
854    use crate::lint_context::LintContext;
855
856    fn create_rule() -> MD063HeadingCapitalization {
857        let config = MD063Config {
858            enabled: true,
859            ..Default::default()
860        };
861        MD063HeadingCapitalization::from_config_struct(config)
862    }
863
864    fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
865        let config = MD063Config {
866            enabled: true,
867            style,
868            ..Default::default()
869        };
870        MD063HeadingCapitalization::from_config_struct(config)
871    }
872
873    // Title case tests
874    #[test]
875    fn test_title_case_basic() {
876        let rule = create_rule();
877        let content = "# hello world\n";
878        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
879        let result = rule.check(&ctx).unwrap();
880        assert_eq!(result.len(), 1);
881        assert!(result[0].message.contains("Hello World"));
882    }
883
884    #[test]
885    fn test_title_case_lowercase_words() {
886        let rule = create_rule();
887        let content = "# the quick brown fox\n";
888        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
889        let result = rule.check(&ctx).unwrap();
890        assert_eq!(result.len(), 1);
891        // "The" should be capitalized (first word), "quick", "brown", "fox" should be capitalized
892        assert!(result[0].message.contains("The Quick Brown Fox"));
893    }
894
895    #[test]
896    fn test_title_case_already_correct() {
897        let rule = create_rule();
898        let content = "# The Quick Brown Fox\n";
899        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
900        let result = rule.check(&ctx).unwrap();
901        assert!(result.is_empty(), "Already correct heading should not be flagged");
902    }
903
904    #[test]
905    fn test_title_case_hyphenated() {
906        let rule = create_rule();
907        let content = "# self-documenting code\n";
908        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
909        let result = rule.check(&ctx).unwrap();
910        assert_eq!(result.len(), 1);
911        assert!(result[0].message.contains("Self-Documenting Code"));
912    }
913
914    // Sentence case tests
915    #[test]
916    fn test_sentence_case_basic() {
917        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
918        let content = "# The Quick Brown Fox\n";
919        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
920        let result = rule.check(&ctx).unwrap();
921        assert_eq!(result.len(), 1);
922        assert!(result[0].message.contains("The quick brown fox"));
923    }
924
925    #[test]
926    fn test_sentence_case_already_correct() {
927        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
928        let content = "# The quick brown fox\n";
929        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
930        let result = rule.check(&ctx).unwrap();
931        assert!(result.is_empty());
932    }
933
934    // All caps tests
935    #[test]
936    fn test_all_caps_basic() {
937        let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
938        let content = "# hello world\n";
939        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
940        let result = rule.check(&ctx).unwrap();
941        assert_eq!(result.len(), 1);
942        assert!(result[0].message.contains("HELLO WORLD"));
943    }
944
945    // Preserve tests
946    #[test]
947    fn test_preserve_ignore_words() {
948        let config = MD063Config {
949            enabled: true,
950            ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
951            ..Default::default()
952        };
953        let rule = MD063HeadingCapitalization::from_config_struct(config);
954
955        let content = "# using iPhone on macOS\n";
956        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
957        let result = rule.check(&ctx).unwrap();
958        assert_eq!(result.len(), 1);
959        // iPhone and macOS should be preserved
960        assert!(result[0].message.contains("iPhone"));
961        assert!(result[0].message.contains("macOS"));
962    }
963
964    #[test]
965    fn test_preserve_cased_words() {
966        let rule = create_rule();
967        let content = "# using GitHub actions\n";
968        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
969        let result = rule.check(&ctx).unwrap();
970        assert_eq!(result.len(), 1);
971        // GitHub should be preserved (has internal capital)
972        assert!(result[0].message.contains("GitHub"));
973    }
974
975    // Inline code tests
976    #[test]
977    fn test_inline_code_preserved() {
978        let rule = create_rule();
979        let content = "# using `const` in javascript\n";
980        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
981        let result = rule.check(&ctx).unwrap();
982        assert_eq!(result.len(), 1);
983        // `const` should be preserved, rest capitalized
984        assert!(result[0].message.contains("`const`"));
985        assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
986    }
987
988    // Level filter tests
989    #[test]
990    fn test_level_filter() {
991        let config = MD063Config {
992            enabled: true,
993            min_level: 2,
994            max_level: 4,
995            ..Default::default()
996        };
997        let rule = MD063HeadingCapitalization::from_config_struct(config);
998
999        let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
1000        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1001        let result = rule.check(&ctx).unwrap();
1002
1003        // Only h2 and h3 should be flagged (h1 < min_level, h5 > max_level)
1004        assert_eq!(result.len(), 2);
1005        assert_eq!(result[0].line, 2); // h2
1006        assert_eq!(result[1].line, 3); // h3
1007    }
1008
1009    // Fix tests
1010    #[test]
1011    fn test_fix_atx_heading() {
1012        let rule = create_rule();
1013        let content = "# hello world\n";
1014        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1015        let fixed = rule.fix(&ctx).unwrap();
1016        assert_eq!(fixed, "# Hello World\n");
1017    }
1018
1019    #[test]
1020    fn test_fix_multiple_headings() {
1021        let rule = create_rule();
1022        let content = "# first heading\n\n## second heading\n";
1023        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1024        let fixed = rule.fix(&ctx).unwrap();
1025        assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
1026    }
1027
1028    // Setext heading tests
1029    #[test]
1030    fn test_setext_heading() {
1031        let rule = create_rule();
1032        let content = "hello world\n============\n";
1033        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1034        let result = rule.check(&ctx).unwrap();
1035        assert_eq!(result.len(), 1);
1036        assert!(result[0].message.contains("Hello World"));
1037    }
1038
1039    // Custom ID tests
1040    #[test]
1041    fn test_custom_id_preserved() {
1042        let rule = create_rule();
1043        let content = "# getting started {#intro}\n";
1044        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1045        let result = rule.check(&ctx).unwrap();
1046        assert_eq!(result.len(), 1);
1047        // Custom ID should be preserved
1048        assert!(result[0].message.contains("{#intro}"));
1049    }
1050
1051    // Acronym preservation tests
1052    #[test]
1053    fn test_preserve_all_caps_acronyms() {
1054        let rule = create_rule();
1055        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1056
1057        // Basic acronyms should be preserved
1058        let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
1059        assert_eq!(fixed, "# Using API in Production\n");
1060
1061        // Multiple acronyms
1062        let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
1063        assert_eq!(fixed, "# API and GPU Integration\n");
1064
1065        // Two-letter acronyms
1066        let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
1067        assert_eq!(fixed, "# IO Performance Guide\n");
1068
1069        // Acronyms with numbers
1070        let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
1071        assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
1072    }
1073
1074    #[test]
1075    fn test_preserve_acronyms_in_hyphenated_words() {
1076        let rule = create_rule();
1077        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1078
1079        // Acronyms at start of hyphenated word
1080        let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
1081        assert_eq!(fixed, "# API-Driven Architecture\n");
1082
1083        // Multiple acronyms with hyphens
1084        let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
1085        assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
1086    }
1087
1088    #[test]
1089    fn test_single_letters_not_treated_as_acronyms() {
1090        let rule = create_rule();
1091        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1092
1093        // Single uppercase letters should follow title case rules, not be preserved
1094        let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
1095        assert_eq!(fixed, "# I Am a Heading\n");
1096    }
1097
1098    #[test]
1099    fn test_lowercase_terms_need_ignore_words() {
1100        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1101
1102        // Without ignore_words: npm gets capitalized
1103        let rule = create_rule();
1104        let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1105        assert_eq!(fixed, "# Using Npm Packages\n");
1106
1107        // With ignore_words: npm preserved
1108        let config = MD063Config {
1109            enabled: true,
1110            ignore_words: vec!["npm".to_string()],
1111            ..Default::default()
1112        };
1113        let rule = MD063HeadingCapitalization::from_config_struct(config);
1114        let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1115        assert_eq!(fixed, "# Using npm Packages\n");
1116    }
1117
1118    #[test]
1119    fn test_acronyms_with_mixed_case_preserved() {
1120        let rule = create_rule();
1121        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1122
1123        // Both acronyms (API, GPU) and mixed-case (GitHub) should be preserved
1124        let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
1125        assert_eq!(fixed, "# Using API with GitHub\n");
1126    }
1127
1128    #[test]
1129    fn test_real_world_acronyms() {
1130        let rule = create_rule();
1131        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1132
1133        // Common technical acronyms from tested repositories
1134        let content = "# FFI bindings for CPU optimization\n";
1135        let fixed = rule.fix(&ctx(content)).unwrap();
1136        assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
1137
1138        let content = "# DOM manipulation and SSR rendering\n";
1139        let fixed = rule.fix(&ctx(content)).unwrap();
1140        assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
1141
1142        let content = "# CVE security and RNN models\n";
1143        let fixed = rule.fix(&ctx(content)).unwrap();
1144        assert_eq!(fixed, "# CVE Security and RNN Models\n");
1145    }
1146
1147    #[test]
1148    fn test_is_all_caps_acronym() {
1149        let rule = create_rule();
1150
1151        // Should return true for all-caps with 2+ letters
1152        assert!(rule.is_all_caps_acronym("API"));
1153        assert!(rule.is_all_caps_acronym("IO"));
1154        assert!(rule.is_all_caps_acronym("GPU"));
1155        assert!(rule.is_all_caps_acronym("HTTP2")); // Numbers don't break it
1156
1157        // Should return false for single letters
1158        assert!(!rule.is_all_caps_acronym("A"));
1159        assert!(!rule.is_all_caps_acronym("I"));
1160
1161        // Should return false for words with lowercase
1162        assert!(!rule.is_all_caps_acronym("Api"));
1163        assert!(!rule.is_all_caps_acronym("npm"));
1164        assert!(!rule.is_all_caps_acronym("iPhone"));
1165    }
1166
1167    #[test]
1168    fn test_sentence_case_ignore_words_first_word() {
1169        let config = MD063Config {
1170            enabled: true,
1171            style: HeadingCapStyle::SentenceCase,
1172            ignore_words: vec!["nvim".to_string()],
1173            ..Default::default()
1174        };
1175        let rule = MD063HeadingCapitalization::from_config_struct(config);
1176
1177        // "nvim" as first word should be preserved exactly
1178        let content = "# nvim config\n";
1179        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1180        let result = rule.check(&ctx).unwrap();
1181        assert!(
1182            result.is_empty(),
1183            "nvim in ignore-words should not be flagged. Got: {result:?}"
1184        );
1185
1186        // Verify fix also preserves it
1187        let fixed = rule.fix(&ctx).unwrap();
1188        assert_eq!(fixed, "# nvim config\n");
1189    }
1190
1191    #[test]
1192    fn test_sentence_case_ignore_words_not_first() {
1193        let config = MD063Config {
1194            enabled: true,
1195            style: HeadingCapStyle::SentenceCase,
1196            ignore_words: vec!["nvim".to_string()],
1197            ..Default::default()
1198        };
1199        let rule = MD063HeadingCapitalization::from_config_struct(config);
1200
1201        // "nvim" in middle should also be preserved
1202        let content = "# Using nvim editor\n";
1203        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1204        let result = rule.check(&ctx).unwrap();
1205        assert!(
1206            result.is_empty(),
1207            "nvim in ignore-words should be preserved. Got: {result:?}"
1208        );
1209    }
1210
1211    #[test]
1212    fn test_preserve_cased_words_ios() {
1213        let config = MD063Config {
1214            enabled: true,
1215            style: HeadingCapStyle::SentenceCase,
1216            preserve_cased_words: true,
1217            ..Default::default()
1218        };
1219        let rule = MD063HeadingCapitalization::from_config_struct(config);
1220
1221        // "iOS" should be preserved (has mixed case: lowercase 'i' + uppercase 'OS')
1222        let content = "## This is iOS\n";
1223        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1224        let result = rule.check(&ctx).unwrap();
1225        assert!(
1226            result.is_empty(),
1227            "iOS should be preserved with preserve-cased-words. Got: {result:?}"
1228        );
1229
1230        // Verify fix also preserves it
1231        let fixed = rule.fix(&ctx).unwrap();
1232        assert_eq!(fixed, "## This is iOS\n");
1233    }
1234
1235    #[test]
1236    fn test_preserve_cased_words_ios_title_case() {
1237        let config = MD063Config {
1238            enabled: true,
1239            style: HeadingCapStyle::TitleCase,
1240            preserve_cased_words: true,
1241            ..Default::default()
1242        };
1243        let rule = MD063HeadingCapitalization::from_config_struct(config);
1244
1245        // "iOS" should be preserved in title case too
1246        let content = "# developing for iOS\n";
1247        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1248        let fixed = rule.fix(&ctx).unwrap();
1249        assert_eq!(fixed, "# Developing for iOS\n");
1250    }
1251
1252    #[test]
1253    fn test_has_internal_capitals_ios() {
1254        let rule = create_rule();
1255
1256        // iOS should be detected as having internal capitals
1257        assert!(
1258            rule.has_internal_capitals("iOS"),
1259            "iOS has mixed case (lowercase i, uppercase OS)"
1260        );
1261
1262        // Other mixed-case words
1263        assert!(rule.has_internal_capitals("iPhone"));
1264        assert!(rule.has_internal_capitals("macOS"));
1265        assert!(rule.has_internal_capitals("GitHub"));
1266        assert!(rule.has_internal_capitals("JavaScript"));
1267        assert!(rule.has_internal_capitals("eBay"));
1268
1269        // All-caps should NOT be detected (handled by is_all_caps_acronym)
1270        assert!(!rule.has_internal_capitals("API"));
1271        assert!(!rule.has_internal_capitals("GPU"));
1272
1273        // All-lowercase should NOT be detected
1274        assert!(!rule.has_internal_capitals("npm"));
1275        assert!(!rule.has_internal_capitals("config"));
1276
1277        // Regular capitalized words should NOT be detected
1278        assert!(!rule.has_internal_capitals("The"));
1279        assert!(!rule.has_internal_capitals("Hello"));
1280    }
1281
1282    #[test]
1283    fn test_lowercase_words_before_trailing_code() {
1284        let config = MD063Config {
1285            enabled: true,
1286            style: HeadingCapStyle::TitleCase,
1287            lowercase_words: vec![
1288                "a".to_string(),
1289                "an".to_string(),
1290                "and".to_string(),
1291                "at".to_string(),
1292                "but".to_string(),
1293                "by".to_string(),
1294                "for".to_string(),
1295                "from".to_string(),
1296                "into".to_string(),
1297                "nor".to_string(),
1298                "on".to_string(),
1299                "onto".to_string(),
1300                "or".to_string(),
1301                "the".to_string(),
1302                "to".to_string(),
1303                "upon".to_string(),
1304                "via".to_string(),
1305                "vs".to_string(),
1306                "with".to_string(),
1307                "without".to_string(),
1308            ],
1309            preserve_cased_words: true,
1310            ..Default::default()
1311        };
1312        let rule = MD063HeadingCapitalization::from_config_struct(config);
1313
1314        // Test: "subtitle with a `app`" (all lowercase input)
1315        // Expected fix: "Subtitle With a `app`" - capitalize "Subtitle" and "With",
1316        // but keep "a" lowercase (it's in lowercase-words and not the last word)
1317        // Incorrect: "Subtitle with A `app`" (would incorrectly capitalize "a")
1318        let content = "## subtitle with a `app`\n";
1319        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1320        let result = rule.check(&ctx).unwrap();
1321
1322        // Should flag it
1323        assert!(!result.is_empty(), "Should flag incorrect capitalization");
1324        let fixed = rule.fix(&ctx).unwrap();
1325        // "a" should remain lowercase (not "A") because inline code at end doesn't change lowercase-words behavior
1326        assert!(
1327            fixed.contains("with a `app`"),
1328            "Expected 'with a `app`' but got: {fixed:?}"
1329        );
1330        assert!(
1331            !fixed.contains("with A `app`"),
1332            "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
1333        );
1334        // "Subtitle" should be capitalized, "with" and "a" should remain lowercase (they're in lowercase-words)
1335        assert!(
1336            fixed.contains("Subtitle with a `app`"),
1337            "Expected 'Subtitle with a `app`' but got: {fixed:?}"
1338        );
1339    }
1340
1341    #[test]
1342    fn test_lowercase_words_preserved_before_trailing_code_variant() {
1343        let config = MD063Config {
1344            enabled: true,
1345            style: HeadingCapStyle::TitleCase,
1346            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1347            ..Default::default()
1348        };
1349        let rule = MD063HeadingCapitalization::from_config_struct(config);
1350
1351        // Another variant: "Title with the `code`"
1352        let content = "## Title with the `code`\n";
1353        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1354        let fixed = rule.fix(&ctx).unwrap();
1355        // "the" should remain lowercase
1356        assert!(
1357            fixed.contains("with the `code`"),
1358            "Expected 'with the `code`' but got: {fixed:?}"
1359        );
1360        assert!(
1361            !fixed.contains("with The `code`"),
1362            "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
1363        );
1364    }
1365
1366    #[test]
1367    fn test_last_word_capitalized_when_no_trailing_code() {
1368        // Verify that when there's NO trailing code, the last word IS capitalized
1369        // (even if it's in lowercase-words) - this is the normal title case behavior
1370        let config = MD063Config {
1371            enabled: true,
1372            style: HeadingCapStyle::TitleCase,
1373            lowercase_words: vec!["a".to_string(), "the".to_string()],
1374            ..Default::default()
1375        };
1376        let rule = MD063HeadingCapitalization::from_config_struct(config);
1377
1378        // "title with a word" - "word" is last, should be capitalized
1379        // "a" is in lowercase-words and not last, so should be lowercase
1380        let content = "## title with a word\n";
1381        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1382        let fixed = rule.fix(&ctx).unwrap();
1383        // "a" should be lowercase, "word" should be capitalized (it's last)
1384        assert!(
1385            fixed.contains("With a Word"),
1386            "Expected 'With a Word' but got: {fixed:?}"
1387        );
1388    }
1389
1390    #[test]
1391    fn test_multiple_lowercase_words_before_code() {
1392        let config = MD063Config {
1393            enabled: true,
1394            style: HeadingCapStyle::TitleCase,
1395            lowercase_words: vec![
1396                "a".to_string(),
1397                "the".to_string(),
1398                "with".to_string(),
1399                "for".to_string(),
1400            ],
1401            ..Default::default()
1402        };
1403        let rule = MD063HeadingCapitalization::from_config_struct(config);
1404
1405        // Multiple lowercase words before code - all should remain lowercase
1406        let content = "## Guide for the `user`\n";
1407        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1408        let fixed = rule.fix(&ctx).unwrap();
1409        assert!(
1410            fixed.contains("for the `user`"),
1411            "Expected 'for the `user`' but got: {fixed:?}"
1412        );
1413        assert!(
1414            !fixed.contains("For The `user`"),
1415            "Should not capitalize lowercase words before code. Got: {fixed:?}"
1416        );
1417    }
1418
1419    #[test]
1420    fn test_code_in_middle_normal_rules_apply() {
1421        let config = MD063Config {
1422            enabled: true,
1423            style: HeadingCapStyle::TitleCase,
1424            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1425            ..Default::default()
1426        };
1427        let rule = MD063HeadingCapitalization::from_config_struct(config);
1428
1429        // Code in the middle - normal title case rules apply (last word capitalized)
1430        let content = "## Using `const` for the code\n";
1431        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1432        let fixed = rule.fix(&ctx).unwrap();
1433        // "for" and "the" should be lowercase (middle), "code" should be capitalized (last)
1434        assert!(
1435            fixed.contains("for the Code"),
1436            "Expected 'for the Code' but got: {fixed:?}"
1437        );
1438    }
1439
1440    #[test]
1441    fn test_link_at_end_same_as_code() {
1442        let config = MD063Config {
1443            enabled: true,
1444            style: HeadingCapStyle::TitleCase,
1445            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1446            ..Default::default()
1447        };
1448        let rule = MD063HeadingCapitalization::from_config_struct(config);
1449
1450        // Link at the end - same behavior as code (lowercase words before should remain lowercase)
1451        let content = "## Guide for the [link](./page.md)\n";
1452        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1453        let fixed = rule.fix(&ctx).unwrap();
1454        // "for" and "the" should remain lowercase (not last word because link follows)
1455        assert!(
1456            fixed.contains("for the [Link]"),
1457            "Expected 'for the [Link]' but got: {fixed:?}"
1458        );
1459        assert!(
1460            !fixed.contains("for The [Link]"),
1461            "Should not capitalize 'the' before link. Got: {fixed:?}"
1462        );
1463    }
1464
1465    #[test]
1466    fn test_multiple_code_segments() {
1467        let config = MD063Config {
1468            enabled: true,
1469            style: HeadingCapStyle::TitleCase,
1470            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1471            ..Default::default()
1472        };
1473        let rule = MD063HeadingCapitalization::from_config_struct(config);
1474
1475        // Multiple code segments - last segment is code, so lowercase words before should remain lowercase
1476        let content = "## Using `const` with a `variable`\n";
1477        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1478        let fixed = rule.fix(&ctx).unwrap();
1479        // "a" should remain lowercase (not last word because code follows)
1480        assert!(
1481            fixed.contains("with a `variable`"),
1482            "Expected 'with a `variable`' but got: {fixed:?}"
1483        );
1484        assert!(
1485            !fixed.contains("with A `variable`"),
1486            "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1487        );
1488    }
1489
1490    #[test]
1491    fn test_code_and_link_combination() {
1492        let config = MD063Config {
1493            enabled: true,
1494            style: HeadingCapStyle::TitleCase,
1495            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1496            ..Default::default()
1497        };
1498        let rule = MD063HeadingCapitalization::from_config_struct(config);
1499
1500        // Code then link - last segment is link, so lowercase words before code should remain lowercase
1501        let content = "## Guide for the `code` [link](./page.md)\n";
1502        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1503        let fixed = rule.fix(&ctx).unwrap();
1504        // "for" and "the" should remain lowercase (not last word because link follows)
1505        assert!(
1506            fixed.contains("for the `code`"),
1507            "Expected 'for the `code`' but got: {fixed:?}"
1508        );
1509    }
1510
1511    #[test]
1512    fn test_text_after_code_capitalizes_last() {
1513        let config = MD063Config {
1514            enabled: true,
1515            style: HeadingCapStyle::TitleCase,
1516            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1517            ..Default::default()
1518        };
1519        let rule = MD063HeadingCapitalization::from_config_struct(config);
1520
1521        // Code in middle, text after - last word should be capitalized
1522        let content = "## Using `const` for the code\n";
1523        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1524        let fixed = rule.fix(&ctx).unwrap();
1525        // "for" and "the" should be lowercase, "code" is last word, should be capitalized
1526        assert!(
1527            fixed.contains("for the Code"),
1528            "Expected 'for the Code' but got: {fixed:?}"
1529        );
1530    }
1531
1532    #[test]
1533    fn test_preserve_cased_words_with_trailing_code() {
1534        let config = MD063Config {
1535            enabled: true,
1536            style: HeadingCapStyle::TitleCase,
1537            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1538            preserve_cased_words: true,
1539            ..Default::default()
1540        };
1541        let rule = MD063HeadingCapitalization::from_config_struct(config);
1542
1543        // Preserve-cased words should still work with trailing code
1544        let content = "## Guide for iOS `app`\n";
1545        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1546        let fixed = rule.fix(&ctx).unwrap();
1547        // "iOS" should be preserved, "for" should be lowercase
1548        assert!(
1549            fixed.contains("for iOS `app`"),
1550            "Expected 'for iOS `app`' but got: {fixed:?}"
1551        );
1552        assert!(
1553            !fixed.contains("For iOS `app`"),
1554            "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
1555        );
1556    }
1557
1558    #[test]
1559    fn test_ignore_words_with_trailing_code() {
1560        let config = MD063Config {
1561            enabled: true,
1562            style: HeadingCapStyle::TitleCase,
1563            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1564            ignore_words: vec!["npm".to_string()],
1565            ..Default::default()
1566        };
1567        let rule = MD063HeadingCapitalization::from_config_struct(config);
1568
1569        // Ignore-words should still work with trailing code
1570        let content = "## Using npm with a `script`\n";
1571        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1572        let fixed = rule.fix(&ctx).unwrap();
1573        // "npm" should be preserved, "with" and "a" should be lowercase
1574        assert!(
1575            fixed.contains("npm with a `script`"),
1576            "Expected 'npm with a `script`' but got: {fixed:?}"
1577        );
1578        assert!(
1579            !fixed.contains("with A `script`"),
1580            "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1581        );
1582    }
1583
1584    #[test]
1585    fn test_empty_text_segment_edge_case() {
1586        let config = MD063Config {
1587            enabled: true,
1588            style: HeadingCapStyle::TitleCase,
1589            lowercase_words: vec!["a".to_string(), "with".to_string()],
1590            ..Default::default()
1591        };
1592        let rule = MD063HeadingCapitalization::from_config_struct(config);
1593
1594        // Edge case: code at start, then text with lowercase word, then code at end
1595        let content = "## `start` with a `end`\n";
1596        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1597        let fixed = rule.fix(&ctx).unwrap();
1598        // "with" is first word in text segment, so capitalized (correct)
1599        // "a" should remain lowercase (not last word because code follows) - this is the key test
1600        assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
1601        assert!(
1602            !fixed.contains("A `end`"),
1603            "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1604        );
1605    }
1606
1607    #[test]
1608    fn test_sentence_case_with_trailing_code() {
1609        let config = MD063Config {
1610            enabled: true,
1611            style: HeadingCapStyle::SentenceCase,
1612            lowercase_words: vec!["a".to_string(), "the".to_string()],
1613            ..Default::default()
1614        };
1615        let rule = MD063HeadingCapitalization::from_config_struct(config);
1616
1617        // Sentence case should also respect lowercase words before code
1618        let content = "## guide for the `user`\n";
1619        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1620        let fixed = rule.fix(&ctx).unwrap();
1621        // First word capitalized, rest lowercase including "the" before code
1622        assert!(
1623            fixed.contains("Guide for the `user`"),
1624            "Expected 'Guide for the `user`' but got: {fixed:?}"
1625        );
1626    }
1627
1628    #[test]
1629    fn test_hyphenated_word_before_code() {
1630        let config = MD063Config {
1631            enabled: true,
1632            style: HeadingCapStyle::TitleCase,
1633            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1634            ..Default::default()
1635        };
1636        let rule = MD063HeadingCapitalization::from_config_struct(config);
1637
1638        // Hyphenated word before code - last part should respect lowercase-words
1639        let content = "## Self-contained with a `feature`\n";
1640        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1641        let fixed = rule.fix(&ctx).unwrap();
1642        // "with" and "a" should remain lowercase (not last word because code follows)
1643        assert!(
1644            fixed.contains("with a `feature`"),
1645            "Expected 'with a `feature`' but got: {fixed:?}"
1646        );
1647    }
1648
1649    // Issue #228: Sentence case with inline code at heading start
1650    // When a heading starts with inline code, the first word after the code
1651    // should NOT be capitalized because the heading already has a "first element"
1652
1653    #[test]
1654    fn test_sentence_case_code_at_start_basic() {
1655        // The exact case from issue #228
1656        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1657        let content = "# `rumdl` is a linter\n";
1658        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1659        let result = rule.check(&ctx).unwrap();
1660        // Should be correct as-is: code is first, "is" stays lowercase
1661        assert!(
1662            result.is_empty(),
1663            "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
1664            result.iter().map(|w| &w.message).collect::<Vec<_>>()
1665        );
1666    }
1667
1668    #[test]
1669    fn test_sentence_case_code_at_start_incorrect_capitalization() {
1670        // Verify we detect incorrect capitalization after code at start
1671        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1672        let content = "# `rumdl` Is a Linter\n";
1673        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1674        let result = rule.check(&ctx).unwrap();
1675        // Should flag: "Is" and "Linter" should be lowercase
1676        assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
1677        assert!(
1678            result[0].message.contains("`rumdl` is a linter"),
1679            "Should suggest lowercase after code. Got: {:?}",
1680            result[0].message
1681        );
1682    }
1683
1684    #[test]
1685    fn test_sentence_case_code_at_start_fix() {
1686        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1687        let content = "# `rumdl` Is A Linter\n";
1688        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1689        let fixed = rule.fix(&ctx).unwrap();
1690        assert!(
1691            fixed.contains("# `rumdl` is a linter"),
1692            "Should fix to lowercase after code. Got: {fixed:?}"
1693        );
1694    }
1695
1696    #[test]
1697    fn test_sentence_case_text_at_start_still_capitalizes() {
1698        // Ensure normal headings still capitalize first word
1699        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1700        let content = "# the quick brown fox\n";
1701        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1702        let result = rule.check(&ctx).unwrap();
1703        assert_eq!(result.len(), 1);
1704        assert!(
1705            result[0].message.contains("The quick brown fox"),
1706            "Text-first heading should capitalize first word. Got: {:?}",
1707            result[0].message
1708        );
1709    }
1710
1711    #[test]
1712    fn test_sentence_case_link_at_start() {
1713        // Links at start: link text is lowercased, following text also lowercase
1714        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1715        // Use lowercase link text to avoid link text case flagging
1716        let content = "# [api](api.md) reference guide\n";
1717        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1718        let result = rule.check(&ctx).unwrap();
1719        // "reference" should be lowercase (link is first)
1720        assert!(
1721            result.is_empty(),
1722            "Heading with link at start should not capitalize 'reference'. Got: {:?}",
1723            result.iter().map(|w| &w.message).collect::<Vec<_>>()
1724        );
1725    }
1726
1727    #[test]
1728    fn test_sentence_case_link_preserves_acronyms() {
1729        // Acronyms in link text should be preserved (API, HTTP, etc.)
1730        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1731        let content = "# [API](api.md) Reference Guide\n";
1732        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1733        let result = rule.check(&ctx).unwrap();
1734        assert_eq!(result.len(), 1);
1735        // "API" should be preserved (acronym), "Reference Guide" should be lowercased
1736        assert!(
1737            result[0].message.contains("[API](api.md) reference guide"),
1738            "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
1739            result[0].message
1740        );
1741    }
1742
1743    #[test]
1744    fn test_sentence_case_link_preserves_brand_names() {
1745        // Brand names with internal capitals should be preserved
1746        let config = MD063Config {
1747            enabled: true,
1748            style: HeadingCapStyle::SentenceCase,
1749            preserve_cased_words: true,
1750            ..Default::default()
1751        };
1752        let rule = MD063HeadingCapitalization::from_config_struct(config);
1753        let content = "# [iPhone](iphone.md) Features Guide\n";
1754        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1755        let result = rule.check(&ctx).unwrap();
1756        assert_eq!(result.len(), 1);
1757        // "iPhone" should be preserved, "Features Guide" should be lowercased
1758        assert!(
1759            result[0].message.contains("[iPhone](iphone.md) features guide"),
1760            "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
1761            result[0].message
1762        );
1763    }
1764
1765    #[test]
1766    fn test_sentence_case_link_lowercases_regular_words() {
1767        // Regular words in link text should be lowercased
1768        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1769        let content = "# [Documentation](docs.md) Reference\n";
1770        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1771        let result = rule.check(&ctx).unwrap();
1772        assert_eq!(result.len(), 1);
1773        // "Documentation" should be lowercased (regular word)
1774        assert!(
1775            result[0].message.contains("[documentation](docs.md) reference"),
1776            "Should lowercase regular link text. Got: {:?}",
1777            result[0].message
1778        );
1779    }
1780
1781    #[test]
1782    fn test_sentence_case_link_at_start_correct_already() {
1783        // Link with correct casing should not be flagged
1784        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1785        let content = "# [API](api.md) reference guide\n";
1786        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1787        let result = rule.check(&ctx).unwrap();
1788        assert!(
1789            result.is_empty(),
1790            "Correctly cased heading with link should not be flagged. Got: {:?}",
1791            result.iter().map(|w| &w.message).collect::<Vec<_>>()
1792        );
1793    }
1794
1795    #[test]
1796    fn test_sentence_case_link_github_preserved() {
1797        // GitHub should be preserved (internal capitals)
1798        let config = MD063Config {
1799            enabled: true,
1800            style: HeadingCapStyle::SentenceCase,
1801            preserve_cased_words: true,
1802            ..Default::default()
1803        };
1804        let rule = MD063HeadingCapitalization::from_config_struct(config);
1805        let content = "# [GitHub](gh.md) Repository Setup\n";
1806        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1807        let result = rule.check(&ctx).unwrap();
1808        assert_eq!(result.len(), 1);
1809        assert!(
1810            result[0].message.contains("[GitHub](gh.md) repository setup"),
1811            "Should preserve 'GitHub'. Got: {:?}",
1812            result[0].message
1813        );
1814    }
1815
1816    #[test]
1817    fn test_sentence_case_multiple_code_spans() {
1818        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1819        let content = "# `foo` and `bar` are methods\n";
1820        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1821        let result = rule.check(&ctx).unwrap();
1822        // All text after first code should be lowercase
1823        assert!(
1824            result.is_empty(),
1825            "Should not capitalize words between/after code spans. Got: {:?}",
1826            result.iter().map(|w| &w.message).collect::<Vec<_>>()
1827        );
1828    }
1829
1830    #[test]
1831    fn test_sentence_case_code_only_heading() {
1832        // Heading with only code, no text
1833        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1834        let content = "# `rumdl`\n";
1835        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1836        let result = rule.check(&ctx).unwrap();
1837        assert!(
1838            result.is_empty(),
1839            "Code-only heading should be fine. Got: {:?}",
1840            result.iter().map(|w| &w.message).collect::<Vec<_>>()
1841        );
1842    }
1843
1844    #[test]
1845    fn test_sentence_case_code_at_end() {
1846        // Heading ending with code, text before should still capitalize first word
1847        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1848        let content = "# install the `rumdl` tool\n";
1849        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1850        let result = rule.check(&ctx).unwrap();
1851        // "install" should be capitalized (first word), rest lowercase
1852        assert_eq!(result.len(), 1);
1853        assert!(
1854            result[0].message.contains("Install the `rumdl` tool"),
1855            "First word should still be capitalized when text comes first. Got: {:?}",
1856            result[0].message
1857        );
1858    }
1859
1860    #[test]
1861    fn test_sentence_case_code_in_middle() {
1862        // Code in middle, text at start should capitalize first word
1863        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1864        let content = "# using the `rumdl` linter for markdown\n";
1865        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1866        let result = rule.check(&ctx).unwrap();
1867        // "using" should be capitalized, rest lowercase
1868        assert_eq!(result.len(), 1);
1869        assert!(
1870            result[0].message.contains("Using the `rumdl` linter for markdown"),
1871            "First word should be capitalized. Got: {:?}",
1872            result[0].message
1873        );
1874    }
1875
1876    #[test]
1877    fn test_sentence_case_preserved_word_after_code() {
1878        // Preserved words (like iPhone) should stay preserved even after code
1879        let config = MD063Config {
1880            enabled: true,
1881            style: HeadingCapStyle::SentenceCase,
1882            preserve_cased_words: true,
1883            ..Default::default()
1884        };
1885        let rule = MD063HeadingCapitalization::from_config_struct(config);
1886        let content = "# `swift` iPhone development\n";
1887        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1888        let result = rule.check(&ctx).unwrap();
1889        // "iPhone" should be preserved, "development" lowercase
1890        assert!(
1891            result.is_empty(),
1892            "Preserved words after code should stay. Got: {:?}",
1893            result.iter().map(|w| &w.message).collect::<Vec<_>>()
1894        );
1895    }
1896
1897    #[test]
1898    fn test_title_case_code_at_start_still_capitalizes() {
1899        // Title case should still capitalize words even after code at start
1900        let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
1901        let content = "# `api` quick start guide\n";
1902        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1903        let result = rule.check(&ctx).unwrap();
1904        // Title case: all major words capitalized
1905        assert_eq!(result.len(), 1);
1906        assert!(
1907            result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
1908            "Title case should capitalize major words after code. Got: {:?}",
1909            result[0].message
1910        );
1911    }
1912
1913    // ======== HTML TAG TESTS ========
1914
1915    #[test]
1916    fn test_sentence_case_html_tag_at_start() {
1917        // HTML tag at start: text after should NOT capitalize first word
1918        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1919        let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
1920        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1921        let result = rule.check(&ctx).unwrap();
1922        // "is", "a", "Modifier", "Key" should all be lowercase (except preserved words)
1923        assert_eq!(result.len(), 1);
1924        let fixed = rule.fix(&ctx).unwrap();
1925        assert_eq!(
1926            fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
1927            "Text after HTML at start should be lowercase"
1928        );
1929    }
1930
1931    #[test]
1932    fn test_sentence_case_html_tag_preserves_content() {
1933        // Content inside HTML tags should be preserved as-is
1934        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1935        let content = "# The <abbr>API</abbr> documentation guide\n";
1936        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1937        let result = rule.check(&ctx).unwrap();
1938        // "The" is first, "API" inside tag preserved, rest lowercase
1939        assert!(
1940            result.is_empty(),
1941            "HTML tag content should be preserved. Got: {:?}",
1942            result.iter().map(|w| &w.message).collect::<Vec<_>>()
1943        );
1944    }
1945
1946    #[test]
1947    fn test_sentence_case_html_tag_at_start_with_acronym() {
1948        // HTML tag at start with acronym content
1949        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1950        let content = "# <abbr>API</abbr> Documentation Guide\n";
1951        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1952        let result = rule.check(&ctx).unwrap();
1953        assert_eq!(result.len(), 1);
1954        let fixed = rule.fix(&ctx).unwrap();
1955        assert_eq!(
1956            fixed, "# <abbr>API</abbr> documentation guide\n",
1957            "Text after HTML at start should be lowercase, HTML content preserved"
1958        );
1959    }
1960
1961    #[test]
1962    fn test_sentence_case_html_tag_in_middle() {
1963        // HTML tag in middle: first word still capitalized
1964        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1965        let content = "# using the <code>config</code> File\n";
1966        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1967        let result = rule.check(&ctx).unwrap();
1968        assert_eq!(result.len(), 1);
1969        let fixed = rule.fix(&ctx).unwrap();
1970        assert_eq!(
1971            fixed, "# Using the <code>config</code> file\n",
1972            "First word capitalized, HTML preserved, rest lowercase"
1973        );
1974    }
1975
1976    #[test]
1977    fn test_html_tag_strong_emphasis() {
1978        // <strong> tag handling
1979        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1980        let content = "# The <strong>Bold</strong> Way\n";
1981        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1982        let result = rule.check(&ctx).unwrap();
1983        assert_eq!(result.len(), 1);
1984        let fixed = rule.fix(&ctx).unwrap();
1985        assert_eq!(
1986            fixed, "# The <strong>Bold</strong> way\n",
1987            "<strong> tag content should be preserved"
1988        );
1989    }
1990
1991    #[test]
1992    fn test_html_tag_with_attributes() {
1993        // HTML tags with attributes should still be detected
1994        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1995        let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
1996        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1997        let result = rule.check(&ctx).unwrap();
1998        assert_eq!(result.len(), 1);
1999        let fixed = rule.fix(&ctx).unwrap();
2000        assert_eq!(
2001            fixed, "# <span class=\"highlight\">Important</span> notice here\n",
2002            "HTML tag with attributes should be preserved"
2003        );
2004    }
2005
2006    #[test]
2007    fn test_multiple_html_tags() {
2008        // Multiple HTML tags in heading
2009        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2010        let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
2011        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2012        let result = rule.check(&ctx).unwrap();
2013        assert_eq!(result.len(), 1);
2014        let fixed = rule.fix(&ctx).unwrap();
2015        assert_eq!(
2016            fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
2017            "Multiple HTML tags should all be preserved"
2018        );
2019    }
2020
2021    #[test]
2022    fn test_html_and_code_mixed() {
2023        // Mix of HTML tags and inline code
2024        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2025        let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
2026        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2027        let result = rule.check(&ctx).unwrap();
2028        assert_eq!(result.len(), 1);
2029        let fixed = rule.fix(&ctx).unwrap();
2030        assert_eq!(
2031            fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
2032            "HTML and code should both be preserved"
2033        );
2034    }
2035
2036    #[test]
2037    fn test_self_closing_html_tag() {
2038        // Self-closing tags like <br/>
2039        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2040        let content = "# Line one<br/>Line Two Here\n";
2041        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2042        let result = rule.check(&ctx).unwrap();
2043        assert_eq!(result.len(), 1);
2044        let fixed = rule.fix(&ctx).unwrap();
2045        assert_eq!(
2046            fixed, "# Line one<br/>line two here\n",
2047            "Self-closing HTML tags should be preserved"
2048        );
2049    }
2050
2051    #[test]
2052    fn test_title_case_with_html_tags() {
2053        // Title case with HTML tags
2054        let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2055        let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
2056        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2057        let result = rule.check(&ctx).unwrap();
2058        assert_eq!(result.len(), 1);
2059        let fixed = rule.fix(&ctx).unwrap();
2060        // "the" as first word should be "The", content inside <kbd> preserved
2061        assert!(
2062            fixed.contains("<kbd>ctrl</kbd>"),
2063            "HTML tag content should be preserved in title case. Got: {fixed}"
2064        );
2065        assert!(
2066            fixed.starts_with("# The ") || fixed.starts_with("# the "),
2067            "Title case should work with HTML. Got: {fixed}"
2068        );
2069    }
2070
2071    // ======== CARET NOTATION TESTS ========
2072
2073    #[test]
2074    fn test_sentence_case_preserves_caret_notation() {
2075        // Caret notation for control characters should be preserved
2076        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2077        let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
2078        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2079        let result = rule.check(&ctx).unwrap();
2080        // Should not flag - ^A and ^R are preserved
2081        assert!(
2082            result.is_empty(),
2083            "Caret notation should be preserved. Got: {:?}",
2084            result.iter().map(|w| &w.message).collect::<Vec<_>>()
2085        );
2086    }
2087
2088    #[test]
2089    fn test_sentence_case_caret_notation_various() {
2090        // Various caret notation patterns
2091        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2092
2093        // ^C for interrupt
2094        let content = "## Press ^C to cancel\n";
2095        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2096        let result = rule.check(&ctx).unwrap();
2097        assert!(
2098            result.is_empty(),
2099            "^C should be preserved. Got: {:?}",
2100            result.iter().map(|w| &w.message).collect::<Vec<_>>()
2101        );
2102
2103        // ^Z for suspend
2104        let content = "## Use ^Z for background\n";
2105        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2106        let result = rule.check(&ctx).unwrap();
2107        assert!(
2108            result.is_empty(),
2109            "^Z should be preserved. Got: {:?}",
2110            result.iter().map(|w| &w.message).collect::<Vec<_>>()
2111        );
2112
2113        // ^[ for escape
2114        let content = "## Press ^[ for escape\n";
2115        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2116        let result = rule.check(&ctx).unwrap();
2117        assert!(
2118            result.is_empty(),
2119            "^[ should be preserved. Got: {:?}",
2120            result.iter().map(|w| &w.message).collect::<Vec<_>>()
2121        );
2122    }
2123
2124    #[test]
2125    fn test_caret_notation_detection() {
2126        let rule = create_rule();
2127
2128        // Valid caret notation
2129        assert!(rule.is_caret_notation("^A"));
2130        assert!(rule.is_caret_notation("^Z"));
2131        assert!(rule.is_caret_notation("^C"));
2132        assert!(rule.is_caret_notation("^@")); // NUL
2133        assert!(rule.is_caret_notation("^[")); // ESC
2134        assert!(rule.is_caret_notation("^]")); // GS
2135        assert!(rule.is_caret_notation("^^")); // RS
2136        assert!(rule.is_caret_notation("^_")); // US
2137
2138        // Not caret notation
2139        assert!(!rule.is_caret_notation("^a")); // lowercase
2140        assert!(!rule.is_caret_notation("A")); // no caret
2141        assert!(!rule.is_caret_notation("^")); // caret alone
2142        assert!(!rule.is_caret_notation("^1")); // digit
2143    }
2144}