1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, 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(super) use md063_config::{HeadingCapStyle, MD063Config};
23
24static INLINE_CODE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`+[^`]+`+").unwrap());
26
27static LINK_REGEX: LazyLock<Regex> =
32 LazyLock::new(|| Regex::new(r"\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)|\[([^\]]*)\]\[[^\]]*\]").unwrap());
33
34static HTML_TAG_REGEX: LazyLock<Regex> = LazyLock::new(|| {
39 let tags = "kbd|abbr|code|span|sub|sup|mark|cite|dfn|var|samp|small|strong|em|b|i|u|s|q|br|wbr";
41 let pattern = format!(r"<({tags})(?:\s[^>]*)?>.*?</({tags})>|<({tags})(?:\s[^>]*)?\s*/?>");
42 Regex::new(&pattern).unwrap()
43});
44
45static CUSTOM_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*\{#[^}]+\}\s*$").unwrap());
47
48#[derive(Debug, Clone)]
50enum HeadingSegment {
51 Text(String),
53 Code(String),
55 Link {
57 full: String,
58 text_start: usize,
59 text_end: usize,
60 },
61 Html(String),
63 Image(String),
66}
67
68#[derive(Clone)]
70pub struct MD063HeadingCapitalization {
71 config: MD063Config,
72 lowercase_set: HashSet<String>,
73 proper_names: Vec<String>,
76}
77
78impl Default for MD063HeadingCapitalization {
79 fn default() -> Self {
80 Self::new()
81 }
82}
83
84impl MD063HeadingCapitalization {
85 pub fn new() -> Self {
86 let config = MD063Config::default();
87 let lowercase_set = config.lowercase_words.iter().cloned().collect();
88 Self {
89 config,
90 lowercase_set,
91 proper_names: Vec::new(),
92 }
93 }
94
95 pub fn from_config_struct(config: MD063Config) -> Self {
96 let lowercase_set = config.lowercase_words.iter().cloned().collect();
97 Self {
98 config,
99 lowercase_set,
100 proper_names: Vec::new(),
101 }
102 }
103
104 fn match_case_insensitive_at(text: &str, start: usize, pattern_lower: &str) -> Option<usize> {
111 if start > text.len() || !text.is_char_boundary(start) || pattern_lower.is_empty() {
112 return None;
113 }
114
115 let mut matched_bytes = 0;
116
117 for (offset, ch) in text[start..].char_indices() {
118 if matched_bytes >= pattern_lower.len() {
119 break;
120 }
121
122 let lowered: String = ch.to_lowercase().collect();
123 if !pattern_lower[matched_bytes..].starts_with(&lowered) {
124 return None;
125 }
126
127 matched_bytes += lowered.len();
128
129 if matched_bytes == pattern_lower.len() {
130 return Some(start + offset + ch.len_utf8());
131 }
132 }
133
134 None
135 }
136
137 fn find_case_insensitive_match(text: &str, pattern_lower: &str, search_start: usize) -> Option<(usize, usize)> {
140 if pattern_lower.is_empty() || search_start >= text.len() || !text.is_char_boundary(search_start) {
141 return None;
142 }
143
144 for (offset, _) in text[search_start..].char_indices() {
145 let start = search_start + offset;
146 if let Some(end) = Self::match_case_insensitive_at(text, start, pattern_lower) {
147 return Some((start, end));
148 }
149 }
150
151 None
152 }
153
154 fn proper_name_canonical_forms(&self, text: &str) -> std::collections::HashMap<usize, &str> {
160 let mut map = std::collections::HashMap::new();
161
162 for name in &self.proper_names {
163 if name.is_empty() {
164 continue;
165 }
166 let name_lower = name.to_lowercase();
167 let canonical_words: Vec<&str> = name.split_whitespace().collect();
168 if canonical_words.is_empty() {
169 continue;
170 }
171 let mut search_start = 0;
172
173 while search_start < text.len() {
174 let Some((abs_pos, end_pos)) = Self::find_case_insensitive_match(text, &name_lower, search_start)
175 else {
176 break;
177 };
178
179 let before_ok = abs_pos == 0 || !text[..abs_pos].chars().last().is_some_and(char::is_alphanumeric);
181 let after_ok =
182 end_pos >= text.len() || !text[end_pos..].chars().next().is_some_and(char::is_alphanumeric);
183
184 if before_ok && after_ok {
185 let text_slice = &text[abs_pos..end_pos];
189 let mut word_idx = 0;
190 let mut slice_offset = 0;
191
192 for text_word in text_slice.split_whitespace() {
193 if let Some(w_rel) = text_slice[slice_offset..].find(text_word) {
194 let word_abs = abs_pos + slice_offset + w_rel;
195 if let Some(&canonical_word) = canonical_words.get(word_idx) {
196 map.insert(word_abs, canonical_word);
197 }
198 slice_offset += w_rel + text_word.len();
199 word_idx += 1;
200 }
201 }
202 }
203
204 search_start = abs_pos + text[abs_pos..].chars().next().map_or(1, char::len_utf8);
207 }
208 }
209
210 map
211 }
212
213 fn has_internal_capitals(&self, word: &str) -> bool {
215 let chars: Vec<char> = word.chars().collect();
216 if chars.len() < 2 {
217 return false;
218 }
219
220 let first = chars[0];
221 let rest = &chars[1..];
222 let has_upper_in_rest = rest.iter().any(|c| c.is_uppercase());
223 let has_lower_in_rest = rest.iter().any(|c| c.is_lowercase());
224
225 if has_upper_in_rest && has_lower_in_rest {
227 return true;
228 }
229
230 if first.is_lowercase() && has_upper_in_rest {
232 return true;
233 }
234
235 false
236 }
237
238 fn is_all_caps_acronym(&self, word: &str) -> bool {
242 if word.len() < 2 {
244 return false;
245 }
246
247 let mut consecutive_upper = 0;
248 let mut max_consecutive = 0;
249
250 for c in word.chars() {
251 if c.is_uppercase() {
252 consecutive_upper += 1;
253 max_consecutive = max_consecutive.max(consecutive_upper);
254 } else if c.is_lowercase() {
255 return false;
257 } else {
258 consecutive_upper = 0;
260 }
261 }
262
263 max_consecutive >= 2
265 }
266
267 fn should_preserve_word(&self, word: &str) -> bool {
269 if self.config.ignore_words.iter().any(|w| w == word) {
271 return true;
272 }
273
274 let is_ordinal = Self::is_numeric_ordinal(word);
280
281 if !is_ordinal {
282 if self.config.preserve_cased_words && self.has_internal_capitals(word) {
284 return true;
285 }
286
287 if self.config.preserve_cased_words && self.is_all_caps_acronym(word) {
289 return true;
290 }
291 }
292
293 if self.is_caret_notation(word) {
295 return true;
296 }
297
298 false
299 }
300
301 fn is_numeric_ordinal(word: &str) -> bool {
309 let bytes = word.as_bytes();
310
311 let alpha_start = match bytes.iter().position(|&b| !b.is_ascii_digit()) {
313 Some(pos) if pos > 0 => pos,
314 _ => return false,
315 };
316
317 let alpha_end = bytes[alpha_start..]
319 .iter()
320 .position(|b| !b.is_ascii_alphabetic())
321 .map_or(bytes.len(), |p| alpha_start + p);
322
323 let suffix = &word[alpha_start..alpha_end];
324 matches!(suffix.to_ascii_lowercase().as_str(), "st" | "nd" | "rd" | "th")
325 }
326
327 fn is_caret_notation(&self, word: &str) -> bool {
329 let chars: Vec<char> = word.chars().collect();
330 if chars.len() >= 2 && chars[0] == '^' {
332 let second = chars[1];
333 if second.is_ascii_uppercase() || "@[\\]^_".contains(second) {
335 return true;
336 }
337 }
338 false
339 }
340
341 fn is_lowercase_word(&self, word: &str) -> bool {
343 self.lowercase_set.contains(&word.to_lowercase())
344 }
345
346 fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
348 if word.is_empty() {
349 return word.to_string();
350 }
351
352 if self.should_preserve_word(word) {
354 return word.to_string();
355 }
356
357 if is_first || is_last {
359 return self.capitalize_first(word);
360 }
361
362 if self.is_lowercase_word(word) {
364 return Self::lowercase_preserving_composition(word);
365 }
366
367 self.capitalize_first(word)
369 }
370
371 fn apply_canonical_form_to_word(word: &str, canonical: &str) -> String {
374 let canonical_lower = canonical.to_lowercase();
375 if canonical_lower.is_empty() {
376 return canonical.to_string();
377 }
378
379 if let Some(end_pos) = Self::match_case_insensitive_at(word, 0, &canonical_lower) {
380 let mut out = String::with_capacity(canonical.len() + word.len().saturating_sub(end_pos));
381 out.push_str(canonical);
382 out.push_str(&word[end_pos..]);
383 out
384 } else {
385 canonical.to_string()
386 }
387 }
388
389 fn capitalize_first(&self, word: &str) -> String {
391 if word.is_empty() {
392 return String::new();
393 }
394
395 let first_alpha_pos = word.find(|c: char| c.is_alphabetic());
397 let Some(pos) = first_alpha_pos else {
398 return word.to_string();
399 };
400
401 let prefix = &word[..pos];
402 let suffix = &word[pos..];
403
404 if Self::is_numeric_ordinal(word) {
407 let suffix_lower = Self::lowercase_preserving_composition(suffix);
408 return format!("{prefix}{suffix_lower}");
409 }
410
411 let mut chars = suffix.chars();
412 let first = chars.next().unwrap();
413 let first_upper = Self::uppercase_preserving_composition(&first.to_string());
416 let rest: String = chars.collect();
417 let rest_lower = Self::lowercase_preserving_composition(&rest);
418 format!("{prefix}{first_upper}{rest_lower}")
419 }
420
421 fn lowercase_preserving_composition(s: &str) -> String {
424 let mut result = String::with_capacity(s.len());
425 for c in s.chars() {
426 let lower: String = c.to_lowercase().collect();
427 if lower.chars().count() == 1 {
428 result.push_str(&lower);
429 } else {
430 result.push(c);
432 }
433 }
434 result
435 }
436
437 fn uppercase_preserving_composition(s: &str) -> String {
442 let mut result = String::with_capacity(s.len());
443 for c in s.chars() {
444 let upper: String = c.to_uppercase().collect();
445 if upper.chars().count() == 1 {
446 result.push_str(&upper);
447 } else {
448 result.push(c);
450 }
451 }
452 result
453 }
454
455 fn apply_title_case(&self, text: &str) -> String {
459 let canonical_forms = self.proper_name_canonical_forms(text);
460
461 let original_words: Vec<&str> = text.split_whitespace().collect();
462 let total_words = original_words.len();
463
464 let mut word_positions: Vec<usize> = Vec::with_capacity(original_words.len());
467 let mut pos = 0;
468 for word in &original_words {
469 if let Some(rel) = text[pos..].find(word) {
470 word_positions.push(pos + rel);
471 pos = pos + rel + word.len();
472 } else {
473 word_positions.push(usize::MAX);
474 }
475 }
476
477 let result_words: Vec<String> = original_words
478 .iter()
479 .enumerate()
480 .map(|(i, word)| {
481 let after_period = i > 0 && original_words[i - 1].ends_with('.');
482 let is_first = i == 0 || after_period;
483 let is_last = i == total_words - 1;
484
485 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
487 return Self::apply_canonical_form_to_word(word, canonical);
488 }
489
490 if self.should_preserve_word(word) {
492 return (*word).to_string();
493 }
494
495 if word.contains('-') {
497 return self.handle_hyphenated_word(word, is_first, is_last);
498 }
499
500 self.title_case_word(word, is_first, is_last)
501 })
502 .collect();
503
504 result_words.join(" ")
505 }
506
507 fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
509 let parts: Vec<&str> = word.split('-').collect();
510 let total_parts = parts.len();
511
512 let result_parts: Vec<String> = parts
513 .iter()
514 .enumerate()
515 .map(|(i, part)| {
516 let part_is_first = is_first && i == 0;
518 let part_is_last = is_last && i == total_parts - 1;
519 self.title_case_word(part, part_is_first, part_is_last)
520 })
521 .collect();
522
523 result_parts.join("-")
524 }
525
526 fn apply_sentence_case(&self, text: &str) -> String {
528 if text.is_empty() {
529 return text.to_string();
530 }
531
532 let canonical_forms = self.proper_name_canonical_forms(text);
533 let mut result = String::new();
534 let mut current_pos = 0;
535 let mut is_first_word = true;
536
537 for word in text.split_whitespace() {
539 if let Some(pos) = text[current_pos..].find(word) {
540 let abs_pos = current_pos + pos;
541
542 result.push_str(&text[current_pos..abs_pos]);
544
545 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
548 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
549 is_first_word = false;
550 } else if is_first_word {
551 if self.should_preserve_word(word) {
553 result.push_str(word);
555 } else {
556 let mut chars = word.chars();
558 if let Some(first) = chars.next() {
559 result.push_str(&Self::uppercase_preserving_composition(&first.to_string()));
560 let rest: String = chars.collect();
561 result.push_str(&Self::lowercase_preserving_composition(&rest));
562 }
563 }
564 is_first_word = false;
565 } else {
566 if self.should_preserve_word(word) {
568 result.push_str(word);
569 } else {
570 result.push_str(&Self::lowercase_preserving_composition(word));
571 }
572 }
573
574 current_pos = abs_pos + word.len();
575 }
576 }
577
578 if current_pos < text.len() {
580 result.push_str(&text[current_pos..]);
581 }
582
583 result
584 }
585
586 fn apply_all_caps(&self, text: &str) -> String {
588 if text.is_empty() {
589 return text.to_string();
590 }
591
592 let canonical_forms = self.proper_name_canonical_forms(text);
593 let mut result = String::new();
594 let mut current_pos = 0;
595
596 for word in text.split_whitespace() {
598 if let Some(pos) = text[current_pos..].find(word) {
599 let abs_pos = current_pos + pos;
600
601 result.push_str(&text[current_pos..abs_pos]);
603
604 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
607 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
608 } else if self.should_preserve_word(word) {
609 result.push_str(word);
610 } else {
611 result.push_str(&Self::uppercase_preserving_composition(word));
612 }
613
614 current_pos = abs_pos + word.len();
615 }
616 }
617
618 if current_pos < text.len() {
620 result.push_str(&text[current_pos..]);
621 }
622
623 result
624 }
625
626 fn parse_segments(&self, text: &str) -> Vec<HeadingSegment> {
628 let mut segments = Vec::new();
629 let mut last_end = 0;
630
631 let mut special_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
633
634 for mat in INLINE_CODE_REGEX.find_iter(text) {
636 special_regions.push((mat.start(), mat.end(), HeadingSegment::Code(mat.as_str().to_string())));
637 }
638
639 for caps in LINK_REGEX.captures_iter(text) {
641 let full_match = caps.get(0).unwrap();
642
643 if full_match.start() >= 1 && text.as_bytes()[full_match.start() - 1] == b'!' {
647 let region_start = full_match.start() - 1;
648 special_regions.push((
649 region_start,
650 full_match.end(),
651 HeadingSegment::Image(text[region_start..full_match.end()].to_string()),
652 ));
653 continue;
654 }
655
656 let text_match = caps.get(1).or_else(|| caps.get(2));
657
658 if let Some(text_m) = text_match {
659 special_regions.push((
660 full_match.start(),
661 full_match.end(),
662 HeadingSegment::Link {
663 full: full_match.as_str().to_string(),
664 text_start: text_m.start() - full_match.start(),
665 text_end: text_m.end() - full_match.start(),
666 },
667 ));
668 }
669 }
670
671 for mat in HTML_TAG_REGEX.find_iter(text) {
673 special_regions.push((mat.start(), mat.end(), HeadingSegment::Html(mat.as_str().to_string())));
674 }
675
676 special_regions.sort_by_key(|(start, _, _)| *start);
678
679 let mut filtered_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
682 for region in special_regions {
683 let overlaps = filtered_regions.iter().any(|(s, e, _)| region.0 < *e && region.1 > *s);
684 if !overlaps {
685 filtered_regions.push(region);
686 }
687 }
688
689 for (start, end, segment) in filtered_regions {
691 if start > last_end {
693 let text_segment = &text[last_end..start];
694 if !text_segment.is_empty() {
695 segments.push(HeadingSegment::Text(text_segment.to_string()));
696 }
697 }
698 segments.push(segment);
699 last_end = end;
700 }
701
702 if last_end < text.len() {
704 let remaining = &text[last_end..];
705 if !remaining.is_empty() {
706 segments.push(HeadingSegment::Text(remaining.to_string()));
707 }
708 }
709
710 if segments.is_empty() && !text.is_empty() {
712 segments.push(HeadingSegment::Text(text.to_string()));
713 }
714
715 segments
716 }
717
718 fn apply_capitalization(&self, text: &str) -> String {
720 let (main_text, custom_id) = if let Some(mat) = CUSTOM_ID_REGEX.find(text) {
722 (&text[..mat.start()], Some(mat.as_str()))
723 } else {
724 (text, None)
725 };
726
727 let segments = self.parse_segments(main_text);
729
730 let text_segments: Vec<usize> = segments
732 .iter()
733 .enumerate()
734 .filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
735 .collect();
736
737 let first_segment_is_text = segments.first().is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
741
742 let last_segment_is_text = segments.last().is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
746
747 let mut result_parts: Vec<String> = Vec::new();
749
750 for (i, segment) in segments.iter().enumerate() {
751 match segment {
752 HeadingSegment::Text(t) => {
753 let is_first_text = text_segments.first() == Some(&i);
754 let is_last_text = text_segments.last() == Some(&i) && last_segment_is_text;
758
759 let capitalized = match self.config.style {
760 HeadingCapStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
761 HeadingCapStyle::SentenceCase => {
762 if is_first_text && first_segment_is_text {
766 self.apply_sentence_case(t)
767 } else {
768 self.apply_sentence_case_non_first(t)
770 }
771 }
772 HeadingCapStyle::AllCaps => self.apply_all_caps(t),
773 };
774 result_parts.push(capitalized);
775 }
776 HeadingSegment::Code(c) => {
777 result_parts.push(c.clone());
778 }
779 HeadingSegment::Link {
780 full,
781 text_start,
782 text_end,
783 } => {
784 let link_text = &full[*text_start..*text_end];
786 let capitalized_text = match self.config.style {
787 HeadingCapStyle::TitleCase => self.apply_title_case(link_text),
788 HeadingCapStyle::SentenceCase => self.apply_sentence_case_non_first(link_text),
791 HeadingCapStyle::AllCaps => self.apply_all_caps(link_text),
792 };
793
794 let mut new_link = String::new();
795 new_link.push_str(&full[..*text_start]);
796 new_link.push_str(&capitalized_text);
797 new_link.push_str(&full[*text_end..]);
798 result_parts.push(new_link);
799 }
800 HeadingSegment::Html(h) => {
801 result_parts.push(h.clone());
803 }
804 HeadingSegment::Image(img) => {
805 result_parts.push(img.clone());
807 }
808 }
809 }
810
811 let mut result = result_parts.join("");
812
813 if let Some(id) = custom_id {
815 result.push_str(id);
816 }
817
818 result
819 }
820
821 fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
823 let canonical_forms = self.proper_name_canonical_forms(text);
824 let words: Vec<&str> = text.split_whitespace().collect();
825 let total_words = words.len();
826
827 if total_words == 0 {
828 return text.to_string();
829 }
830
831 let mut word_positions: Vec<usize> = Vec::with_capacity(words.len());
834 let mut pos = 0;
835 for word in &words {
836 if let Some(rel) = text[pos..].find(word) {
837 word_positions.push(pos + rel);
838 pos = pos + rel + word.len();
839 } else {
840 word_positions.push(usize::MAX);
841 }
842 }
843
844 let result_words: Vec<String> = words
845 .iter()
846 .enumerate()
847 .map(|(i, word)| {
848 let after_period = i > 0 && words[i - 1].ends_with('.');
849 let is_first = (is_first_segment && i == 0) || after_period;
850 let is_last = is_last_segment && i == total_words - 1;
851
852 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
854 return Self::apply_canonical_form_to_word(word, canonical);
855 }
856
857 if word.contains('-') {
859 return self.handle_hyphenated_word(word, is_first, is_last);
860 }
861
862 self.title_case_word(word, is_first, is_last)
863 })
864 .collect();
865
866 let mut result = String::new();
868 let mut word_iter = result_words.iter();
869 let mut in_word = false;
870
871 for c in text.chars() {
872 if c.is_whitespace() {
873 if in_word {
874 in_word = false;
875 }
876 result.push(c);
877 } else if !in_word {
878 if let Some(word) = word_iter.next() {
879 result.push_str(word);
880 }
881 in_word = true;
882 }
883 }
884
885 result
886 }
887
888 fn apply_sentence_case_non_first(&self, text: &str) -> String {
890 if text.is_empty() {
891 return text.to_string();
892 }
893
894 let canonical_forms = self.proper_name_canonical_forms(text);
895 let mut result = String::new();
896 let mut current_pos = 0;
897
898 for word in text.split_whitespace() {
901 if let Some(pos) = text[current_pos..].find(word) {
902 let abs_pos = current_pos + pos;
903
904 result.push_str(&text[current_pos..abs_pos]);
906
907 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
909 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
910 } else if self.should_preserve_word(word) {
911 result.push_str(word);
912 } else {
913 result.push_str(&Self::lowercase_preserving_composition(word));
914 }
915
916 current_pos = abs_pos + word.len();
917 }
918 }
919
920 if current_pos < text.len() {
922 result.push_str(&text[current_pos..]);
923 }
924
925 result
926 }
927
928 fn get_line_byte_range(&self, content: &str, line_num: usize, line_index: &LineIndex) -> Range<usize> {
930 let start_pos = line_index.get_line_start_byte(line_num).unwrap_or(content.len());
931 let line = content.lines().nth(line_num - 1).unwrap_or("");
932 Range {
933 start: start_pos,
934 end: start_pos + line.len(),
935 }
936 }
937
938 fn fix_atx_heading(&self, _line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
940 let indent = " ".repeat(heading.marker_column);
942 let hashes = "#".repeat(heading.level as usize);
943
944 let fixed_text = self.apply_capitalization(&heading.raw_text);
946
947 let closing = &heading.closing_sequence;
949 if heading.has_closing_sequence {
950 format!("{indent}{hashes} {fixed_text} {closing}")
951 } else {
952 format!("{indent}{hashes} {fixed_text}")
953 }
954 }
955
956 fn fix_setext_heading(&self, line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
958 let fixed_text = self.apply_capitalization(&heading.raw_text);
960
961 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
963
964 format!("{leading_ws}{fixed_text}")
965 }
966}
967
968impl Rule for MD063HeadingCapitalization {
969 fn name(&self) -> &'static str {
970 "MD063"
971 }
972
973 fn description(&self) -> &'static str {
974 "Heading capitalization"
975 }
976
977 fn category(&self) -> RuleCategory {
978 RuleCategory::Heading
979 }
980
981 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
982 !ctx.likely_has_headings() || !ctx.lines.iter().any(|line| line.heading.is_some())
983 }
984
985 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
986 let content = ctx.content;
987
988 if content.is_empty() {
989 return Ok(Vec::new());
990 }
991
992 let mut warnings = Vec::new();
993 let line_index = &ctx.line_index;
994
995 for (line_num, line_info) in ctx.lines.iter().enumerate() {
996 if let Some(heading) = &line_info.heading {
997 if heading.level < self.config.min_level || heading.level > self.config.max_level {
999 continue;
1000 }
1001
1002 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
1004 continue;
1005 }
1006
1007 if !heading.is_valid {
1009 continue;
1010 }
1011
1012 let original_text = &heading.raw_text;
1014 let fixed_text = self.apply_capitalization(original_text);
1015
1016 if original_text != &fixed_text {
1017 let line = line_info.content(ctx.content);
1018 let style_name = match self.config.style {
1019 HeadingCapStyle::TitleCase => "title case",
1020 HeadingCapStyle::SentenceCase => "sentence case",
1021 HeadingCapStyle::AllCaps => "ALL CAPS",
1022 };
1023
1024 warnings.push(LintWarning {
1025 rule_name: Some(self.name().to_string()),
1026 line: line_num + 1,
1027 column: heading.content_column + 1,
1028 end_line: line_num + 1,
1029 end_column: heading.content_column + 1 + original_text.len(),
1030 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1031 severity: Severity::Warning,
1032 fix: Some(Fix::new(
1033 self.get_line_byte_range(content, line_num + 1, line_index),
1034 match heading.style {
1035 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
1036 _ => self.fix_setext_heading(line, heading),
1037 },
1038 )),
1039 });
1040 }
1041 }
1042 }
1043
1044 Ok(warnings)
1045 }
1046
1047 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1048 let content = ctx.content;
1049
1050 if content.is_empty() {
1051 return Ok(content.to_string());
1052 }
1053
1054 let lines = ctx.raw_lines();
1055 let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1056
1057 for (line_num, line_info) in ctx.lines.iter().enumerate() {
1058 if ctx.is_rule_disabled(self.name(), line_num + 1) {
1060 continue;
1061 }
1062
1063 if let Some(heading) = &line_info.heading {
1064 if heading.level < self.config.min_level || heading.level > self.config.max_level {
1066 continue;
1067 }
1068
1069 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
1071 continue;
1072 }
1073
1074 if !heading.is_valid {
1076 continue;
1077 }
1078
1079 let original_text = &heading.raw_text;
1080 let fixed_text = self.apply_capitalization(original_text);
1081
1082 if original_text != &fixed_text {
1083 let line = line_info.content(ctx.content);
1084 fixed_lines[line_num] = match heading.style {
1085 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
1086 _ => self.fix_setext_heading(line, heading),
1087 };
1088 }
1089 }
1090 }
1091
1092 let mut result = String::with_capacity(content.len());
1094 for (i, line) in fixed_lines.iter().enumerate() {
1095 result.push_str(line);
1096 if i < fixed_lines.len() - 1 || content.ends_with('\n') {
1097 result.push('\n');
1098 }
1099 }
1100
1101 Ok(result)
1102 }
1103
1104 fn as_any(&self) -> &dyn std::any::Any {
1105 self
1106 }
1107
1108 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1109 let json_value = serde_json::to_value(&self.config).ok()?;
1110 Some((
1111 self.name().to_string(),
1112 crate::rule_config_serde::json_to_toml_value(&json_value)?,
1113 ))
1114 }
1115
1116 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1117 where
1118 Self: Sized,
1119 {
1120 let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
1121 let md044_config =
1122 crate::rule_config_serde::load_rule_config::<crate::rules::md044_proper_names::MD044Config>(config);
1123 let mut rule = Self::from_config_struct(rule_config);
1124 rule.proper_names = md044_config.names;
1125 Box::new(rule)
1126 }
1127}
1128
1129#[cfg(test)]
1130mod tests {
1131 use super::*;
1132 use crate::lint_context::LintContext;
1133
1134 fn create_rule() -> MD063HeadingCapitalization {
1135 let config = MD063Config {
1136 enabled: true,
1137 ..Default::default()
1138 };
1139 MD063HeadingCapitalization::from_config_struct(config)
1140 }
1141
1142 fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
1143 let config = MD063Config {
1144 enabled: true,
1145 style,
1146 ..Default::default()
1147 };
1148 MD063HeadingCapitalization::from_config_struct(config)
1149 }
1150
1151 #[test]
1153 fn test_title_case_basic() {
1154 let rule = create_rule();
1155 let content = "# hello world\n";
1156 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1157 let result = rule.check(&ctx).unwrap();
1158 assert_eq!(result.len(), 1);
1159 assert!(result[0].message.contains("Hello World"));
1160 }
1161
1162 #[test]
1163 fn test_title_case_lowercase_words() {
1164 let rule = create_rule();
1165 let content = "# the quick brown fox\n";
1166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1167 let result = rule.check(&ctx).unwrap();
1168 assert_eq!(result.len(), 1);
1169 assert!(result[0].message.contains("The Quick Brown Fox"));
1171 }
1172
1173 #[test]
1174 fn test_title_case_already_correct() {
1175 let rule = create_rule();
1176 let content = "# The Quick Brown Fox\n";
1177 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1178 let result = rule.check(&ctx).unwrap();
1179 assert!(result.is_empty(), "Already correct heading should not be flagged");
1180 }
1181
1182 #[test]
1183 fn test_title_case_hyphenated() {
1184 let rule = create_rule();
1185 let content = "# self-documenting code\n";
1186 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1187 let result = rule.check(&ctx).unwrap();
1188 assert_eq!(result.len(), 1);
1189 assert!(result[0].message.contains("Self-Documenting Code"));
1190 }
1191
1192 #[test]
1193 fn test_title_case_preserves_url_with_nested_parens() {
1194 let rule = create_rule();
1195 let content = "# guide for [the api](https://example.com/docs/v(2)beta)\n";
1197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1198 let fixed = rule.fix(&ctx).unwrap();
1199 assert!(
1202 fixed.contains("https://example.com/docs/v(2)beta"),
1203 "URL with nested parens was corrupted: {fixed:?}"
1204 );
1205 }
1206
1207 #[test]
1208 fn test_title_case_does_not_recase_image_alt() {
1209 let rule = create_rule();
1210 let content = "# overview \n";
1211 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1212 let fixed = rule.fix(&ctx).unwrap();
1213 assert!(
1215 fixed.contains(""),
1216 "image alt text was modified: {fixed:?}"
1217 );
1218 assert!(
1219 fixed.contains("# Overview"),
1220 "surrounding prose should still be title-cased: {fixed:?}"
1221 );
1222 }
1223
1224 #[test]
1226 fn test_sentence_case_basic() {
1227 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1228 let content = "# The Quick Brown Fox\n";
1229 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1230 let result = rule.check(&ctx).unwrap();
1231 assert_eq!(result.len(), 1);
1232 assert!(result[0].message.contains("The quick brown fox"));
1233 }
1234
1235 #[test]
1236 fn test_sentence_case_already_correct() {
1237 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1238 let content = "# The quick brown fox\n";
1239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1240 let result = rule.check(&ctx).unwrap();
1241 assert!(result.is_empty());
1242 }
1243
1244 #[test]
1246 fn test_all_caps_basic() {
1247 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1248 let content = "# hello world\n";
1249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1250 let result = rule.check(&ctx).unwrap();
1251 assert_eq!(result.len(), 1);
1252 assert!(result[0].message.contains("HELLO WORLD"));
1253 }
1254
1255 #[test]
1257 fn test_preserve_ignore_words() {
1258 let config = MD063Config {
1259 enabled: true,
1260 ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
1261 ..Default::default()
1262 };
1263 let rule = MD063HeadingCapitalization::from_config_struct(config);
1264
1265 let content = "# using iPhone on macOS\n";
1266 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1267 let result = rule.check(&ctx).unwrap();
1268 assert_eq!(result.len(), 1);
1269 assert!(result[0].message.contains("iPhone"));
1271 assert!(result[0].message.contains("macOS"));
1272 }
1273
1274 #[test]
1275 fn test_preserve_cased_words() {
1276 let rule = create_rule();
1277 let content = "# using GitHub actions\n";
1278 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1279 let result = rule.check(&ctx).unwrap();
1280 assert_eq!(result.len(), 1);
1281 assert!(result[0].message.contains("GitHub"));
1283 }
1284
1285 #[test]
1287 fn test_inline_code_preserved() {
1288 let rule = create_rule();
1289 let content = "# using `const` in javascript\n";
1290 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1291 let result = rule.check(&ctx).unwrap();
1292 assert_eq!(result.len(), 1);
1293 assert!(result[0].message.contains("`const`"));
1295 assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
1296 }
1297
1298 #[test]
1300 fn test_level_filter() {
1301 let config = MD063Config {
1302 enabled: true,
1303 min_level: 2,
1304 max_level: 4,
1305 ..Default::default()
1306 };
1307 let rule = MD063HeadingCapitalization::from_config_struct(config);
1308
1309 let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
1310 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1311 let result = rule.check(&ctx).unwrap();
1312
1313 assert_eq!(result.len(), 2);
1315 assert_eq!(result[0].line, 2); assert_eq!(result[1].line, 3); }
1318
1319 #[test]
1321 fn test_fix_atx_heading() {
1322 let rule = create_rule();
1323 let content = "# hello world\n";
1324 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1325 let fixed = rule.fix(&ctx).unwrap();
1326 assert_eq!(fixed, "# Hello World\n");
1327 }
1328
1329 #[test]
1330 fn test_fix_multiple_headings() {
1331 let rule = create_rule();
1332 let content = "# first heading\n\n## second heading\n";
1333 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1334 let fixed = rule.fix(&ctx).unwrap();
1335 assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
1336 }
1337
1338 #[test]
1340 fn test_setext_heading() {
1341 let rule = create_rule();
1342 let content = "hello world\n============\n";
1343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1344 let result = rule.check(&ctx).unwrap();
1345 assert_eq!(result.len(), 1);
1346 assert!(result[0].message.contains("Hello World"));
1347 }
1348
1349 #[test]
1351 fn test_custom_id_preserved() {
1352 let rule = create_rule();
1353 let content = "# getting started {#intro}\n";
1354 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1355 let result = rule.check(&ctx).unwrap();
1356 assert_eq!(result.len(), 1);
1357 assert!(result[0].message.contains("{#intro}"));
1359 }
1360
1361 #[test]
1363 fn test_skip_obsidian_tags_not_headings() {
1364 let rule = create_rule();
1365
1366 let content = "# H1\n\n#tag\n";
1368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1369 let result = rule.check(&ctx).unwrap();
1370 assert!(
1371 result.is_empty() || result.iter().all(|w| w.line != 3),
1372 "Obsidian tag #tag should not be treated as a heading: {result:?}"
1373 );
1374 }
1375
1376 #[test]
1377 fn test_skip_invalid_atx_headings_no_space() {
1378 let rule = create_rule();
1379
1380 let content = "#notaheading\n";
1382 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1383 let result = rule.check(&ctx).unwrap();
1384 assert!(
1385 result.is_empty(),
1386 "Invalid ATX heading without space should not be flagged: {result:?}"
1387 );
1388 }
1389
1390 #[test]
1391 fn test_fix_skips_obsidian_tags() {
1392 let rule = create_rule();
1393
1394 let content = "# hello world\n\n#tag\n";
1395 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1396 let fixed = rule.fix(&ctx).unwrap();
1397 assert!(fixed.contains("#tag"), "Fix should not modify Obsidian tag #tag");
1399 assert!(fixed.contains("# Hello World"), "Fix should still fix real headings");
1400 }
1401
1402 #[test]
1403 fn test_preserve_all_caps_acronyms() {
1404 let rule = create_rule();
1405 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1406
1407 let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
1409 assert_eq!(fixed, "# Using API in Production\n");
1410
1411 let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
1413 assert_eq!(fixed, "# API and GPU Integration\n");
1414
1415 let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
1417 assert_eq!(fixed, "# IO Performance Guide\n");
1418
1419 let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
1421 assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
1422 }
1423
1424 #[test]
1425 fn test_preserve_acronyms_in_hyphenated_words() {
1426 let rule = create_rule();
1427 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1428
1429 let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
1431 assert_eq!(fixed, "# API-Driven Architecture\n");
1432
1433 let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
1435 assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
1436 }
1437
1438 #[test]
1439 fn test_single_letters_not_treated_as_acronyms() {
1440 let rule = create_rule();
1441 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1442
1443 let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
1445 assert_eq!(fixed, "# I Am a Heading\n");
1446 }
1447
1448 #[test]
1449 fn test_lowercase_terms_need_ignore_words() {
1450 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1451
1452 let rule = create_rule();
1454 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1455 assert_eq!(fixed, "# Using Npm Packages\n");
1456
1457 let config = MD063Config {
1459 enabled: true,
1460 ignore_words: vec!["npm".to_string()],
1461 ..Default::default()
1462 };
1463 let rule = MD063HeadingCapitalization::from_config_struct(config);
1464 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1465 assert_eq!(fixed, "# Using npm Packages\n");
1466 }
1467
1468 #[test]
1469 fn test_acronyms_with_mixed_case_preserved() {
1470 let rule = create_rule();
1471 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1472
1473 let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
1475 assert_eq!(fixed, "# Using API with GitHub\n");
1476 }
1477
1478 #[test]
1479 fn test_real_world_acronyms() {
1480 let rule = create_rule();
1481 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1482
1483 let content = "# FFI bindings for CPU optimization\n";
1485 let fixed = rule.fix(&ctx(content)).unwrap();
1486 assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
1487
1488 let content = "# DOM manipulation and SSR rendering\n";
1489 let fixed = rule.fix(&ctx(content)).unwrap();
1490 assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
1491
1492 let content = "# CVE security and RNN models\n";
1493 let fixed = rule.fix(&ctx(content)).unwrap();
1494 assert_eq!(fixed, "# CVE Security and RNN Models\n");
1495 }
1496
1497 #[test]
1498 fn test_is_all_caps_acronym() {
1499 let rule = create_rule();
1500
1501 assert!(rule.is_all_caps_acronym("API"));
1503 assert!(rule.is_all_caps_acronym("IO"));
1504 assert!(rule.is_all_caps_acronym("GPU"));
1505 assert!(rule.is_all_caps_acronym("HTTP2")); assert!(!rule.is_all_caps_acronym("A"));
1509 assert!(!rule.is_all_caps_acronym("I"));
1510
1511 assert!(!rule.is_all_caps_acronym("Api"));
1513 assert!(!rule.is_all_caps_acronym("npm"));
1514 assert!(!rule.is_all_caps_acronym("iPhone"));
1515 }
1516
1517 #[test]
1518 fn test_sentence_case_ignore_words_first_word() {
1519 let config = MD063Config {
1520 enabled: true,
1521 style: HeadingCapStyle::SentenceCase,
1522 ignore_words: vec!["nvim".to_string()],
1523 ..Default::default()
1524 };
1525 let rule = MD063HeadingCapitalization::from_config_struct(config);
1526
1527 let content = "# nvim config\n";
1529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1530 let result = rule.check(&ctx).unwrap();
1531 assert!(
1532 result.is_empty(),
1533 "nvim in ignore-words should not be flagged. Got: {result:?}"
1534 );
1535
1536 let fixed = rule.fix(&ctx).unwrap();
1538 assert_eq!(fixed, "# nvim config\n");
1539 }
1540
1541 #[test]
1542 fn test_sentence_case_ignore_words_not_first() {
1543 let config = MD063Config {
1544 enabled: true,
1545 style: HeadingCapStyle::SentenceCase,
1546 ignore_words: vec!["nvim".to_string()],
1547 ..Default::default()
1548 };
1549 let rule = MD063HeadingCapitalization::from_config_struct(config);
1550
1551 let content = "# Using nvim editor\n";
1553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1554 let result = rule.check(&ctx).unwrap();
1555 assert!(
1556 result.is_empty(),
1557 "nvim in ignore-words should be preserved. Got: {result:?}"
1558 );
1559 }
1560
1561 #[test]
1562 fn test_preserve_cased_words_ios() {
1563 let config = MD063Config {
1564 enabled: true,
1565 style: HeadingCapStyle::SentenceCase,
1566 preserve_cased_words: true,
1567 ..Default::default()
1568 };
1569 let rule = MD063HeadingCapitalization::from_config_struct(config);
1570
1571 let content = "## This is iOS\n";
1573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1574 let result = rule.check(&ctx).unwrap();
1575 assert!(
1576 result.is_empty(),
1577 "iOS should be preserved with preserve-cased-words. Got: {result:?}"
1578 );
1579
1580 let fixed = rule.fix(&ctx).unwrap();
1582 assert_eq!(fixed, "## This is iOS\n");
1583 }
1584
1585 #[test]
1586 fn test_preserve_cased_words_ios_title_case() {
1587 let config = MD063Config {
1588 enabled: true,
1589 style: HeadingCapStyle::TitleCase,
1590 preserve_cased_words: true,
1591 ..Default::default()
1592 };
1593 let rule = MD063HeadingCapitalization::from_config_struct(config);
1594
1595 let content = "# developing for iOS\n";
1597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1598 let fixed = rule.fix(&ctx).unwrap();
1599 assert_eq!(fixed, "# Developing for iOS\n");
1600 }
1601
1602 #[test]
1603 fn test_has_internal_capitals_ios() {
1604 let rule = create_rule();
1605
1606 assert!(
1608 rule.has_internal_capitals("iOS"),
1609 "iOS has mixed case (lowercase i, uppercase OS)"
1610 );
1611
1612 assert!(rule.has_internal_capitals("iPhone"));
1614 assert!(rule.has_internal_capitals("macOS"));
1615 assert!(rule.has_internal_capitals("GitHub"));
1616 assert!(rule.has_internal_capitals("JavaScript"));
1617 assert!(rule.has_internal_capitals("eBay"));
1618
1619 assert!(!rule.has_internal_capitals("API"));
1621 assert!(!rule.has_internal_capitals("GPU"));
1622
1623 assert!(!rule.has_internal_capitals("npm"));
1625 assert!(!rule.has_internal_capitals("config"));
1626
1627 assert!(!rule.has_internal_capitals("The"));
1629 assert!(!rule.has_internal_capitals("Hello"));
1630 }
1631
1632 #[test]
1633 fn test_lowercase_words_before_trailing_code() {
1634 let config = MD063Config {
1635 enabled: true,
1636 style: HeadingCapStyle::TitleCase,
1637 lowercase_words: vec![
1638 "a".to_string(),
1639 "an".to_string(),
1640 "and".to_string(),
1641 "at".to_string(),
1642 "but".to_string(),
1643 "by".to_string(),
1644 "for".to_string(),
1645 "from".to_string(),
1646 "into".to_string(),
1647 "nor".to_string(),
1648 "on".to_string(),
1649 "onto".to_string(),
1650 "or".to_string(),
1651 "the".to_string(),
1652 "to".to_string(),
1653 "upon".to_string(),
1654 "via".to_string(),
1655 "vs".to_string(),
1656 "with".to_string(),
1657 "without".to_string(),
1658 ],
1659 preserve_cased_words: true,
1660 ..Default::default()
1661 };
1662 let rule = MD063HeadingCapitalization::from_config_struct(config);
1663
1664 let content = "## subtitle with a `app`\n";
1669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1670 let result = rule.check(&ctx).unwrap();
1671
1672 assert!(!result.is_empty(), "Should flag incorrect capitalization");
1674 let fixed = rule.fix(&ctx).unwrap();
1675 assert!(
1677 fixed.contains("with a `app`"),
1678 "Expected 'with a `app`' but got: {fixed:?}"
1679 );
1680 assert!(
1681 !fixed.contains("with A `app`"),
1682 "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
1683 );
1684 assert!(
1686 fixed.contains("Subtitle with a `app`"),
1687 "Expected 'Subtitle with a `app`' but got: {fixed:?}"
1688 );
1689 }
1690
1691 #[test]
1692 fn test_lowercase_words_preserved_before_trailing_code_variant() {
1693 let config = MD063Config {
1694 enabled: true,
1695 style: HeadingCapStyle::TitleCase,
1696 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1697 ..Default::default()
1698 };
1699 let rule = MD063HeadingCapitalization::from_config_struct(config);
1700
1701 let content = "## Title with the `code`\n";
1703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1704 let fixed = rule.fix(&ctx).unwrap();
1705 assert!(
1707 fixed.contains("with the `code`"),
1708 "Expected 'with the `code`' but got: {fixed:?}"
1709 );
1710 assert!(
1711 !fixed.contains("with The `code`"),
1712 "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
1713 );
1714 }
1715
1716 #[test]
1717 fn test_last_word_capitalized_when_no_trailing_code() {
1718 let config = MD063Config {
1721 enabled: true,
1722 style: HeadingCapStyle::TitleCase,
1723 lowercase_words: vec!["a".to_string(), "the".to_string()],
1724 ..Default::default()
1725 };
1726 let rule = MD063HeadingCapitalization::from_config_struct(config);
1727
1728 let content = "## title with a word\n";
1731 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1732 let fixed = rule.fix(&ctx).unwrap();
1733 assert!(
1735 fixed.contains("With a Word"),
1736 "Expected 'With a Word' but got: {fixed:?}"
1737 );
1738 }
1739
1740 #[test]
1741 fn test_multiple_lowercase_words_before_code() {
1742 let config = MD063Config {
1743 enabled: true,
1744 style: HeadingCapStyle::TitleCase,
1745 lowercase_words: vec![
1746 "a".to_string(),
1747 "the".to_string(),
1748 "with".to_string(),
1749 "for".to_string(),
1750 ],
1751 ..Default::default()
1752 };
1753 let rule = MD063HeadingCapitalization::from_config_struct(config);
1754
1755 let content = "## Guide for the `user`\n";
1757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1758 let fixed = rule.fix(&ctx).unwrap();
1759 assert!(
1760 fixed.contains("for the `user`"),
1761 "Expected 'for the `user`' but got: {fixed:?}"
1762 );
1763 assert!(
1764 !fixed.contains("For The `user`"),
1765 "Should not capitalize lowercase words before code. Got: {fixed:?}"
1766 );
1767 }
1768
1769 #[test]
1770 fn test_code_in_middle_normal_rules_apply() {
1771 let config = MD063Config {
1772 enabled: true,
1773 style: HeadingCapStyle::TitleCase,
1774 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1775 ..Default::default()
1776 };
1777 let rule = MD063HeadingCapitalization::from_config_struct(config);
1778
1779 let content = "## Using `const` for the code\n";
1781 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1782 let fixed = rule.fix(&ctx).unwrap();
1783 assert!(
1785 fixed.contains("for the Code"),
1786 "Expected 'for the Code' but got: {fixed:?}"
1787 );
1788 }
1789
1790 #[test]
1791 fn test_link_at_end_same_as_code() {
1792 let config = MD063Config {
1793 enabled: true,
1794 style: HeadingCapStyle::TitleCase,
1795 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1796 ..Default::default()
1797 };
1798 let rule = MD063HeadingCapitalization::from_config_struct(config);
1799
1800 let content = "## Guide for the [link](./page.md)\n";
1802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1803 let fixed = rule.fix(&ctx).unwrap();
1804 assert!(
1806 fixed.contains("for the [Link]"),
1807 "Expected 'for the [Link]' but got: {fixed:?}"
1808 );
1809 assert!(
1810 !fixed.contains("for The [Link]"),
1811 "Should not capitalize 'the' before link. Got: {fixed:?}"
1812 );
1813 }
1814
1815 #[test]
1816 fn test_multiple_code_segments() {
1817 let config = MD063Config {
1818 enabled: true,
1819 style: HeadingCapStyle::TitleCase,
1820 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1821 ..Default::default()
1822 };
1823 let rule = MD063HeadingCapitalization::from_config_struct(config);
1824
1825 let content = "## Using `const` with a `variable`\n";
1827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1828 let fixed = rule.fix(&ctx).unwrap();
1829 assert!(
1831 fixed.contains("with a `variable`"),
1832 "Expected 'with a `variable`' but got: {fixed:?}"
1833 );
1834 assert!(
1835 !fixed.contains("with A `variable`"),
1836 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1837 );
1838 }
1839
1840 #[test]
1841 fn test_code_and_link_combination() {
1842 let config = MD063Config {
1843 enabled: true,
1844 style: HeadingCapStyle::TitleCase,
1845 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1846 ..Default::default()
1847 };
1848 let rule = MD063HeadingCapitalization::from_config_struct(config);
1849
1850 let content = "## Guide for the `code` [link](./page.md)\n";
1852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1853 let fixed = rule.fix(&ctx).unwrap();
1854 assert!(
1856 fixed.contains("for the `code`"),
1857 "Expected 'for the `code`' but got: {fixed:?}"
1858 );
1859 }
1860
1861 #[test]
1862 fn test_text_after_code_capitalizes_last() {
1863 let config = MD063Config {
1864 enabled: true,
1865 style: HeadingCapStyle::TitleCase,
1866 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1867 ..Default::default()
1868 };
1869 let rule = MD063HeadingCapitalization::from_config_struct(config);
1870
1871 let content = "## Using `const` for the code\n";
1873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1874 let fixed = rule.fix(&ctx).unwrap();
1875 assert!(
1877 fixed.contains("for the Code"),
1878 "Expected 'for the Code' but got: {fixed:?}"
1879 );
1880 }
1881
1882 #[test]
1883 fn test_preserve_cased_words_with_trailing_code() {
1884 let config = MD063Config {
1885 enabled: true,
1886 style: HeadingCapStyle::TitleCase,
1887 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1888 preserve_cased_words: true,
1889 ..Default::default()
1890 };
1891 let rule = MD063HeadingCapitalization::from_config_struct(config);
1892
1893 let content = "## Guide for iOS `app`\n";
1895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1896 let fixed = rule.fix(&ctx).unwrap();
1897 assert!(
1899 fixed.contains("for iOS `app`"),
1900 "Expected 'for iOS `app`' but got: {fixed:?}"
1901 );
1902 assert!(
1903 !fixed.contains("For iOS `app`"),
1904 "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
1905 );
1906 }
1907
1908 #[test]
1909 fn test_ignore_words_with_trailing_code() {
1910 let config = MD063Config {
1911 enabled: true,
1912 style: HeadingCapStyle::TitleCase,
1913 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1914 ignore_words: vec!["npm".to_string()],
1915 ..Default::default()
1916 };
1917 let rule = MD063HeadingCapitalization::from_config_struct(config);
1918
1919 let content = "## Using npm with a `script`\n";
1921 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1922 let fixed = rule.fix(&ctx).unwrap();
1923 assert!(
1925 fixed.contains("npm with a `script`"),
1926 "Expected 'npm with a `script`' but got: {fixed:?}"
1927 );
1928 assert!(
1929 !fixed.contains("with A `script`"),
1930 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1931 );
1932 }
1933
1934 #[test]
1935 fn test_empty_text_segment_edge_case() {
1936 let config = MD063Config {
1937 enabled: true,
1938 style: HeadingCapStyle::TitleCase,
1939 lowercase_words: vec!["a".to_string(), "with".to_string()],
1940 ..Default::default()
1941 };
1942 let rule = MD063HeadingCapitalization::from_config_struct(config);
1943
1944 let content = "## `start` with a `end`\n";
1946 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1947 let fixed = rule.fix(&ctx).unwrap();
1948 assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
1951 assert!(
1952 !fixed.contains("A `end`"),
1953 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1954 );
1955 }
1956
1957 #[test]
1958 fn test_sentence_case_with_trailing_code() {
1959 let config = MD063Config {
1960 enabled: true,
1961 style: HeadingCapStyle::SentenceCase,
1962 lowercase_words: vec!["a".to_string(), "the".to_string()],
1963 ..Default::default()
1964 };
1965 let rule = MD063HeadingCapitalization::from_config_struct(config);
1966
1967 let content = "## guide for the `user`\n";
1969 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1970 let fixed = rule.fix(&ctx).unwrap();
1971 assert!(
1973 fixed.contains("Guide for the `user`"),
1974 "Expected 'Guide for the `user`' but got: {fixed:?}"
1975 );
1976 }
1977
1978 #[test]
1979 fn test_hyphenated_word_before_code() {
1980 let config = MD063Config {
1981 enabled: true,
1982 style: HeadingCapStyle::TitleCase,
1983 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1984 ..Default::default()
1985 };
1986 let rule = MD063HeadingCapitalization::from_config_struct(config);
1987
1988 let content = "## Self-contained with a `feature`\n";
1990 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1991 let fixed = rule.fix(&ctx).unwrap();
1992 assert!(
1994 fixed.contains("with a `feature`"),
1995 "Expected 'with a `feature`' but got: {fixed:?}"
1996 );
1997 }
1998
1999 #[test]
2004 fn test_sentence_case_code_at_start_basic() {
2005 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2007 let content = "# `rumdl` is a linter\n";
2008 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2009 let result = rule.check(&ctx).unwrap();
2010 assert!(
2012 result.is_empty(),
2013 "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
2014 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2015 );
2016 }
2017
2018 #[test]
2019 fn test_sentence_case_code_at_start_incorrect_capitalization() {
2020 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2022 let content = "# `rumdl` Is a Linter\n";
2023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2024 let result = rule.check(&ctx).unwrap();
2025 assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
2027 assert!(
2028 result[0].message.contains("`rumdl` is a linter"),
2029 "Should suggest lowercase after code. Got: {:?}",
2030 result[0].message
2031 );
2032 }
2033
2034 #[test]
2035 fn test_sentence_case_code_at_start_fix() {
2036 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2037 let content = "# `rumdl` Is A Linter\n";
2038 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2039 let fixed = rule.fix(&ctx).unwrap();
2040 assert!(
2041 fixed.contains("# `rumdl` is a linter"),
2042 "Should fix to lowercase after code. Got: {fixed:?}"
2043 );
2044 }
2045
2046 #[test]
2047 fn test_sentence_case_text_at_start_still_capitalizes() {
2048 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2050 let content = "# the quick brown fox\n";
2051 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2052 let result = rule.check(&ctx).unwrap();
2053 assert_eq!(result.len(), 1);
2054 assert!(
2055 result[0].message.contains("The quick brown fox"),
2056 "Text-first heading should capitalize first word. Got: {:?}",
2057 result[0].message
2058 );
2059 }
2060
2061 #[test]
2062 fn test_sentence_case_link_at_start() {
2063 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2065 let content = "# [api](api.md) reference guide\n";
2067 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2068 let result = rule.check(&ctx).unwrap();
2069 assert!(
2071 result.is_empty(),
2072 "Heading with link at start should not capitalize 'reference'. Got: {:?}",
2073 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2074 );
2075 }
2076
2077 #[test]
2078 fn test_sentence_case_link_preserves_acronyms() {
2079 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2081 let content = "# [API](api.md) Reference Guide\n";
2082 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2083 let result = rule.check(&ctx).unwrap();
2084 assert_eq!(result.len(), 1);
2085 assert!(
2087 result[0].message.contains("[API](api.md) reference guide"),
2088 "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
2089 result[0].message
2090 );
2091 }
2092
2093 #[test]
2094 fn test_sentence_case_link_preserves_brand_names() {
2095 let config = MD063Config {
2097 enabled: true,
2098 style: HeadingCapStyle::SentenceCase,
2099 preserve_cased_words: true,
2100 ..Default::default()
2101 };
2102 let rule = MD063HeadingCapitalization::from_config_struct(config);
2103 let content = "# [iPhone](iphone.md) Features Guide\n";
2104 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2105 let result = rule.check(&ctx).unwrap();
2106 assert_eq!(result.len(), 1);
2107 assert!(
2109 result[0].message.contains("[iPhone](iphone.md) features guide"),
2110 "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
2111 result[0].message
2112 );
2113 }
2114
2115 #[test]
2116 fn test_sentence_case_link_lowercases_regular_words() {
2117 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2119 let content = "# [Documentation](docs.md) Reference\n";
2120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2121 let result = rule.check(&ctx).unwrap();
2122 assert_eq!(result.len(), 1);
2123 assert!(
2125 result[0].message.contains("[documentation](docs.md) reference"),
2126 "Should lowercase regular link text. Got: {:?}",
2127 result[0].message
2128 );
2129 }
2130
2131 #[test]
2132 fn test_sentence_case_link_at_start_correct_already() {
2133 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2135 let content = "# [API](api.md) reference guide\n";
2136 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2137 let result = rule.check(&ctx).unwrap();
2138 assert!(
2139 result.is_empty(),
2140 "Correctly cased heading with link should not be flagged. Got: {:?}",
2141 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2142 );
2143 }
2144
2145 #[test]
2146 fn test_sentence_case_link_github_preserved() {
2147 let config = MD063Config {
2149 enabled: true,
2150 style: HeadingCapStyle::SentenceCase,
2151 preserve_cased_words: true,
2152 ..Default::default()
2153 };
2154 let rule = MD063HeadingCapitalization::from_config_struct(config);
2155 let content = "# [GitHub](gh.md) Repository Setup\n";
2156 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2157 let result = rule.check(&ctx).unwrap();
2158 assert_eq!(result.len(), 1);
2159 assert!(
2160 result[0].message.contains("[GitHub](gh.md) repository setup"),
2161 "Should preserve 'GitHub'. Got: {:?}",
2162 result[0].message
2163 );
2164 }
2165
2166 #[test]
2167 fn test_sentence_case_multiple_code_spans() {
2168 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2169 let content = "# `foo` and `bar` are methods\n";
2170 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2171 let result = rule.check(&ctx).unwrap();
2172 assert!(
2174 result.is_empty(),
2175 "Should not capitalize words between/after code spans. Got: {:?}",
2176 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2177 );
2178 }
2179
2180 #[test]
2181 fn test_sentence_case_code_only_heading() {
2182 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2184 let content = "# `rumdl`\n";
2185 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2186 let result = rule.check(&ctx).unwrap();
2187 assert!(
2188 result.is_empty(),
2189 "Code-only heading should be fine. Got: {:?}",
2190 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2191 );
2192 }
2193
2194 #[test]
2195 fn test_sentence_case_code_at_end() {
2196 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2198 let content = "# install the `rumdl` tool\n";
2199 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2200 let result = rule.check(&ctx).unwrap();
2201 assert_eq!(result.len(), 1);
2203 assert!(
2204 result[0].message.contains("Install the `rumdl` tool"),
2205 "First word should still be capitalized when text comes first. Got: {:?}",
2206 result[0].message
2207 );
2208 }
2209
2210 #[test]
2211 fn test_sentence_case_code_in_middle() {
2212 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2214 let content = "# using the `rumdl` linter for markdown\n";
2215 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2216 let result = rule.check(&ctx).unwrap();
2217 assert_eq!(result.len(), 1);
2219 assert!(
2220 result[0].message.contains("Using the `rumdl` linter for markdown"),
2221 "First word should be capitalized. Got: {:?}",
2222 result[0].message
2223 );
2224 }
2225
2226 #[test]
2227 fn test_sentence_case_preserved_word_after_code() {
2228 let config = MD063Config {
2230 enabled: true,
2231 style: HeadingCapStyle::SentenceCase,
2232 preserve_cased_words: true,
2233 ..Default::default()
2234 };
2235 let rule = MD063HeadingCapitalization::from_config_struct(config);
2236 let content = "# `swift` iPhone development\n";
2237 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2238 let result = rule.check(&ctx).unwrap();
2239 assert!(
2241 result.is_empty(),
2242 "Preserved words after code should stay. Got: {:?}",
2243 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2244 );
2245 }
2246
2247 #[test]
2248 fn test_title_case_code_at_start_still_capitalizes() {
2249 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2251 let content = "# `api` quick start guide\n";
2252 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2253 let result = rule.check(&ctx).unwrap();
2254 assert_eq!(result.len(), 1);
2256 assert!(
2257 result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
2258 "Title case should capitalize major words after code. Got: {:?}",
2259 result[0].message
2260 );
2261 }
2262
2263 #[test]
2266 fn test_sentence_case_html_tag_at_start() {
2267 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2269 let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
2270 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2271 let result = rule.check(&ctx).unwrap();
2272 assert_eq!(result.len(), 1);
2274 let fixed = rule.fix(&ctx).unwrap();
2275 assert_eq!(
2276 fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
2277 "Text after HTML at start should be lowercase"
2278 );
2279 }
2280
2281 #[test]
2282 fn test_sentence_case_html_tag_preserves_content() {
2283 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2285 let content = "# The <abbr>API</abbr> documentation guide\n";
2286 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2287 let result = rule.check(&ctx).unwrap();
2288 assert!(
2290 result.is_empty(),
2291 "HTML tag content should be preserved. Got: {:?}",
2292 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2293 );
2294 }
2295
2296 #[test]
2297 fn test_sentence_case_html_tag_at_start_with_acronym() {
2298 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2300 let content = "# <abbr>API</abbr> Documentation Guide\n";
2301 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2302 let result = rule.check(&ctx).unwrap();
2303 assert_eq!(result.len(), 1);
2304 let fixed = rule.fix(&ctx).unwrap();
2305 assert_eq!(
2306 fixed, "# <abbr>API</abbr> documentation guide\n",
2307 "Text after HTML at start should be lowercase, HTML content preserved"
2308 );
2309 }
2310
2311 #[test]
2312 fn test_sentence_case_html_tag_in_middle() {
2313 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2315 let content = "# using the <code>config</code> File\n";
2316 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2317 let result = rule.check(&ctx).unwrap();
2318 assert_eq!(result.len(), 1);
2319 let fixed = rule.fix(&ctx).unwrap();
2320 assert_eq!(
2321 fixed, "# Using the <code>config</code> file\n",
2322 "First word capitalized, HTML preserved, rest lowercase"
2323 );
2324 }
2325
2326 #[test]
2327 fn test_html_tag_strong_emphasis() {
2328 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2330 let content = "# The <strong>Bold</strong> Way\n";
2331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2332 let result = rule.check(&ctx).unwrap();
2333 assert_eq!(result.len(), 1);
2334 let fixed = rule.fix(&ctx).unwrap();
2335 assert_eq!(
2336 fixed, "# The <strong>Bold</strong> way\n",
2337 "<strong> tag content should be preserved"
2338 );
2339 }
2340
2341 #[test]
2342 fn test_html_tag_with_attributes() {
2343 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2345 let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
2346 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2347 let result = rule.check(&ctx).unwrap();
2348 assert_eq!(result.len(), 1);
2349 let fixed = rule.fix(&ctx).unwrap();
2350 assert_eq!(
2351 fixed, "# <span class=\"highlight\">Important</span> notice here\n",
2352 "HTML tag with attributes should be preserved"
2353 );
2354 }
2355
2356 #[test]
2357 fn test_multiple_html_tags() {
2358 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2360 let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
2361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2362 let result = rule.check(&ctx).unwrap();
2363 assert_eq!(result.len(), 1);
2364 let fixed = rule.fix(&ctx).unwrap();
2365 assert_eq!(
2366 fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
2367 "Multiple HTML tags should all be preserved"
2368 );
2369 }
2370
2371 #[test]
2372 fn test_html_and_code_mixed() {
2373 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2375 let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
2376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2377 let result = rule.check(&ctx).unwrap();
2378 assert_eq!(result.len(), 1);
2379 let fixed = rule.fix(&ctx).unwrap();
2380 assert_eq!(
2381 fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
2382 "HTML and code should both be preserved"
2383 );
2384 }
2385
2386 #[test]
2387 fn test_self_closing_html_tag() {
2388 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2390 let content = "# Line one<br/>Line Two Here\n";
2391 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2392 let result = rule.check(&ctx).unwrap();
2393 assert_eq!(result.len(), 1);
2394 let fixed = rule.fix(&ctx).unwrap();
2395 assert_eq!(
2396 fixed, "# Line one<br/>line two here\n",
2397 "Self-closing HTML tags should be preserved"
2398 );
2399 }
2400
2401 #[test]
2402 fn test_title_case_with_html_tags() {
2403 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2405 let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
2406 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2407 let result = rule.check(&ctx).unwrap();
2408 assert_eq!(result.len(), 1);
2409 let fixed = rule.fix(&ctx).unwrap();
2410 assert!(
2412 fixed.contains("<kbd>ctrl</kbd>"),
2413 "HTML tag content should be preserved in title case. Got: {fixed}"
2414 );
2415 assert!(
2416 fixed.starts_with("# The ") || fixed.starts_with("# the "),
2417 "Title case should work with HTML. Got: {fixed}"
2418 );
2419 }
2420
2421 #[test]
2424 fn test_sentence_case_preserves_caret_notation() {
2425 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2427 let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
2428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2429 let result = rule.check(&ctx).unwrap();
2430 assert!(
2432 result.is_empty(),
2433 "Caret notation should be preserved. Got: {:?}",
2434 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2435 );
2436 }
2437
2438 #[test]
2439 fn test_sentence_case_caret_notation_various() {
2440 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2442
2443 let content = "## Press ^C to cancel\n";
2445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2446 let result = rule.check(&ctx).unwrap();
2447 assert!(
2448 result.is_empty(),
2449 "^C should be preserved. Got: {:?}",
2450 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2451 );
2452
2453 let content = "## Use ^Z for background\n";
2455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2456 let result = rule.check(&ctx).unwrap();
2457 assert!(
2458 result.is_empty(),
2459 "^Z should be preserved. Got: {:?}",
2460 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2461 );
2462
2463 let content = "## Press ^[ for escape\n";
2465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2466 let result = rule.check(&ctx).unwrap();
2467 assert!(
2468 result.is_empty(),
2469 "^[ should be preserved. Got: {:?}",
2470 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2471 );
2472 }
2473
2474 #[test]
2475 fn test_caret_notation_detection() {
2476 let rule = create_rule();
2477
2478 assert!(rule.is_caret_notation("^A"));
2480 assert!(rule.is_caret_notation("^Z"));
2481 assert!(rule.is_caret_notation("^C"));
2482 assert!(rule.is_caret_notation("^@")); assert!(rule.is_caret_notation("^[")); assert!(rule.is_caret_notation("^]")); assert!(rule.is_caret_notation("^^")); assert!(rule.is_caret_notation("^_")); assert!(!rule.is_caret_notation("^a")); assert!(!rule.is_caret_notation("A")); assert!(!rule.is_caret_notation("^")); assert!(!rule.is_caret_notation("^1")); }
2494
2495 fn create_sentence_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2502 let config = MD063Config {
2503 enabled: true,
2504 style: HeadingCapStyle::SentenceCase,
2505 ..Default::default()
2506 };
2507 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2508 rule.proper_names = names;
2509 rule
2510 }
2511
2512 #[test]
2513 fn test_sentence_case_preserves_single_word_proper_name() {
2514 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2515 let content = "# installing javascript\n";
2517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2518 let result = rule.check(&ctx).unwrap();
2519 assert_eq!(result.len(), 1, "Should flag the heading");
2520 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2521 assert!(
2522 fix_text.contains("JavaScript"),
2523 "Fix should preserve proper name 'JavaScript', got: {fix_text:?}"
2524 );
2525 assert!(
2526 !fix_text.contains("javascript"),
2527 "Fix should not have lowercase 'javascript', got: {fix_text:?}"
2528 );
2529 }
2530
2531 #[test]
2532 fn test_sentence_case_preserves_multi_word_proper_name() {
2533 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2534 let content = "# using good application features\n";
2536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2537 let result = rule.check(&ctx).unwrap();
2538 assert_eq!(result.len(), 1, "Should flag the heading");
2539 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2540 assert!(
2541 fix_text.contains("Good Application"),
2542 "Fix should preserve 'Good Application' as a phrase, got: {fix_text:?}"
2543 );
2544 }
2545
2546 #[test]
2547 fn test_sentence_case_proper_name_at_start_of_heading() {
2548 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2549 let content = "# good application overview\n";
2551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2552 let result = rule.check(&ctx).unwrap();
2553 assert_eq!(result.len(), 1, "Should flag the heading");
2554 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2555 assert!(
2556 fix_text.contains("Good Application"),
2557 "Fix should produce 'Good Application' at start of heading, got: {fix_text:?}"
2558 );
2559 assert!(
2560 fix_text.contains("overview"),
2561 "Non-proper-name word 'overview' should be lowercase, got: {fix_text:?}"
2562 );
2563 }
2564
2565 #[test]
2566 fn test_sentence_case_with_proper_names_no_oscillation() {
2567 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2570
2571 let content = "# installing good application on your system\n";
2573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2574 let result = rule.check(&ctx).unwrap();
2575 assert_eq!(result.len(), 1);
2576 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
2577
2578 assert!(
2580 fixed_heading.contains("Good Application"),
2581 "After fix, proper name must be preserved: {fixed_heading:?}"
2582 );
2583
2584 let fixed_line = format!("{fixed_heading}\n");
2586 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
2587 let result2 = rule.check(&ctx2).unwrap();
2588 assert!(
2589 result2.is_empty(),
2590 "After one fix, heading must already satisfy both MD063 and MD044 - no oscillation. \
2591 Second pass warnings: {result2:?}"
2592 );
2593 }
2594
2595 #[test]
2596 fn test_sentence_case_proper_names_already_correct() {
2597 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2598 let content = "# Installing Good Application\n";
2600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2601 let result = rule.check(&ctx).unwrap();
2602 assert!(
2603 result.is_empty(),
2604 "Correct sentence-case heading with proper name should not be flagged, got: {result:?}"
2605 );
2606 }
2607
2608 #[test]
2609 fn test_sentence_case_multiple_proper_names_in_heading() {
2610 let rule = create_sentence_case_rule_with_proper_names(vec!["TypeScript".to_string(), "React".to_string()]);
2611 let content = "# using typescript with react\n";
2612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2613 let result = rule.check(&ctx).unwrap();
2614 assert_eq!(result.len(), 1);
2615 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2616 assert!(
2617 fix_text.contains("TypeScript"),
2618 "Fix should preserve 'TypeScript', got: {fix_text:?}"
2619 );
2620 assert!(
2621 fix_text.contains("React"),
2622 "Fix should preserve 'React', got: {fix_text:?}"
2623 );
2624 }
2625
2626 #[test]
2627 fn test_sentence_case_unicode_casefold_expansion_before_proper_name() {
2628 let rule = create_sentence_case_rule_with_proper_names(vec!["Österreich".to_string()]);
2631 let content = "# İ österreich guide\n";
2632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2633
2634 let result = rule.check(&ctx).unwrap();
2636 assert_eq!(result.len(), 1, "Should flag heading for canonical proper-name casing");
2637 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2638 assert!(
2639 fix_text.contains("Österreich"),
2640 "Fix should preserve canonical 'Österreich', got: {fix_text:?}"
2641 );
2642 }
2643
2644 #[test]
2645 fn test_sentence_case_preserves_trailing_punctuation_on_proper_name() {
2646 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2647 let content = "# using javascript, today\n";
2648 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2649 let result = rule.check(&ctx).unwrap();
2650 assert_eq!(result.len(), 1, "Should flag heading");
2651 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2652 assert!(
2653 fix_text.contains("JavaScript,"),
2654 "Fix should preserve trailing punctuation, got: {fix_text:?}"
2655 );
2656 }
2657
2658 fn create_title_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2665 let config = MD063Config {
2666 enabled: true,
2667 style: HeadingCapStyle::TitleCase,
2668 ..Default::default()
2669 };
2670 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2671 rule.proper_names = names;
2672 rule
2673 }
2674
2675 #[test]
2676 fn test_title_case_preserves_proper_name_with_lowercase_article() {
2677 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
2681 let content = "# listening to the rolling stones today\n";
2682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2683 let result = rule.check(&ctx).unwrap();
2684 assert_eq!(result.len(), 1, "Should flag the heading");
2685 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2686 assert!(
2687 fix_text.contains("The Rolling Stones"),
2688 "Fix should preserve proper name 'The Rolling Stones', got: {fix_text:?}"
2689 );
2690 }
2691
2692 #[test]
2693 fn test_title_case_proper_name_no_oscillation() {
2694 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
2696 let content = "# listening to the rolling stones today\n";
2697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2698 let result = rule.check(&ctx).unwrap();
2699 assert_eq!(result.len(), 1);
2700 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
2701
2702 let fixed_line = format!("{fixed_heading}\n");
2703 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
2704 let result2 = rule.check(&ctx2).unwrap();
2705 assert!(
2706 result2.is_empty(),
2707 "After one title-case fix, heading must already satisfy both rules. \
2708 Second pass warnings: {result2:?}"
2709 );
2710 }
2711
2712 #[test]
2713 fn test_title_case_unicode_casefold_expansion_before_proper_name() {
2714 let rule = create_title_case_rule_with_proper_names(vec!["Österreich".to_string()]);
2715 let content = "# İ österreich guide\n";
2716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2717 let result = rule.check(&ctx).unwrap();
2718 assert_eq!(result.len(), 1, "Should flag the heading");
2719 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2720 assert!(
2721 fix_text.contains("Österreich"),
2722 "Fix should preserve canonical proper-name casing, got: {fix_text:?}"
2723 );
2724 }
2725
2726 #[test]
2732 fn test_from_config_loads_md044_names_into_md063() {
2733 use crate::config::{Config, RuleConfig};
2734 use crate::rule::Rule;
2735 use std::collections::BTreeMap;
2736
2737 let mut config = Config::default();
2738
2739 let mut md063_values = BTreeMap::new();
2741 md063_values.insert("style".to_string(), toml::Value::String("sentence_case".to_string()));
2742 md063_values.insert("enabled".to_string(), toml::Value::Boolean(true));
2743 config.rules.insert(
2744 "MD063".to_string(),
2745 RuleConfig {
2746 values: md063_values,
2747 severity: None,
2748 },
2749 );
2750
2751 let mut md044_values = BTreeMap::new();
2753 md044_values.insert(
2754 "names".to_string(),
2755 toml::Value::Array(vec![toml::Value::String("Good Application".to_string())]),
2756 );
2757 config.rules.insert(
2758 "MD044".to_string(),
2759 RuleConfig {
2760 values: md044_values,
2761 severity: None,
2762 },
2763 );
2764
2765 let rule = MD063HeadingCapitalization::from_config(&config);
2767
2768 let content = "# using good application features\n";
2770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2771 let result = rule.check(&ctx).unwrap();
2772 assert_eq!(result.len(), 1, "Should flag the heading");
2773 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2774 assert!(
2775 fix_text.contains("Good Application"),
2776 "from_config should wire MD044 names into MD063; fix should preserve \
2777 'Good Application', got: {fix_text:?}"
2778 );
2779 }
2780
2781 #[test]
2782 fn test_title_case_short_word_not_confused_with_substring() {
2783 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2787
2788 let content = "# in the insert\n";
2791 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2792 let result = rule.check(&ctx).unwrap();
2793 assert_eq!(result.len(), 1, "Should flag the heading");
2794 let fix = result[0].fix.as_ref().expect("Fix should be present");
2795 assert!(
2797 fix.replacement.contains("In the Insert"),
2798 "Expected 'In the Insert', got: {:?}",
2799 fix.replacement
2800 );
2801 }
2802
2803 #[test]
2804 fn test_title_case_or_not_confused_with_orchestra() {
2805 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2806
2807 let content = "# or the orchestra\n";
2810 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2811 let result = rule.check(&ctx).unwrap();
2812 assert_eq!(result.len(), 1, "Should flag the heading");
2813 let fix = result[0].fix.as_ref().expect("Fix should be present");
2814 assert!(
2816 fix.replacement.contains("Or the Orchestra"),
2817 "Expected 'Or the Orchestra', got: {:?}",
2818 fix.replacement
2819 );
2820 }
2821
2822 #[test]
2823 fn test_all_caps_preserves_all_words() {
2824 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
2825
2826 let content = "# in the insert\n";
2827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2828 let result = rule.check(&ctx).unwrap();
2829 assert_eq!(result.len(), 1, "Should flag the heading");
2830 let fix = result[0].fix.as_ref().expect("Fix should be present");
2831 assert!(
2832 fix.replacement.contains("IN THE INSERT"),
2833 "All caps should uppercase all words, got: {:?}",
2834 fix.replacement
2835 );
2836 }
2837
2838 #[test]
2840 fn test_title_case_numbered_prefix_lowercase_word() {
2841 let rule = create_rule();
2843 let content = "## 1. To Be a Thing\n";
2844 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2845 let result = rule.check(&ctx).unwrap();
2846 assert!(
2847 result.is_empty(),
2848 "Should not flag '## 1. To Be a Thing', got: {result:?}"
2849 );
2850
2851 let content_lower = "## 1. to be a thing\n";
2852 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2853 let result2 = rule.check(&ctx2).unwrap();
2854 assert!(!result2.is_empty(), "Should flag '## 1. to be a thing'");
2855 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2856 assert!(
2857 fix.replacement.contains("1. To Be a Thing"),
2858 "Fix should capitalize 'To', got: {:?}",
2859 fix.replacement
2860 );
2861 }
2862
2863 #[test]
2864 fn test_title_case_numbered_prefix_article() {
2865 let rule = create_rule();
2867 let content = "## 2. A Guide to the Galaxy\n";
2868 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2869 let result = rule.check(&ctx).unwrap();
2870 assert!(
2871 result.is_empty(),
2872 "Should not flag '## 2. A Guide to the Galaxy', got: {result:?}"
2873 );
2874
2875 let content_lower = "## 2. a guide to the galaxy\n";
2876 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2877 let result2 = rule.check(&ctx2).unwrap();
2878 assert!(!result2.is_empty(), "Should flag '## 2. a guide to the galaxy'");
2879 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2880 assert!(
2881 fix.replacement.contains("2. A Guide to the Galaxy"),
2882 "Fix should capitalize 'A', got: {:?}",
2883 fix.replacement
2884 );
2885 }
2886
2887 #[test]
2888 fn test_title_case_mid_sentence_period_word() {
2889 let rule = create_rule();
2891 let content = "## Step 1. Introduction to the Problem\n";
2892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2893 let result = rule.check(&ctx).unwrap();
2894 assert!(
2895 result.is_empty(),
2896 "Should not flag '## Step 1. Introduction to the Problem', got: {result:?}"
2897 );
2898
2899 let content_lower = "## Step 1. introduction to the problem\n";
2900 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2901 let result2 = rule.check(&ctx2).unwrap();
2902 assert!(
2903 !result2.is_empty(),
2904 "Should flag '## Step 1. introduction to the problem'"
2905 );
2906 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2907 assert!(
2908 fix.replacement.contains("Step 1. Introduction to the Problem"),
2909 "Fix should capitalize 'Introduction', got: {:?}",
2910 fix.replacement
2911 );
2912 }
2913
2914 #[test]
2915 fn test_title_case_numbered_prefix_in_link_text() {
2916 let config = MD063Config {
2919 enabled: true,
2920 style: HeadingCapStyle::TitleCase,
2921 ..Default::default()
2922 };
2923 let rule = MD063HeadingCapitalization::from_config_struct(config);
2924
2925 let content = "## [1. To Be a Thing](https://example.com)\n";
2927 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2928 let result = rule.check(&ctx).unwrap();
2929 assert!(
2930 result.is_empty(),
2931 "Should not flag '## [1. To Be a Thing](url)', got: {result:?}"
2932 );
2933
2934 let content_lower = "## [1. to be a thing](https://example.com)\n";
2936 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2937 let result2 = rule.check(&ctx2).unwrap();
2938 assert!(!result2.is_empty(), "Should flag '## [1. to be a thing](url)'");
2939 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2940 assert!(
2941 fix.replacement.contains("1. To Be a Thing"),
2942 "Fix should capitalize 'To' in link text, got: {:?}",
2943 fix.replacement
2944 );
2945 }
2946
2947 #[test]
2952 fn test_is_numeric_ordinal_recognises_canonical_forms() {
2953 for word in &[
2954 "1st", "2nd", "3rd", "4th", "5th", "11th", "21st", "22nd", "23rd", "100th", "1ST", "5Th", "21St", "21sT",
2955 ] {
2956 assert!(
2957 MD063HeadingCapitalization::is_numeric_ordinal(word),
2958 "expected `{word}` to be detected as a numeric ordinal"
2959 );
2960 }
2961 }
2962
2963 #[test]
2964 fn test_is_numeric_ordinal_rejects_non_ordinals() {
2965 for word in &[
2970 "first", "1stop", "ist", "5", "th", "abc", "4G", "4K", "30s", "100k", "5x", "1.5", "iPhone6S",
2971 ] {
2972 assert!(
2973 !MD063HeadingCapitalization::is_numeric_ordinal(word),
2974 "expected `{word}` NOT to be detected as a numeric ordinal"
2975 );
2976 }
2977 }
2978
2979 #[test]
2980 fn test_is_numeric_ordinal_strips_trailing_punctuation() {
2981 for word in &["5th.", "1st,", "21st!", "3rd:", "4th)", "5th's"] {
2982 assert!(
2983 MD063HeadingCapitalization::is_numeric_ordinal(word),
2984 "expected `{word}` to be detected as a numeric ordinal (with punctuation)"
2985 );
2986 }
2987 }
2988
2989 #[test]
2990 fn test_title_case_ordinal_first_word_not_flagged() {
2991 let rule = create_rule();
2992 for content in &[
2993 "# 1st Place\n",
2994 "# 2nd Edition\n",
2995 "# 3rd Time\n",
2996 "# 5th Avenue\n",
2997 "# 21st Century Skills\n",
2998 "# 100th Customer\n",
2999 ] {
3000 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3001 let result = rule.check(&ctx).unwrap();
3002 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3003 }
3004 }
3005
3006 #[test]
3007 fn test_title_case_ordinal_mid_heading_not_flagged() {
3008 let rule = create_rule();
3009 for content in &[
3010 "# May 3rd Notes\n",
3011 "# Top 100th Customer\n",
3012 "# Notes for the 5th of May\n",
3013 ] {
3014 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3015 let result = rule.check(&ctx).unwrap();
3016 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3017 }
3018 }
3019
3020 #[test]
3021 fn test_title_case_ordinal_corrupted_form_is_fixed() {
3022 let rule = create_rule();
3025 for (input, expected) in &[
3026 ("# 1St Place\n", "1st Place"),
3027 ("# 5Th Avenue\n", "5th Avenue"),
3028 ("# 21St Century Skills\n", "21st Century Skills"),
3029 ("# May 3Rd Notes\n", "May 3rd Notes"),
3030 ("# 22Nd Edition\n", "22nd Edition"),
3031 ] {
3032 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
3033 let result = rule.check(&ctx).unwrap();
3034 assert!(!result.is_empty(), "Should flag {input:?}");
3035 let fix = result[0].fix.as_ref().expect("should have a fix");
3036 assert!(
3037 fix.replacement.contains(expected),
3038 "Fix for {input:?} should contain {expected:?}, got: {:?}",
3039 fix.replacement
3040 );
3041 }
3042 }
3043
3044 #[test]
3045 fn test_title_case_ordinal_lowercase_other_words_capitalised() {
3046 let rule = create_rule();
3048 let content = "# 5th avenue\n";
3049 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3050 let result = rule.check(&ctx).unwrap();
3051 assert_eq!(result.len(), 1);
3052 let fix = result[0].fix.as_ref().expect("should have a fix");
3053 assert!(
3054 fix.replacement.contains("5th Avenue"),
3055 "Fix should produce '5th Avenue', got: {:?}",
3056 fix.replacement
3057 );
3058 }
3059
3060 #[test]
3061 fn test_title_case_ordinal_with_trailing_punctuation() {
3062 let rule = create_rule();
3063 let content = "# Released on the 5th.\n";
3064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3065 let result = rule.check(&ctx).unwrap();
3066 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3067 }
3068
3069 #[test]
3070 fn test_title_case_ordinal_hyphenated() {
3071 let rule = create_rule();
3072 for content in &["# 21st-Century Skills\n", "# A 19th-Century Novel\n"] {
3073 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3074 let result = rule.check(&ctx).unwrap();
3075 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3076 }
3077 }
3078
3079 #[test]
3080 fn test_sentence_case_ordinal_corrupted_form_is_fixed() {
3081 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3082 let content = "# 5Th avenue\n";
3083 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3084 let result = rule.check(&ctx).unwrap();
3085 assert_eq!(result.len(), 1);
3086 let fix = result[0].fix.as_ref().expect("should have a fix");
3087 assert!(
3088 fix.replacement.contains("5th avenue"),
3089 "Fix should produce '5th avenue', got: {:?}",
3090 fix.replacement
3091 );
3092 }
3093
3094 #[test]
3095 fn test_title_case_digit_acronym_unchanged() {
3096 let rule = create_rule();
3099 for content in &["# 4G Networks\n", "# 4K Streaming\n"] {
3100 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3101 let result = rule.check(&ctx).unwrap();
3102 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3103 }
3104 }
3105}