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        // Skip if rule is disabled or no headings
724        !self.config.enabled || !ctx.likely_has_headings() || !ctx.lines.iter().any(|line| line.heading.is_some())
725    }
726
727    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
728        if !self.config.enabled {
729            return Ok(Vec::new());
730        }
731
732        let content = ctx.content;
733
734        if content.is_empty() {
735            return Ok(Vec::new());
736        }
737
738        let mut warnings = Vec::new();
739        let line_index = &ctx.line_index;
740
741        for (line_num, line_info) in ctx.lines.iter().enumerate() {
742            if let Some(heading) = &line_info.heading {
743                // Check level filter
744                if heading.level < self.config.min_level || heading.level > self.config.max_level {
745                    continue;
746                }
747
748                // Skip headings in code blocks (indented headings)
749                if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
750                    continue;
751                }
752
753                // Apply capitalization and compare
754                let original_text = &heading.raw_text;
755                let fixed_text = self.apply_capitalization(original_text);
756
757                if original_text != &fixed_text {
758                    let line = line_info.content(ctx.content);
759                    let style_name = match self.config.style {
760                        HeadingCapStyle::TitleCase => "title case",
761                        HeadingCapStyle::SentenceCase => "sentence case",
762                        HeadingCapStyle::AllCaps => "ALL CAPS",
763                    };
764
765                    warnings.push(LintWarning {
766                        rule_name: Some(self.name().to_string()),
767                        line: line_num + 1,
768                        column: heading.content_column + 1,
769                        end_line: line_num + 1,
770                        end_column: heading.content_column + 1 + original_text.len(),
771                        message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
772                        severity: Severity::Warning,
773                        fix: Some(Fix {
774                            range: self.get_line_byte_range(content, line_num + 1, line_index),
775                            replacement: match heading.style {
776                                crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
777                                _ => self.fix_setext_heading(line, heading),
778                            },
779                        }),
780                    });
781                }
782            }
783        }
784
785        Ok(warnings)
786    }
787
788    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
789        if !self.config.enabled {
790            return Ok(ctx.content.to_string());
791        }
792
793        let content = ctx.content;
794
795        if content.is_empty() {
796            return Ok(content.to_string());
797        }
798
799        let lines: Vec<&str> = content.lines().collect();
800        let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
801
802        for (line_num, line_info) in ctx.lines.iter().enumerate() {
803            if let Some(heading) = &line_info.heading {
804                // Check level filter
805                if heading.level < self.config.min_level || heading.level > self.config.max_level {
806                    continue;
807                }
808
809                // Skip headings in code blocks
810                if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
811                    continue;
812                }
813
814                let original_text = &heading.raw_text;
815                let fixed_text = self.apply_capitalization(original_text);
816
817                if original_text != &fixed_text {
818                    let line = line_info.content(ctx.content);
819                    fixed_lines[line_num] = match heading.style {
820                        crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
821                        _ => self.fix_setext_heading(line, heading),
822                    };
823                }
824            }
825        }
826
827        // Reconstruct content preserving line endings
828        let mut result = String::with_capacity(content.len());
829        for (i, line) in fixed_lines.iter().enumerate() {
830            result.push_str(line);
831            if i < fixed_lines.len() - 1 || content.ends_with('\n') {
832                result.push('\n');
833            }
834        }
835
836        Ok(result)
837    }
838
839    fn as_any(&self) -> &dyn std::any::Any {
840        self
841    }
842
843    fn default_config_section(&self) -> Option<(String, toml::Value)> {
844        let json_value = serde_json::to_value(&self.config).ok()?;
845        Some((
846            self.name().to_string(),
847            crate::rule_config_serde::json_to_toml_value(&json_value)?,
848        ))
849    }
850
851    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
852    where
853        Self: Sized,
854    {
855        let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
856        Box::new(Self::from_config_struct(rule_config))
857    }
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863    use crate::lint_context::LintContext;
864
865    fn create_rule() -> MD063HeadingCapitalization {
866        let config = MD063Config {
867            enabled: true,
868            ..Default::default()
869        };
870        MD063HeadingCapitalization::from_config_struct(config)
871    }
872
873    fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
874        let config = MD063Config {
875            enabled: true,
876            style,
877            ..Default::default()
878        };
879        MD063HeadingCapitalization::from_config_struct(config)
880    }
881
882    // Title case tests
883    #[test]
884    fn test_title_case_basic() {
885        let rule = create_rule();
886        let content = "# hello world\n";
887        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888        let result = rule.check(&ctx).unwrap();
889        assert_eq!(result.len(), 1);
890        assert!(result[0].message.contains("Hello World"));
891    }
892
893    #[test]
894    fn test_title_case_lowercase_words() {
895        let rule = create_rule();
896        let content = "# the quick brown fox\n";
897        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898        let result = rule.check(&ctx).unwrap();
899        assert_eq!(result.len(), 1);
900        // "The" should be capitalized (first word), "quick", "brown", "fox" should be capitalized
901        assert!(result[0].message.contains("The Quick Brown Fox"));
902    }
903
904    #[test]
905    fn test_title_case_already_correct() {
906        let rule = create_rule();
907        let content = "# The Quick Brown Fox\n";
908        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
909        let result = rule.check(&ctx).unwrap();
910        assert!(result.is_empty(), "Already correct heading should not be flagged");
911    }
912
913    #[test]
914    fn test_title_case_hyphenated() {
915        let rule = create_rule();
916        let content = "# self-documenting code\n";
917        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
918        let result = rule.check(&ctx).unwrap();
919        assert_eq!(result.len(), 1);
920        assert!(result[0].message.contains("Self-Documenting Code"));
921    }
922
923    // Sentence case tests
924    #[test]
925    fn test_sentence_case_basic() {
926        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
927        let content = "# The Quick Brown Fox\n";
928        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
929        let result = rule.check(&ctx).unwrap();
930        assert_eq!(result.len(), 1);
931        assert!(result[0].message.contains("The quick brown fox"));
932    }
933
934    #[test]
935    fn test_sentence_case_already_correct() {
936        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
937        let content = "# The quick brown fox\n";
938        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
939        let result = rule.check(&ctx).unwrap();
940        assert!(result.is_empty());
941    }
942
943    // All caps tests
944    #[test]
945    fn test_all_caps_basic() {
946        let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
947        let content = "# hello world\n";
948        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
949        let result = rule.check(&ctx).unwrap();
950        assert_eq!(result.len(), 1);
951        assert!(result[0].message.contains("HELLO WORLD"));
952    }
953
954    // Preserve tests
955    #[test]
956    fn test_preserve_ignore_words() {
957        let config = MD063Config {
958            enabled: true,
959            ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
960            ..Default::default()
961        };
962        let rule = MD063HeadingCapitalization::from_config_struct(config);
963
964        let content = "# using iPhone on macOS\n";
965        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
966        let result = rule.check(&ctx).unwrap();
967        assert_eq!(result.len(), 1);
968        // iPhone and macOS should be preserved
969        assert!(result[0].message.contains("iPhone"));
970        assert!(result[0].message.contains("macOS"));
971    }
972
973    #[test]
974    fn test_preserve_cased_words() {
975        let rule = create_rule();
976        let content = "# using GitHub actions\n";
977        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
978        let result = rule.check(&ctx).unwrap();
979        assert_eq!(result.len(), 1);
980        // GitHub should be preserved (has internal capital)
981        assert!(result[0].message.contains("GitHub"));
982    }
983
984    // Inline code tests
985    #[test]
986    fn test_inline_code_preserved() {
987        let rule = create_rule();
988        let content = "# using `const` in javascript\n";
989        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
990        let result = rule.check(&ctx).unwrap();
991        assert_eq!(result.len(), 1);
992        // `const` should be preserved, rest capitalized
993        assert!(result[0].message.contains("`const`"));
994        assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
995    }
996
997    // Level filter tests
998    #[test]
999    fn test_level_filter() {
1000        let config = MD063Config {
1001            enabled: true,
1002            min_level: 2,
1003            max_level: 4,
1004            ..Default::default()
1005        };
1006        let rule = MD063HeadingCapitalization::from_config_struct(config);
1007
1008        let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
1009        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1010        let result = rule.check(&ctx).unwrap();
1011
1012        // Only h2 and h3 should be flagged (h1 < min_level, h5 > max_level)
1013        assert_eq!(result.len(), 2);
1014        assert_eq!(result[0].line, 2); // h2
1015        assert_eq!(result[1].line, 3); // h3
1016    }
1017
1018    // Fix tests
1019    #[test]
1020    fn test_fix_atx_heading() {
1021        let rule = create_rule();
1022        let content = "# hello world\n";
1023        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1024        let fixed = rule.fix(&ctx).unwrap();
1025        assert_eq!(fixed, "# Hello World\n");
1026    }
1027
1028    #[test]
1029    fn test_fix_multiple_headings() {
1030        let rule = create_rule();
1031        let content = "# first heading\n\n## second heading\n";
1032        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1033        let fixed = rule.fix(&ctx).unwrap();
1034        assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
1035    }
1036
1037    // Setext heading tests
1038    #[test]
1039    fn test_setext_heading() {
1040        let rule = create_rule();
1041        let content = "hello world\n============\n";
1042        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1043        let result = rule.check(&ctx).unwrap();
1044        assert_eq!(result.len(), 1);
1045        assert!(result[0].message.contains("Hello World"));
1046    }
1047
1048    // Custom ID tests
1049    #[test]
1050    fn test_custom_id_preserved() {
1051        let rule = create_rule();
1052        let content = "# getting started {#intro}\n";
1053        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1054        let result = rule.check(&ctx).unwrap();
1055        assert_eq!(result.len(), 1);
1056        // Custom ID should be preserved
1057        assert!(result[0].message.contains("{#intro}"));
1058    }
1059
1060    #[test]
1061    fn test_md063_disabled_by_default() {
1062        let rule = MD063HeadingCapitalization::new();
1063        let content = "# hello world\n";
1064        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1065
1066        // Should return no warnings when disabled
1067        let warnings = rule.check(&ctx).unwrap();
1068        assert_eq!(warnings.len(), 0);
1069
1070        // Should return content unchanged when disabled
1071        let fixed = rule.fix(&ctx).unwrap();
1072        assert_eq!(fixed, content);
1073    }
1074
1075    // Acronym preservation tests
1076    #[test]
1077    fn test_preserve_all_caps_acronyms() {
1078        let rule = create_rule();
1079        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1080
1081        // Basic acronyms should be preserved
1082        let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
1083        assert_eq!(fixed, "# Using API in Production\n");
1084
1085        // Multiple acronyms
1086        let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
1087        assert_eq!(fixed, "# API and GPU Integration\n");
1088
1089        // Two-letter acronyms
1090        let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
1091        assert_eq!(fixed, "# IO Performance Guide\n");
1092
1093        // Acronyms with numbers
1094        let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
1095        assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
1096    }
1097
1098    #[test]
1099    fn test_preserve_acronyms_in_hyphenated_words() {
1100        let rule = create_rule();
1101        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1102
1103        // Acronyms at start of hyphenated word
1104        let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
1105        assert_eq!(fixed, "# API-Driven Architecture\n");
1106
1107        // Multiple acronyms with hyphens
1108        let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
1109        assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
1110    }
1111
1112    #[test]
1113    fn test_single_letters_not_treated_as_acronyms() {
1114        let rule = create_rule();
1115        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1116
1117        // Single uppercase letters should follow title case rules, not be preserved
1118        let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
1119        assert_eq!(fixed, "# I Am a Heading\n");
1120    }
1121
1122    #[test]
1123    fn test_lowercase_terms_need_ignore_words() {
1124        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1125
1126        // Without ignore_words: npm gets capitalized
1127        let rule = create_rule();
1128        let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1129        assert_eq!(fixed, "# Using Npm Packages\n");
1130
1131        // With ignore_words: npm preserved
1132        let config = MD063Config {
1133            enabled: true,
1134            ignore_words: vec!["npm".to_string()],
1135            ..Default::default()
1136        };
1137        let rule = MD063HeadingCapitalization::from_config_struct(config);
1138        let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1139        assert_eq!(fixed, "# Using npm Packages\n");
1140    }
1141
1142    #[test]
1143    fn test_acronyms_with_mixed_case_preserved() {
1144        let rule = create_rule();
1145        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1146
1147        // Both acronyms (API, GPU) and mixed-case (GitHub) should be preserved
1148        let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
1149        assert_eq!(fixed, "# Using API with GitHub\n");
1150    }
1151
1152    #[test]
1153    fn test_real_world_acronyms() {
1154        let rule = create_rule();
1155        let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1156
1157        // Common technical acronyms from tested repositories
1158        let content = "# FFI bindings for CPU optimization\n";
1159        let fixed = rule.fix(&ctx(content)).unwrap();
1160        assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
1161
1162        let content = "# DOM manipulation and SSR rendering\n";
1163        let fixed = rule.fix(&ctx(content)).unwrap();
1164        assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
1165
1166        let content = "# CVE security and RNN models\n";
1167        let fixed = rule.fix(&ctx(content)).unwrap();
1168        assert_eq!(fixed, "# CVE Security and RNN Models\n");
1169    }
1170
1171    #[test]
1172    fn test_is_all_caps_acronym() {
1173        let rule = create_rule();
1174
1175        // Should return true for all-caps with 2+ letters
1176        assert!(rule.is_all_caps_acronym("API"));
1177        assert!(rule.is_all_caps_acronym("IO"));
1178        assert!(rule.is_all_caps_acronym("GPU"));
1179        assert!(rule.is_all_caps_acronym("HTTP2")); // Numbers don't break it
1180
1181        // Should return false for single letters
1182        assert!(!rule.is_all_caps_acronym("A"));
1183        assert!(!rule.is_all_caps_acronym("I"));
1184
1185        // Should return false for words with lowercase
1186        assert!(!rule.is_all_caps_acronym("Api"));
1187        assert!(!rule.is_all_caps_acronym("npm"));
1188        assert!(!rule.is_all_caps_acronym("iPhone"));
1189    }
1190
1191    #[test]
1192    fn test_sentence_case_ignore_words_first_word() {
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" as first word should be preserved exactly
1202        let content = "# nvim config\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 not be flagged. Got: {result:?}"
1208        );
1209
1210        // Verify fix also preserves it
1211        let fixed = rule.fix(&ctx).unwrap();
1212        assert_eq!(fixed, "# nvim config\n");
1213    }
1214
1215    #[test]
1216    fn test_sentence_case_ignore_words_not_first() {
1217        let config = MD063Config {
1218            enabled: true,
1219            style: HeadingCapStyle::SentenceCase,
1220            ignore_words: vec!["nvim".to_string()],
1221            ..Default::default()
1222        };
1223        let rule = MD063HeadingCapitalization::from_config_struct(config);
1224
1225        // "nvim" in middle should also be preserved
1226        let content = "# Using nvim editor\n";
1227        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1228        let result = rule.check(&ctx).unwrap();
1229        assert!(
1230            result.is_empty(),
1231            "nvim in ignore-words should be preserved. Got: {result:?}"
1232        );
1233    }
1234
1235    #[test]
1236    fn test_preserve_cased_words_ios() {
1237        let config = MD063Config {
1238            enabled: true,
1239            style: HeadingCapStyle::SentenceCase,
1240            preserve_cased_words: true,
1241            ..Default::default()
1242        };
1243        let rule = MD063HeadingCapitalization::from_config_struct(config);
1244
1245        // "iOS" should be preserved (has mixed case: lowercase 'i' + uppercase 'OS')
1246        let content = "## This is iOS\n";
1247        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1248        let result = rule.check(&ctx).unwrap();
1249        assert!(
1250            result.is_empty(),
1251            "iOS should be preserved with preserve-cased-words. Got: {result:?}"
1252        );
1253
1254        // Verify fix also preserves it
1255        let fixed = rule.fix(&ctx).unwrap();
1256        assert_eq!(fixed, "## This is iOS\n");
1257    }
1258
1259    #[test]
1260    fn test_preserve_cased_words_ios_title_case() {
1261        let config = MD063Config {
1262            enabled: true,
1263            style: HeadingCapStyle::TitleCase,
1264            preserve_cased_words: true,
1265            ..Default::default()
1266        };
1267        let rule = MD063HeadingCapitalization::from_config_struct(config);
1268
1269        // "iOS" should be preserved in title case too
1270        let content = "# developing for iOS\n";
1271        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1272        let fixed = rule.fix(&ctx).unwrap();
1273        assert_eq!(fixed, "# Developing for iOS\n");
1274    }
1275
1276    #[test]
1277    fn test_has_internal_capitals_ios() {
1278        let rule = create_rule();
1279
1280        // iOS should be detected as having internal capitals
1281        assert!(
1282            rule.has_internal_capitals("iOS"),
1283            "iOS has mixed case (lowercase i, uppercase OS)"
1284        );
1285
1286        // Other mixed-case words
1287        assert!(rule.has_internal_capitals("iPhone"));
1288        assert!(rule.has_internal_capitals("macOS"));
1289        assert!(rule.has_internal_capitals("GitHub"));
1290        assert!(rule.has_internal_capitals("JavaScript"));
1291        assert!(rule.has_internal_capitals("eBay"));
1292
1293        // All-caps should NOT be detected (handled by is_all_caps_acronym)
1294        assert!(!rule.has_internal_capitals("API"));
1295        assert!(!rule.has_internal_capitals("GPU"));
1296
1297        // All-lowercase should NOT be detected
1298        assert!(!rule.has_internal_capitals("npm"));
1299        assert!(!rule.has_internal_capitals("config"));
1300
1301        // Regular capitalized words should NOT be detected
1302        assert!(!rule.has_internal_capitals("The"));
1303        assert!(!rule.has_internal_capitals("Hello"));
1304    }
1305
1306    #[test]
1307    fn test_lowercase_words_before_trailing_code() {
1308        let config = MD063Config {
1309            enabled: true,
1310            style: HeadingCapStyle::TitleCase,
1311            lowercase_words: vec![
1312                "a".to_string(),
1313                "an".to_string(),
1314                "and".to_string(),
1315                "at".to_string(),
1316                "but".to_string(),
1317                "by".to_string(),
1318                "for".to_string(),
1319                "from".to_string(),
1320                "into".to_string(),
1321                "nor".to_string(),
1322                "on".to_string(),
1323                "onto".to_string(),
1324                "or".to_string(),
1325                "the".to_string(),
1326                "to".to_string(),
1327                "upon".to_string(),
1328                "via".to_string(),
1329                "vs".to_string(),
1330                "with".to_string(),
1331                "without".to_string(),
1332            ],
1333            preserve_cased_words: true,
1334            ..Default::default()
1335        };
1336        let rule = MD063HeadingCapitalization::from_config_struct(config);
1337
1338        // Test: "subtitle with a `app`" (all lowercase input)
1339        // Expected fix: "Subtitle With a `app`" - capitalize "Subtitle" and "With",
1340        // but keep "a" lowercase (it's in lowercase-words and not the last word)
1341        // Incorrect: "Subtitle with A `app`" (would incorrectly capitalize "a")
1342        let content = "## subtitle with a `app`\n";
1343        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1344        let result = rule.check(&ctx).unwrap();
1345
1346        // Should flag it
1347        assert!(!result.is_empty(), "Should flag incorrect capitalization");
1348        let fixed = rule.fix(&ctx).unwrap();
1349        // "a" should remain lowercase (not "A") because inline code at end doesn't change lowercase-words behavior
1350        assert!(
1351            fixed.contains("with a `app`"),
1352            "Expected 'with a `app`' but got: {fixed:?}"
1353        );
1354        assert!(
1355            !fixed.contains("with A `app`"),
1356            "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
1357        );
1358        // "Subtitle" should be capitalized, "with" and "a" should remain lowercase (they're in lowercase-words)
1359        assert!(
1360            fixed.contains("Subtitle with a `app`"),
1361            "Expected 'Subtitle with a `app`' but got: {fixed:?}"
1362        );
1363    }
1364
1365    #[test]
1366    fn test_lowercase_words_preserved_before_trailing_code_variant() {
1367        let config = MD063Config {
1368            enabled: true,
1369            style: HeadingCapStyle::TitleCase,
1370            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1371            ..Default::default()
1372        };
1373        let rule = MD063HeadingCapitalization::from_config_struct(config);
1374
1375        // Another variant: "Title with the `code`"
1376        let content = "## Title with the `code`\n";
1377        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1378        let fixed = rule.fix(&ctx).unwrap();
1379        // "the" should remain lowercase
1380        assert!(
1381            fixed.contains("with the `code`"),
1382            "Expected 'with the `code`' but got: {fixed:?}"
1383        );
1384        assert!(
1385            !fixed.contains("with The `code`"),
1386            "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
1387        );
1388    }
1389
1390    #[test]
1391    fn test_last_word_capitalized_when_no_trailing_code() {
1392        // Verify that when there's NO trailing code, the last word IS capitalized
1393        // (even if it's in lowercase-words) - this is the normal title case behavior
1394        let config = MD063Config {
1395            enabled: true,
1396            style: HeadingCapStyle::TitleCase,
1397            lowercase_words: vec!["a".to_string(), "the".to_string()],
1398            ..Default::default()
1399        };
1400        let rule = MD063HeadingCapitalization::from_config_struct(config);
1401
1402        // "title with a word" - "word" is last, should be capitalized
1403        // "a" is in lowercase-words and not last, so should be lowercase
1404        let content = "## title with a word\n";
1405        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1406        let fixed = rule.fix(&ctx).unwrap();
1407        // "a" should be lowercase, "word" should be capitalized (it's last)
1408        assert!(
1409            fixed.contains("With a Word"),
1410            "Expected 'With a Word' but got: {fixed:?}"
1411        );
1412    }
1413
1414    #[test]
1415    fn test_multiple_lowercase_words_before_code() {
1416        let config = MD063Config {
1417            enabled: true,
1418            style: HeadingCapStyle::TitleCase,
1419            lowercase_words: vec![
1420                "a".to_string(),
1421                "the".to_string(),
1422                "with".to_string(),
1423                "for".to_string(),
1424            ],
1425            ..Default::default()
1426        };
1427        let rule = MD063HeadingCapitalization::from_config_struct(config);
1428
1429        // Multiple lowercase words before code - all should remain lowercase
1430        let content = "## Guide for the `user`\n";
1431        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1432        let fixed = rule.fix(&ctx).unwrap();
1433        assert!(
1434            fixed.contains("for the `user`"),
1435            "Expected 'for the `user`' but got: {fixed:?}"
1436        );
1437        assert!(
1438            !fixed.contains("For The `user`"),
1439            "Should not capitalize lowercase words before code. Got: {fixed:?}"
1440        );
1441    }
1442
1443    #[test]
1444    fn test_code_in_middle_normal_rules_apply() {
1445        let config = MD063Config {
1446            enabled: true,
1447            style: HeadingCapStyle::TitleCase,
1448            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1449            ..Default::default()
1450        };
1451        let rule = MD063HeadingCapitalization::from_config_struct(config);
1452
1453        // Code in the middle - normal title case rules apply (last word capitalized)
1454        let content = "## Using `const` for the code\n";
1455        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1456        let fixed = rule.fix(&ctx).unwrap();
1457        // "for" and "the" should be lowercase (middle), "code" should be capitalized (last)
1458        assert!(
1459            fixed.contains("for the Code"),
1460            "Expected 'for the Code' but got: {fixed:?}"
1461        );
1462    }
1463
1464    #[test]
1465    fn test_link_at_end_same_as_code() {
1466        let config = MD063Config {
1467            enabled: true,
1468            style: HeadingCapStyle::TitleCase,
1469            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1470            ..Default::default()
1471        };
1472        let rule = MD063HeadingCapitalization::from_config_struct(config);
1473
1474        // Link at the end - same behavior as code (lowercase words before should remain lowercase)
1475        let content = "## Guide for the [link](./page.md)\n";
1476        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1477        let fixed = rule.fix(&ctx).unwrap();
1478        // "for" and "the" should remain lowercase (not last word because link follows)
1479        assert!(
1480            fixed.contains("for the [Link]"),
1481            "Expected 'for the [Link]' but got: {fixed:?}"
1482        );
1483        assert!(
1484            !fixed.contains("for The [Link]"),
1485            "Should not capitalize 'the' before link. Got: {fixed:?}"
1486        );
1487    }
1488
1489    #[test]
1490    fn test_multiple_code_segments() {
1491        let config = MD063Config {
1492            enabled: true,
1493            style: HeadingCapStyle::TitleCase,
1494            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1495            ..Default::default()
1496        };
1497        let rule = MD063HeadingCapitalization::from_config_struct(config);
1498
1499        // Multiple code segments - last segment is code, so lowercase words before should remain lowercase
1500        let content = "## Using `const` with a `variable`\n";
1501        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1502        let fixed = rule.fix(&ctx).unwrap();
1503        // "a" should remain lowercase (not last word because code follows)
1504        assert!(
1505            fixed.contains("with a `variable`"),
1506            "Expected 'with a `variable`' but got: {fixed:?}"
1507        );
1508        assert!(
1509            !fixed.contains("with A `variable`"),
1510            "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1511        );
1512    }
1513
1514    #[test]
1515    fn test_code_and_link_combination() {
1516        let config = MD063Config {
1517            enabled: true,
1518            style: HeadingCapStyle::TitleCase,
1519            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1520            ..Default::default()
1521        };
1522        let rule = MD063HeadingCapitalization::from_config_struct(config);
1523
1524        // Code then link - last segment is link, so lowercase words before code should remain lowercase
1525        let content = "## Guide for the `code` [link](./page.md)\n";
1526        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1527        let fixed = rule.fix(&ctx).unwrap();
1528        // "for" and "the" should remain lowercase (not last word because link follows)
1529        assert!(
1530            fixed.contains("for the `code`"),
1531            "Expected 'for the `code`' but got: {fixed:?}"
1532        );
1533    }
1534
1535    #[test]
1536    fn test_text_after_code_capitalizes_last() {
1537        let config = MD063Config {
1538            enabled: true,
1539            style: HeadingCapStyle::TitleCase,
1540            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1541            ..Default::default()
1542        };
1543        let rule = MD063HeadingCapitalization::from_config_struct(config);
1544
1545        // Code in middle, text after - last word should be capitalized
1546        let content = "## Using `const` for the code\n";
1547        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1548        let fixed = rule.fix(&ctx).unwrap();
1549        // "for" and "the" should be lowercase, "code" is last word, should be capitalized
1550        assert!(
1551            fixed.contains("for the Code"),
1552            "Expected 'for the Code' but got: {fixed:?}"
1553        );
1554    }
1555
1556    #[test]
1557    fn test_preserve_cased_words_with_trailing_code() {
1558        let config = MD063Config {
1559            enabled: true,
1560            style: HeadingCapStyle::TitleCase,
1561            lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1562            preserve_cased_words: true,
1563            ..Default::default()
1564        };
1565        let rule = MD063HeadingCapitalization::from_config_struct(config);
1566
1567        // Preserve-cased words should still work with trailing code
1568        let content = "## Guide for iOS `app`\n";
1569        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1570        let fixed = rule.fix(&ctx).unwrap();
1571        // "iOS" should be preserved, "for" should be lowercase
1572        assert!(
1573            fixed.contains("for iOS `app`"),
1574            "Expected 'for iOS `app`' but got: {fixed:?}"
1575        );
1576        assert!(
1577            !fixed.contains("For iOS `app`"),
1578            "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
1579        );
1580    }
1581
1582    #[test]
1583    fn test_ignore_words_with_trailing_code() {
1584        let config = MD063Config {
1585            enabled: true,
1586            style: HeadingCapStyle::TitleCase,
1587            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1588            ignore_words: vec!["npm".to_string()],
1589            ..Default::default()
1590        };
1591        let rule = MD063HeadingCapitalization::from_config_struct(config);
1592
1593        // Ignore-words should still work with trailing code
1594        let content = "## Using npm with a `script`\n";
1595        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1596        let fixed = rule.fix(&ctx).unwrap();
1597        // "npm" should be preserved, "with" and "a" should be lowercase
1598        assert!(
1599            fixed.contains("npm with a `script`"),
1600            "Expected 'npm with a `script`' but got: {fixed:?}"
1601        );
1602        assert!(
1603            !fixed.contains("with A `script`"),
1604            "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1605        );
1606    }
1607
1608    #[test]
1609    fn test_empty_text_segment_edge_case() {
1610        let config = MD063Config {
1611            enabled: true,
1612            style: HeadingCapStyle::TitleCase,
1613            lowercase_words: vec!["a".to_string(), "with".to_string()],
1614            ..Default::default()
1615        };
1616        let rule = MD063HeadingCapitalization::from_config_struct(config);
1617
1618        // Edge case: code at start, then text with lowercase word, then code at end
1619        let content = "## `start` with a `end`\n";
1620        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1621        let fixed = rule.fix(&ctx).unwrap();
1622        // "with" is first word in text segment, so capitalized (correct)
1623        // "a" should remain lowercase (not last word because code follows) - this is the key test
1624        assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
1625        assert!(
1626            !fixed.contains("A `end`"),
1627            "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1628        );
1629    }
1630
1631    #[test]
1632    fn test_sentence_case_with_trailing_code() {
1633        let config = MD063Config {
1634            enabled: true,
1635            style: HeadingCapStyle::SentenceCase,
1636            lowercase_words: vec!["a".to_string(), "the".to_string()],
1637            ..Default::default()
1638        };
1639        let rule = MD063HeadingCapitalization::from_config_struct(config);
1640
1641        // Sentence case should also respect lowercase words before code
1642        let content = "## guide for the `user`\n";
1643        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1644        let fixed = rule.fix(&ctx).unwrap();
1645        // First word capitalized, rest lowercase including "the" before code
1646        assert!(
1647            fixed.contains("Guide for the `user`"),
1648            "Expected 'Guide for the `user`' but got: {fixed:?}"
1649        );
1650    }
1651
1652    #[test]
1653    fn test_hyphenated_word_before_code() {
1654        let config = MD063Config {
1655            enabled: true,
1656            style: HeadingCapStyle::TitleCase,
1657            lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1658            ..Default::default()
1659        };
1660        let rule = MD063HeadingCapitalization::from_config_struct(config);
1661
1662        // Hyphenated word before code - last part should respect lowercase-words
1663        let content = "## Self-contained with a `feature`\n";
1664        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1665        let fixed = rule.fix(&ctx).unwrap();
1666        // "with" and "a" should remain lowercase (not last word because code follows)
1667        assert!(
1668            fixed.contains("with a `feature`"),
1669            "Expected 'with a `feature`' but got: {fixed:?}"
1670        );
1671    }
1672
1673    // Issue #228: Sentence case with inline code at heading start
1674    // When a heading starts with inline code, the first word after the code
1675    // should NOT be capitalized because the heading already has a "first element"
1676
1677    #[test]
1678    fn test_sentence_case_code_at_start_basic() {
1679        // The exact case from issue #228
1680        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1681        let content = "# `rumdl` is a linter\n";
1682        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1683        let result = rule.check(&ctx).unwrap();
1684        // Should be correct as-is: code is first, "is" stays lowercase
1685        assert!(
1686            result.is_empty(),
1687            "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
1688            result.iter().map(|w| &w.message).collect::<Vec<_>>()
1689        );
1690    }
1691
1692    #[test]
1693    fn test_sentence_case_code_at_start_incorrect_capitalization() {
1694        // Verify we detect incorrect capitalization after code at start
1695        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1696        let content = "# `rumdl` Is a Linter\n";
1697        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1698        let result = rule.check(&ctx).unwrap();
1699        // Should flag: "Is" and "Linter" should be lowercase
1700        assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
1701        assert!(
1702            result[0].message.contains("`rumdl` is a linter"),
1703            "Should suggest lowercase after code. Got: {:?}",
1704            result[0].message
1705        );
1706    }
1707
1708    #[test]
1709    fn test_sentence_case_code_at_start_fix() {
1710        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1711        let content = "# `rumdl` Is A Linter\n";
1712        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1713        let fixed = rule.fix(&ctx).unwrap();
1714        assert!(
1715            fixed.contains("# `rumdl` is a linter"),
1716            "Should fix to lowercase after code. Got: {fixed:?}"
1717        );
1718    }
1719
1720    #[test]
1721    fn test_sentence_case_text_at_start_still_capitalizes() {
1722        // Ensure normal headings still capitalize first word
1723        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1724        let content = "# the quick brown fox\n";
1725        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1726        let result = rule.check(&ctx).unwrap();
1727        assert_eq!(result.len(), 1);
1728        assert!(
1729            result[0].message.contains("The quick brown fox"),
1730            "Text-first heading should capitalize first word. Got: {:?}",
1731            result[0].message
1732        );
1733    }
1734
1735    #[test]
1736    fn test_sentence_case_link_at_start() {
1737        // Links at start: link text is lowercased, following text also lowercase
1738        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1739        // Use lowercase link text to avoid link text case flagging
1740        let content = "# [api](api.md) reference guide\n";
1741        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1742        let result = rule.check(&ctx).unwrap();
1743        // "reference" should be lowercase (link is first)
1744        assert!(
1745            result.is_empty(),
1746            "Heading with link at start should not capitalize 'reference'. Got: {:?}",
1747            result.iter().map(|w| &w.message).collect::<Vec<_>>()
1748        );
1749    }
1750
1751    #[test]
1752    fn test_sentence_case_link_preserves_acronyms() {
1753        // Acronyms in link text should be preserved (API, HTTP, etc.)
1754        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1755        let content = "# [API](api.md) Reference Guide\n";
1756        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1757        let result = rule.check(&ctx).unwrap();
1758        assert_eq!(result.len(), 1);
1759        // "API" should be preserved (acronym), "Reference Guide" should be lowercased
1760        assert!(
1761            result[0].message.contains("[API](api.md) reference guide"),
1762            "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
1763            result[0].message
1764        );
1765    }
1766
1767    #[test]
1768    fn test_sentence_case_link_preserves_brand_names() {
1769        // Brand names with internal capitals should be preserved
1770        let config = MD063Config {
1771            enabled: true,
1772            style: HeadingCapStyle::SentenceCase,
1773            preserve_cased_words: true,
1774            ..Default::default()
1775        };
1776        let rule = MD063HeadingCapitalization::from_config_struct(config);
1777        let content = "# [iPhone](iphone.md) Features Guide\n";
1778        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1779        let result = rule.check(&ctx).unwrap();
1780        assert_eq!(result.len(), 1);
1781        // "iPhone" should be preserved, "Features Guide" should be lowercased
1782        assert!(
1783            result[0].message.contains("[iPhone](iphone.md) features guide"),
1784            "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
1785            result[0].message
1786        );
1787    }
1788
1789    #[test]
1790    fn test_sentence_case_link_lowercases_regular_words() {
1791        // Regular words in link text should be lowercased
1792        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1793        let content = "# [Documentation](docs.md) Reference\n";
1794        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1795        let result = rule.check(&ctx).unwrap();
1796        assert_eq!(result.len(), 1);
1797        // "Documentation" should be lowercased (regular word)
1798        assert!(
1799            result[0].message.contains("[documentation](docs.md) reference"),
1800            "Should lowercase regular link text. Got: {:?}",
1801            result[0].message
1802        );
1803    }
1804
1805    #[test]
1806    fn test_sentence_case_link_at_start_correct_already() {
1807        // Link with correct casing should not be flagged
1808        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1809        let content = "# [API](api.md) reference guide\n";
1810        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1811        let result = rule.check(&ctx).unwrap();
1812        assert!(
1813            result.is_empty(),
1814            "Correctly cased heading with link should not be flagged. Got: {:?}",
1815            result.iter().map(|w| &w.message).collect::<Vec<_>>()
1816        );
1817    }
1818
1819    #[test]
1820    fn test_sentence_case_link_github_preserved() {
1821        // GitHub should be preserved (internal capitals)
1822        let config = MD063Config {
1823            enabled: true,
1824            style: HeadingCapStyle::SentenceCase,
1825            preserve_cased_words: true,
1826            ..Default::default()
1827        };
1828        let rule = MD063HeadingCapitalization::from_config_struct(config);
1829        let content = "# [GitHub](gh.md) Repository Setup\n";
1830        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1831        let result = rule.check(&ctx).unwrap();
1832        assert_eq!(result.len(), 1);
1833        assert!(
1834            result[0].message.contains("[GitHub](gh.md) repository setup"),
1835            "Should preserve 'GitHub'. Got: {:?}",
1836            result[0].message
1837        );
1838    }
1839
1840    #[test]
1841    fn test_sentence_case_multiple_code_spans() {
1842        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1843        let content = "# `foo` and `bar` are methods\n";
1844        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1845        let result = rule.check(&ctx).unwrap();
1846        // All text after first code should be lowercase
1847        assert!(
1848            result.is_empty(),
1849            "Should not capitalize words between/after code spans. Got: {:?}",
1850            result.iter().map(|w| &w.message).collect::<Vec<_>>()
1851        );
1852    }
1853
1854    #[test]
1855    fn test_sentence_case_code_only_heading() {
1856        // Heading with only code, no text
1857        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1858        let content = "# `rumdl`\n";
1859        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1860        let result = rule.check(&ctx).unwrap();
1861        assert!(
1862            result.is_empty(),
1863            "Code-only heading should be fine. Got: {:?}",
1864            result.iter().map(|w| &w.message).collect::<Vec<_>>()
1865        );
1866    }
1867
1868    #[test]
1869    fn test_sentence_case_code_at_end() {
1870        // Heading ending with code, text before should still capitalize first word
1871        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1872        let content = "# install the `rumdl` tool\n";
1873        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1874        let result = rule.check(&ctx).unwrap();
1875        // "install" should be capitalized (first word), rest lowercase
1876        assert_eq!(result.len(), 1);
1877        assert!(
1878            result[0].message.contains("Install the `rumdl` tool"),
1879            "First word should still be capitalized when text comes first. Got: {:?}",
1880            result[0].message
1881        );
1882    }
1883
1884    #[test]
1885    fn test_sentence_case_code_in_middle() {
1886        // Code in middle, text at start should capitalize first word
1887        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1888        let content = "# using the `rumdl` linter for markdown\n";
1889        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1890        let result = rule.check(&ctx).unwrap();
1891        // "using" should be capitalized, rest lowercase
1892        assert_eq!(result.len(), 1);
1893        assert!(
1894            result[0].message.contains("Using the `rumdl` linter for markdown"),
1895            "First word should be capitalized. Got: {:?}",
1896            result[0].message
1897        );
1898    }
1899
1900    #[test]
1901    fn test_sentence_case_preserved_word_after_code() {
1902        // Preserved words (like iPhone) should stay preserved even after code
1903        let config = MD063Config {
1904            enabled: true,
1905            style: HeadingCapStyle::SentenceCase,
1906            preserve_cased_words: true,
1907            ..Default::default()
1908        };
1909        let rule = MD063HeadingCapitalization::from_config_struct(config);
1910        let content = "# `swift` iPhone development\n";
1911        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1912        let result = rule.check(&ctx).unwrap();
1913        // "iPhone" should be preserved, "development" lowercase
1914        assert!(
1915            result.is_empty(),
1916            "Preserved words after code should stay. Got: {:?}",
1917            result.iter().map(|w| &w.message).collect::<Vec<_>>()
1918        );
1919    }
1920
1921    #[test]
1922    fn test_title_case_code_at_start_still_capitalizes() {
1923        // Title case should still capitalize words even after code at start
1924        let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
1925        let content = "# `api` quick start guide\n";
1926        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1927        let result = rule.check(&ctx).unwrap();
1928        // Title case: all major words capitalized
1929        assert_eq!(result.len(), 1);
1930        assert!(
1931            result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
1932            "Title case should capitalize major words after code. Got: {:?}",
1933            result[0].message
1934        );
1935    }
1936
1937    // ======== HTML TAG TESTS ========
1938
1939    #[test]
1940    fn test_sentence_case_html_tag_at_start() {
1941        // HTML tag at start: text after should NOT capitalize first word
1942        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1943        let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
1944        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1945        let result = rule.check(&ctx).unwrap();
1946        // "is", "a", "Modifier", "Key" should all be lowercase (except preserved words)
1947        assert_eq!(result.len(), 1);
1948        let fixed = rule.fix(&ctx).unwrap();
1949        assert_eq!(
1950            fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
1951            "Text after HTML at start should be lowercase"
1952        );
1953    }
1954
1955    #[test]
1956    fn test_sentence_case_html_tag_preserves_content() {
1957        // Content inside HTML tags should be preserved as-is
1958        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1959        let content = "# The <abbr>API</abbr> documentation guide\n";
1960        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1961        let result = rule.check(&ctx).unwrap();
1962        // "The" is first, "API" inside tag preserved, rest lowercase
1963        assert!(
1964            result.is_empty(),
1965            "HTML tag content should be preserved. Got: {:?}",
1966            result.iter().map(|w| &w.message).collect::<Vec<_>>()
1967        );
1968    }
1969
1970    #[test]
1971    fn test_sentence_case_html_tag_at_start_with_acronym() {
1972        // HTML tag at start with acronym content
1973        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1974        let content = "# <abbr>API</abbr> Documentation Guide\n";
1975        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1976        let result = rule.check(&ctx).unwrap();
1977        assert_eq!(result.len(), 1);
1978        let fixed = rule.fix(&ctx).unwrap();
1979        assert_eq!(
1980            fixed, "# <abbr>API</abbr> documentation guide\n",
1981            "Text after HTML at start should be lowercase, HTML content preserved"
1982        );
1983    }
1984
1985    #[test]
1986    fn test_sentence_case_html_tag_in_middle() {
1987        // HTML tag in middle: first word still capitalized
1988        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1989        let content = "# using the <code>config</code> File\n";
1990        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1991        let result = rule.check(&ctx).unwrap();
1992        assert_eq!(result.len(), 1);
1993        let fixed = rule.fix(&ctx).unwrap();
1994        assert_eq!(
1995            fixed, "# Using the <code>config</code> file\n",
1996            "First word capitalized, HTML preserved, rest lowercase"
1997        );
1998    }
1999
2000    #[test]
2001    fn test_html_tag_strong_emphasis() {
2002        // <strong> tag handling
2003        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2004        let content = "# The <strong>Bold</strong> Way\n";
2005        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2006        let result = rule.check(&ctx).unwrap();
2007        assert_eq!(result.len(), 1);
2008        let fixed = rule.fix(&ctx).unwrap();
2009        assert_eq!(
2010            fixed, "# The <strong>Bold</strong> way\n",
2011            "<strong> tag content should be preserved"
2012        );
2013    }
2014
2015    #[test]
2016    fn test_html_tag_with_attributes() {
2017        // HTML tags with attributes should still be detected
2018        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2019        let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
2020        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2021        let result = rule.check(&ctx).unwrap();
2022        assert_eq!(result.len(), 1);
2023        let fixed = rule.fix(&ctx).unwrap();
2024        assert_eq!(
2025            fixed, "# <span class=\"highlight\">Important</span> notice here\n",
2026            "HTML tag with attributes should be preserved"
2027        );
2028    }
2029
2030    #[test]
2031    fn test_multiple_html_tags() {
2032        // Multiple HTML tags in heading
2033        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2034        let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
2035        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2036        let result = rule.check(&ctx).unwrap();
2037        assert_eq!(result.len(), 1);
2038        let fixed = rule.fix(&ctx).unwrap();
2039        assert_eq!(
2040            fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
2041            "Multiple HTML tags should all be preserved"
2042        );
2043    }
2044
2045    #[test]
2046    fn test_html_and_code_mixed() {
2047        // Mix of HTML tags and inline code
2048        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2049        let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
2050        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2051        let result = rule.check(&ctx).unwrap();
2052        assert_eq!(result.len(), 1);
2053        let fixed = rule.fix(&ctx).unwrap();
2054        assert_eq!(
2055            fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
2056            "HTML and code should both be preserved"
2057        );
2058    }
2059
2060    #[test]
2061    fn test_self_closing_html_tag() {
2062        // Self-closing tags like <br/>
2063        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2064        let content = "# Line one<br/>Line Two Here\n";
2065        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2066        let result = rule.check(&ctx).unwrap();
2067        assert_eq!(result.len(), 1);
2068        let fixed = rule.fix(&ctx).unwrap();
2069        assert_eq!(
2070            fixed, "# Line one<br/>line two here\n",
2071            "Self-closing HTML tags should be preserved"
2072        );
2073    }
2074
2075    #[test]
2076    fn test_title_case_with_html_tags() {
2077        // Title case with HTML tags
2078        let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2079        let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
2080        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2081        let result = rule.check(&ctx).unwrap();
2082        assert_eq!(result.len(), 1);
2083        let fixed = rule.fix(&ctx).unwrap();
2084        // "the" as first word should be "The", content inside <kbd> preserved
2085        assert!(
2086            fixed.contains("<kbd>ctrl</kbd>"),
2087            "HTML tag content should be preserved in title case. Got: {fixed}"
2088        );
2089        assert!(
2090            fixed.starts_with("# The ") || fixed.starts_with("# the "),
2091            "Title case should work with HTML. Got: {fixed}"
2092        );
2093    }
2094
2095    // ======== CARET NOTATION TESTS ========
2096
2097    #[test]
2098    fn test_sentence_case_preserves_caret_notation() {
2099        // Caret notation for control characters should be preserved
2100        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2101        let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
2102        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2103        let result = rule.check(&ctx).unwrap();
2104        // Should not flag - ^A and ^R are preserved
2105        assert!(
2106            result.is_empty(),
2107            "Caret notation should be preserved. Got: {:?}",
2108            result.iter().map(|w| &w.message).collect::<Vec<_>>()
2109        );
2110    }
2111
2112    #[test]
2113    fn test_sentence_case_caret_notation_various() {
2114        // Various caret notation patterns
2115        let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2116
2117        // ^C for interrupt
2118        let content = "## Press ^C to cancel\n";
2119        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2120        let result = rule.check(&ctx).unwrap();
2121        assert!(
2122            result.is_empty(),
2123            "^C should be preserved. Got: {:?}",
2124            result.iter().map(|w| &w.message).collect::<Vec<_>>()
2125        );
2126
2127        // ^Z for suspend
2128        let content = "## Use ^Z for background\n";
2129        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2130        let result = rule.check(&ctx).unwrap();
2131        assert!(
2132            result.is_empty(),
2133            "^Z should be preserved. Got: {:?}",
2134            result.iter().map(|w| &w.message).collect::<Vec<_>>()
2135        );
2136
2137        // ^[ for escape
2138        let content = "## Press ^[ for escape\n";
2139        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2140        let result = rule.check(&ctx).unwrap();
2141        assert!(
2142            result.is_empty(),
2143            "^[ should be preserved. Got: {:?}",
2144            result.iter().map(|w| &w.message).collect::<Vec<_>>()
2145        );
2146    }
2147
2148    #[test]
2149    fn test_caret_notation_detection() {
2150        let rule = create_rule();
2151
2152        // Valid caret notation
2153        assert!(rule.is_caret_notation("^A"));
2154        assert!(rule.is_caret_notation("^Z"));
2155        assert!(rule.is_caret_notation("^C"));
2156        assert!(rule.is_caret_notation("^@")); // NUL
2157        assert!(rule.is_caret_notation("^[")); // ESC
2158        assert!(rule.is_caret_notation("^]")); // GS
2159        assert!(rule.is_caret_notation("^^")); // RS
2160        assert!(rule.is_caret_notation("^_")); // US
2161
2162        // Not caret notation
2163        assert!(!rule.is_caret_notation("^a")); // lowercase
2164        assert!(!rule.is_caret_notation("A")); // no caret
2165        assert!(!rule.is_caret_notation("^")); // caret alone
2166        assert!(!rule.is_caret_notation("^1")); // digit
2167    }
2168}