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