1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
15use crate::utils::range_utils::byte_to_char_count;
16use regex::Regex;
17use std::collections::HashSet;
18use std::sync::LazyLock;
19
20mod md063_config;
21pub(super) use md063_config::{HeadingCapStyle, MD063Config};
22
23static INLINE_CODE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`+[^`]+`+").unwrap());
25
26static LINK_REGEX: LazyLock<Regex> =
31 LazyLock::new(|| Regex::new(r"\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)|\[([^\]]*)\]\[[^\]]*\]").unwrap());
32
33static HTML_TAG_REGEX: LazyLock<Regex> = LazyLock::new(|| {
38 let tags = "kbd|abbr|code|span|sub|sup|mark|cite|dfn|var|samp|small|strong|em|b|i|u|s|q|br|wbr";
40 let pattern = format!(r"<({tags})(?:\s[^>]*)?>.*?</({tags})>|<({tags})(?:\s[^>]*)?\s*/?>");
41 Regex::new(&pattern).unwrap()
42});
43
44static CUSTOM_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*\{#[^}]+\}\s*$").unwrap());
46
47#[derive(Debug, Clone)]
49enum HeadingSegment {
50 Text(String),
52 Code(String),
54 Link {
56 full: String,
57 text_start: usize,
58 text_end: usize,
59 },
60 Html(String),
62 Image(String),
65}
66
67#[derive(Clone)]
69pub struct MD063HeadingCapitalization {
70 config: MD063Config,
71 lowercase_set: HashSet<String>,
72 proper_names: Vec<String>,
75}
76
77impl Default for MD063HeadingCapitalization {
78 fn default() -> Self {
79 Self::new()
80 }
81}
82
83impl MD063HeadingCapitalization {
84 pub fn new() -> Self {
85 let config = MD063Config::default();
86 let lowercase_set = config.lowercase_words.iter().cloned().collect();
87 Self {
88 config,
89 lowercase_set,
90 proper_names: Vec::new(),
91 }
92 }
93
94 pub fn from_config_struct(config: MD063Config) -> Self {
95 let lowercase_set = config.lowercase_words.iter().cloned().collect();
96 Self {
97 config,
98 lowercase_set,
99 proper_names: Vec::new(),
100 }
101 }
102
103 fn match_case_insensitive_at(text: &str, start: usize, pattern_lower: &str) -> Option<usize> {
110 if start > text.len() || !text.is_char_boundary(start) || pattern_lower.is_empty() {
111 return None;
112 }
113
114 let mut matched_bytes = 0;
115
116 for (offset, ch) in text[start..].char_indices() {
117 if matched_bytes >= pattern_lower.len() {
118 break;
119 }
120
121 let lowered: String = ch.to_lowercase().collect();
122 if !pattern_lower[matched_bytes..].starts_with(&lowered) {
123 return None;
124 }
125
126 matched_bytes += lowered.len();
127
128 if matched_bytes == pattern_lower.len() {
129 return Some(start + offset + ch.len_utf8());
130 }
131 }
132
133 None
134 }
135
136 fn find_case_insensitive_match(text: &str, pattern_lower: &str, search_start: usize) -> Option<(usize, usize)> {
139 if pattern_lower.is_empty() || search_start >= text.len() || !text.is_char_boundary(search_start) {
140 return None;
141 }
142
143 for (offset, _) in text[search_start..].char_indices() {
144 let start = search_start + offset;
145 if let Some(end) = Self::match_case_insensitive_at(text, start, pattern_lower) {
146 return Some((start, end));
147 }
148 }
149
150 None
151 }
152
153 fn proper_name_canonical_forms(&self, text: &str) -> std::collections::HashMap<usize, &str> {
159 let mut map = std::collections::HashMap::new();
160
161 for name in &self.proper_names {
162 if name.is_empty() {
163 continue;
164 }
165 let name_lower = name.to_lowercase();
166 let canonical_words: Vec<&str> = name.split_whitespace().collect();
167 if canonical_words.is_empty() {
168 continue;
169 }
170 let mut search_start = 0;
171
172 while search_start < text.len() {
173 let Some((abs_pos, end_pos)) = Self::find_case_insensitive_match(text, &name_lower, search_start)
174 else {
175 break;
176 };
177
178 let before_ok = abs_pos == 0 || !text[..abs_pos].chars().last().is_some_and(char::is_alphanumeric);
180 let after_ok =
181 end_pos >= text.len() || !text[end_pos..].chars().next().is_some_and(char::is_alphanumeric);
182
183 if before_ok && after_ok {
184 let text_slice = &text[abs_pos..end_pos];
188 let mut word_idx = 0;
189 let mut slice_offset = 0;
190
191 for text_word in text_slice.split_whitespace() {
192 if let Some(w_rel) = text_slice[slice_offset..].find(text_word) {
193 let word_abs = abs_pos + slice_offset + w_rel;
194 if let Some(&canonical_word) = canonical_words.get(word_idx) {
195 map.insert(word_abs, canonical_word);
196 }
197 slice_offset += w_rel + text_word.len();
198 word_idx += 1;
199 }
200 }
201 }
202
203 search_start = abs_pos + text[abs_pos..].chars().next().map_or(1, char::len_utf8);
206 }
207 }
208
209 map
210 }
211
212 fn has_internal_capitals(&self, word: &str) -> bool {
214 let chars: Vec<char> = word.chars().collect();
215 if chars.len() < 2 {
216 return false;
217 }
218
219 let first = chars[0];
220 let rest = &chars[1..];
221 let has_upper_in_rest = rest.iter().any(|c| c.is_uppercase());
222 let has_lower_in_rest = rest.iter().any(|c| c.is_lowercase());
223
224 if has_upper_in_rest && has_lower_in_rest {
226 return true;
227 }
228
229 if first.is_lowercase() && has_upper_in_rest {
231 return true;
232 }
233
234 false
235 }
236
237 fn is_all_caps_acronym(&self, word: &str) -> bool {
241 if word.len() < 2 {
243 return false;
244 }
245
246 let mut consecutive_upper = 0;
247 let mut max_consecutive = 0;
248
249 for c in word.chars() {
250 if c.is_uppercase() {
251 consecutive_upper += 1;
252 max_consecutive = max_consecutive.max(consecutive_upper);
253 } else if c.is_lowercase() {
254 return false;
256 } else {
257 consecutive_upper = 0;
259 }
260 }
261
262 max_consecutive >= 2
264 }
265
266 fn should_preserve_word(&self, word: &str) -> bool {
268 if self.config.ignore_words.iter().any(|w| w == word) {
270 return true;
271 }
272
273 let is_ordinal = Self::is_numeric_ordinal(word);
279
280 if !is_ordinal {
281 if self.config.preserve_cased_words && self.has_internal_capitals(word) {
283 return true;
284 }
285
286 if self.config.preserve_cased_words && self.is_all_caps_acronym(word) {
288 return true;
289 }
290 }
291
292 if self.is_caret_notation(word) {
294 return true;
295 }
296
297 false
298 }
299
300 fn is_numeric_ordinal(word: &str) -> bool {
308 let bytes = word.as_bytes();
309
310 let alpha_start = match bytes.iter().position(|&b| !b.is_ascii_digit()) {
312 Some(pos) if pos > 0 => pos,
313 _ => return false,
314 };
315
316 let alpha_end = bytes[alpha_start..]
318 .iter()
319 .position(|b| !b.is_ascii_alphabetic())
320 .map_or(bytes.len(), |p| alpha_start + p);
321
322 let suffix = &word[alpha_start..alpha_end];
323 matches!(suffix.to_ascii_lowercase().as_str(), "st" | "nd" | "rd" | "th")
324 }
325
326 fn is_caret_notation(&self, word: &str) -> bool {
328 let chars: Vec<char> = word.chars().collect();
329 if chars.len() >= 2 && chars[0] == '^' {
331 let second = chars[1];
332 if second.is_ascii_uppercase() || "@[\\]^_".contains(second) {
334 return true;
335 }
336 }
337 false
338 }
339
340 fn is_lowercase_word(&self, word: &str) -> bool {
342 self.lowercase_set.contains(&word.to_lowercase())
343 }
344
345 fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
347 if word.is_empty() {
348 return word.to_string();
349 }
350
351 if self.should_preserve_word(word) {
353 return word.to_string();
354 }
355
356 if is_first || is_last {
358 return self.capitalize_first(word);
359 }
360
361 if self.is_lowercase_word(word) {
363 return Self::lowercase_preserving_composition(word);
364 }
365
366 self.capitalize_first(word)
368 }
369
370 fn apply_canonical_form_to_word(word: &str, canonical: &str) -> String {
373 let canonical_lower = canonical.to_lowercase();
374 if canonical_lower.is_empty() {
375 return canonical.to_string();
376 }
377
378 if let Some(end_pos) = Self::match_case_insensitive_at(word, 0, &canonical_lower) {
379 let mut out = String::with_capacity(canonical.len() + word.len().saturating_sub(end_pos));
380 out.push_str(canonical);
381 out.push_str(&word[end_pos..]);
382 out
383 } else {
384 canonical.to_string()
385 }
386 }
387
388 fn capitalize_first(&self, word: &str) -> String {
390 if word.is_empty() {
391 return String::new();
392 }
393
394 let first_alpha_pos = word.find(|c: char| c.is_alphabetic());
396 let Some(pos) = first_alpha_pos else {
397 return word.to_string();
398 };
399
400 let prefix = &word[..pos];
401 let suffix = &word[pos..];
402
403 if Self::is_numeric_ordinal(word) {
406 let suffix_lower = Self::lowercase_preserving_composition(suffix);
407 return format!("{prefix}{suffix_lower}");
408 }
409
410 let mut chars = suffix.chars();
411 let first = chars.next().unwrap();
412 let first_upper = Self::uppercase_preserving_composition(&first.to_string());
415 let rest: String = chars.collect();
416 let rest_lower = Self::lowercase_preserving_composition(&rest);
417 format!("{prefix}{first_upper}{rest_lower}")
418 }
419
420 fn lowercase_preserving_composition(s: &str) -> String {
423 let mut result = String::with_capacity(s.len());
424 for c in s.chars() {
425 let lower: String = c.to_lowercase().collect();
426 if lower.chars().count() == 1 {
427 result.push_str(&lower);
428 } else {
429 result.push(c);
431 }
432 }
433 result
434 }
435
436 fn uppercase_preserving_composition(s: &str) -> String {
441 let mut result = String::with_capacity(s.len());
442 for c in s.chars() {
443 let upper: String = c.to_uppercase().collect();
444 if upper.chars().count() == 1 {
445 result.push_str(&upper);
446 } else {
447 result.push(c);
449 }
450 }
451 result
452 }
453
454 fn apply_title_case(&self, text: &str) -> String {
458 let canonical_forms = self.proper_name_canonical_forms(text);
459
460 let original_words: Vec<&str> = text.split_whitespace().collect();
461 let total_words = original_words.len();
462
463 let mut word_positions: Vec<usize> = Vec::with_capacity(original_words.len());
466 let mut pos = 0;
467 for word in &original_words {
468 if let Some(rel) = text[pos..].find(word) {
469 word_positions.push(pos + rel);
470 pos = pos + rel + word.len();
471 } else {
472 word_positions.push(usize::MAX);
473 }
474 }
475
476 let result_words: Vec<String> = original_words
477 .iter()
478 .enumerate()
479 .map(|(i, word)| {
480 let after_period = i > 0 && original_words[i - 1].ends_with('.');
481 let is_first = i == 0 || after_period;
482 let is_last = i == total_words - 1;
483
484 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
486 return Self::apply_canonical_form_to_word(word, canonical);
487 }
488
489 if self.should_preserve_word(word) {
491 return (*word).to_string();
492 }
493
494 if word.contains('-') {
496 return self.handle_hyphenated_word(word, is_first, is_last);
497 }
498
499 self.title_case_word(word, is_first, is_last)
500 })
501 .collect();
502
503 result_words.join(" ")
504 }
505
506 fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
508 let parts: Vec<&str> = word.split('-').collect();
509 let total_parts = parts.len();
510
511 let result_parts: Vec<String> = parts
512 .iter()
513 .enumerate()
514 .map(|(i, part)| {
515 let part_is_first = is_first && i == 0;
517 let part_is_last = is_last && i == total_parts - 1;
518 self.title_case_word(part, part_is_first, part_is_last)
519 })
520 .collect();
521
522 result_parts.join("-")
523 }
524
525 fn ends_sentence(&self, word: &str) -> bool {
531 self.config
532 .sentence_case_restart_after
533 .iter()
534 .any(|boundary| !boundary.is_empty() && word.ends_with(boundary.as_str()))
535 }
536
537 fn apply_sentence_case_from(&self, text: &str, starts_sentence: bool) -> String {
541 if text.is_empty() {
542 return text.to_string();
543 }
544
545 let canonical_forms = self.proper_name_canonical_forms(text);
546 let mut result = String::new();
547 let mut current_pos = 0;
548 let mut at_sentence_start = starts_sentence;
549
550 for word in text.split_whitespace() {
552 if let Some(pos) = text[current_pos..].find(word) {
553 let abs_pos = current_pos + pos;
554
555 result.push_str(&text[current_pos..abs_pos]);
557
558 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
561 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
562 } else if at_sentence_start {
563 if self.should_preserve_word(word) {
565 result.push_str(word);
567 } else {
568 let mut chars = word.chars();
570 if let Some(first) = chars.next() {
571 result.push_str(&Self::uppercase_preserving_composition(&first.to_string()));
572 let rest: String = chars.collect();
573 result.push_str(&Self::lowercase_preserving_composition(&rest));
574 }
575 }
576 } else {
577 if self.should_preserve_word(word) {
579 result.push_str(word);
580 } else {
581 result.push_str(&Self::lowercase_preserving_composition(word));
582 }
583 }
584
585 at_sentence_start = self.ends_sentence(word);
586 current_pos = abs_pos + word.len();
587 }
588 }
589
590 if current_pos < text.len() {
592 result.push_str(&text[current_pos..]);
593 }
594
595 result
596 }
597
598 fn apply_all_caps(&self, text: &str) -> String {
600 if text.is_empty() {
601 return text.to_string();
602 }
603
604 let canonical_forms = self.proper_name_canonical_forms(text);
605 let mut result = String::new();
606 let mut current_pos = 0;
607
608 for word in text.split_whitespace() {
610 if let Some(pos) = text[current_pos..].find(word) {
611 let abs_pos = current_pos + pos;
612
613 result.push_str(&text[current_pos..abs_pos]);
615
616 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
619 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
620 } else if self.should_preserve_word(word) {
621 result.push_str(word);
622 } else {
623 result.push_str(&Self::uppercase_preserving_composition(word));
624 }
625
626 current_pos = abs_pos + word.len();
627 }
628 }
629
630 if current_pos < text.len() {
632 result.push_str(&text[current_pos..]);
633 }
634
635 result
636 }
637
638 fn parse_segments(&self, text: &str) -> Vec<HeadingSegment> {
640 let mut segments = Vec::new();
641 let mut last_end = 0;
642
643 let mut special_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
645
646 for mat in INLINE_CODE_REGEX.find_iter(text) {
648 special_regions.push((mat.start(), mat.end(), HeadingSegment::Code(mat.as_str().to_string())));
649 }
650
651 for caps in LINK_REGEX.captures_iter(text) {
653 let full_match = caps.get(0).unwrap();
654
655 if full_match.start() >= 1 && text.as_bytes()[full_match.start() - 1] == b'!' {
659 let region_start = full_match.start() - 1;
660 special_regions.push((
661 region_start,
662 full_match.end(),
663 HeadingSegment::Image(text[region_start..full_match.end()].to_string()),
664 ));
665 continue;
666 }
667
668 let text_match = caps.get(1).or_else(|| caps.get(2));
669
670 if let Some(text_m) = text_match {
671 special_regions.push((
672 full_match.start(),
673 full_match.end(),
674 HeadingSegment::Link {
675 full: full_match.as_str().to_string(),
676 text_start: text_m.start() - full_match.start(),
677 text_end: text_m.end() - full_match.start(),
678 },
679 ));
680 }
681 }
682
683 for mat in HTML_TAG_REGEX.find_iter(text) {
685 special_regions.push((mat.start(), mat.end(), HeadingSegment::Html(mat.as_str().to_string())));
686 }
687
688 special_regions.sort_by_key(|(start, _, _)| *start);
690
691 let mut filtered_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
694 for region in special_regions {
695 let overlaps = filtered_regions.iter().any(|(s, e, _)| region.0 < *e && region.1 > *s);
696 if !overlaps {
697 filtered_regions.push(region);
698 }
699 }
700
701 for (start, end, segment) in filtered_regions {
703 if start > last_end {
705 let text_segment = &text[last_end..start];
706 if !text_segment.is_empty() {
707 segments.push(HeadingSegment::Text(text_segment.to_string()));
708 }
709 }
710 segments.push(segment);
711 last_end = end;
712 }
713
714 if last_end < text.len() {
716 let remaining = &text[last_end..];
717 if !remaining.is_empty() {
718 segments.push(HeadingSegment::Text(remaining.to_string()));
719 }
720 }
721
722 if segments.is_empty() && !text.is_empty() {
724 segments.push(HeadingSegment::Text(text.to_string()));
725 }
726
727 segments
728 }
729
730 fn apply_capitalization(&self, text: &str) -> String {
732 let (main_text, custom_id) = if let Some(mat) = CUSTOM_ID_REGEX.find(text) {
734 (&text[..mat.start()], Some(mat.as_str()))
735 } else {
736 (text, None)
737 };
738
739 let segments = self.parse_segments(main_text);
741
742 let text_segments: Vec<usize> = segments
744 .iter()
745 .enumerate()
746 .filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
747 .collect();
748
749 let first_segment_is_text = segments.first().is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
753
754 let last_segment_is_text = segments.last().is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
758
759 let mut result_parts: Vec<String> = Vec::new();
761
762 let mut at_sentence_start = first_segment_is_text;
766
767 for (i, segment) in segments.iter().enumerate() {
768 at_sentence_start = match segment {
773 HeadingSegment::Text(t) => {
774 let is_first_text = text_segments.first() == Some(&i);
775 let is_last_text = text_segments.last() == Some(&i) && last_segment_is_text;
779
780 let capitalized = match self.config.style {
781 HeadingCapStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
782 HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(t, at_sentence_start),
783 HeadingCapStyle::AllCaps => self.apply_all_caps(t),
784 };
785 let ends_sentence = self.ends_sentence(capitalized.trim_end());
786 result_parts.push(capitalized);
787 ends_sentence
788 }
789 HeadingSegment::Code(c) => {
790 result_parts.push(c.clone());
791 false
792 }
793 HeadingSegment::Link {
794 full,
795 text_start,
796 text_end,
797 } => {
798 let link_text = &full[*text_start..*text_end];
800 let capitalized_text = match self.config.style {
801 HeadingCapStyle::TitleCase => self.apply_title_case(link_text),
802 HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(link_text, at_sentence_start),
805 HeadingCapStyle::AllCaps => self.apply_all_caps(link_text),
806 };
807 let ends_sentence = self.ends_sentence(capitalized_text.trim_end());
810
811 let mut new_link = String::new();
812 new_link.push_str(&full[..*text_start]);
813 new_link.push_str(&capitalized_text);
814 new_link.push_str(&full[*text_end..]);
815 result_parts.push(new_link);
816 ends_sentence
817 }
818 HeadingSegment::Html(h) => {
819 result_parts.push(h.clone());
821 false
822 }
823 HeadingSegment::Image(img) => {
824 result_parts.push(img.clone());
826 false
827 }
828 };
829 }
830
831 let mut result = result_parts.join("");
832
833 if let Some(id) = custom_id {
835 result.push_str(id);
836 }
837
838 result
839 }
840
841 fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
843 let canonical_forms = self.proper_name_canonical_forms(text);
844 let words: Vec<&str> = text.split_whitespace().collect();
845 let total_words = words.len();
846
847 if total_words == 0 {
848 return text.to_string();
849 }
850
851 let mut word_positions: Vec<usize> = Vec::with_capacity(words.len());
854 let mut pos = 0;
855 for word in &words {
856 if let Some(rel) = text[pos..].find(word) {
857 word_positions.push(pos + rel);
858 pos = pos + rel + word.len();
859 } else {
860 word_positions.push(usize::MAX);
861 }
862 }
863
864 let result_words: Vec<String> = words
865 .iter()
866 .enumerate()
867 .map(|(i, word)| {
868 let after_period = i > 0 && words[i - 1].ends_with('.');
869 let is_first = (is_first_segment && i == 0) || after_period;
870 let is_last = is_last_segment && i == total_words - 1;
871
872 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
874 return Self::apply_canonical_form_to_word(word, canonical);
875 }
876
877 if word.contains('-') {
879 return self.handle_hyphenated_word(word, is_first, is_last);
880 }
881
882 self.title_case_word(word, is_first, is_last)
883 })
884 .collect();
885
886 let mut result = String::new();
888 let mut word_iter = result_words.iter();
889 let mut in_word = false;
890
891 for c in text.chars() {
892 if c.is_whitespace() {
893 if in_word {
894 in_word = false;
895 }
896 result.push(c);
897 } else if !in_word {
898 if let Some(word) = word_iter.next() {
899 result.push_str(word);
900 }
901 in_word = true;
902 }
903 }
904
905 result
906 }
907
908 fn fix_atx_heading(&self, _line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
910 let indent = " ".repeat(heading.marker_column);
912 let hashes = "#".repeat(heading.level as usize);
913
914 let fixed_text = self.apply_capitalization(&heading.raw_text);
916
917 let closing = &heading.closing_sequence;
919 if heading.has_closing_sequence {
920 format!("{indent}{hashes} {fixed_text} {closing}")
921 } else {
922 format!("{indent}{hashes} {fixed_text}")
923 }
924 }
925
926 fn fix_setext_heading(&self, line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
928 let fixed_text = self.apply_capitalization(&heading.raw_text);
930
931 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
933
934 format!("{leading_ws}{fixed_text}")
935 }
936}
937
938impl Rule for MD063HeadingCapitalization {
939 fn name(&self) -> &'static str {
940 "MD063"
941 }
942
943 fn description(&self) -> &'static str {
944 "Heading capitalization"
945 }
946
947 fn category(&self) -> RuleCategory {
948 RuleCategory::Heading
949 }
950
951 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
952 !ctx.likely_has_headings() || !ctx.lines.iter().any(|line| line.heading.is_some())
953 }
954
955 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
956 let content = ctx.content;
957
958 if content.is_empty() {
959 return Ok(Vec::new());
960 }
961
962 let mut warnings = Vec::new();
963
964 for (line_num, line_info) in ctx.lines.iter().enumerate() {
965 if let Some(heading) = &line_info.heading {
966 if heading.level < self.config.min_level || heading.level > self.config.max_level {
968 continue;
969 }
970
971 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
973 continue;
974 }
975
976 if !heading.is_valid {
978 continue;
979 }
980
981 let original_text = &heading.raw_text;
983 let fixed_text = self.apply_capitalization(original_text);
984
985 if original_text != &fixed_text {
986 let line = line_info.content(ctx.content);
987 let style_name = match self.config.style {
988 HeadingCapStyle::TitleCase => "title case",
989 HeadingCapStyle::SentenceCase => "sentence case",
990 HeadingCapStyle::AllCaps => "ALL CAPS",
991 };
992
993 warnings.push(LintWarning {
994 rule_name: Some(self.name().to_string()),
995 line: line_num + 1,
996 column: byte_to_char_count(line, heading.content_column),
997 end_line: line_num + 1,
998 end_column: byte_to_char_count(line, heading.content_column) + original_text.chars().count(),
999 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1000 severity: Severity::Warning,
1001 fix: Some(Fix::new(
1002 ctx.line_content_byte_range(line_num + 1),
1003 match heading.style {
1004 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
1005 _ => self.fix_setext_heading(line, heading),
1006 },
1007 )),
1008 });
1009 }
1010 }
1011 }
1012
1013 Ok(warnings)
1014 }
1015
1016 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1017 let content = ctx.content;
1018
1019 if content.is_empty() {
1020 return Ok(content.to_string());
1021 }
1022
1023 let lines = ctx.raw_lines();
1024 let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1025
1026 for (line_num, line_info) in ctx.lines.iter().enumerate() {
1027 if ctx.is_rule_disabled(self.name(), line_num + 1) {
1029 continue;
1030 }
1031
1032 if let Some(heading) = &line_info.heading {
1033 if heading.level < self.config.min_level || heading.level > self.config.max_level {
1035 continue;
1036 }
1037
1038 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
1040 continue;
1041 }
1042
1043 if !heading.is_valid {
1045 continue;
1046 }
1047
1048 let original_text = &heading.raw_text;
1049 let fixed_text = self.apply_capitalization(original_text);
1050
1051 if original_text != &fixed_text {
1052 let line = line_info.content(ctx.content);
1053 fixed_lines[line_num] = match heading.style {
1054 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
1055 _ => self.fix_setext_heading(line, heading),
1056 };
1057 }
1058 }
1059 }
1060
1061 let mut result = String::with_capacity(content.len());
1063 for (i, line) in fixed_lines.iter().enumerate() {
1064 result.push_str(line);
1065 if i < fixed_lines.len() - 1 || content.ends_with('\n') {
1066 result.push('\n');
1067 }
1068 }
1069
1070 Ok(result)
1071 }
1072
1073 fn as_any(&self) -> &dyn std::any::Any {
1074 self
1075 }
1076
1077 crate::impl_rule_config_sections!(MD063Config);
1078
1079 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1080 where
1081 Self: Sized,
1082 {
1083 let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
1084 let md044_config =
1085 crate::rule_config_serde::load_rule_config::<crate::rules::md044_proper_names::MD044Config>(config);
1086 let mut rule = Self::from_config_struct(rule_config);
1087 rule.proper_names = md044_config.names;
1088 Box::new(rule)
1089 }
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094 use super::*;
1095 use crate::lint_context::LintContext;
1096
1097 fn create_rule() -> MD063HeadingCapitalization {
1098 let config = MD063Config {
1099 enabled: true,
1100 ..Default::default()
1101 };
1102 MD063HeadingCapitalization::from_config_struct(config)
1103 }
1104
1105 fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
1106 let config = MD063Config {
1107 enabled: true,
1108 style,
1109 ..Default::default()
1110 };
1111 MD063HeadingCapitalization::from_config_struct(config)
1112 }
1113
1114 #[test]
1116 fn test_title_case_basic() {
1117 let rule = create_rule();
1118 let content = "# hello world\n";
1119 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1120 let result = rule.check(&ctx).unwrap();
1121 assert_eq!(result.len(), 1);
1122 assert!(result[0].message.contains("Hello World"));
1123 }
1124
1125 #[test]
1126 fn test_title_case_lowercase_words() {
1127 let rule = create_rule();
1128 let content = "# the quick brown fox\n";
1129 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1130 let result = rule.check(&ctx).unwrap();
1131 assert_eq!(result.len(), 1);
1132 assert!(result[0].message.contains("The Quick Brown Fox"));
1134 }
1135
1136 #[test]
1137 fn test_title_case_already_correct() {
1138 let rule = create_rule();
1139 let content = "# The Quick Brown Fox\n";
1140 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1141 let result = rule.check(&ctx).unwrap();
1142 assert!(result.is_empty(), "Already correct heading should not be flagged");
1143 }
1144
1145 #[test]
1146 fn test_title_case_hyphenated() {
1147 let rule = create_rule();
1148 let content = "# self-documenting code\n";
1149 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1150 let result = rule.check(&ctx).unwrap();
1151 assert_eq!(result.len(), 1);
1152 assert!(result[0].message.contains("Self-Documenting Code"));
1153 }
1154
1155 #[test]
1156 fn test_title_case_preserves_url_with_nested_parens() {
1157 let rule = create_rule();
1158 let content = "# guide for [the api](https://example.com/docs/v(2)beta)\n";
1160 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1161 let fixed = rule.fix(&ctx).unwrap();
1162 assert!(
1165 fixed.contains("https://example.com/docs/v(2)beta"),
1166 "URL with nested parens was corrupted: {fixed:?}"
1167 );
1168 }
1169
1170 #[test]
1171 fn test_title_case_does_not_recase_image_alt() {
1172 let rule = create_rule();
1173 let content = "# overview \n";
1174 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1175 let fixed = rule.fix(&ctx).unwrap();
1176 assert!(
1178 fixed.contains(""),
1179 "image alt text was modified: {fixed:?}"
1180 );
1181 assert!(
1182 fixed.contains("# Overview"),
1183 "surrounding prose should still be title-cased: {fixed:?}"
1184 );
1185 }
1186
1187 #[test]
1189 fn test_sentence_case_basic() {
1190 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1191 let content = "# The Quick Brown Fox\n";
1192 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1193 let result = rule.check(&ctx).unwrap();
1194 assert_eq!(result.len(), 1);
1195 assert!(result[0].message.contains("The quick brown fox"));
1196 }
1197
1198 #[test]
1199 fn test_sentence_case_already_correct() {
1200 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1201 let content = "# The quick brown fox\n";
1202 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1203 let result = rule.check(&ctx).unwrap();
1204 assert!(result.is_empty());
1205 }
1206
1207 #[test]
1209 fn test_all_caps_basic() {
1210 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1211 let content = "# hello world\n";
1212 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1213 let result = rule.check(&ctx).unwrap();
1214 assert_eq!(result.len(), 1);
1215 assert!(result[0].message.contains("HELLO WORLD"));
1216 }
1217
1218 #[test]
1220 fn test_preserve_ignore_words() {
1221 let config = MD063Config {
1222 enabled: true,
1223 ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
1224 ..Default::default()
1225 };
1226 let rule = MD063HeadingCapitalization::from_config_struct(config);
1227
1228 let content = "# using iPhone on macOS\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("iPhone"));
1234 assert!(result[0].message.contains("macOS"));
1235 }
1236
1237 #[test]
1238 fn test_preserve_cased_words() {
1239 let rule = create_rule();
1240 let content = "# using GitHub actions\n";
1241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1242 let result = rule.check(&ctx).unwrap();
1243 assert_eq!(result.len(), 1);
1244 assert!(result[0].message.contains("GitHub"));
1246 }
1247
1248 #[test]
1250 fn test_inline_code_preserved() {
1251 let rule = create_rule();
1252 let content = "# using `const` in javascript\n";
1253 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1254 let result = rule.check(&ctx).unwrap();
1255 assert_eq!(result.len(), 1);
1256 assert!(result[0].message.contains("`const`"));
1258 assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
1259 }
1260
1261 #[test]
1263 fn test_level_filter() {
1264 let config = MD063Config {
1265 enabled: true,
1266 min_level: 2,
1267 max_level: 4,
1268 ..Default::default()
1269 };
1270 let rule = MD063HeadingCapitalization::from_config_struct(config);
1271
1272 let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
1273 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1274 let result = rule.check(&ctx).unwrap();
1275
1276 assert_eq!(result.len(), 2);
1278 assert_eq!(result[0].line, 2); assert_eq!(result[1].line, 3); }
1281
1282 #[test]
1284 fn test_fix_atx_heading() {
1285 let rule = create_rule();
1286 let content = "# hello world\n";
1287 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1288 let fixed = rule.fix(&ctx).unwrap();
1289 assert_eq!(fixed, "# Hello World\n");
1290 }
1291
1292 #[test]
1293 fn test_fix_multiple_headings() {
1294 let rule = create_rule();
1295 let content = "# first heading\n\n## second heading\n";
1296 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1297 let fixed = rule.fix(&ctx).unwrap();
1298 assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
1299 }
1300
1301 #[test]
1303 fn test_setext_heading() {
1304 let rule = create_rule();
1305 let content = "hello world\n============\n";
1306 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1307 let result = rule.check(&ctx).unwrap();
1308 assert_eq!(result.len(), 1);
1309 assert!(result[0].message.contains("Hello World"));
1310 }
1311
1312 #[test]
1314 fn test_custom_id_preserved() {
1315 let rule = create_rule();
1316 let content = "# getting started {#intro}\n";
1317 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1318 let result = rule.check(&ctx).unwrap();
1319 assert_eq!(result.len(), 1);
1320 assert!(result[0].message.contains("{#intro}"));
1322 }
1323
1324 #[test]
1326 fn test_skip_obsidian_tags_not_headings() {
1327 let rule = create_rule();
1328
1329 let content = "# H1\n\n#tag\n";
1331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1332 let result = rule.check(&ctx).unwrap();
1333 assert!(
1334 result.is_empty() || result.iter().all(|w| w.line != 3),
1335 "Obsidian tag #tag should not be treated as a heading: {result:?}"
1336 );
1337 }
1338
1339 #[test]
1340 fn test_skip_invalid_atx_headings_no_space() {
1341 let rule = create_rule();
1342
1343 let content = "#notaheading\n";
1345 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1346 let result = rule.check(&ctx).unwrap();
1347 assert!(
1348 result.is_empty(),
1349 "Invalid ATX heading without space should not be flagged: {result:?}"
1350 );
1351 }
1352
1353 #[test]
1354 fn test_fix_skips_obsidian_tags() {
1355 let rule = create_rule();
1356
1357 let content = "# hello world\n\n#tag\n";
1358 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1359 let fixed = rule.fix(&ctx).unwrap();
1360 assert!(fixed.contains("#tag"), "Fix should not modify Obsidian tag #tag");
1362 assert!(fixed.contains("# Hello World"), "Fix should still fix real headings");
1363 }
1364
1365 #[test]
1366 fn test_preserve_all_caps_acronyms() {
1367 let rule = create_rule();
1368 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1369
1370 let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
1372 assert_eq!(fixed, "# Using API in Production\n");
1373
1374 let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
1376 assert_eq!(fixed, "# API and GPU Integration\n");
1377
1378 let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
1380 assert_eq!(fixed, "# IO Performance Guide\n");
1381
1382 let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
1384 assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
1385 }
1386
1387 #[test]
1388 fn test_preserve_acronyms_in_hyphenated_words() {
1389 let rule = create_rule();
1390 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1391
1392 let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
1394 assert_eq!(fixed, "# API-Driven Architecture\n");
1395
1396 let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
1398 assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
1399 }
1400
1401 #[test]
1402 fn test_single_letters_not_treated_as_acronyms() {
1403 let rule = create_rule();
1404 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1405
1406 let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
1408 assert_eq!(fixed, "# I Am a Heading\n");
1409 }
1410
1411 #[test]
1412 fn test_lowercase_terms_need_ignore_words() {
1413 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1414
1415 let rule = create_rule();
1417 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1418 assert_eq!(fixed, "# Using Npm Packages\n");
1419
1420 let config = MD063Config {
1422 enabled: true,
1423 ignore_words: vec!["npm".to_string()],
1424 ..Default::default()
1425 };
1426 let rule = MD063HeadingCapitalization::from_config_struct(config);
1427 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1428 assert_eq!(fixed, "# Using npm Packages\n");
1429 }
1430
1431 #[test]
1432 fn test_acronyms_with_mixed_case_preserved() {
1433 let rule = create_rule();
1434 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1435
1436 let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
1438 assert_eq!(fixed, "# Using API with GitHub\n");
1439 }
1440
1441 #[test]
1442 fn test_real_world_acronyms() {
1443 let rule = create_rule();
1444 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1445
1446 let content = "# FFI bindings for CPU optimization\n";
1448 let fixed = rule.fix(&ctx(content)).unwrap();
1449 assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
1450
1451 let content = "# DOM manipulation and SSR rendering\n";
1452 let fixed = rule.fix(&ctx(content)).unwrap();
1453 assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
1454
1455 let content = "# CVE security and RNN models\n";
1456 let fixed = rule.fix(&ctx(content)).unwrap();
1457 assert_eq!(fixed, "# CVE Security and RNN Models\n");
1458 }
1459
1460 #[test]
1461 fn test_is_all_caps_acronym() {
1462 let rule = create_rule();
1463
1464 assert!(rule.is_all_caps_acronym("API"));
1466 assert!(rule.is_all_caps_acronym("IO"));
1467 assert!(rule.is_all_caps_acronym("GPU"));
1468 assert!(rule.is_all_caps_acronym("HTTP2")); assert!(!rule.is_all_caps_acronym("A"));
1472 assert!(!rule.is_all_caps_acronym("I"));
1473
1474 assert!(!rule.is_all_caps_acronym("Api"));
1476 assert!(!rule.is_all_caps_acronym("npm"));
1477 assert!(!rule.is_all_caps_acronym("iPhone"));
1478 }
1479
1480 #[test]
1481 fn test_sentence_case_ignore_words_first_word() {
1482 let config = MD063Config {
1483 enabled: true,
1484 style: HeadingCapStyle::SentenceCase,
1485 ignore_words: vec!["nvim".to_string()],
1486 ..Default::default()
1487 };
1488 let rule = MD063HeadingCapitalization::from_config_struct(config);
1489
1490 let content = "# nvim config\n";
1492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1493 let result = rule.check(&ctx).unwrap();
1494 assert!(
1495 result.is_empty(),
1496 "nvim in ignore-words should not be flagged. Got: {result:?}"
1497 );
1498
1499 let fixed = rule.fix(&ctx).unwrap();
1501 assert_eq!(fixed, "# nvim config\n");
1502 }
1503
1504 #[test]
1505 fn test_sentence_case_ignore_words_not_first() {
1506 let config = MD063Config {
1507 enabled: true,
1508 style: HeadingCapStyle::SentenceCase,
1509 ignore_words: vec!["nvim".to_string()],
1510 ..Default::default()
1511 };
1512 let rule = MD063HeadingCapitalization::from_config_struct(config);
1513
1514 let content = "# Using nvim editor\n";
1516 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1517 let result = rule.check(&ctx).unwrap();
1518 assert!(
1519 result.is_empty(),
1520 "nvim in ignore-words should be preserved. Got: {result:?}"
1521 );
1522 }
1523
1524 #[test]
1525 fn test_preserve_cased_words_ios() {
1526 let config = MD063Config {
1527 enabled: true,
1528 style: HeadingCapStyle::SentenceCase,
1529 preserve_cased_words: true,
1530 ..Default::default()
1531 };
1532 let rule = MD063HeadingCapitalization::from_config_struct(config);
1533
1534 let content = "## This is iOS\n";
1536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1537 let result = rule.check(&ctx).unwrap();
1538 assert!(
1539 result.is_empty(),
1540 "iOS should be preserved with preserve-cased-words. Got: {result:?}"
1541 );
1542
1543 let fixed = rule.fix(&ctx).unwrap();
1545 assert_eq!(fixed, "## This is iOS\n");
1546 }
1547
1548 #[test]
1549 fn test_preserve_cased_words_ios_title_case() {
1550 let config = MD063Config {
1551 enabled: true,
1552 style: HeadingCapStyle::TitleCase,
1553 preserve_cased_words: true,
1554 ..Default::default()
1555 };
1556 let rule = MD063HeadingCapitalization::from_config_struct(config);
1557
1558 let content = "# developing for iOS\n";
1560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1561 let fixed = rule.fix(&ctx).unwrap();
1562 assert_eq!(fixed, "# Developing for iOS\n");
1563 }
1564
1565 #[test]
1566 fn test_has_internal_capitals_ios() {
1567 let rule = create_rule();
1568
1569 assert!(
1571 rule.has_internal_capitals("iOS"),
1572 "iOS has mixed case (lowercase i, uppercase OS)"
1573 );
1574
1575 assert!(rule.has_internal_capitals("iPhone"));
1577 assert!(rule.has_internal_capitals("macOS"));
1578 assert!(rule.has_internal_capitals("GitHub"));
1579 assert!(rule.has_internal_capitals("JavaScript"));
1580 assert!(rule.has_internal_capitals("eBay"));
1581
1582 assert!(!rule.has_internal_capitals("API"));
1584 assert!(!rule.has_internal_capitals("GPU"));
1585
1586 assert!(!rule.has_internal_capitals("npm"));
1588 assert!(!rule.has_internal_capitals("config"));
1589
1590 assert!(!rule.has_internal_capitals("The"));
1592 assert!(!rule.has_internal_capitals("Hello"));
1593 }
1594
1595 #[test]
1596 fn test_lowercase_words_before_trailing_code() {
1597 let config = MD063Config {
1598 enabled: true,
1599 style: HeadingCapStyle::TitleCase,
1600 lowercase_words: vec![
1601 "a".to_string(),
1602 "an".to_string(),
1603 "and".to_string(),
1604 "at".to_string(),
1605 "but".to_string(),
1606 "by".to_string(),
1607 "for".to_string(),
1608 "from".to_string(),
1609 "into".to_string(),
1610 "nor".to_string(),
1611 "on".to_string(),
1612 "onto".to_string(),
1613 "or".to_string(),
1614 "the".to_string(),
1615 "to".to_string(),
1616 "upon".to_string(),
1617 "via".to_string(),
1618 "vs".to_string(),
1619 "with".to_string(),
1620 "without".to_string(),
1621 ],
1622 preserve_cased_words: true,
1623 ..Default::default()
1624 };
1625 let rule = MD063HeadingCapitalization::from_config_struct(config);
1626
1627 let content = "## subtitle with a `app`\n";
1632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1633 let result = rule.check(&ctx).unwrap();
1634
1635 assert!(!result.is_empty(), "Should flag incorrect capitalization");
1637 let fixed = rule.fix(&ctx).unwrap();
1638 assert!(
1640 fixed.contains("with a `app`"),
1641 "Expected 'with a `app`' but got: {fixed:?}"
1642 );
1643 assert!(
1644 !fixed.contains("with A `app`"),
1645 "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
1646 );
1647 assert!(
1649 fixed.contains("Subtitle with a `app`"),
1650 "Expected 'Subtitle with a `app`' but got: {fixed:?}"
1651 );
1652 }
1653
1654 #[test]
1655 fn test_lowercase_words_preserved_before_trailing_code_variant() {
1656 let config = MD063Config {
1657 enabled: true,
1658 style: HeadingCapStyle::TitleCase,
1659 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1660 ..Default::default()
1661 };
1662 let rule = MD063HeadingCapitalization::from_config_struct(config);
1663
1664 let content = "## Title with the `code`\n";
1666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1667 let fixed = rule.fix(&ctx).unwrap();
1668 assert!(
1670 fixed.contains("with the `code`"),
1671 "Expected 'with the `code`' but got: {fixed:?}"
1672 );
1673 assert!(
1674 !fixed.contains("with The `code`"),
1675 "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
1676 );
1677 }
1678
1679 #[test]
1680 fn test_last_word_capitalized_when_no_trailing_code() {
1681 let config = MD063Config {
1684 enabled: true,
1685 style: HeadingCapStyle::TitleCase,
1686 lowercase_words: vec!["a".to_string(), "the".to_string()],
1687 ..Default::default()
1688 };
1689 let rule = MD063HeadingCapitalization::from_config_struct(config);
1690
1691 let content = "## title with a word\n";
1694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1695 let fixed = rule.fix(&ctx).unwrap();
1696 assert!(
1698 fixed.contains("With a Word"),
1699 "Expected 'With a Word' but got: {fixed:?}"
1700 );
1701 }
1702
1703 #[test]
1704 fn test_multiple_lowercase_words_before_code() {
1705 let config = MD063Config {
1706 enabled: true,
1707 style: HeadingCapStyle::TitleCase,
1708 lowercase_words: vec![
1709 "a".to_string(),
1710 "the".to_string(),
1711 "with".to_string(),
1712 "for".to_string(),
1713 ],
1714 ..Default::default()
1715 };
1716 let rule = MD063HeadingCapitalization::from_config_struct(config);
1717
1718 let content = "## Guide for the `user`\n";
1720 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1721 let fixed = rule.fix(&ctx).unwrap();
1722 assert!(
1723 fixed.contains("for the `user`"),
1724 "Expected 'for the `user`' but got: {fixed:?}"
1725 );
1726 assert!(
1727 !fixed.contains("For The `user`"),
1728 "Should not capitalize lowercase words before code. Got: {fixed:?}"
1729 );
1730 }
1731
1732 #[test]
1733 fn test_code_in_middle_normal_rules_apply() {
1734 let config = MD063Config {
1735 enabled: true,
1736 style: HeadingCapStyle::TitleCase,
1737 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1738 ..Default::default()
1739 };
1740 let rule = MD063HeadingCapitalization::from_config_struct(config);
1741
1742 let content = "## Using `const` for the code\n";
1744 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1745 let fixed = rule.fix(&ctx).unwrap();
1746 assert!(
1748 fixed.contains("for the Code"),
1749 "Expected 'for the Code' but got: {fixed:?}"
1750 );
1751 }
1752
1753 #[test]
1754 fn test_link_at_end_same_as_code() {
1755 let config = MD063Config {
1756 enabled: true,
1757 style: HeadingCapStyle::TitleCase,
1758 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1759 ..Default::default()
1760 };
1761 let rule = MD063HeadingCapitalization::from_config_struct(config);
1762
1763 let content = "## Guide for the [link](./page.md)\n";
1765 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1766 let fixed = rule.fix(&ctx).unwrap();
1767 assert!(
1769 fixed.contains("for the [Link]"),
1770 "Expected 'for the [Link]' but got: {fixed:?}"
1771 );
1772 assert!(
1773 !fixed.contains("for The [Link]"),
1774 "Should not capitalize 'the' before link. Got: {fixed:?}"
1775 );
1776 }
1777
1778 #[test]
1779 fn test_multiple_code_segments() {
1780 let config = MD063Config {
1781 enabled: true,
1782 style: HeadingCapStyle::TitleCase,
1783 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1784 ..Default::default()
1785 };
1786 let rule = MD063HeadingCapitalization::from_config_struct(config);
1787
1788 let content = "## Using `const` with a `variable`\n";
1790 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1791 let fixed = rule.fix(&ctx).unwrap();
1792 assert!(
1794 fixed.contains("with a `variable`"),
1795 "Expected 'with a `variable`' but got: {fixed:?}"
1796 );
1797 assert!(
1798 !fixed.contains("with A `variable`"),
1799 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1800 );
1801 }
1802
1803 #[test]
1804 fn test_code_and_link_combination() {
1805 let config = MD063Config {
1806 enabled: true,
1807 style: HeadingCapStyle::TitleCase,
1808 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1809 ..Default::default()
1810 };
1811 let rule = MD063HeadingCapitalization::from_config_struct(config);
1812
1813 let content = "## Guide for the `code` [link](./page.md)\n";
1815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1816 let fixed = rule.fix(&ctx).unwrap();
1817 assert!(
1819 fixed.contains("for the `code`"),
1820 "Expected 'for the `code`' but got: {fixed:?}"
1821 );
1822 }
1823
1824 #[test]
1825 fn test_text_after_code_capitalizes_last() {
1826 let config = MD063Config {
1827 enabled: true,
1828 style: HeadingCapStyle::TitleCase,
1829 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1830 ..Default::default()
1831 };
1832 let rule = MD063HeadingCapitalization::from_config_struct(config);
1833
1834 let content = "## Using `const` for the code\n";
1836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1837 let fixed = rule.fix(&ctx).unwrap();
1838 assert!(
1840 fixed.contains("for the Code"),
1841 "Expected 'for the Code' but got: {fixed:?}"
1842 );
1843 }
1844
1845 #[test]
1846 fn test_preserve_cased_words_with_trailing_code() {
1847 let config = MD063Config {
1848 enabled: true,
1849 style: HeadingCapStyle::TitleCase,
1850 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1851 preserve_cased_words: true,
1852 ..Default::default()
1853 };
1854 let rule = MD063HeadingCapitalization::from_config_struct(config);
1855
1856 let content = "## Guide for iOS `app`\n";
1858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1859 let fixed = rule.fix(&ctx).unwrap();
1860 assert!(
1862 fixed.contains("for iOS `app`"),
1863 "Expected 'for iOS `app`' but got: {fixed:?}"
1864 );
1865 assert!(
1866 !fixed.contains("For iOS `app`"),
1867 "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
1868 );
1869 }
1870
1871 #[test]
1872 fn test_ignore_words_with_trailing_code() {
1873 let config = MD063Config {
1874 enabled: true,
1875 style: HeadingCapStyle::TitleCase,
1876 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1877 ignore_words: vec!["npm".to_string()],
1878 ..Default::default()
1879 };
1880 let rule = MD063HeadingCapitalization::from_config_struct(config);
1881
1882 let content = "## Using npm with a `script`\n";
1884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1885 let fixed = rule.fix(&ctx).unwrap();
1886 assert!(
1888 fixed.contains("npm with a `script`"),
1889 "Expected 'npm with a `script`' but got: {fixed:?}"
1890 );
1891 assert!(
1892 !fixed.contains("with A `script`"),
1893 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1894 );
1895 }
1896
1897 #[test]
1898 fn test_empty_text_segment_edge_case() {
1899 let config = MD063Config {
1900 enabled: true,
1901 style: HeadingCapStyle::TitleCase,
1902 lowercase_words: vec!["a".to_string(), "with".to_string()],
1903 ..Default::default()
1904 };
1905 let rule = MD063HeadingCapitalization::from_config_struct(config);
1906
1907 let content = "## `start` with a `end`\n";
1909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1910 let fixed = rule.fix(&ctx).unwrap();
1911 assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
1914 assert!(
1915 !fixed.contains("A `end`"),
1916 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1917 );
1918 }
1919
1920 #[test]
1921 fn test_sentence_case_with_trailing_code() {
1922 let config = MD063Config {
1923 enabled: true,
1924 style: HeadingCapStyle::SentenceCase,
1925 lowercase_words: vec!["a".to_string(), "the".to_string()],
1926 ..Default::default()
1927 };
1928 let rule = MD063HeadingCapitalization::from_config_struct(config);
1929
1930 let content = "## guide for the `user`\n";
1932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1933 let fixed = rule.fix(&ctx).unwrap();
1934 assert!(
1936 fixed.contains("Guide for the `user`"),
1937 "Expected 'Guide for the `user`' but got: {fixed:?}"
1938 );
1939 }
1940
1941 #[test]
1942 fn test_hyphenated_word_before_code() {
1943 let config = MD063Config {
1944 enabled: true,
1945 style: HeadingCapStyle::TitleCase,
1946 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1947 ..Default::default()
1948 };
1949 let rule = MD063HeadingCapitalization::from_config_struct(config);
1950
1951 let content = "## Self-contained with a `feature`\n";
1953 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1954 let fixed = rule.fix(&ctx).unwrap();
1955 assert!(
1957 fixed.contains("with a `feature`"),
1958 "Expected 'with a `feature`' but got: {fixed:?}"
1959 );
1960 }
1961
1962 #[test]
1967 fn test_sentence_case_code_at_start_basic() {
1968 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1970 let content = "# `rumdl` is a linter\n";
1971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1972 let result = rule.check(&ctx).unwrap();
1973 assert!(
1975 result.is_empty(),
1976 "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
1977 result.iter().map(|w| &w.message).collect::<Vec<_>>()
1978 );
1979 }
1980
1981 #[test]
1982 fn test_sentence_case_code_at_start_incorrect_capitalization() {
1983 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1985 let content = "# `rumdl` Is a Linter\n";
1986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1987 let result = rule.check(&ctx).unwrap();
1988 assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
1990 assert!(
1991 result[0].message.contains("`rumdl` is a linter"),
1992 "Should suggest lowercase after code. Got: {:?}",
1993 result[0].message
1994 );
1995 }
1996
1997 #[test]
1998 fn test_sentence_case_code_at_start_fix() {
1999 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2000 let content = "# `rumdl` Is A Linter\n";
2001 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2002 let fixed = rule.fix(&ctx).unwrap();
2003 assert!(
2004 fixed.contains("# `rumdl` is a linter"),
2005 "Should fix to lowercase after code. Got: {fixed:?}"
2006 );
2007 }
2008
2009 #[test]
2010 fn test_sentence_case_text_at_start_still_capitalizes() {
2011 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2013 let content = "# the quick brown fox\n";
2014 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2015 let result = rule.check(&ctx).unwrap();
2016 assert_eq!(result.len(), 1);
2017 assert!(
2018 result[0].message.contains("The quick brown fox"),
2019 "Text-first heading should capitalize first word. Got: {:?}",
2020 result[0].message
2021 );
2022 }
2023
2024 #[test]
2025 fn test_sentence_case_link_at_start() {
2026 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2028 let content = "# [api](api.md) reference guide\n";
2030 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2031 let result = rule.check(&ctx).unwrap();
2032 assert!(
2034 result.is_empty(),
2035 "Heading with link at start should not capitalize 'reference'. Got: {:?}",
2036 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2037 );
2038 }
2039
2040 #[test]
2041 fn test_sentence_case_link_preserves_acronyms() {
2042 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2044 let content = "# [API](api.md) Reference Guide\n";
2045 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2046 let result = rule.check(&ctx).unwrap();
2047 assert_eq!(result.len(), 1);
2048 assert!(
2050 result[0].message.contains("[API](api.md) reference guide"),
2051 "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
2052 result[0].message
2053 );
2054 }
2055
2056 #[test]
2057 fn test_sentence_case_link_preserves_brand_names() {
2058 let config = MD063Config {
2060 enabled: true,
2061 style: HeadingCapStyle::SentenceCase,
2062 preserve_cased_words: true,
2063 ..Default::default()
2064 };
2065 let rule = MD063HeadingCapitalization::from_config_struct(config);
2066 let content = "# [iPhone](iphone.md) Features Guide\n";
2067 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2068 let result = rule.check(&ctx).unwrap();
2069 assert_eq!(result.len(), 1);
2070 assert!(
2072 result[0].message.contains("[iPhone](iphone.md) features guide"),
2073 "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
2074 result[0].message
2075 );
2076 }
2077
2078 #[test]
2079 fn test_sentence_case_link_lowercases_regular_words() {
2080 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2082 let content = "# [Documentation](docs.md) Reference\n";
2083 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2084 let result = rule.check(&ctx).unwrap();
2085 assert_eq!(result.len(), 1);
2086 assert!(
2088 result[0].message.contains("[documentation](docs.md) reference"),
2089 "Should lowercase regular link text. Got: {:?}",
2090 result[0].message
2091 );
2092 }
2093
2094 #[test]
2095 fn test_sentence_case_link_at_start_correct_already() {
2096 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2098 let content = "# [API](api.md) reference guide\n";
2099 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2100 let result = rule.check(&ctx).unwrap();
2101 assert!(
2102 result.is_empty(),
2103 "Correctly cased heading with link should not be flagged. Got: {:?}",
2104 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2105 );
2106 }
2107
2108 #[test]
2109 fn test_sentence_case_link_github_preserved() {
2110 let config = MD063Config {
2112 enabled: true,
2113 style: HeadingCapStyle::SentenceCase,
2114 preserve_cased_words: true,
2115 ..Default::default()
2116 };
2117 let rule = MD063HeadingCapitalization::from_config_struct(config);
2118 let content = "# [GitHub](gh.md) Repository Setup\n";
2119 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2120 let result = rule.check(&ctx).unwrap();
2121 assert_eq!(result.len(), 1);
2122 assert!(
2123 result[0].message.contains("[GitHub](gh.md) repository setup"),
2124 "Should preserve 'GitHub'. Got: {:?}",
2125 result[0].message
2126 );
2127 }
2128
2129 #[test]
2130 fn test_sentence_case_multiple_code_spans() {
2131 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2132 let content = "# `foo` and `bar` are methods\n";
2133 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2134 let result = rule.check(&ctx).unwrap();
2135 assert!(
2137 result.is_empty(),
2138 "Should not capitalize words between/after code spans. Got: {:?}",
2139 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2140 );
2141 }
2142
2143 #[test]
2144 fn test_sentence_case_code_only_heading() {
2145 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2147 let content = "# `rumdl`\n";
2148 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2149 let result = rule.check(&ctx).unwrap();
2150 assert!(
2151 result.is_empty(),
2152 "Code-only heading should be fine. Got: {:?}",
2153 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2154 );
2155 }
2156
2157 #[test]
2158 fn test_sentence_case_code_at_end() {
2159 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2161 let content = "# install the `rumdl` tool\n";
2162 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2163 let result = rule.check(&ctx).unwrap();
2164 assert_eq!(result.len(), 1);
2166 assert!(
2167 result[0].message.contains("Install the `rumdl` tool"),
2168 "First word should still be capitalized when text comes first. Got: {:?}",
2169 result[0].message
2170 );
2171 }
2172
2173 #[test]
2174 fn test_sentence_case_code_in_middle() {
2175 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2177 let content = "# using the `rumdl` linter for markdown\n";
2178 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2179 let result = rule.check(&ctx).unwrap();
2180 assert_eq!(result.len(), 1);
2182 assert!(
2183 result[0].message.contains("Using the `rumdl` linter for markdown"),
2184 "First word should be capitalized. Got: {:?}",
2185 result[0].message
2186 );
2187 }
2188
2189 #[test]
2190 fn test_sentence_case_preserved_word_after_code() {
2191 let config = MD063Config {
2193 enabled: true,
2194 style: HeadingCapStyle::SentenceCase,
2195 preserve_cased_words: true,
2196 ..Default::default()
2197 };
2198 let rule = MD063HeadingCapitalization::from_config_struct(config);
2199 let content = "# `swift` iPhone development\n";
2200 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2201 let result = rule.check(&ctx).unwrap();
2202 assert!(
2204 result.is_empty(),
2205 "Preserved words after code should stay. Got: {:?}",
2206 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2207 );
2208 }
2209
2210 #[test]
2211 fn test_title_case_code_at_start_still_capitalizes() {
2212 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2214 let content = "# `api` quick start guide\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("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
2221 "Title case should capitalize major words after code. Got: {:?}",
2222 result[0].message
2223 );
2224 }
2225
2226 #[test]
2229 fn test_sentence_case_html_tag_at_start() {
2230 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2232 let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
2233 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2234 let result = rule.check(&ctx).unwrap();
2235 assert_eq!(result.len(), 1);
2237 let fixed = rule.fix(&ctx).unwrap();
2238 assert_eq!(
2239 fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
2240 "Text after HTML at start should be lowercase"
2241 );
2242 }
2243
2244 #[test]
2245 fn test_sentence_case_html_tag_preserves_content() {
2246 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2248 let content = "# The <abbr>API</abbr> documentation guide\n";
2249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2250 let result = rule.check(&ctx).unwrap();
2251 assert!(
2253 result.is_empty(),
2254 "HTML tag content should be preserved. Got: {:?}",
2255 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2256 );
2257 }
2258
2259 #[test]
2260 fn test_sentence_case_html_tag_at_start_with_acronym() {
2261 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2263 let content = "# <abbr>API</abbr> Documentation Guide\n";
2264 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2265 let result = rule.check(&ctx).unwrap();
2266 assert_eq!(result.len(), 1);
2267 let fixed = rule.fix(&ctx).unwrap();
2268 assert_eq!(
2269 fixed, "# <abbr>API</abbr> documentation guide\n",
2270 "Text after HTML at start should be lowercase, HTML content preserved"
2271 );
2272 }
2273
2274 #[test]
2275 fn test_sentence_case_html_tag_in_middle() {
2276 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2278 let content = "# using the <code>config</code> File\n";
2279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2280 let result = rule.check(&ctx).unwrap();
2281 assert_eq!(result.len(), 1);
2282 let fixed = rule.fix(&ctx).unwrap();
2283 assert_eq!(
2284 fixed, "# Using the <code>config</code> file\n",
2285 "First word capitalized, HTML preserved, rest lowercase"
2286 );
2287 }
2288
2289 #[test]
2290 fn test_html_tag_strong_emphasis() {
2291 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2293 let content = "# The <strong>Bold</strong> Way\n";
2294 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2295 let result = rule.check(&ctx).unwrap();
2296 assert_eq!(result.len(), 1);
2297 let fixed = rule.fix(&ctx).unwrap();
2298 assert_eq!(
2299 fixed, "# The <strong>Bold</strong> way\n",
2300 "<strong> tag content should be preserved"
2301 );
2302 }
2303
2304 #[test]
2305 fn test_html_tag_with_attributes() {
2306 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2308 let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
2309 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2310 let result = rule.check(&ctx).unwrap();
2311 assert_eq!(result.len(), 1);
2312 let fixed = rule.fix(&ctx).unwrap();
2313 assert_eq!(
2314 fixed, "# <span class=\"highlight\">Important</span> notice here\n",
2315 "HTML tag with attributes should be preserved"
2316 );
2317 }
2318
2319 #[test]
2320 fn test_multiple_html_tags() {
2321 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2323 let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
2324 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2325 let result = rule.check(&ctx).unwrap();
2326 assert_eq!(result.len(), 1);
2327 let fixed = rule.fix(&ctx).unwrap();
2328 assert_eq!(
2329 fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
2330 "Multiple HTML tags should all be preserved"
2331 );
2332 }
2333
2334 #[test]
2335 fn test_html_and_code_mixed() {
2336 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2338 let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
2339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2340 let result = rule.check(&ctx).unwrap();
2341 assert_eq!(result.len(), 1);
2342 let fixed = rule.fix(&ctx).unwrap();
2343 assert_eq!(
2344 fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
2345 "HTML and code should both be preserved"
2346 );
2347 }
2348
2349 #[test]
2350 fn test_self_closing_html_tag() {
2351 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2353 let content = "# Line one<br/>Line Two Here\n";
2354 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2355 let result = rule.check(&ctx).unwrap();
2356 assert_eq!(result.len(), 1);
2357 let fixed = rule.fix(&ctx).unwrap();
2358 assert_eq!(
2359 fixed, "# Line one<br/>line two here\n",
2360 "Self-closing HTML tags should be preserved"
2361 );
2362 }
2363
2364 #[test]
2365 fn test_title_case_with_html_tags() {
2366 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2368 let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
2369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2370 let result = rule.check(&ctx).unwrap();
2371 assert_eq!(result.len(), 1);
2372 let fixed = rule.fix(&ctx).unwrap();
2373 assert!(
2375 fixed.contains("<kbd>ctrl</kbd>"),
2376 "HTML tag content should be preserved in title case. Got: {fixed}"
2377 );
2378 assert!(
2379 fixed.starts_with("# The ") || fixed.starts_with("# the "),
2380 "Title case should work with HTML. Got: {fixed}"
2381 );
2382 }
2383
2384 #[test]
2387 fn test_sentence_case_preserves_caret_notation() {
2388 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2390 let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
2391 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2392 let result = rule.check(&ctx).unwrap();
2393 assert!(
2395 result.is_empty(),
2396 "Caret notation should be preserved. Got: {:?}",
2397 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2398 );
2399 }
2400
2401 #[test]
2402 fn test_sentence_case_caret_notation_various() {
2403 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2405
2406 let content = "## Press ^C to cancel\n";
2408 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2409 let result = rule.check(&ctx).unwrap();
2410 assert!(
2411 result.is_empty(),
2412 "^C should be preserved. Got: {:?}",
2413 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2414 );
2415
2416 let content = "## Use ^Z for background\n";
2418 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2419 let result = rule.check(&ctx).unwrap();
2420 assert!(
2421 result.is_empty(),
2422 "^Z should be preserved. Got: {:?}",
2423 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2424 );
2425
2426 let content = "## Press ^[ for escape\n";
2428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2429 let result = rule.check(&ctx).unwrap();
2430 assert!(
2431 result.is_empty(),
2432 "^[ should be preserved. Got: {:?}",
2433 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2434 );
2435 }
2436
2437 #[test]
2438 fn test_caret_notation_detection() {
2439 let rule = create_rule();
2440
2441 assert!(rule.is_caret_notation("^A"));
2443 assert!(rule.is_caret_notation("^Z"));
2444 assert!(rule.is_caret_notation("^C"));
2445 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")); }
2457
2458 fn create_sentence_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2465 let config = MD063Config {
2466 enabled: true,
2467 style: HeadingCapStyle::SentenceCase,
2468 ..Default::default()
2469 };
2470 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2471 rule.proper_names = names;
2472 rule
2473 }
2474
2475 #[test]
2476 fn test_sentence_case_preserves_single_word_proper_name() {
2477 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2478 let content = "# installing javascript\n";
2480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2481 let result = rule.check(&ctx).unwrap();
2482 assert_eq!(result.len(), 1, "Should flag the heading");
2483 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2484 assert!(
2485 fix_text.contains("JavaScript"),
2486 "Fix should preserve proper name 'JavaScript', got: {fix_text:?}"
2487 );
2488 assert!(
2489 !fix_text.contains("javascript"),
2490 "Fix should not have lowercase 'javascript', got: {fix_text:?}"
2491 );
2492 }
2493
2494 #[test]
2495 fn test_sentence_case_preserves_multi_word_proper_name() {
2496 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2497 let content = "# using good application features\n";
2499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2500 let result = rule.check(&ctx).unwrap();
2501 assert_eq!(result.len(), 1, "Should flag the heading");
2502 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2503 assert!(
2504 fix_text.contains("Good Application"),
2505 "Fix should preserve 'Good Application' as a phrase, got: {fix_text:?}"
2506 );
2507 }
2508
2509 #[test]
2510 fn test_sentence_case_proper_name_at_start_of_heading() {
2511 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2512 let content = "# good application overview\n";
2514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2515 let result = rule.check(&ctx).unwrap();
2516 assert_eq!(result.len(), 1, "Should flag the heading");
2517 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2518 assert!(
2519 fix_text.contains("Good Application"),
2520 "Fix should produce 'Good Application' at start of heading, got: {fix_text:?}"
2521 );
2522 assert!(
2523 fix_text.contains("overview"),
2524 "Non-proper-name word 'overview' should be lowercase, got: {fix_text:?}"
2525 );
2526 }
2527
2528 #[test]
2529 fn test_sentence_case_with_proper_names_no_oscillation() {
2530 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2533
2534 let content = "# installing good application on your system\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);
2539 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
2540
2541 assert!(
2543 fixed_heading.contains("Good Application"),
2544 "After fix, proper name must be preserved: {fixed_heading:?}"
2545 );
2546
2547 let fixed_line = format!("{fixed_heading}\n");
2549 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
2550 let result2 = rule.check(&ctx2).unwrap();
2551 assert!(
2552 result2.is_empty(),
2553 "After one fix, heading must already satisfy both MD063 and MD044 - no oscillation. \
2554 Second pass warnings: {result2:?}"
2555 );
2556 }
2557
2558 #[test]
2559 fn test_sentence_case_proper_names_already_correct() {
2560 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2561 let content = "# Installing Good Application\n";
2563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2564 let result = rule.check(&ctx).unwrap();
2565 assert!(
2566 result.is_empty(),
2567 "Correct sentence-case heading with proper name should not be flagged, got: {result:?}"
2568 );
2569 }
2570
2571 #[test]
2572 fn test_sentence_case_multiple_proper_names_in_heading() {
2573 let rule = create_sentence_case_rule_with_proper_names(vec!["TypeScript".to_string(), "React".to_string()]);
2574 let content = "# using typescript with react\n";
2575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2576 let result = rule.check(&ctx).unwrap();
2577 assert_eq!(result.len(), 1);
2578 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2579 assert!(
2580 fix_text.contains("TypeScript"),
2581 "Fix should preserve 'TypeScript', got: {fix_text:?}"
2582 );
2583 assert!(
2584 fix_text.contains("React"),
2585 "Fix should preserve 'React', got: {fix_text:?}"
2586 );
2587 }
2588
2589 #[test]
2590 fn test_sentence_case_unicode_casefold_expansion_before_proper_name() {
2591 let rule = create_sentence_case_rule_with_proper_names(vec!["Österreich".to_string()]);
2594 let content = "# İ österreich guide\n";
2595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2596
2597 let result = rule.check(&ctx).unwrap();
2599 assert_eq!(result.len(), 1, "Should flag heading for canonical proper-name casing");
2600 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2601 assert!(
2602 fix_text.contains("Österreich"),
2603 "Fix should preserve canonical 'Österreich', got: {fix_text:?}"
2604 );
2605 }
2606
2607 #[test]
2608 fn test_sentence_case_preserves_trailing_punctuation_on_proper_name() {
2609 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2610 let content = "# using javascript, today\n";
2611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2612 let result = rule.check(&ctx).unwrap();
2613 assert_eq!(result.len(), 1, "Should flag heading");
2614 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2615 assert!(
2616 fix_text.contains("JavaScript,"),
2617 "Fix should preserve trailing punctuation, got: {fix_text:?}"
2618 );
2619 }
2620
2621 fn create_title_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2628 let config = MD063Config {
2629 enabled: true,
2630 style: HeadingCapStyle::TitleCase,
2631 ..Default::default()
2632 };
2633 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2634 rule.proper_names = names;
2635 rule
2636 }
2637
2638 #[test]
2639 fn test_title_case_preserves_proper_name_with_lowercase_article() {
2640 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
2644 let content = "# listening to the rolling stones today\n";
2645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2646 let result = rule.check(&ctx).unwrap();
2647 assert_eq!(result.len(), 1, "Should flag the heading");
2648 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2649 assert!(
2650 fix_text.contains("The Rolling Stones"),
2651 "Fix should preserve proper name 'The Rolling Stones', got: {fix_text:?}"
2652 );
2653 }
2654
2655 #[test]
2656 fn test_title_case_proper_name_no_oscillation() {
2657 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
2659 let content = "# listening to the rolling stones today\n";
2660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2661 let result = rule.check(&ctx).unwrap();
2662 assert_eq!(result.len(), 1);
2663 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
2664
2665 let fixed_line = format!("{fixed_heading}\n");
2666 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
2667 let result2 = rule.check(&ctx2).unwrap();
2668 assert!(
2669 result2.is_empty(),
2670 "After one title-case fix, heading must already satisfy both rules. \
2671 Second pass warnings: {result2:?}"
2672 );
2673 }
2674
2675 #[test]
2676 fn test_title_case_unicode_casefold_expansion_before_proper_name() {
2677 let rule = create_title_case_rule_with_proper_names(vec!["Österreich".to_string()]);
2678 let content = "# İ österreich guide\n";
2679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2680 let result = rule.check(&ctx).unwrap();
2681 assert_eq!(result.len(), 1, "Should flag the heading");
2682 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2683 assert!(
2684 fix_text.contains("Österreich"),
2685 "Fix should preserve canonical proper-name casing, got: {fix_text:?}"
2686 );
2687 }
2688
2689 #[test]
2695 fn test_from_config_loads_md044_names_into_md063() {
2696 use crate::config::{Config, RuleConfig};
2697 use crate::rule::Rule;
2698 use std::collections::BTreeMap;
2699
2700 let mut config = Config::default();
2701
2702 let mut md063_values = BTreeMap::new();
2704 md063_values.insert("style".to_string(), toml::Value::String("sentence_case".to_string()));
2705 md063_values.insert("enabled".to_string(), toml::Value::Boolean(true));
2706 config.rules.insert(
2707 "MD063".to_string(),
2708 RuleConfig {
2709 values: md063_values,
2710 severity: None,
2711 },
2712 );
2713
2714 let mut md044_values = BTreeMap::new();
2716 md044_values.insert(
2717 "names".to_string(),
2718 toml::Value::Array(vec![toml::Value::String("Good Application".to_string())]),
2719 );
2720 config.rules.insert(
2721 "MD044".to_string(),
2722 RuleConfig {
2723 values: md044_values,
2724 severity: None,
2725 },
2726 );
2727
2728 let rule = MD063HeadingCapitalization::from_config(&config);
2730
2731 let content = "# using good application features\n";
2733 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2734 let result = rule.check(&ctx).unwrap();
2735 assert_eq!(result.len(), 1, "Should flag the heading");
2736 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2737 assert!(
2738 fix_text.contains("Good Application"),
2739 "from_config should wire MD044 names into MD063; fix should preserve \
2740 'Good Application', got: {fix_text:?}"
2741 );
2742 }
2743
2744 #[test]
2745 fn test_title_case_short_word_not_confused_with_substring() {
2746 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2750
2751 let content = "# in the insert\n";
2754 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2755 let result = rule.check(&ctx).unwrap();
2756 assert_eq!(result.len(), 1, "Should flag the heading");
2757 let fix = result[0].fix.as_ref().expect("Fix should be present");
2758 assert!(
2760 fix.replacement.contains("In the Insert"),
2761 "Expected 'In the Insert', got: {:?}",
2762 fix.replacement
2763 );
2764 }
2765
2766 #[test]
2767 fn test_title_case_or_not_confused_with_orchestra() {
2768 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2769
2770 let content = "# or the orchestra\n";
2773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2774 let result = rule.check(&ctx).unwrap();
2775 assert_eq!(result.len(), 1, "Should flag the heading");
2776 let fix = result[0].fix.as_ref().expect("Fix should be present");
2777 assert!(
2779 fix.replacement.contains("Or the Orchestra"),
2780 "Expected 'Or the Orchestra', got: {:?}",
2781 fix.replacement
2782 );
2783 }
2784
2785 #[test]
2786 fn test_all_caps_preserves_all_words() {
2787 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
2788
2789 let content = "# in the insert\n";
2790 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2791 let result = rule.check(&ctx).unwrap();
2792 assert_eq!(result.len(), 1, "Should flag the heading");
2793 let fix = result[0].fix.as_ref().expect("Fix should be present");
2794 assert!(
2795 fix.replacement.contains("IN THE INSERT"),
2796 "All caps should uppercase all words, got: {:?}",
2797 fix.replacement
2798 );
2799 }
2800
2801 #[test]
2803 fn test_title_case_numbered_prefix_lowercase_word() {
2804 let rule = create_rule();
2806 let content = "## 1. To Be a Thing\n";
2807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2808 let result = rule.check(&ctx).unwrap();
2809 assert!(
2810 result.is_empty(),
2811 "Should not flag '## 1. To Be a Thing', got: {result:?}"
2812 );
2813
2814 let content_lower = "## 1. to be a thing\n";
2815 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2816 let result2 = rule.check(&ctx2).unwrap();
2817 assert!(!result2.is_empty(), "Should flag '## 1. to be a thing'");
2818 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2819 assert!(
2820 fix.replacement.contains("1. To Be a Thing"),
2821 "Fix should capitalize 'To', got: {:?}",
2822 fix.replacement
2823 );
2824 }
2825
2826 #[test]
2827 fn test_title_case_numbered_prefix_article() {
2828 let rule = create_rule();
2830 let content = "## 2. A Guide to the Galaxy\n";
2831 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2832 let result = rule.check(&ctx).unwrap();
2833 assert!(
2834 result.is_empty(),
2835 "Should not flag '## 2. A Guide to the Galaxy', got: {result:?}"
2836 );
2837
2838 let content_lower = "## 2. a guide to the galaxy\n";
2839 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2840 let result2 = rule.check(&ctx2).unwrap();
2841 assert!(!result2.is_empty(), "Should flag '## 2. a guide to the galaxy'");
2842 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2843 assert!(
2844 fix.replacement.contains("2. A Guide to the Galaxy"),
2845 "Fix should capitalize 'A', got: {:?}",
2846 fix.replacement
2847 );
2848 }
2849
2850 #[test]
2851 fn test_title_case_mid_sentence_period_word() {
2852 let rule = create_rule();
2854 let content = "## Step 1. Introduction to the Problem\n";
2855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2856 let result = rule.check(&ctx).unwrap();
2857 assert!(
2858 result.is_empty(),
2859 "Should not flag '## Step 1. Introduction to the Problem', got: {result:?}"
2860 );
2861
2862 let content_lower = "## Step 1. introduction to the problem\n";
2863 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2864 let result2 = rule.check(&ctx2).unwrap();
2865 assert!(
2866 !result2.is_empty(),
2867 "Should flag '## Step 1. introduction to the problem'"
2868 );
2869 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2870 assert!(
2871 fix.replacement.contains("Step 1. Introduction to the Problem"),
2872 "Fix should capitalize 'Introduction', got: {:?}",
2873 fix.replacement
2874 );
2875 }
2876
2877 #[test]
2878 fn test_title_case_numbered_prefix_in_link_text() {
2879 let config = MD063Config {
2882 enabled: true,
2883 style: HeadingCapStyle::TitleCase,
2884 ..Default::default()
2885 };
2886 let rule = MD063HeadingCapitalization::from_config_struct(config);
2887
2888 let content = "## [1. To Be a Thing](https://example.com)\n";
2890 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2891 let result = rule.check(&ctx).unwrap();
2892 assert!(
2893 result.is_empty(),
2894 "Should not flag '## [1. To Be a Thing](url)', got: {result:?}"
2895 );
2896
2897 let content_lower = "## [1. to be a thing](https://example.com)\n";
2899 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2900 let result2 = rule.check(&ctx2).unwrap();
2901 assert!(!result2.is_empty(), "Should flag '## [1. to be a thing](url)'");
2902 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2903 assert!(
2904 fix.replacement.contains("1. To Be a Thing"),
2905 "Fix should capitalize 'To' in link text, got: {:?}",
2906 fix.replacement
2907 );
2908 }
2909
2910 #[test]
2915 fn test_is_numeric_ordinal_recognises_canonical_forms() {
2916 for word in &[
2917 "1st", "2nd", "3rd", "4th", "5th", "11th", "21st", "22nd", "23rd", "100th", "1ST", "5Th", "21St", "21sT",
2918 ] {
2919 assert!(
2920 MD063HeadingCapitalization::is_numeric_ordinal(word),
2921 "expected `{word}` to be detected as a numeric ordinal"
2922 );
2923 }
2924 }
2925
2926 #[test]
2927 fn test_is_numeric_ordinal_rejects_non_ordinals() {
2928 for word in &[
2933 "first", "1stop", "ist", "5", "th", "abc", "4G", "4K", "30s", "100k", "5x", "1.5", "iPhone6S",
2934 ] {
2935 assert!(
2936 !MD063HeadingCapitalization::is_numeric_ordinal(word),
2937 "expected `{word}` NOT to be detected as a numeric ordinal"
2938 );
2939 }
2940 }
2941
2942 #[test]
2943 fn test_is_numeric_ordinal_strips_trailing_punctuation() {
2944 for word in &["5th.", "1st,", "21st!", "3rd:", "4th)", "5th's"] {
2945 assert!(
2946 MD063HeadingCapitalization::is_numeric_ordinal(word),
2947 "expected `{word}` to be detected as a numeric ordinal (with punctuation)"
2948 );
2949 }
2950 }
2951
2952 #[test]
2953 fn test_title_case_ordinal_first_word_not_flagged() {
2954 let rule = create_rule();
2955 for content in &[
2956 "# 1st Place\n",
2957 "# 2nd Edition\n",
2958 "# 3rd Time\n",
2959 "# 5th Avenue\n",
2960 "# 21st Century Skills\n",
2961 "# 100th Customer\n",
2962 ] {
2963 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2964 let result = rule.check(&ctx).unwrap();
2965 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
2966 }
2967 }
2968
2969 #[test]
2970 fn test_title_case_ordinal_mid_heading_not_flagged() {
2971 let rule = create_rule();
2972 for content in &[
2973 "# May 3rd Notes\n",
2974 "# Top 100th Customer\n",
2975 "# Notes for the 5th of May\n",
2976 ] {
2977 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2978 let result = rule.check(&ctx).unwrap();
2979 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
2980 }
2981 }
2982
2983 #[test]
2984 fn test_title_case_ordinal_corrupted_form_is_fixed() {
2985 let rule = create_rule();
2988 for (input, expected) in &[
2989 ("# 1St Place\n", "1st Place"),
2990 ("# 5Th Avenue\n", "5th Avenue"),
2991 ("# 21St Century Skills\n", "21st Century Skills"),
2992 ("# May 3Rd Notes\n", "May 3rd Notes"),
2993 ("# 22Nd Edition\n", "22nd Edition"),
2994 ] {
2995 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
2996 let result = rule.check(&ctx).unwrap();
2997 assert!(!result.is_empty(), "Should flag {input:?}");
2998 let fix = result[0].fix.as_ref().expect("should have a fix");
2999 assert!(
3000 fix.replacement.contains(expected),
3001 "Fix for {input:?} should contain {expected:?}, got: {:?}",
3002 fix.replacement
3003 );
3004 }
3005 }
3006
3007 #[test]
3008 fn test_title_case_ordinal_lowercase_other_words_capitalised() {
3009 let rule = create_rule();
3011 let content = "# 5th avenue\n";
3012 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3013 let result = rule.check(&ctx).unwrap();
3014 assert_eq!(result.len(), 1);
3015 let fix = result[0].fix.as_ref().expect("should have a fix");
3016 assert!(
3017 fix.replacement.contains("5th Avenue"),
3018 "Fix should produce '5th Avenue', got: {:?}",
3019 fix.replacement
3020 );
3021 }
3022
3023 #[test]
3024 fn test_title_case_ordinal_with_trailing_punctuation() {
3025 let rule = create_rule();
3026 let content = "# Released on the 5th.\n";
3027 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3028 let result = rule.check(&ctx).unwrap();
3029 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3030 }
3031
3032 #[test]
3033 fn test_title_case_ordinal_hyphenated() {
3034 let rule = create_rule();
3035 for content in &["# 21st-Century Skills\n", "# A 19th-Century Novel\n"] {
3036 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3037 let result = rule.check(&ctx).unwrap();
3038 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3039 }
3040 }
3041
3042 #[test]
3043 fn test_sentence_case_ordinal_corrupted_form_is_fixed() {
3044 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3045 let content = "# 5Th avenue\n";
3046 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3047 let result = rule.check(&ctx).unwrap();
3048 assert_eq!(result.len(), 1);
3049 let fix = result[0].fix.as_ref().expect("should have a fix");
3050 assert!(
3051 fix.replacement.contains("5th avenue"),
3052 "Fix should produce '5th avenue', got: {:?}",
3053 fix.replacement
3054 );
3055 }
3056
3057 #[test]
3058 fn test_title_case_digit_acronym_unchanged() {
3059 let rule = create_rule();
3062 for content in &["# 4G Networks\n", "# 4K Streaming\n"] {
3063 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3064 let result = rule.check(&ctx).unwrap();
3065 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3066 }
3067 }
3068
3069 fn restart_rule(boundaries: &[&str]) -> MD063HeadingCapitalization {
3072 let config = MD063Config {
3073 enabled: true,
3074 style: HeadingCapStyle::SentenceCase,
3075 sentence_case_restart_after: boundaries.iter().copied().map(String::from).collect(),
3076 ..Default::default()
3077 };
3078 MD063HeadingCapitalization::from_config_struct(config)
3079 }
3080
3081 fn suggested(rule: &MD063HeadingCapitalization, content: &str) -> Option<String> {
3084 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3085 let warnings = rule.check(&ctx).unwrap();
3086 let fixed = rule.fix(&ctx).unwrap();
3087 assert_eq!(
3088 warnings.is_empty(),
3089 fixed == content,
3090 "a warning and a rewrite must agree for {content:?}"
3091 );
3092 (!warnings.is_empty()).then(|| fixed.trim_start_matches('#').trim().to_string())
3093 }
3094
3095 #[test]
3096 fn test_restart_after_capitalizes_the_word_following_a_boundary() {
3097 let rule = restart_rule(&[":"]);
3098 assert_eq!(
3099 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3100 Some("Requirement 1: Struct to logger slice conversion")
3101 );
3102 }
3103
3104 #[test]
3105 fn test_restart_after_defaults_to_no_boundaries() {
3106 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3108 assert_eq!(
3109 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3110 Some("Requirement 1: struct to logger slice conversion")
3111 );
3112 }
3113
3114 #[test]
3115 fn test_restart_after_only_honors_configured_punctuation() {
3116 let rule = restart_rule(&[":"]);
3118 assert_eq!(
3119 suggested(&rule, "# Design - Data Model Overview\n").as_deref(),
3120 Some("Design - data model overview")
3121 );
3122 assert_eq!(
3123 suggested(&rule, "# Setup; Then Run\n").as_deref(),
3124 Some("Setup; then run")
3125 );
3126
3127 let rule = restart_rule(&[";", "\u{2014}"]);
3128 assert_eq!(
3129 suggested(&rule, "# Setup; Then Run\n").as_deref(),
3130 Some("Setup; Then run")
3131 );
3132 assert_eq!(
3133 suggested(&rule, "# Part One \u{2014} The Big Idea\n").as_deref(),
3134 Some("Part one \u{2014} The big idea")
3135 );
3136 }
3137
3138 #[test]
3139 fn test_restart_after_matches_only_at_the_end_of_a_word() {
3140 let rule = restart_rule(&["-", ":"]);
3143 assert_eq!(
3144 suggested(&rule, "# Ports: Well-Known Ports Explained\n").as_deref(),
3145 Some("Ports: Well-Known ports explained")
3146 );
3147 assert_eq!(
3148 suggested(&rule, "# See https://example.com/A/B For Details\n").as_deref(),
3149 Some("See https://example.com/A/B for details")
3150 );
3151 }
3152
3153 #[test]
3154 fn test_restart_after_a_trailing_boundary_is_a_no_op() {
3155 let rule = restart_rule(&[":"]);
3156 assert_eq!(suggested(&rule, "# Setup:\n"), None);
3157 }
3158
3159 #[test]
3160 fn test_restart_after_does_not_override_preserved_words() {
3161 let rule = restart_rule(&[":"]);
3164 assert_eq!(
3165 suggested(&rule, "# Devices: iPhone And Android\n").as_deref(),
3166 Some("Devices: iPhone and android")
3167 );
3168
3169 let config = MD063Config {
3170 enabled: true,
3171 style: HeadingCapStyle::SentenceCase,
3172 sentence_case_restart_after: vec![":".to_string()],
3173 ignore_words: vec!["kubectl".to_string()],
3174 preserve_cased_words: false,
3175 ..Default::default()
3176 };
3177 let rule = MD063HeadingCapitalization::from_config_struct(config);
3178 assert_eq!(
3179 suggested(&rule, "# Tools: kubectl And Helm\n").as_deref(),
3180 Some("Tools: kubectl and helm")
3181 );
3182 }
3183
3184 #[test]
3185 fn test_restart_after_keeps_md044_canonical_forms() {
3186 let config = MD063Config {
3187 enabled: true,
3188 style: HeadingCapStyle::SentenceCase,
3189 sentence_case_restart_after: vec![":".to_string()],
3190 ..Default::default()
3191 };
3192 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3193 rule.proper_names = vec!["GitHub".to_string()];
3194
3195 assert_eq!(
3197 suggested(&rule, "# Docs: github Actions Guide\n").as_deref(),
3198 Some("Docs: GitHub actions guide")
3199 );
3200 assert_eq!(suggested(&rule, "# Docs: GitHub actions guide\n"), None);
3201 }
3202
3203 #[test]
3204 fn test_restart_after_carries_across_segments() {
3205 let rule = restart_rule(&[":"]);
3208 assert_eq!(
3209 suggested(
3210 &rule,
3211 "# Overview: [Some Link Here](https://example.com) Trailing Words\n"
3212 )
3213 .as_deref(),
3214 Some("Overview: [Some link here](https://example.com) trailing words")
3215 );
3216 assert_eq!(
3217 suggested(&rule, "# Overview: `code` Then More Words\n").as_deref(),
3218 Some("Overview: `code` then more words")
3219 );
3220 }
3221
3222 #[test]
3223 fn test_restart_after_ends_a_sentence_at_the_end_of_link_text() {
3224 let rule = restart_rule(&[":"]);
3228 assert_eq!(
3229 suggested(&rule, "# Topic [See:](https://example.com) More Words\n").as_deref(),
3230 Some("Topic [see:](https://example.com) More words")
3231 );
3232
3233 assert_eq!(
3235 suggested(&rule, "# Topic [See](https://example.com) More Words\n").as_deref(),
3236 Some("Topic [see](https://example.com) more words")
3237 );
3238 }
3239
3240 #[test]
3241 fn test_restart_after_ignores_boundaries_inside_opaque_segments() {
3242 let rule = restart_rule(&[":"]);
3245 for content in [
3246 "# Topic `see:` More Words\n",
3247 "# Topic  More Words\n",
3248 "# Topic <span title=\"x:\">y</span> More Words\n",
3249 ] {
3250 let fixed = suggested(&rule, content).expect("heading should be rewritten");
3251 assert!(
3252 fixed.ends_with("more words"),
3253 "opaque segment restarted the sentence in {content:?}: {fixed}"
3254 );
3255 }
3256 }
3257
3258 #[test]
3259 fn test_restart_after_leaves_a_leading_link_mid_sentence() {
3260 for rule in [restart_rule(&[]), restart_rule(&[":"])] {
3263 assert_eq!(
3264 suggested(&rule, "# [Some Link Here](https://example.com) Trailing Words\n").as_deref(),
3265 Some("[some link here](https://example.com) trailing words")
3266 );
3267 }
3268 }
3269
3270 #[test]
3271 fn test_restart_after_fix_is_idempotent() {
3272 let rule = restart_rule(&[":", ";", "-", "\u{2014}"]);
3273 for content in [
3274 "# Requirement 1: Struct to Logger Slice Conversion\n",
3275 "# Ports: Well-Known Ports Explained\n",
3276 "# Devices: iPhone And Android\n",
3277 "# Overview: [Some Link Here](https://example.com) Trailing Words\n",
3278 "# Setup:\n",
3279 ] {
3280 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3281 let once = rule.fix(&ctx).unwrap();
3282 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
3283 assert_eq!(rule.fix(&ctx).unwrap(), once, "fix is not idempotent for {content:?}");
3284 }
3285 }
3286
3287 #[test]
3288 fn test_restart_after_ignores_empty_boundary_entries() {
3289 let rule = restart_rule(&[""]);
3291 assert_eq!(
3292 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3293 Some("Requirement 1: struct to logger slice conversion")
3294 );
3295 }
3296}