1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
15use crate::utils::mdg;
16use crate::utils::range_utils::byte_to_char_count;
17use regex::Regex;
18use std::collections::HashSet;
19use std::sync::LazyLock;
20
21mod md063_config;
22pub(super) use md063_config::{HeadingCapStyle, MD063Config};
23
24static INLINE_CODE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`+[^`]+`+").unwrap());
26
27static LINK_REGEX: LazyLock<Regex> =
32 LazyLock::new(|| Regex::new(r"\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)|\[([^\]]*)\]\[[^\]]*\]").unwrap());
33
34static HTML_TAG_REGEX: LazyLock<Regex> = LazyLock::new(|| {
39 let tags = "kbd|abbr|code|span|sub|sup|mark|cite|dfn|var|samp|small|strong|em|b|i|u|s|q|br|wbr";
41 let pattern = format!(r"<({tags})(?:\s[^>]*)?>.*?</({tags})>|<({tags})(?:\s[^>]*)?\s*/?>");
42 Regex::new(&pattern).unwrap()
43});
44
45static CUSTOM_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*\{#[^}]+\}\s*$").unwrap());
47
48#[derive(Debug, Clone)]
50enum HeadingSegment {
51 Text(String),
53 Code(String),
55 Link {
57 full: String,
58 text_start: usize,
59 text_end: usize,
60 },
61 Html(String),
63 Image(String),
66}
67
68#[derive(Clone)]
70pub struct MD063HeadingCapitalization {
71 config: MD063Config,
72 lowercase_set: HashSet<String>,
73 proper_names: Vec<String>,
76}
77
78impl Default for MD063HeadingCapitalization {
79 fn default() -> Self {
80 Self::new()
81 }
82}
83
84impl MD063HeadingCapitalization {
85 pub fn new() -> Self {
86 let config = MD063Config::default();
87 let lowercase_set = config.lowercase_words.iter().cloned().collect();
88 Self {
89 config,
90 lowercase_set,
91 proper_names: Vec::new(),
92 }
93 }
94
95 pub fn from_config_struct(config: MD063Config) -> Self {
96 let lowercase_set = config.lowercase_words.iter().cloned().collect();
97 Self {
98 config,
99 lowercase_set,
100 proper_names: Vec::new(),
101 }
102 }
103
104 fn match_case_insensitive_at(text: &str, start: usize, pattern_lower: &str) -> Option<usize> {
111 if start > text.len() || !text.is_char_boundary(start) || pattern_lower.is_empty() {
112 return None;
113 }
114
115 let mut matched_bytes = 0;
116
117 for (offset, ch) in text[start..].char_indices() {
118 if matched_bytes >= pattern_lower.len() {
119 break;
120 }
121
122 let lowered: String = ch.to_lowercase().collect();
123 if !pattern_lower[matched_bytes..].starts_with(&lowered) {
124 return None;
125 }
126
127 matched_bytes += lowered.len();
128
129 if matched_bytes == pattern_lower.len() {
130 return Some(start + offset + ch.len_utf8());
131 }
132 }
133
134 None
135 }
136
137 fn find_case_insensitive_match(text: &str, pattern_lower: &str, search_start: usize) -> Option<(usize, usize)> {
140 if pattern_lower.is_empty() || search_start >= text.len() || !text.is_char_boundary(search_start) {
141 return None;
142 }
143
144 for (offset, _) in text[search_start..].char_indices() {
145 let start = search_start + offset;
146 if let Some(end) = Self::match_case_insensitive_at(text, start, pattern_lower) {
147 return Some((start, end));
148 }
149 }
150
151 None
152 }
153
154 fn proper_name_canonical_forms(&self, text: &str) -> std::collections::HashMap<usize, &str> {
160 let mut map = std::collections::HashMap::new();
161
162 for name in &self.proper_names {
163 if name.is_empty() {
164 continue;
165 }
166 let name_lower = name.to_lowercase();
167 let canonical_words: Vec<&str> = name.split_whitespace().collect();
168 if canonical_words.is_empty() {
169 continue;
170 }
171 let mut search_start = 0;
172
173 while search_start < text.len() {
174 let Some((abs_pos, end_pos)) = Self::find_case_insensitive_match(text, &name_lower, search_start)
175 else {
176 break;
177 };
178
179 let before_ok = abs_pos == 0 || !text[..abs_pos].chars().last().is_some_and(char::is_alphanumeric);
181 let after_ok =
182 end_pos >= text.len() || !text[end_pos..].chars().next().is_some_and(char::is_alphanumeric);
183
184 if before_ok && after_ok {
185 let text_slice = &text[abs_pos..end_pos];
189 let mut word_idx = 0;
190 let mut slice_offset = 0;
191
192 for text_word in text_slice.split_whitespace() {
193 if let Some(w_rel) = text_slice[slice_offset..].find(text_word) {
194 let word_abs = abs_pos + slice_offset + w_rel;
195 if let Some(&canonical_word) = canonical_words.get(word_idx) {
196 map.insert(word_abs, canonical_word);
197 }
198 slice_offset += w_rel + text_word.len();
199 word_idx += 1;
200 }
201 }
202 }
203
204 search_start = abs_pos + text[abs_pos..].chars().next().map_or(1, char::len_utf8);
207 }
208 }
209
210 map
211 }
212
213 fn has_internal_capitals(&self, word: &str) -> bool {
215 let chars: Vec<char> = word.chars().collect();
216 if chars.len() < 2 {
217 return false;
218 }
219
220 let first = chars[0];
221 let rest = &chars[1..];
222 let has_upper_in_rest = rest.iter().any(|c| c.is_uppercase());
223 let has_lower_in_rest = rest.iter().any(|c| c.is_lowercase());
224
225 if has_upper_in_rest && has_lower_in_rest {
227 return true;
228 }
229
230 if first.is_lowercase() && has_upper_in_rest {
232 return true;
233 }
234
235 false
236 }
237
238 fn is_all_caps_acronym(&self, word: &str) -> bool {
242 if word.len() < 2 {
244 return false;
245 }
246
247 let mut consecutive_upper = 0;
248 let mut max_consecutive = 0;
249
250 for c in word.chars() {
251 if c.is_uppercase() {
252 consecutive_upper += 1;
253 max_consecutive = max_consecutive.max(consecutive_upper);
254 } else if c.is_lowercase() {
255 return false;
257 } else {
258 consecutive_upper = 0;
260 }
261 }
262
263 max_consecutive >= 2
265 }
266
267 fn should_preserve_word(&self, word: &str) -> bool {
269 if self.config.ignore_words.iter().any(|w| w == word) {
271 return true;
272 }
273
274 let is_ordinal = Self::is_numeric_ordinal(word);
280
281 if !is_ordinal {
282 if self.config.preserve_cased_words && self.has_internal_capitals(word) {
284 return true;
285 }
286
287 if self.config.preserve_cased_words && self.is_all_caps_acronym(word) {
289 return true;
290 }
291 }
292
293 if self.is_caret_notation(word) {
295 return true;
296 }
297
298 false
299 }
300
301 fn is_numeric_ordinal(word: &str) -> bool {
309 let bytes = word.as_bytes();
310
311 let alpha_start = match bytes.iter().position(|&b| !b.is_ascii_digit()) {
313 Some(pos) if pos > 0 => pos,
314 _ => return false,
315 };
316
317 let alpha_end = bytes[alpha_start..]
319 .iter()
320 .position(|b| !b.is_ascii_alphabetic())
321 .map_or(bytes.len(), |p| alpha_start + p);
322
323 let suffix = &word[alpha_start..alpha_end];
324 matches!(suffix.to_ascii_lowercase().as_str(), "st" | "nd" | "rd" | "th")
325 }
326
327 fn is_caret_notation(&self, word: &str) -> bool {
329 let chars: Vec<char> = word.chars().collect();
330 if chars.len() >= 2 && chars[0] == '^' {
332 let second = chars[1];
333 if second.is_ascii_uppercase() || "@[\\]^_".contains(second) {
335 return true;
336 }
337 }
338 false
339 }
340
341 fn is_lowercase_word(&self, word: &str) -> bool {
343 self.lowercase_set.contains(&word.to_lowercase())
344 }
345
346 fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
348 if word.is_empty() {
349 return word.to_string();
350 }
351
352 if self.should_preserve_word(word) {
354 return word.to_string();
355 }
356
357 if is_first || is_last {
359 return self.capitalize_first(word);
360 }
361
362 if self.is_lowercase_word(word) {
364 return Self::lowercase_preserving_composition(word);
365 }
366
367 self.capitalize_first(word)
369 }
370
371 fn apply_canonical_form_to_word(word: &str, canonical: &str) -> String {
374 let canonical_lower = canonical.to_lowercase();
375 if canonical_lower.is_empty() {
376 return canonical.to_string();
377 }
378
379 if let Some(end_pos) = Self::match_case_insensitive_at(word, 0, &canonical_lower) {
380 let mut out = String::with_capacity(canonical.len() + word.len().saturating_sub(end_pos));
381 out.push_str(canonical);
382 out.push_str(&word[end_pos..]);
383 out
384 } else {
385 canonical.to_string()
386 }
387 }
388
389 fn capitalize_first(&self, word: &str) -> String {
391 if word.is_empty() {
392 return String::new();
393 }
394
395 let first_alpha_pos = word.find(|c: char| c.is_alphabetic());
397 let Some(pos) = first_alpha_pos else {
398 return word.to_string();
399 };
400
401 let prefix = &word[..pos];
402 let suffix = &word[pos..];
403
404 if Self::is_numeric_ordinal(word) {
407 let suffix_lower = Self::lowercase_preserving_composition(suffix);
408 return format!("{prefix}{suffix_lower}");
409 }
410
411 let mut chars = suffix.chars();
412 let first = chars.next().unwrap();
413 let first_upper = Self::uppercase_preserving_composition(&first.to_string());
416 let rest: String = chars.collect();
417 let rest_lower = Self::lowercase_preserving_composition(&rest);
418 format!("{prefix}{first_upper}{rest_lower}")
419 }
420
421 fn lowercase_preserving_composition(s: &str) -> String {
424 let mut result = String::with_capacity(s.len());
425 for c in s.chars() {
426 let lower: String = c.to_lowercase().collect();
427 if lower.chars().count() == 1 {
428 result.push_str(&lower);
429 } else {
430 result.push(c);
432 }
433 }
434 result
435 }
436
437 fn uppercase_preserving_composition(s: &str) -> String {
442 let mut result = String::with_capacity(s.len());
443 for c in s.chars() {
444 let upper: String = c.to_uppercase().collect();
445 if upper.chars().count() == 1 {
446 result.push_str(&upper);
447 } else {
448 result.push(c);
450 }
451 }
452 result
453 }
454
455 fn apply_title_case(&self, text: &str) -> String {
459 let canonical_forms = self.proper_name_canonical_forms(text);
460
461 let original_words: Vec<&str> = text.split_whitespace().collect();
462 let total_words = original_words.len();
463
464 let mut word_positions: Vec<usize> = Vec::with_capacity(original_words.len());
467 let mut pos = 0;
468 for word in &original_words {
469 if let Some(rel) = text[pos..].find(word) {
470 word_positions.push(pos + rel);
471 pos = pos + rel + word.len();
472 } else {
473 word_positions.push(usize::MAX);
474 }
475 }
476
477 let result_words: Vec<String> = original_words
478 .iter()
479 .enumerate()
480 .map(|(i, word)| {
481 let after_period = i > 0 && original_words[i - 1].ends_with('.');
482 let is_first = i == 0 || after_period;
483 let is_last = i == total_words - 1;
484
485 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
487 return Self::apply_canonical_form_to_word(word, canonical);
488 }
489
490 if self.should_preserve_word(word) {
492 return (*word).to_string();
493 }
494
495 if word.contains('-') {
497 return self.handle_hyphenated_word(word, is_first, is_last);
498 }
499
500 self.title_case_word(word, is_first, is_last)
501 })
502 .collect();
503
504 result_words.join(" ")
505 }
506
507 fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
509 let parts: Vec<&str> = word.split('-').collect();
510 let total_parts = parts.len();
511
512 let result_parts: Vec<String> = parts
513 .iter()
514 .enumerate()
515 .map(|(i, part)| {
516 let part_is_first = is_first && i == 0;
518 let part_is_last = is_last && i == total_parts - 1;
519 self.title_case_word(part, part_is_first, part_is_last)
520 })
521 .collect();
522
523 result_parts.join("-")
524 }
525
526 fn ends_sentence(&self, word: &str) -> bool {
532 self.config
533 .sentence_case_restart_after
534 .iter()
535 .any(|boundary| !boundary.is_empty() && word.ends_with(boundary.as_str()))
536 }
537
538 fn apply_sentence_case_from(&self, text: &str, starts_sentence: bool) -> String {
542 if text.is_empty() {
543 return text.to_string();
544 }
545
546 let canonical_forms = self.proper_name_canonical_forms(text);
547 let mut result = String::new();
548 let mut current_pos = 0;
549 let mut at_sentence_start = starts_sentence;
550
551 for word in text.split_whitespace() {
553 if let Some(pos) = text[current_pos..].find(word) {
554 let abs_pos = current_pos + pos;
555
556 result.push_str(&text[current_pos..abs_pos]);
558
559 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
562 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
563 } else if at_sentence_start {
564 if self.should_preserve_word(word) {
566 result.push_str(word);
568 } else {
569 let mut chars = word.chars();
571 if let Some(first) = chars.next() {
572 result.push_str(&Self::uppercase_preserving_composition(&first.to_string()));
573 let rest: String = chars.collect();
574 result.push_str(&Self::lowercase_preserving_composition(&rest));
575 }
576 }
577 } else {
578 if self.should_preserve_word(word) {
580 result.push_str(word);
581 } else {
582 result.push_str(&Self::lowercase_preserving_composition(word));
583 }
584 }
585
586 at_sentence_start = self.ends_sentence(word);
587 current_pos = abs_pos + word.len();
588 }
589 }
590
591 if current_pos < text.len() {
593 result.push_str(&text[current_pos..]);
594 }
595
596 result
597 }
598
599 fn apply_all_caps(&self, text: &str) -> String {
601 if text.is_empty() {
602 return text.to_string();
603 }
604
605 let canonical_forms = self.proper_name_canonical_forms(text);
606 let mut result = String::new();
607 let mut current_pos = 0;
608
609 for word in text.split_whitespace() {
611 if let Some(pos) = text[current_pos..].find(word) {
612 let abs_pos = current_pos + pos;
613
614 result.push_str(&text[current_pos..abs_pos]);
616
617 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
620 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
621 } else if self.should_preserve_word(word) {
622 result.push_str(word);
623 } else {
624 result.push_str(&Self::uppercase_preserving_composition(word));
625 }
626
627 current_pos = abs_pos + word.len();
628 }
629 }
630
631 if current_pos < text.len() {
633 result.push_str(&text[current_pos..]);
634 }
635
636 result
637 }
638
639 fn parse_segments(&self, text: &str) -> Vec<HeadingSegment> {
641 let mut segments = Vec::new();
642 let mut last_end = 0;
643
644 let mut special_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
646
647 for mat in INLINE_CODE_REGEX.find_iter(text) {
649 special_regions.push((mat.start(), mat.end(), HeadingSegment::Code(mat.as_str().to_string())));
650 }
651
652 for caps in LINK_REGEX.captures_iter(text) {
654 let full_match = caps.get(0).unwrap();
655
656 if full_match.start() >= 1 && text.as_bytes()[full_match.start() - 1] == b'!' {
660 let region_start = full_match.start() - 1;
661 special_regions.push((
662 region_start,
663 full_match.end(),
664 HeadingSegment::Image(text[region_start..full_match.end()].to_string()),
665 ));
666 continue;
667 }
668
669 let text_match = caps.get(1).or_else(|| caps.get(2));
670
671 if let Some(text_m) = text_match {
672 special_regions.push((
673 full_match.start(),
674 full_match.end(),
675 HeadingSegment::Link {
676 full: full_match.as_str().to_string(),
677 text_start: text_m.start() - full_match.start(),
678 text_end: text_m.end() - full_match.start(),
679 },
680 ));
681 }
682 }
683
684 for mat in HTML_TAG_REGEX.find_iter(text) {
686 special_regions.push((mat.start(), mat.end(), HeadingSegment::Html(mat.as_str().to_string())));
687 }
688
689 special_regions.sort_by_key(|(start, _, _)| *start);
691
692 let mut filtered_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
695 for region in special_regions {
696 let overlaps = filtered_regions.iter().any(|(s, e, _)| region.0 < *e && region.1 > *s);
697 if !overlaps {
698 filtered_regions.push(region);
699 }
700 }
701
702 for (start, end, segment) in filtered_regions {
704 if start > last_end {
706 let text_segment = &text[last_end..start];
707 if !text_segment.is_empty() {
708 segments.push(HeadingSegment::Text(text_segment.to_string()));
709 }
710 }
711 segments.push(segment);
712 last_end = end;
713 }
714
715 if last_end < text.len() {
717 let remaining = &text[last_end..];
718 if !remaining.is_empty() {
719 segments.push(HeadingSegment::Text(remaining.to_string()));
720 }
721 }
722
723 if segments.is_empty() && !text.is_empty() {
725 segments.push(HeadingSegment::Text(text.to_string()));
726 }
727
728 segments
729 }
730
731 fn apply_capitalization(&self, text: &str, flavor: crate::config::MarkdownFlavor) -> String {
733 let (main_text, custom_id) = if let Some(mat) = CUSTOM_ID_REGEX.find(text) {
735 (&text[..mat.start()], Some(mat.as_str()))
736 } else {
737 (text, None)
738 };
739
740 let (keyword, main_text) = if flavor == crate::config::MarkdownFlavor::MDG {
747 mdg::keyword_split(main_text).unwrap_or(("", main_text))
748 } else {
749 ("", main_text)
750 };
751
752 let segments = self.parse_segments(main_text);
754
755 let text_segments: Vec<usize> = segments
757 .iter()
758 .enumerate()
759 .filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
760 .collect();
761
762 let first_segment_is_text = segments.first().is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
766
767 let last_segment_is_text = segments.last().is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
771
772 let mut result_parts: Vec<String> = Vec::new();
774
775 let mut at_sentence_start = first_segment_is_text;
779
780 for (i, segment) in segments.iter().enumerate() {
781 at_sentence_start = match segment {
786 HeadingSegment::Text(t) => {
787 let is_first_text = text_segments.first() == Some(&i);
788 let is_last_text = text_segments.last() == Some(&i) && last_segment_is_text;
792
793 let capitalized = match self.config.style {
794 HeadingCapStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
795 HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(t, at_sentence_start),
796 HeadingCapStyle::AllCaps => self.apply_all_caps(t),
797 };
798 let ends_sentence = self.ends_sentence(capitalized.trim_end());
799 result_parts.push(capitalized);
800 ends_sentence
801 }
802 HeadingSegment::Code(c) => {
803 result_parts.push(c.clone());
804 false
805 }
806 HeadingSegment::Link {
807 full,
808 text_start,
809 text_end,
810 } => {
811 let link_text = &full[*text_start..*text_end];
813 let capitalized_text = match self.config.style {
814 HeadingCapStyle::TitleCase => self.apply_title_case(link_text),
815 HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(link_text, at_sentence_start),
818 HeadingCapStyle::AllCaps => self.apply_all_caps(link_text),
819 };
820 let ends_sentence = self.ends_sentence(capitalized_text.trim_end());
823
824 let mut new_link = String::new();
825 new_link.push_str(&full[..*text_start]);
826 new_link.push_str(&capitalized_text);
827 new_link.push_str(&full[*text_end..]);
828 result_parts.push(new_link);
829 ends_sentence
830 }
831 HeadingSegment::Html(h) => {
832 result_parts.push(h.clone());
834 false
835 }
836 HeadingSegment::Image(img) => {
837 result_parts.push(img.clone());
839 false
840 }
841 };
842 }
843
844 let mut result = String::with_capacity(text.len());
845 result.push_str(keyword);
846 result.push_str(&result_parts.join(""));
847
848 if let Some(id) = custom_id {
850 result.push_str(id);
851 }
852
853 result
854 }
855
856 fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
858 let canonical_forms = self.proper_name_canonical_forms(text);
859 let words: Vec<&str> = text.split_whitespace().collect();
860 let total_words = words.len();
861
862 if total_words == 0 {
863 return text.to_string();
864 }
865
866 let mut word_positions: Vec<usize> = Vec::with_capacity(words.len());
869 let mut pos = 0;
870 for word in &words {
871 if let Some(rel) = text[pos..].find(word) {
872 word_positions.push(pos + rel);
873 pos = pos + rel + word.len();
874 } else {
875 word_positions.push(usize::MAX);
876 }
877 }
878
879 let result_words: Vec<String> = words
880 .iter()
881 .enumerate()
882 .map(|(i, word)| {
883 let after_period = i > 0 && words[i - 1].ends_with('.');
884 let is_first = (is_first_segment && i == 0) || after_period;
885 let is_last = is_last_segment && i == total_words - 1;
886
887 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
889 return Self::apply_canonical_form_to_word(word, canonical);
890 }
891
892 if word.contains('-') {
894 return self.handle_hyphenated_word(word, is_first, is_last);
895 }
896
897 self.title_case_word(word, is_first, is_last)
898 })
899 .collect();
900
901 let mut result = String::new();
903 let mut word_iter = result_words.iter();
904 let mut in_word = false;
905
906 for c in text.chars() {
907 if c.is_whitespace() {
908 if in_word {
909 in_word = false;
910 }
911 result.push(c);
912 } else if !in_word {
913 if let Some(word) = word_iter.next() {
914 result.push_str(word);
915 }
916 in_word = true;
917 }
918 }
919
920 result
921 }
922
923 fn fix_atx_heading(
925 &self,
926 _line: &str,
927 heading: &crate::lint_context::HeadingInfo,
928 flavor: crate::config::MarkdownFlavor,
929 ) -> String {
930 let indent = " ".repeat(heading.marker_column);
932 let hashes = "#".repeat(heading.level as usize);
933
934 let fixed_text = self.apply_capitalization(&heading.raw_text, flavor);
936
937 let closing = &heading.closing_sequence;
939 if heading.has_closing_sequence {
940 format!("{indent}{hashes} {fixed_text} {closing}")
941 } else {
942 format!("{indent}{hashes} {fixed_text}")
943 }
944 }
945
946 fn fix_setext_heading(
948 &self,
949 line: &str,
950 heading: &crate::lint_context::HeadingInfo,
951 flavor: crate::config::MarkdownFlavor,
952 ) -> String {
953 let fixed_text = self.apply_capitalization(&heading.raw_text, flavor);
955
956 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
958
959 format!("{leading_ws}{fixed_text}")
960 }
961}
962
963impl Rule for MD063HeadingCapitalization {
964 fn name(&self) -> &'static str {
965 "MD063"
966 }
967
968 fn description(&self) -> &'static str {
969 "Heading capitalization"
970 }
971
972 fn category(&self) -> RuleCategory {
973 RuleCategory::Heading
974 }
975
976 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
977 !ctx.likely_has_headings() || !ctx.lines.iter().any(|line| line.heading.is_some())
978 }
979
980 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
981 let content = ctx.content;
982
983 if content.is_empty() {
984 return Ok(Vec::new());
985 }
986
987 let mut warnings = Vec::new();
988
989 for (line_num, line_info) in ctx.lines.iter().enumerate() {
990 if let Some(heading) = &line_info.heading {
991 if heading.level < self.config.min_level || heading.level > self.config.max_level {
993 continue;
994 }
995
996 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
998 continue;
999 }
1000
1001 if !heading.is_valid {
1003 continue;
1004 }
1005
1006 let original_text = &heading.raw_text;
1008 let fixed_text = self.apply_capitalization(original_text, ctx.flavor);
1009
1010 if original_text != &fixed_text {
1011 let line = line_info.content(ctx.content);
1012 let style_name = match self.config.style {
1013 HeadingCapStyle::TitleCase => "title case",
1014 HeadingCapStyle::SentenceCase => "sentence case",
1015 HeadingCapStyle::AllCaps => "ALL CAPS",
1016 };
1017
1018 warnings.push(LintWarning {
1019 rule_name: Some(self.name().to_string()),
1020 line: line_num + 1,
1021 column: byte_to_char_count(line, heading.content_column),
1022 end_line: line_num + 1,
1023 end_column: byte_to_char_count(line, heading.content_column) + original_text.chars().count(),
1024 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1025 severity: Severity::Warning,
1026 fix: Some(Fix::new(
1027 ctx.line_content_byte_range(line_num + 1),
1028 match heading.style {
1029 crate::lint_context::HeadingStyle::ATX => {
1030 self.fix_atx_heading(line, heading, ctx.flavor)
1031 }
1032 _ => self.fix_setext_heading(line, heading, ctx.flavor),
1033 },
1034 )),
1035 });
1036 }
1037 }
1038 }
1039
1040 Ok(warnings)
1041 }
1042
1043 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1044 let content = ctx.content;
1045
1046 if content.is_empty() {
1047 return Ok(content.to_string());
1048 }
1049
1050 let lines = ctx.raw_lines();
1051 let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1052
1053 for (line_num, line_info) in ctx.lines.iter().enumerate() {
1054 if ctx.is_rule_disabled(self.name(), line_num + 1) {
1056 continue;
1057 }
1058
1059 if let Some(heading) = &line_info.heading {
1060 if heading.level < self.config.min_level || heading.level > self.config.max_level {
1062 continue;
1063 }
1064
1065 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
1067 continue;
1068 }
1069
1070 if !heading.is_valid {
1072 continue;
1073 }
1074
1075 let original_text = &heading.raw_text;
1076 let fixed_text = self.apply_capitalization(original_text, ctx.flavor);
1077
1078 if original_text != &fixed_text {
1079 let line = line_info.content(ctx.content);
1080 fixed_lines[line_num] = match heading.style {
1081 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading, ctx.flavor),
1082 _ => self.fix_setext_heading(line, heading, ctx.flavor),
1083 };
1084 }
1085 }
1086 }
1087
1088 let mut result = String::with_capacity(content.len());
1090 for (i, line) in fixed_lines.iter().enumerate() {
1091 result.push_str(line);
1092 if i < fixed_lines.len() - 1 || content.ends_with('\n') {
1093 result.push('\n');
1094 }
1095 }
1096
1097 Ok(result)
1098 }
1099
1100 fn as_any(&self) -> &dyn std::any::Any {
1101 self
1102 }
1103
1104 crate::impl_rule_config_sections!(MD063Config);
1105
1106 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1107 where
1108 Self: Sized,
1109 {
1110 let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
1111 let md044_config =
1112 crate::rule_config_serde::load_rule_config::<crate::rules::md044_proper_names::MD044Config>(config);
1113 let mut rule = Self::from_config_struct(rule_config);
1114 rule.proper_names = md044_config.names;
1115 Box::new(rule)
1116 }
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121 use super::*;
1122 use crate::lint_context::LintContext;
1123
1124 fn create_rule() -> MD063HeadingCapitalization {
1125 let config = MD063Config {
1126 enabled: true,
1127 ..Default::default()
1128 };
1129 MD063HeadingCapitalization::from_config_struct(config)
1130 }
1131
1132 fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
1133 let config = MD063Config {
1134 enabled: true,
1135 style,
1136 ..Default::default()
1137 };
1138 MD063HeadingCapitalization::from_config_struct(config)
1139 }
1140
1141 #[test]
1143 fn test_title_case_basic() {
1144 let rule = create_rule();
1145 let content = "# hello world\n";
1146 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1147 let result = rule.check(&ctx).unwrap();
1148 assert_eq!(result.len(), 1);
1149 assert!(result[0].message.contains("Hello World"));
1150 }
1151
1152 #[test]
1153 fn test_title_case_lowercase_words() {
1154 let rule = create_rule();
1155 let content = "# the quick brown fox\n";
1156 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1157 let result = rule.check(&ctx).unwrap();
1158 assert_eq!(result.len(), 1);
1159 assert!(result[0].message.contains("The Quick Brown Fox"));
1161 }
1162
1163 #[test]
1164 fn test_title_case_already_correct() {
1165 let rule = create_rule();
1166 let content = "# The Quick Brown Fox\n";
1167 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1168 let result = rule.check(&ctx).unwrap();
1169 assert!(result.is_empty(), "Already correct heading should not be flagged");
1170 }
1171
1172 #[test]
1173 fn test_title_case_hyphenated() {
1174 let rule = create_rule();
1175 let content = "# self-documenting code\n";
1176 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1177 let result = rule.check(&ctx).unwrap();
1178 assert_eq!(result.len(), 1);
1179 assert!(result[0].message.contains("Self-Documenting Code"));
1180 }
1181
1182 #[test]
1183 fn test_title_case_preserves_url_with_nested_parens() {
1184 let rule = create_rule();
1185 let content = "# guide for [the api](https://example.com/docs/v(2)beta)\n";
1187 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1188 let fixed = rule.fix(&ctx).unwrap();
1189 assert!(
1192 fixed.contains("https://example.com/docs/v(2)beta"),
1193 "URL with nested parens was corrupted: {fixed:?}"
1194 );
1195 }
1196
1197 #[test]
1198 fn test_title_case_does_not_recase_image_alt() {
1199 let rule = create_rule();
1200 let content = "# overview \n";
1201 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1202 let fixed = rule.fix(&ctx).unwrap();
1203 assert!(
1205 fixed.contains(""),
1206 "image alt text was modified: {fixed:?}"
1207 );
1208 assert!(
1209 fixed.contains("# Overview"),
1210 "surrounding prose should still be title-cased: {fixed:?}"
1211 );
1212 }
1213
1214 #[test]
1216 fn test_sentence_case_basic() {
1217 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1218 let content = "# The Quick Brown Fox\n";
1219 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1220 let result = rule.check(&ctx).unwrap();
1221 assert_eq!(result.len(), 1);
1222 assert!(result[0].message.contains("The quick brown fox"));
1223 }
1224
1225 #[test]
1226 fn test_sentence_case_already_correct() {
1227 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1228 let content = "# The quick brown fox\n";
1229 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1230 let result = rule.check(&ctx).unwrap();
1231 assert!(result.is_empty());
1232 }
1233
1234 #[test]
1236 fn test_all_caps_basic() {
1237 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1238 let content = "# hello world\n";
1239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1240 let result = rule.check(&ctx).unwrap();
1241 assert_eq!(result.len(), 1);
1242 assert!(result[0].message.contains("HELLO WORLD"));
1243 }
1244
1245 #[test]
1247 fn test_preserve_ignore_words() {
1248 let config = MD063Config {
1249 enabled: true,
1250 ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
1251 ..Default::default()
1252 };
1253 let rule = MD063HeadingCapitalization::from_config_struct(config);
1254
1255 let content = "# using iPhone on macOS\n";
1256 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1257 let result = rule.check(&ctx).unwrap();
1258 assert_eq!(result.len(), 1);
1259 assert!(result[0].message.contains("iPhone"));
1261 assert!(result[0].message.contains("macOS"));
1262 }
1263
1264 #[test]
1265 fn test_preserve_cased_words() {
1266 let rule = create_rule();
1267 let content = "# using GitHub actions\n";
1268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1269 let result = rule.check(&ctx).unwrap();
1270 assert_eq!(result.len(), 1);
1271 assert!(result[0].message.contains("GitHub"));
1273 }
1274
1275 #[test]
1277 fn test_inline_code_preserved() {
1278 let rule = create_rule();
1279 let content = "# using `const` in javascript\n";
1280 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1281 let result = rule.check(&ctx).unwrap();
1282 assert_eq!(result.len(), 1);
1283 assert!(result[0].message.contains("`const`"));
1285 assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
1286 }
1287
1288 #[test]
1290 fn test_level_filter() {
1291 let config = MD063Config {
1292 enabled: true,
1293 min_level: 2,
1294 max_level: 4,
1295 ..Default::default()
1296 };
1297 let rule = MD063HeadingCapitalization::from_config_struct(config);
1298
1299 let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
1300 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1301 let result = rule.check(&ctx).unwrap();
1302
1303 assert_eq!(result.len(), 2);
1305 assert_eq!(result[0].line, 2); assert_eq!(result[1].line, 3); }
1308
1309 #[test]
1311 fn test_fix_atx_heading() {
1312 let rule = create_rule();
1313 let content = "# hello world\n";
1314 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1315 let fixed = rule.fix(&ctx).unwrap();
1316 assert_eq!(fixed, "# Hello World\n");
1317 }
1318
1319 #[test]
1320 fn test_fix_multiple_headings() {
1321 let rule = create_rule();
1322 let content = "# first heading\n\n## second heading\n";
1323 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1324 let fixed = rule.fix(&ctx).unwrap();
1325 assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
1326 }
1327
1328 #[test]
1330 fn test_setext_heading() {
1331 let rule = create_rule();
1332 let content = "hello world\n============\n";
1333 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1334 let result = rule.check(&ctx).unwrap();
1335 assert_eq!(result.len(), 1);
1336 assert!(result[0].message.contains("Hello World"));
1337 }
1338
1339 #[test]
1341 fn test_custom_id_preserved() {
1342 let rule = create_rule();
1343 let content = "# getting started {#intro}\n";
1344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1345 let result = rule.check(&ctx).unwrap();
1346 assert_eq!(result.len(), 1);
1347 assert!(result[0].message.contains("{#intro}"));
1349 }
1350
1351 #[test]
1353 fn test_skip_obsidian_tags_not_headings() {
1354 let rule = create_rule();
1355
1356 let content = "# H1\n\n#tag\n";
1358 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1359 let result = rule.check(&ctx).unwrap();
1360 assert!(
1361 result.is_empty() || result.iter().all(|w| w.line != 3),
1362 "Obsidian tag #tag should not be treated as a heading: {result:?}"
1363 );
1364 }
1365
1366 #[test]
1367 fn test_skip_invalid_atx_headings_no_space() {
1368 let rule = create_rule();
1369
1370 let content = "#notaheading\n";
1372 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1373 let result = rule.check(&ctx).unwrap();
1374 assert!(
1375 result.is_empty(),
1376 "Invalid ATX heading without space should not be flagged: {result:?}"
1377 );
1378 }
1379
1380 #[test]
1381 fn test_fix_skips_obsidian_tags() {
1382 let rule = create_rule();
1383
1384 let content = "# hello world\n\n#tag\n";
1385 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1386 let fixed = rule.fix(&ctx).unwrap();
1387 assert!(fixed.contains("#tag"), "Fix should not modify Obsidian tag #tag");
1389 assert!(fixed.contains("# Hello World"), "Fix should still fix real headings");
1390 }
1391
1392 #[test]
1393 fn test_preserve_all_caps_acronyms() {
1394 let rule = create_rule();
1395 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1396
1397 let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
1399 assert_eq!(fixed, "# Using API in Production\n");
1400
1401 let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
1403 assert_eq!(fixed, "# API and GPU Integration\n");
1404
1405 let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
1407 assert_eq!(fixed, "# IO Performance Guide\n");
1408
1409 let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
1411 assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
1412 }
1413
1414 #[test]
1415 fn test_preserve_acronyms_in_hyphenated_words() {
1416 let rule = create_rule();
1417 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1418
1419 let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
1421 assert_eq!(fixed, "# API-Driven Architecture\n");
1422
1423 let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
1425 assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
1426 }
1427
1428 #[test]
1429 fn test_single_letters_not_treated_as_acronyms() {
1430 let rule = create_rule();
1431 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1432
1433 let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
1435 assert_eq!(fixed, "# I Am a Heading\n");
1436 }
1437
1438 #[test]
1439 fn test_lowercase_terms_need_ignore_words() {
1440 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1441
1442 let rule = create_rule();
1444 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1445 assert_eq!(fixed, "# Using Npm Packages\n");
1446
1447 let config = MD063Config {
1449 enabled: true,
1450 ignore_words: vec!["npm".to_string()],
1451 ..Default::default()
1452 };
1453 let rule = MD063HeadingCapitalization::from_config_struct(config);
1454 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1455 assert_eq!(fixed, "# Using npm Packages\n");
1456 }
1457
1458 #[test]
1459 fn test_acronyms_with_mixed_case_preserved() {
1460 let rule = create_rule();
1461 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1462
1463 let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
1465 assert_eq!(fixed, "# Using API with GitHub\n");
1466 }
1467
1468 #[test]
1469 fn test_real_world_acronyms() {
1470 let rule = create_rule();
1471 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1472
1473 let content = "# FFI bindings for CPU optimization\n";
1475 let fixed = rule.fix(&ctx(content)).unwrap();
1476 assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
1477
1478 let content = "# DOM manipulation and SSR rendering\n";
1479 let fixed = rule.fix(&ctx(content)).unwrap();
1480 assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
1481
1482 let content = "# CVE security and RNN models\n";
1483 let fixed = rule.fix(&ctx(content)).unwrap();
1484 assert_eq!(fixed, "# CVE Security and RNN Models\n");
1485 }
1486
1487 #[test]
1488 fn test_is_all_caps_acronym() {
1489 let rule = create_rule();
1490
1491 assert!(rule.is_all_caps_acronym("API"));
1493 assert!(rule.is_all_caps_acronym("IO"));
1494 assert!(rule.is_all_caps_acronym("GPU"));
1495 assert!(rule.is_all_caps_acronym("HTTP2")); assert!(!rule.is_all_caps_acronym("A"));
1499 assert!(!rule.is_all_caps_acronym("I"));
1500
1501 assert!(!rule.is_all_caps_acronym("Api"));
1503 assert!(!rule.is_all_caps_acronym("npm"));
1504 assert!(!rule.is_all_caps_acronym("iPhone"));
1505 }
1506
1507 #[test]
1508 fn test_sentence_case_ignore_words_first_word() {
1509 let config = MD063Config {
1510 enabled: true,
1511 style: HeadingCapStyle::SentenceCase,
1512 ignore_words: vec!["nvim".to_string()],
1513 ..Default::default()
1514 };
1515 let rule = MD063HeadingCapitalization::from_config_struct(config);
1516
1517 let content = "# nvim config\n";
1519 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1520 let result = rule.check(&ctx).unwrap();
1521 assert!(
1522 result.is_empty(),
1523 "nvim in ignore-words should not be flagged. Got: {result:?}"
1524 );
1525
1526 let fixed = rule.fix(&ctx).unwrap();
1528 assert_eq!(fixed, "# nvim config\n");
1529 }
1530
1531 #[test]
1532 fn test_sentence_case_ignore_words_not_first() {
1533 let config = MD063Config {
1534 enabled: true,
1535 style: HeadingCapStyle::SentenceCase,
1536 ignore_words: vec!["nvim".to_string()],
1537 ..Default::default()
1538 };
1539 let rule = MD063HeadingCapitalization::from_config_struct(config);
1540
1541 let content = "# Using nvim editor\n";
1543 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1544 let result = rule.check(&ctx).unwrap();
1545 assert!(
1546 result.is_empty(),
1547 "nvim in ignore-words should be preserved. Got: {result:?}"
1548 );
1549 }
1550
1551 #[test]
1552 fn test_preserve_cased_words_ios() {
1553 let config = MD063Config {
1554 enabled: true,
1555 style: HeadingCapStyle::SentenceCase,
1556 preserve_cased_words: true,
1557 ..Default::default()
1558 };
1559 let rule = MD063HeadingCapitalization::from_config_struct(config);
1560
1561 let content = "## This is iOS\n";
1563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1564 let result = rule.check(&ctx).unwrap();
1565 assert!(
1566 result.is_empty(),
1567 "iOS should be preserved with preserve-cased-words. Got: {result:?}"
1568 );
1569
1570 let fixed = rule.fix(&ctx).unwrap();
1572 assert_eq!(fixed, "## This is iOS\n");
1573 }
1574
1575 #[test]
1576 fn test_preserve_cased_words_ios_title_case() {
1577 let config = MD063Config {
1578 enabled: true,
1579 style: HeadingCapStyle::TitleCase,
1580 preserve_cased_words: true,
1581 ..Default::default()
1582 };
1583 let rule = MD063HeadingCapitalization::from_config_struct(config);
1584
1585 let content = "# developing for iOS\n";
1587 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1588 let fixed = rule.fix(&ctx).unwrap();
1589 assert_eq!(fixed, "# Developing for iOS\n");
1590 }
1591
1592 #[test]
1593 fn test_has_internal_capitals_ios() {
1594 let rule = create_rule();
1595
1596 assert!(
1598 rule.has_internal_capitals("iOS"),
1599 "iOS has mixed case (lowercase i, uppercase OS)"
1600 );
1601
1602 assert!(rule.has_internal_capitals("iPhone"));
1604 assert!(rule.has_internal_capitals("macOS"));
1605 assert!(rule.has_internal_capitals("GitHub"));
1606 assert!(rule.has_internal_capitals("JavaScript"));
1607 assert!(rule.has_internal_capitals("eBay"));
1608
1609 assert!(!rule.has_internal_capitals("API"));
1611 assert!(!rule.has_internal_capitals("GPU"));
1612
1613 assert!(!rule.has_internal_capitals("npm"));
1615 assert!(!rule.has_internal_capitals("config"));
1616
1617 assert!(!rule.has_internal_capitals("The"));
1619 assert!(!rule.has_internal_capitals("Hello"));
1620 }
1621
1622 #[test]
1623 fn test_lowercase_words_before_trailing_code() {
1624 let config = MD063Config {
1625 enabled: true,
1626 style: HeadingCapStyle::TitleCase,
1627 lowercase_words: vec![
1628 "a".to_string(),
1629 "an".to_string(),
1630 "and".to_string(),
1631 "at".to_string(),
1632 "but".to_string(),
1633 "by".to_string(),
1634 "for".to_string(),
1635 "from".to_string(),
1636 "into".to_string(),
1637 "nor".to_string(),
1638 "on".to_string(),
1639 "onto".to_string(),
1640 "or".to_string(),
1641 "the".to_string(),
1642 "to".to_string(),
1643 "upon".to_string(),
1644 "via".to_string(),
1645 "vs".to_string(),
1646 "with".to_string(),
1647 "without".to_string(),
1648 ],
1649 preserve_cased_words: true,
1650 ..Default::default()
1651 };
1652 let rule = MD063HeadingCapitalization::from_config_struct(config);
1653
1654 let content = "## subtitle with a `app`\n";
1659 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1660 let result = rule.check(&ctx).unwrap();
1661
1662 assert!(!result.is_empty(), "Should flag incorrect capitalization");
1664 let fixed = rule.fix(&ctx).unwrap();
1665 assert!(
1667 fixed.contains("with a `app`"),
1668 "Expected 'with a `app`' but got: {fixed:?}"
1669 );
1670 assert!(
1671 !fixed.contains("with A `app`"),
1672 "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
1673 );
1674 assert!(
1676 fixed.contains("Subtitle with a `app`"),
1677 "Expected 'Subtitle with a `app`' but got: {fixed:?}"
1678 );
1679 }
1680
1681 #[test]
1682 fn test_lowercase_words_preserved_before_trailing_code_variant() {
1683 let config = MD063Config {
1684 enabled: true,
1685 style: HeadingCapStyle::TitleCase,
1686 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1687 ..Default::default()
1688 };
1689 let rule = MD063HeadingCapitalization::from_config_struct(config);
1690
1691 let content = "## Title with the `code`\n";
1693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1694 let fixed = rule.fix(&ctx).unwrap();
1695 assert!(
1697 fixed.contains("with the `code`"),
1698 "Expected 'with the `code`' but got: {fixed:?}"
1699 );
1700 assert!(
1701 !fixed.contains("with The `code`"),
1702 "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
1703 );
1704 }
1705
1706 #[test]
1707 fn test_last_word_capitalized_when_no_trailing_code() {
1708 let config = MD063Config {
1711 enabled: true,
1712 style: HeadingCapStyle::TitleCase,
1713 lowercase_words: vec!["a".to_string(), "the".to_string()],
1714 ..Default::default()
1715 };
1716 let rule = MD063HeadingCapitalization::from_config_struct(config);
1717
1718 let content = "## title with a word\n";
1721 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1722 let fixed = rule.fix(&ctx).unwrap();
1723 assert!(
1725 fixed.contains("With a Word"),
1726 "Expected 'With a Word' but got: {fixed:?}"
1727 );
1728 }
1729
1730 #[test]
1731 fn test_multiple_lowercase_words_before_code() {
1732 let config = MD063Config {
1733 enabled: true,
1734 style: HeadingCapStyle::TitleCase,
1735 lowercase_words: vec![
1736 "a".to_string(),
1737 "the".to_string(),
1738 "with".to_string(),
1739 "for".to_string(),
1740 ],
1741 ..Default::default()
1742 };
1743 let rule = MD063HeadingCapitalization::from_config_struct(config);
1744
1745 let content = "## Guide for the `user`\n";
1747 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1748 let fixed = rule.fix(&ctx).unwrap();
1749 assert!(
1750 fixed.contains("for the `user`"),
1751 "Expected 'for the `user`' but got: {fixed:?}"
1752 );
1753 assert!(
1754 !fixed.contains("For The `user`"),
1755 "Should not capitalize lowercase words before code. Got: {fixed:?}"
1756 );
1757 }
1758
1759 #[test]
1760 fn test_code_in_middle_normal_rules_apply() {
1761 let config = MD063Config {
1762 enabled: true,
1763 style: HeadingCapStyle::TitleCase,
1764 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1765 ..Default::default()
1766 };
1767 let rule = MD063HeadingCapitalization::from_config_struct(config);
1768
1769 let content = "## Using `const` for the code\n";
1771 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1772 let fixed = rule.fix(&ctx).unwrap();
1773 assert!(
1775 fixed.contains("for the Code"),
1776 "Expected 'for the Code' but got: {fixed:?}"
1777 );
1778 }
1779
1780 #[test]
1781 fn test_link_at_end_same_as_code() {
1782 let config = MD063Config {
1783 enabled: true,
1784 style: HeadingCapStyle::TitleCase,
1785 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1786 ..Default::default()
1787 };
1788 let rule = MD063HeadingCapitalization::from_config_struct(config);
1789
1790 let content = "## Guide for the [link](./page.md)\n";
1792 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1793 let fixed = rule.fix(&ctx).unwrap();
1794 assert!(
1796 fixed.contains("for the [Link]"),
1797 "Expected 'for the [Link]' but got: {fixed:?}"
1798 );
1799 assert!(
1800 !fixed.contains("for The [Link]"),
1801 "Should not capitalize 'the' before link. Got: {fixed:?}"
1802 );
1803 }
1804
1805 #[test]
1806 fn test_multiple_code_segments() {
1807 let config = MD063Config {
1808 enabled: true,
1809 style: HeadingCapStyle::TitleCase,
1810 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1811 ..Default::default()
1812 };
1813 let rule = MD063HeadingCapitalization::from_config_struct(config);
1814
1815 let content = "## Using `const` with a `variable`\n";
1817 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1818 let fixed = rule.fix(&ctx).unwrap();
1819 assert!(
1821 fixed.contains("with a `variable`"),
1822 "Expected 'with a `variable`' but got: {fixed:?}"
1823 );
1824 assert!(
1825 !fixed.contains("with A `variable`"),
1826 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1827 );
1828 }
1829
1830 #[test]
1831 fn test_code_and_link_combination() {
1832 let config = MD063Config {
1833 enabled: true,
1834 style: HeadingCapStyle::TitleCase,
1835 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1836 ..Default::default()
1837 };
1838 let rule = MD063HeadingCapitalization::from_config_struct(config);
1839
1840 let content = "## Guide for the `code` [link](./page.md)\n";
1842 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1843 let fixed = rule.fix(&ctx).unwrap();
1844 assert!(
1846 fixed.contains("for the `code`"),
1847 "Expected 'for the `code`' but got: {fixed:?}"
1848 );
1849 }
1850
1851 #[test]
1852 fn test_text_after_code_capitalizes_last() {
1853 let config = MD063Config {
1854 enabled: true,
1855 style: HeadingCapStyle::TitleCase,
1856 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1857 ..Default::default()
1858 };
1859 let rule = MD063HeadingCapitalization::from_config_struct(config);
1860
1861 let content = "## Using `const` for the code\n";
1863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1864 let fixed = rule.fix(&ctx).unwrap();
1865 assert!(
1867 fixed.contains("for the Code"),
1868 "Expected 'for the Code' but got: {fixed:?}"
1869 );
1870 }
1871
1872 #[test]
1873 fn test_preserve_cased_words_with_trailing_code() {
1874 let config = MD063Config {
1875 enabled: true,
1876 style: HeadingCapStyle::TitleCase,
1877 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1878 preserve_cased_words: true,
1879 ..Default::default()
1880 };
1881 let rule = MD063HeadingCapitalization::from_config_struct(config);
1882
1883 let content = "## Guide for iOS `app`\n";
1885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1886 let fixed = rule.fix(&ctx).unwrap();
1887 assert!(
1889 fixed.contains("for iOS `app`"),
1890 "Expected 'for iOS `app`' but got: {fixed:?}"
1891 );
1892 assert!(
1893 !fixed.contains("For iOS `app`"),
1894 "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
1895 );
1896 }
1897
1898 #[test]
1899 fn test_ignore_words_with_trailing_code() {
1900 let config = MD063Config {
1901 enabled: true,
1902 style: HeadingCapStyle::TitleCase,
1903 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1904 ignore_words: vec!["npm".to_string()],
1905 ..Default::default()
1906 };
1907 let rule = MD063HeadingCapitalization::from_config_struct(config);
1908
1909 let content = "## Using npm with a `script`\n";
1911 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1912 let fixed = rule.fix(&ctx).unwrap();
1913 assert!(
1915 fixed.contains("npm with a `script`"),
1916 "Expected 'npm with a `script`' but got: {fixed:?}"
1917 );
1918 assert!(
1919 !fixed.contains("with A `script`"),
1920 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1921 );
1922 }
1923
1924 #[test]
1925 fn test_empty_text_segment_edge_case() {
1926 let config = MD063Config {
1927 enabled: true,
1928 style: HeadingCapStyle::TitleCase,
1929 lowercase_words: vec!["a".to_string(), "with".to_string()],
1930 ..Default::default()
1931 };
1932 let rule = MD063HeadingCapitalization::from_config_struct(config);
1933
1934 let content = "## `start` with a `end`\n";
1936 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1937 let fixed = rule.fix(&ctx).unwrap();
1938 assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
1941 assert!(
1942 !fixed.contains("A `end`"),
1943 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1944 );
1945 }
1946
1947 #[test]
1948 fn test_sentence_case_with_trailing_code() {
1949 let config = MD063Config {
1950 enabled: true,
1951 style: HeadingCapStyle::SentenceCase,
1952 lowercase_words: vec!["a".to_string(), "the".to_string()],
1953 ..Default::default()
1954 };
1955 let rule = MD063HeadingCapitalization::from_config_struct(config);
1956
1957 let content = "## guide for the `user`\n";
1959 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1960 let fixed = rule.fix(&ctx).unwrap();
1961 assert!(
1963 fixed.contains("Guide for the `user`"),
1964 "Expected 'Guide for the `user`' but got: {fixed:?}"
1965 );
1966 }
1967
1968 #[test]
1969 fn test_hyphenated_word_before_code() {
1970 let config = MD063Config {
1971 enabled: true,
1972 style: HeadingCapStyle::TitleCase,
1973 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1974 ..Default::default()
1975 };
1976 let rule = MD063HeadingCapitalization::from_config_struct(config);
1977
1978 let content = "## Self-contained with a `feature`\n";
1980 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1981 let fixed = rule.fix(&ctx).unwrap();
1982 assert!(
1984 fixed.contains("with a `feature`"),
1985 "Expected 'with a `feature`' but got: {fixed:?}"
1986 );
1987 }
1988
1989 #[test]
1994 fn test_sentence_case_code_at_start_basic() {
1995 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1997 let content = "# `rumdl` is a linter\n";
1998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1999 let result = rule.check(&ctx).unwrap();
2000 assert!(
2002 result.is_empty(),
2003 "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
2004 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2005 );
2006 }
2007
2008 #[test]
2009 fn test_sentence_case_code_at_start_incorrect_capitalization() {
2010 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2012 let content = "# `rumdl` Is a Linter\n";
2013 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2014 let result = rule.check(&ctx).unwrap();
2015 assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
2017 assert!(
2018 result[0].message.contains("`rumdl` is a linter"),
2019 "Should suggest lowercase after code. Got: {:?}",
2020 result[0].message
2021 );
2022 }
2023
2024 #[test]
2025 fn test_sentence_case_code_at_start_fix() {
2026 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2027 let content = "# `rumdl` Is A Linter\n";
2028 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2029 let fixed = rule.fix(&ctx).unwrap();
2030 assert!(
2031 fixed.contains("# `rumdl` is a linter"),
2032 "Should fix to lowercase after code. Got: {fixed:?}"
2033 );
2034 }
2035
2036 #[test]
2037 fn test_sentence_case_text_at_start_still_capitalizes() {
2038 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2040 let content = "# the quick brown fox\n";
2041 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2042 let result = rule.check(&ctx).unwrap();
2043 assert_eq!(result.len(), 1);
2044 assert!(
2045 result[0].message.contains("The quick brown fox"),
2046 "Text-first heading should capitalize first word. Got: {:?}",
2047 result[0].message
2048 );
2049 }
2050
2051 #[test]
2052 fn test_sentence_case_link_at_start() {
2053 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2055 let content = "# [api](api.md) reference guide\n";
2057 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2058 let result = rule.check(&ctx).unwrap();
2059 assert!(
2061 result.is_empty(),
2062 "Heading with link at start should not capitalize 'reference'. Got: {:?}",
2063 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2064 );
2065 }
2066
2067 #[test]
2068 fn test_sentence_case_link_preserves_acronyms() {
2069 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2071 let content = "# [API](api.md) Reference Guide\n";
2072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2073 let result = rule.check(&ctx).unwrap();
2074 assert_eq!(result.len(), 1);
2075 assert!(
2077 result[0].message.contains("[API](api.md) reference guide"),
2078 "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
2079 result[0].message
2080 );
2081 }
2082
2083 #[test]
2084 fn test_sentence_case_link_preserves_brand_names() {
2085 let config = MD063Config {
2087 enabled: true,
2088 style: HeadingCapStyle::SentenceCase,
2089 preserve_cased_words: true,
2090 ..Default::default()
2091 };
2092 let rule = MD063HeadingCapitalization::from_config_struct(config);
2093 let content = "# [iPhone](iphone.md) Features Guide\n";
2094 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2095 let result = rule.check(&ctx).unwrap();
2096 assert_eq!(result.len(), 1);
2097 assert!(
2099 result[0].message.contains("[iPhone](iphone.md) features guide"),
2100 "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
2101 result[0].message
2102 );
2103 }
2104
2105 #[test]
2106 fn test_sentence_case_link_lowercases_regular_words() {
2107 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2109 let content = "# [Documentation](docs.md) Reference\n";
2110 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2111 let result = rule.check(&ctx).unwrap();
2112 assert_eq!(result.len(), 1);
2113 assert!(
2115 result[0].message.contains("[documentation](docs.md) reference"),
2116 "Should lowercase regular link text. Got: {:?}",
2117 result[0].message
2118 );
2119 }
2120
2121 #[test]
2122 fn test_sentence_case_link_at_start_correct_already() {
2123 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2125 let content = "# [API](api.md) reference guide\n";
2126 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2127 let result = rule.check(&ctx).unwrap();
2128 assert!(
2129 result.is_empty(),
2130 "Correctly cased heading with link should not be flagged. Got: {:?}",
2131 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2132 );
2133 }
2134
2135 #[test]
2136 fn test_sentence_case_link_github_preserved() {
2137 let config = MD063Config {
2139 enabled: true,
2140 style: HeadingCapStyle::SentenceCase,
2141 preserve_cased_words: true,
2142 ..Default::default()
2143 };
2144 let rule = MD063HeadingCapitalization::from_config_struct(config);
2145 let content = "# [GitHub](gh.md) Repository Setup\n";
2146 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2147 let result = rule.check(&ctx).unwrap();
2148 assert_eq!(result.len(), 1);
2149 assert!(
2150 result[0].message.contains("[GitHub](gh.md) repository setup"),
2151 "Should preserve 'GitHub'. Got: {:?}",
2152 result[0].message
2153 );
2154 }
2155
2156 #[test]
2157 fn test_sentence_case_multiple_code_spans() {
2158 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2159 let content = "# `foo` and `bar` are methods\n";
2160 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2161 let result = rule.check(&ctx).unwrap();
2162 assert!(
2164 result.is_empty(),
2165 "Should not capitalize words between/after code spans. Got: {:?}",
2166 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2167 );
2168 }
2169
2170 #[test]
2171 fn test_sentence_case_code_only_heading() {
2172 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2174 let content = "# `rumdl`\n";
2175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2176 let result = rule.check(&ctx).unwrap();
2177 assert!(
2178 result.is_empty(),
2179 "Code-only heading should be fine. Got: {:?}",
2180 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2181 );
2182 }
2183
2184 #[test]
2185 fn test_sentence_case_code_at_end() {
2186 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2188 let content = "# install the `rumdl` tool\n";
2189 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2190 let result = rule.check(&ctx).unwrap();
2191 assert_eq!(result.len(), 1);
2193 assert!(
2194 result[0].message.contains("Install the `rumdl` tool"),
2195 "First word should still be capitalized when text comes first. Got: {:?}",
2196 result[0].message
2197 );
2198 }
2199
2200 #[test]
2201 fn test_sentence_case_code_in_middle() {
2202 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2204 let content = "# using the `rumdl` linter for markdown\n";
2205 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2206 let result = rule.check(&ctx).unwrap();
2207 assert_eq!(result.len(), 1);
2209 assert!(
2210 result[0].message.contains("Using the `rumdl` linter for markdown"),
2211 "First word should be capitalized. Got: {:?}",
2212 result[0].message
2213 );
2214 }
2215
2216 #[test]
2217 fn test_sentence_case_preserved_word_after_code() {
2218 let config = MD063Config {
2220 enabled: true,
2221 style: HeadingCapStyle::SentenceCase,
2222 preserve_cased_words: true,
2223 ..Default::default()
2224 };
2225 let rule = MD063HeadingCapitalization::from_config_struct(config);
2226 let content = "# `swift` iPhone development\n";
2227 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2228 let result = rule.check(&ctx).unwrap();
2229 assert!(
2231 result.is_empty(),
2232 "Preserved words after code should stay. Got: {:?}",
2233 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2234 );
2235 }
2236
2237 #[test]
2238 fn test_title_case_code_at_start_still_capitalizes() {
2239 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2241 let content = "# `api` quick start guide\n";
2242 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2243 let result = rule.check(&ctx).unwrap();
2244 assert_eq!(result.len(), 1);
2246 assert!(
2247 result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
2248 "Title case should capitalize major words after code. Got: {:?}",
2249 result[0].message
2250 );
2251 }
2252
2253 #[test]
2256 fn test_sentence_case_html_tag_at_start() {
2257 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2259 let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
2260 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2261 let result = rule.check(&ctx).unwrap();
2262 assert_eq!(result.len(), 1);
2264 let fixed = rule.fix(&ctx).unwrap();
2265 assert_eq!(
2266 fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
2267 "Text after HTML at start should be lowercase"
2268 );
2269 }
2270
2271 #[test]
2272 fn test_sentence_case_html_tag_preserves_content() {
2273 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2275 let content = "# The <abbr>API</abbr> documentation guide\n";
2276 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2277 let result = rule.check(&ctx).unwrap();
2278 assert!(
2280 result.is_empty(),
2281 "HTML tag content should be preserved. Got: {:?}",
2282 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2283 );
2284 }
2285
2286 #[test]
2287 fn test_sentence_case_html_tag_at_start_with_acronym() {
2288 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2290 let content = "# <abbr>API</abbr> Documentation Guide\n";
2291 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2292 let result = rule.check(&ctx).unwrap();
2293 assert_eq!(result.len(), 1);
2294 let fixed = rule.fix(&ctx).unwrap();
2295 assert_eq!(
2296 fixed, "# <abbr>API</abbr> documentation guide\n",
2297 "Text after HTML at start should be lowercase, HTML content preserved"
2298 );
2299 }
2300
2301 #[test]
2302 fn test_sentence_case_html_tag_in_middle() {
2303 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2305 let content = "# using the <code>config</code> File\n";
2306 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2307 let result = rule.check(&ctx).unwrap();
2308 assert_eq!(result.len(), 1);
2309 let fixed = rule.fix(&ctx).unwrap();
2310 assert_eq!(
2311 fixed, "# Using the <code>config</code> file\n",
2312 "First word capitalized, HTML preserved, rest lowercase"
2313 );
2314 }
2315
2316 #[test]
2317 fn test_html_tag_strong_emphasis() {
2318 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2320 let content = "# The <strong>Bold</strong> Way\n";
2321 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2322 let result = rule.check(&ctx).unwrap();
2323 assert_eq!(result.len(), 1);
2324 let fixed = rule.fix(&ctx).unwrap();
2325 assert_eq!(
2326 fixed, "# The <strong>Bold</strong> way\n",
2327 "<strong> tag content should be preserved"
2328 );
2329 }
2330
2331 #[test]
2332 fn test_html_tag_with_attributes() {
2333 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2335 let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
2336 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2337 let result = rule.check(&ctx).unwrap();
2338 assert_eq!(result.len(), 1);
2339 let fixed = rule.fix(&ctx).unwrap();
2340 assert_eq!(
2341 fixed, "# <span class=\"highlight\">Important</span> notice here\n",
2342 "HTML tag with attributes should be preserved"
2343 );
2344 }
2345
2346 #[test]
2347 fn test_multiple_html_tags() {
2348 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2350 let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
2351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2352 let result = rule.check(&ctx).unwrap();
2353 assert_eq!(result.len(), 1);
2354 let fixed = rule.fix(&ctx).unwrap();
2355 assert_eq!(
2356 fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
2357 "Multiple HTML tags should all be preserved"
2358 );
2359 }
2360
2361 #[test]
2362 fn test_html_and_code_mixed() {
2363 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2365 let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
2366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2367 let result = rule.check(&ctx).unwrap();
2368 assert_eq!(result.len(), 1);
2369 let fixed = rule.fix(&ctx).unwrap();
2370 assert_eq!(
2371 fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
2372 "HTML and code should both be preserved"
2373 );
2374 }
2375
2376 #[test]
2377 fn test_self_closing_html_tag() {
2378 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2380 let content = "# Line one<br/>Line Two Here\n";
2381 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2382 let result = rule.check(&ctx).unwrap();
2383 assert_eq!(result.len(), 1);
2384 let fixed = rule.fix(&ctx).unwrap();
2385 assert_eq!(
2386 fixed, "# Line one<br/>line two here\n",
2387 "Self-closing HTML tags should be preserved"
2388 );
2389 }
2390
2391 #[test]
2392 fn test_title_case_with_html_tags() {
2393 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2395 let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
2396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2397 let result = rule.check(&ctx).unwrap();
2398 assert_eq!(result.len(), 1);
2399 let fixed = rule.fix(&ctx).unwrap();
2400 assert!(
2402 fixed.contains("<kbd>ctrl</kbd>"),
2403 "HTML tag content should be preserved in title case. Got: {fixed}"
2404 );
2405 assert!(
2406 fixed.starts_with("# The ") || fixed.starts_with("# the "),
2407 "Title case should work with HTML. Got: {fixed}"
2408 );
2409 }
2410
2411 #[test]
2414 fn test_sentence_case_preserves_caret_notation() {
2415 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2417 let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
2418 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2419 let result = rule.check(&ctx).unwrap();
2420 assert!(
2422 result.is_empty(),
2423 "Caret notation should be preserved. Got: {:?}",
2424 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2425 );
2426 }
2427
2428 #[test]
2429 fn test_sentence_case_caret_notation_various() {
2430 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2432
2433 let content = "## Press ^C to cancel\n";
2435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2436 let result = rule.check(&ctx).unwrap();
2437 assert!(
2438 result.is_empty(),
2439 "^C should be preserved. Got: {:?}",
2440 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2441 );
2442
2443 let content = "## Use ^Z for background\n";
2445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2446 let result = rule.check(&ctx).unwrap();
2447 assert!(
2448 result.is_empty(),
2449 "^Z should be preserved. Got: {:?}",
2450 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2451 );
2452
2453 let content = "## Press ^[ for escape\n";
2455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2456 let result = rule.check(&ctx).unwrap();
2457 assert!(
2458 result.is_empty(),
2459 "^[ should be preserved. Got: {:?}",
2460 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2461 );
2462 }
2463
2464 #[test]
2465 fn test_caret_notation_detection() {
2466 let rule = create_rule();
2467
2468 assert!(rule.is_caret_notation("^A"));
2470 assert!(rule.is_caret_notation("^Z"));
2471 assert!(rule.is_caret_notation("^C"));
2472 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")); }
2484
2485 fn create_sentence_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2492 let config = MD063Config {
2493 enabled: true,
2494 style: HeadingCapStyle::SentenceCase,
2495 ..Default::default()
2496 };
2497 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2498 rule.proper_names = names;
2499 rule
2500 }
2501
2502 #[test]
2503 fn test_sentence_case_preserves_single_word_proper_name() {
2504 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2505 let content = "# installing javascript\n";
2507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2508 let result = rule.check(&ctx).unwrap();
2509 assert_eq!(result.len(), 1, "Should flag the heading");
2510 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2511 assert!(
2512 fix_text.contains("JavaScript"),
2513 "Fix should preserve proper name 'JavaScript', got: {fix_text:?}"
2514 );
2515 assert!(
2516 !fix_text.contains("javascript"),
2517 "Fix should not have lowercase 'javascript', got: {fix_text:?}"
2518 );
2519 }
2520
2521 #[test]
2522 fn test_sentence_case_preserves_multi_word_proper_name() {
2523 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2524 let content = "# using good application features\n";
2526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2527 let result = rule.check(&ctx).unwrap();
2528 assert_eq!(result.len(), 1, "Should flag the heading");
2529 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2530 assert!(
2531 fix_text.contains("Good Application"),
2532 "Fix should preserve 'Good Application' as a phrase, got: {fix_text:?}"
2533 );
2534 }
2535
2536 #[test]
2537 fn test_sentence_case_proper_name_at_start_of_heading() {
2538 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2539 let content = "# good application overview\n";
2541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2542 let result = rule.check(&ctx).unwrap();
2543 assert_eq!(result.len(), 1, "Should flag the heading");
2544 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2545 assert!(
2546 fix_text.contains("Good Application"),
2547 "Fix should produce 'Good Application' at start of heading, got: {fix_text:?}"
2548 );
2549 assert!(
2550 fix_text.contains("overview"),
2551 "Non-proper-name word 'overview' should be lowercase, got: {fix_text:?}"
2552 );
2553 }
2554
2555 #[test]
2556 fn test_sentence_case_with_proper_names_no_oscillation() {
2557 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2560
2561 let content = "# installing good application on your system\n";
2563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2564 let result = rule.check(&ctx).unwrap();
2565 assert_eq!(result.len(), 1);
2566 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
2567
2568 assert!(
2570 fixed_heading.contains("Good Application"),
2571 "After fix, proper name must be preserved: {fixed_heading:?}"
2572 );
2573
2574 let fixed_line = format!("{fixed_heading}\n");
2576 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
2577 let result2 = rule.check(&ctx2).unwrap();
2578 assert!(
2579 result2.is_empty(),
2580 "After one fix, heading must already satisfy both MD063 and MD044 - no oscillation. \
2581 Second pass warnings: {result2:?}"
2582 );
2583 }
2584
2585 #[test]
2586 fn test_sentence_case_proper_names_already_correct() {
2587 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2588 let content = "# Installing Good Application\n";
2590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2591 let result = rule.check(&ctx).unwrap();
2592 assert!(
2593 result.is_empty(),
2594 "Correct sentence-case heading with proper name should not be flagged, got: {result:?}"
2595 );
2596 }
2597
2598 #[test]
2599 fn test_sentence_case_multiple_proper_names_in_heading() {
2600 let rule = create_sentence_case_rule_with_proper_names(vec!["TypeScript".to_string(), "React".to_string()]);
2601 let content = "# using typescript with react\n";
2602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2603 let result = rule.check(&ctx).unwrap();
2604 assert_eq!(result.len(), 1);
2605 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2606 assert!(
2607 fix_text.contains("TypeScript"),
2608 "Fix should preserve 'TypeScript', got: {fix_text:?}"
2609 );
2610 assert!(
2611 fix_text.contains("React"),
2612 "Fix should preserve 'React', got: {fix_text:?}"
2613 );
2614 }
2615
2616 #[test]
2617 fn test_sentence_case_unicode_casefold_expansion_before_proper_name() {
2618 let rule = create_sentence_case_rule_with_proper_names(vec!["Österreich".to_string()]);
2621 let content = "# İ österreich guide\n";
2622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2623
2624 let result = rule.check(&ctx).unwrap();
2626 assert_eq!(result.len(), 1, "Should flag heading for canonical proper-name casing");
2627 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2628 assert!(
2629 fix_text.contains("Österreich"),
2630 "Fix should preserve canonical 'Österreich', got: {fix_text:?}"
2631 );
2632 }
2633
2634 #[test]
2635 fn test_sentence_case_preserves_trailing_punctuation_on_proper_name() {
2636 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2637 let content = "# using javascript, today\n";
2638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2639 let result = rule.check(&ctx).unwrap();
2640 assert_eq!(result.len(), 1, "Should flag heading");
2641 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2642 assert!(
2643 fix_text.contains("JavaScript,"),
2644 "Fix should preserve trailing punctuation, got: {fix_text:?}"
2645 );
2646 }
2647
2648 fn create_title_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2655 let config = MD063Config {
2656 enabled: true,
2657 style: HeadingCapStyle::TitleCase,
2658 ..Default::default()
2659 };
2660 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2661 rule.proper_names = names;
2662 rule
2663 }
2664
2665 #[test]
2666 fn test_title_case_preserves_proper_name_with_lowercase_article() {
2667 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
2671 let content = "# listening to the rolling stones today\n";
2672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2673 let result = rule.check(&ctx).unwrap();
2674 assert_eq!(result.len(), 1, "Should flag the heading");
2675 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2676 assert!(
2677 fix_text.contains("The Rolling Stones"),
2678 "Fix should preserve proper name 'The Rolling Stones', got: {fix_text:?}"
2679 );
2680 }
2681
2682 #[test]
2683 fn test_title_case_proper_name_no_oscillation() {
2684 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
2686 let content = "# listening to the rolling stones today\n";
2687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2688 let result = rule.check(&ctx).unwrap();
2689 assert_eq!(result.len(), 1);
2690 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
2691
2692 let fixed_line = format!("{fixed_heading}\n");
2693 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
2694 let result2 = rule.check(&ctx2).unwrap();
2695 assert!(
2696 result2.is_empty(),
2697 "After one title-case fix, heading must already satisfy both rules. \
2698 Second pass warnings: {result2:?}"
2699 );
2700 }
2701
2702 #[test]
2703 fn test_title_case_unicode_casefold_expansion_before_proper_name() {
2704 let rule = create_title_case_rule_with_proper_names(vec!["Österreich".to_string()]);
2705 let content = "# İ österreich guide\n";
2706 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2707 let result = rule.check(&ctx).unwrap();
2708 assert_eq!(result.len(), 1, "Should flag the heading");
2709 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2710 assert!(
2711 fix_text.contains("Österreich"),
2712 "Fix should preserve canonical proper-name casing, got: {fix_text:?}"
2713 );
2714 }
2715
2716 #[test]
2722 fn test_from_config_loads_md044_names_into_md063() {
2723 use crate::config::{Config, RuleConfig};
2724 use crate::rule::Rule;
2725 use std::collections::BTreeMap;
2726
2727 let mut config = Config::default();
2728
2729 let mut md063_values = BTreeMap::new();
2731 md063_values.insert("style".to_string(), toml::Value::String("sentence_case".to_string()));
2732 md063_values.insert("enabled".to_string(), toml::Value::Boolean(true));
2733 config.rules.insert(
2734 "MD063".to_string(),
2735 RuleConfig {
2736 values: md063_values,
2737 severity: None,
2738 },
2739 );
2740
2741 let mut md044_values = BTreeMap::new();
2743 md044_values.insert(
2744 "names".to_string(),
2745 toml::Value::Array(vec![toml::Value::String("Good Application".to_string())]),
2746 );
2747 config.rules.insert(
2748 "MD044".to_string(),
2749 RuleConfig {
2750 values: md044_values,
2751 severity: None,
2752 },
2753 );
2754
2755 let rule = MD063HeadingCapitalization::from_config(&config);
2757
2758 let content = "# using good application features\n";
2760 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2761 let result = rule.check(&ctx).unwrap();
2762 assert_eq!(result.len(), 1, "Should flag the heading");
2763 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2764 assert!(
2765 fix_text.contains("Good Application"),
2766 "from_config should wire MD044 names into MD063; fix should preserve \
2767 'Good Application', got: {fix_text:?}"
2768 );
2769 }
2770
2771 #[test]
2772 fn test_title_case_short_word_not_confused_with_substring() {
2773 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2777
2778 let content = "# in the insert\n";
2781 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2782 let result = rule.check(&ctx).unwrap();
2783 assert_eq!(result.len(), 1, "Should flag the heading");
2784 let fix = result[0].fix.as_ref().expect("Fix should be present");
2785 assert!(
2787 fix.replacement.contains("In the Insert"),
2788 "Expected 'In the Insert', got: {:?}",
2789 fix.replacement
2790 );
2791 }
2792
2793 #[test]
2794 fn test_title_case_or_not_confused_with_orchestra() {
2795 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2796
2797 let content = "# or the orchestra\n";
2800 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2801 let result = rule.check(&ctx).unwrap();
2802 assert_eq!(result.len(), 1, "Should flag the heading");
2803 let fix = result[0].fix.as_ref().expect("Fix should be present");
2804 assert!(
2806 fix.replacement.contains("Or the Orchestra"),
2807 "Expected 'Or the Orchestra', got: {:?}",
2808 fix.replacement
2809 );
2810 }
2811
2812 #[test]
2813 fn test_all_caps_preserves_all_words() {
2814 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
2815
2816 let content = "# in the insert\n";
2817 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2818 let result = rule.check(&ctx).unwrap();
2819 assert_eq!(result.len(), 1, "Should flag the heading");
2820 let fix = result[0].fix.as_ref().expect("Fix should be present");
2821 assert!(
2822 fix.replacement.contains("IN THE INSERT"),
2823 "All caps should uppercase all words, got: {:?}",
2824 fix.replacement
2825 );
2826 }
2827
2828 #[test]
2830 fn test_title_case_numbered_prefix_lowercase_word() {
2831 let rule = create_rule();
2833 let content = "## 1. To Be a Thing\n";
2834 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2835 let result = rule.check(&ctx).unwrap();
2836 assert!(
2837 result.is_empty(),
2838 "Should not flag '## 1. To Be a Thing', got: {result:?}"
2839 );
2840
2841 let content_lower = "## 1. to be a thing\n";
2842 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2843 let result2 = rule.check(&ctx2).unwrap();
2844 assert!(!result2.is_empty(), "Should flag '## 1. to be a thing'");
2845 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2846 assert!(
2847 fix.replacement.contains("1. To Be a Thing"),
2848 "Fix should capitalize 'To', got: {:?}",
2849 fix.replacement
2850 );
2851 }
2852
2853 #[test]
2854 fn test_title_case_numbered_prefix_article() {
2855 let rule = create_rule();
2857 let content = "## 2. A Guide to the Galaxy\n";
2858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2859 let result = rule.check(&ctx).unwrap();
2860 assert!(
2861 result.is_empty(),
2862 "Should not flag '## 2. A Guide to the Galaxy', got: {result:?}"
2863 );
2864
2865 let content_lower = "## 2. a guide to the galaxy\n";
2866 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2867 let result2 = rule.check(&ctx2).unwrap();
2868 assert!(!result2.is_empty(), "Should flag '## 2. a guide to the galaxy'");
2869 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2870 assert!(
2871 fix.replacement.contains("2. A Guide to the Galaxy"),
2872 "Fix should capitalize 'A', got: {:?}",
2873 fix.replacement
2874 );
2875 }
2876
2877 #[test]
2878 fn test_title_case_mid_sentence_period_word() {
2879 let rule = create_rule();
2881 let content = "## Step 1. Introduction to the Problem\n";
2882 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2883 let result = rule.check(&ctx).unwrap();
2884 assert!(
2885 result.is_empty(),
2886 "Should not flag '## Step 1. Introduction to the Problem', got: {result:?}"
2887 );
2888
2889 let content_lower = "## Step 1. introduction to the problem\n";
2890 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2891 let result2 = rule.check(&ctx2).unwrap();
2892 assert!(
2893 !result2.is_empty(),
2894 "Should flag '## Step 1. introduction to the problem'"
2895 );
2896 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2897 assert!(
2898 fix.replacement.contains("Step 1. Introduction to the Problem"),
2899 "Fix should capitalize 'Introduction', got: {:?}",
2900 fix.replacement
2901 );
2902 }
2903
2904 #[test]
2905 fn test_title_case_numbered_prefix_in_link_text() {
2906 let config = MD063Config {
2909 enabled: true,
2910 style: HeadingCapStyle::TitleCase,
2911 ..Default::default()
2912 };
2913 let rule = MD063HeadingCapitalization::from_config_struct(config);
2914
2915 let content = "## [1. To Be a Thing](https://example.com)\n";
2917 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2918 let result = rule.check(&ctx).unwrap();
2919 assert!(
2920 result.is_empty(),
2921 "Should not flag '## [1. To Be a Thing](url)', got: {result:?}"
2922 );
2923
2924 let content_lower = "## [1. to be a thing](https://example.com)\n";
2926 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2927 let result2 = rule.check(&ctx2).unwrap();
2928 assert!(!result2.is_empty(), "Should flag '## [1. to be a thing](url)'");
2929 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2930 assert!(
2931 fix.replacement.contains("1. To Be a Thing"),
2932 "Fix should capitalize 'To' in link text, got: {:?}",
2933 fix.replacement
2934 );
2935 }
2936
2937 #[test]
2942 fn test_is_numeric_ordinal_recognises_canonical_forms() {
2943 for word in &[
2944 "1st", "2nd", "3rd", "4th", "5th", "11th", "21st", "22nd", "23rd", "100th", "1ST", "5Th", "21St", "21sT",
2945 ] {
2946 assert!(
2947 MD063HeadingCapitalization::is_numeric_ordinal(word),
2948 "expected `{word}` to be detected as a numeric ordinal"
2949 );
2950 }
2951 }
2952
2953 #[test]
2954 fn test_is_numeric_ordinal_rejects_non_ordinals() {
2955 for word in &[
2960 "first", "1stop", "ist", "5", "th", "abc", "4G", "4K", "30s", "100k", "5x", "1.5", "iPhone6S",
2961 ] {
2962 assert!(
2963 !MD063HeadingCapitalization::is_numeric_ordinal(word),
2964 "expected `{word}` NOT to be detected as a numeric ordinal"
2965 );
2966 }
2967 }
2968
2969 #[test]
2970 fn test_is_numeric_ordinal_strips_trailing_punctuation() {
2971 for word in &["5th.", "1st,", "21st!", "3rd:", "4th)", "5th's"] {
2972 assert!(
2973 MD063HeadingCapitalization::is_numeric_ordinal(word),
2974 "expected `{word}` to be detected as a numeric ordinal (with punctuation)"
2975 );
2976 }
2977 }
2978
2979 #[test]
2980 fn test_title_case_ordinal_first_word_not_flagged() {
2981 let rule = create_rule();
2982 for content in &[
2983 "# 1st Place\n",
2984 "# 2nd Edition\n",
2985 "# 3rd Time\n",
2986 "# 5th Avenue\n",
2987 "# 21st Century Skills\n",
2988 "# 100th Customer\n",
2989 ] {
2990 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2991 let result = rule.check(&ctx).unwrap();
2992 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
2993 }
2994 }
2995
2996 #[test]
2997 fn test_title_case_ordinal_mid_heading_not_flagged() {
2998 let rule = create_rule();
2999 for content in &[
3000 "# May 3rd Notes\n",
3001 "# Top 100th Customer\n",
3002 "# Notes for the 5th of May\n",
3003 ] {
3004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3005 let result = rule.check(&ctx).unwrap();
3006 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3007 }
3008 }
3009
3010 #[test]
3011 fn test_title_case_ordinal_corrupted_form_is_fixed() {
3012 let rule = create_rule();
3015 for (input, expected) in &[
3016 ("# 1St Place\n", "1st Place"),
3017 ("# 5Th Avenue\n", "5th Avenue"),
3018 ("# 21St Century Skills\n", "21st Century Skills"),
3019 ("# May 3Rd Notes\n", "May 3rd Notes"),
3020 ("# 22Nd Edition\n", "22nd Edition"),
3021 ] {
3022 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
3023 let result = rule.check(&ctx).unwrap();
3024 assert!(!result.is_empty(), "Should flag {input:?}");
3025 let fix = result[0].fix.as_ref().expect("should have a fix");
3026 assert!(
3027 fix.replacement.contains(expected),
3028 "Fix for {input:?} should contain {expected:?}, got: {:?}",
3029 fix.replacement
3030 );
3031 }
3032 }
3033
3034 #[test]
3035 fn test_title_case_ordinal_lowercase_other_words_capitalised() {
3036 let rule = create_rule();
3038 let content = "# 5th avenue\n";
3039 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3040 let result = rule.check(&ctx).unwrap();
3041 assert_eq!(result.len(), 1);
3042 let fix = result[0].fix.as_ref().expect("should have a fix");
3043 assert!(
3044 fix.replacement.contains("5th Avenue"),
3045 "Fix should produce '5th Avenue', got: {:?}",
3046 fix.replacement
3047 );
3048 }
3049
3050 #[test]
3051 fn test_title_case_ordinal_with_trailing_punctuation() {
3052 let rule = create_rule();
3053 let content = "# Released on the 5th.\n";
3054 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3055 let result = rule.check(&ctx).unwrap();
3056 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3057 }
3058
3059 #[test]
3060 fn test_title_case_ordinal_hyphenated() {
3061 let rule = create_rule();
3062 for content in &["# 21st-Century Skills\n", "# A 19th-Century Novel\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 #[test]
3070 fn test_sentence_case_ordinal_corrupted_form_is_fixed() {
3071 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3072 let content = "# 5Th avenue\n";
3073 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3074 let result = rule.check(&ctx).unwrap();
3075 assert_eq!(result.len(), 1);
3076 let fix = result[0].fix.as_ref().expect("should have a fix");
3077 assert!(
3078 fix.replacement.contains("5th avenue"),
3079 "Fix should produce '5th avenue', got: {:?}",
3080 fix.replacement
3081 );
3082 }
3083
3084 #[test]
3085 fn test_title_case_digit_acronym_unchanged() {
3086 let rule = create_rule();
3089 for content in &["# 4G Networks\n", "# 4K Streaming\n"] {
3090 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3091 let result = rule.check(&ctx).unwrap();
3092 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3093 }
3094 }
3095
3096 fn restart_rule(boundaries: &[&str]) -> MD063HeadingCapitalization {
3099 let config = MD063Config {
3100 enabled: true,
3101 style: HeadingCapStyle::SentenceCase,
3102 sentence_case_restart_after: boundaries.iter().copied().map(String::from).collect(),
3103 ..Default::default()
3104 };
3105 MD063HeadingCapitalization::from_config_struct(config)
3106 }
3107
3108 fn suggested(rule: &MD063HeadingCapitalization, content: &str) -> Option<String> {
3111 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3112 let warnings = rule.check(&ctx).unwrap();
3113 let fixed = rule.fix(&ctx).unwrap();
3114 assert_eq!(
3115 warnings.is_empty(),
3116 fixed == content,
3117 "a warning and a rewrite must agree for {content:?}"
3118 );
3119 (!warnings.is_empty()).then(|| fixed.trim_start_matches('#').trim().to_string())
3120 }
3121
3122 #[test]
3123 fn test_restart_after_capitalizes_the_word_following_a_boundary() {
3124 let rule = restart_rule(&[":"]);
3125 assert_eq!(
3126 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3127 Some("Requirement 1: Struct to logger slice conversion")
3128 );
3129 }
3130
3131 #[test]
3132 fn test_restart_after_defaults_to_no_boundaries() {
3133 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3135 assert_eq!(
3136 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3137 Some("Requirement 1: struct to logger slice conversion")
3138 );
3139 }
3140
3141 #[test]
3142 fn test_restart_after_only_honors_configured_punctuation() {
3143 let rule = restart_rule(&[":"]);
3145 assert_eq!(
3146 suggested(&rule, "# Design - Data Model Overview\n").as_deref(),
3147 Some("Design - data model overview")
3148 );
3149 assert_eq!(
3150 suggested(&rule, "# Setup; Then Run\n").as_deref(),
3151 Some("Setup; then run")
3152 );
3153
3154 let rule = restart_rule(&[";", "\u{2014}"]);
3155 assert_eq!(
3156 suggested(&rule, "# Setup; Then Run\n").as_deref(),
3157 Some("Setup; Then run")
3158 );
3159 assert_eq!(
3160 suggested(&rule, "# Part One \u{2014} The Big Idea\n").as_deref(),
3161 Some("Part one \u{2014} The big idea")
3162 );
3163 }
3164
3165 #[test]
3166 fn test_restart_after_matches_only_at_the_end_of_a_word() {
3167 let rule = restart_rule(&["-", ":"]);
3170 assert_eq!(
3171 suggested(&rule, "# Ports: Well-Known Ports Explained\n").as_deref(),
3172 Some("Ports: Well-Known ports explained")
3173 );
3174 assert_eq!(
3175 suggested(&rule, "# See https://example.com/A/B For Details\n").as_deref(),
3176 Some("See https://example.com/A/B for details")
3177 );
3178 }
3179
3180 #[test]
3181 fn test_restart_after_a_trailing_boundary_is_a_no_op() {
3182 let rule = restart_rule(&[":"]);
3183 assert_eq!(suggested(&rule, "# Setup:\n"), None);
3184 }
3185
3186 #[test]
3187 fn test_restart_after_does_not_override_preserved_words() {
3188 let rule = restart_rule(&[":"]);
3191 assert_eq!(
3192 suggested(&rule, "# Devices: iPhone And Android\n").as_deref(),
3193 Some("Devices: iPhone and android")
3194 );
3195
3196 let config = MD063Config {
3197 enabled: true,
3198 style: HeadingCapStyle::SentenceCase,
3199 sentence_case_restart_after: vec![":".to_string()],
3200 ignore_words: vec!["kubectl".to_string()],
3201 preserve_cased_words: false,
3202 ..Default::default()
3203 };
3204 let rule = MD063HeadingCapitalization::from_config_struct(config);
3205 assert_eq!(
3206 suggested(&rule, "# Tools: kubectl And Helm\n").as_deref(),
3207 Some("Tools: kubectl and helm")
3208 );
3209 }
3210
3211 #[test]
3212 fn test_restart_after_keeps_md044_canonical_forms() {
3213 let config = MD063Config {
3214 enabled: true,
3215 style: HeadingCapStyle::SentenceCase,
3216 sentence_case_restart_after: vec![":".to_string()],
3217 ..Default::default()
3218 };
3219 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3220 rule.proper_names = vec!["GitHub".to_string()];
3221
3222 assert_eq!(
3224 suggested(&rule, "# Docs: github Actions Guide\n").as_deref(),
3225 Some("Docs: GitHub actions guide")
3226 );
3227 assert_eq!(suggested(&rule, "# Docs: GitHub actions guide\n"), None);
3228 }
3229
3230 #[test]
3231 fn test_restart_after_carries_across_segments() {
3232 let rule = restart_rule(&[":"]);
3235 assert_eq!(
3236 suggested(
3237 &rule,
3238 "# Overview: [Some Link Here](https://example.com) Trailing Words\n"
3239 )
3240 .as_deref(),
3241 Some("Overview: [Some link here](https://example.com) trailing words")
3242 );
3243 assert_eq!(
3244 suggested(&rule, "# Overview: `code` Then More Words\n").as_deref(),
3245 Some("Overview: `code` then more words")
3246 );
3247 }
3248
3249 #[test]
3250 fn test_restart_after_ends_a_sentence_at_the_end_of_link_text() {
3251 let rule = restart_rule(&[":"]);
3255 assert_eq!(
3256 suggested(&rule, "# Topic [See:](https://example.com) More Words\n").as_deref(),
3257 Some("Topic [see:](https://example.com) More words")
3258 );
3259
3260 assert_eq!(
3262 suggested(&rule, "# Topic [See](https://example.com) More Words\n").as_deref(),
3263 Some("Topic [see](https://example.com) more words")
3264 );
3265 }
3266
3267 #[test]
3268 fn test_restart_after_ignores_boundaries_inside_opaque_segments() {
3269 let rule = restart_rule(&[":"]);
3272 for content in [
3273 "# Topic `see:` More Words\n",
3274 "# Topic  More Words\n",
3275 "# Topic <span title=\"x:\">y</span> More Words\n",
3276 ] {
3277 let fixed = suggested(&rule, content).expect("heading should be rewritten");
3278 assert!(
3279 fixed.ends_with("more words"),
3280 "opaque segment restarted the sentence in {content:?}: {fixed}"
3281 );
3282 }
3283 }
3284
3285 #[test]
3286 fn test_restart_after_leaves_a_leading_link_mid_sentence() {
3287 for rule in [restart_rule(&[]), restart_rule(&[":"])] {
3290 assert_eq!(
3291 suggested(&rule, "# [Some Link Here](https://example.com) Trailing Words\n").as_deref(),
3292 Some("[some link here](https://example.com) trailing words")
3293 );
3294 }
3295 }
3296
3297 #[test]
3298 fn test_restart_after_fix_is_idempotent() {
3299 let rule = restart_rule(&[":", ";", "-", "\u{2014}"]);
3300 for content in [
3301 "# Requirement 1: Struct to Logger Slice Conversion\n",
3302 "# Ports: Well-Known Ports Explained\n",
3303 "# Devices: iPhone And Android\n",
3304 "# Overview: [Some Link Here](https://example.com) Trailing Words\n",
3305 "# Setup:\n",
3306 ] {
3307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3308 let once = rule.fix(&ctx).unwrap();
3309 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
3310 assert_eq!(rule.fix(&ctx).unwrap(), once, "fix is not idempotent for {content:?}");
3311 }
3312 }
3313
3314 #[test]
3315 fn test_restart_after_ignores_empty_boundary_entries() {
3316 let rule = restart_rule(&[""]);
3318 assert_eq!(
3319 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3320 Some("Requirement 1: struct to logger slice conversion")
3321 );
3322 }
3323
3324 const STYLES: [HeadingCapStyle; 3] = [
3327 HeadingCapStyle::TitleCase,
3328 HeadingCapStyle::SentenceCase,
3329 HeadingCapStyle::AllCaps,
3330 ];
3331
3332 const GHERKIN_STRUCTURES: [(&str, &str, &str, &str); 6] = [
3335 (
3336 "# Feature: the system under test",
3337 "# Feature: The System Under Test",
3338 "# Feature: The system under test",
3339 "# Feature: THE SYSTEM UNDER TEST",
3340 ),
3341 (
3342 "## Background: a shared setup",
3343 "## Background: A Shared Setup",
3344 "## Background: A shared setup",
3345 "## Background: A SHARED SETUP",
3346 ),
3347 (
3348 "## Rule: money is never lost",
3349 "## Rule: Money Is Never Lost",
3350 "## Rule: Money is never lost",
3351 "## Rule: MONEY IS NEVER LOST",
3352 ),
3353 (
3354 "### Scenario: add two numbers",
3355 "### Scenario: Add Two Numbers",
3356 "### Scenario: Add two numbers",
3357 "### Scenario: ADD TWO NUMBERS",
3358 ),
3359 (
3360 "### Scenario Outline: add two numbers",
3361 "### Scenario Outline: Add Two Numbers",
3362 "### Scenario Outline: Add two numbers",
3363 "### Scenario Outline: ADD TWO NUMBERS",
3364 ),
3365 (
3366 "#### Examples: happy path",
3367 "#### Examples: Happy Path",
3368 "#### Examples: Happy path",
3369 "#### Examples: HAPPY PATH",
3370 ),
3371 ];
3372
3373 fn recased(style: HeadingCapStyle, heading: &str, flavor: crate::config::MarkdownFlavor) -> String {
3375 let rule = create_rule_with_style(style);
3376 let content = format!("{heading}\n");
3377 let ctx = LintContext::new(&content, flavor, None);
3378 let warnings = rule.check(&ctx).unwrap();
3379 let fixed = rule.fix(&ctx).unwrap();
3380 assert_eq!(
3381 warnings.is_empty(),
3382 fixed == content,
3383 "a warning and a rewrite must agree for {content:?} under {flavor:?}"
3384 );
3385 fixed.trim_end().to_string()
3386 }
3387
3388 #[test]
3389 fn test_mdg_keeps_the_keyword_of_every_structure() {
3390 for (heading, ..) in GHERKIN_STRUCTURES {
3393 let keyword = &heading[..=heading.find(':').unwrap()];
3394 for style in STYLES {
3395 let fixed = recased(style, heading, crate::config::MarkdownFlavor::MDG);
3396 assert!(
3397 fixed.starts_with(keyword),
3398 "{style:?} lost the keyword of {heading:?}: {fixed}"
3399 );
3400 }
3401 }
3402 }
3403
3404 #[test]
3405 fn test_mdg_recases_only_the_name_of_a_structure() {
3406 for (heading, title, sentence, caps) in GHERKIN_STRUCTURES {
3407 let mdg = crate::config::MarkdownFlavor::MDG;
3408 assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), title);
3409 assert_eq!(recased(HeadingCapStyle::SentenceCase, heading, mdg), sentence);
3410 assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), caps);
3411 }
3412 }
3413
3414 #[test]
3415 fn test_standard_flavor_recases_a_keyword_like_any_other_word() {
3416 let standard = crate::config::MarkdownFlavor::Standard;
3418 assert_eq!(
3419 recased(HeadingCapStyle::TitleCase, "# Feature: the system under test", standard),
3420 "# Feature: the System Under Test"
3421 );
3422 assert_eq!(
3423 recased(
3424 HeadingCapStyle::SentenceCase,
3425 "### Scenario Outline: add two numbers",
3426 standard
3427 ),
3428 "### Scenario outline: add two numbers"
3429 );
3430 assert_eq!(
3431 recased(HeadingCapStyle::AllCaps, "# Feature: the system under test", standard),
3432 "# FEATURE: THE SYSTEM UNDER TEST"
3433 );
3434 }
3435
3436 #[test]
3437 fn test_mdg_leaves_a_heading_without_a_colon_to_the_normal_rule() {
3438 for heading in ["## notes about the system", "## Notes", "# THE SYSTEM"] {
3439 for style in STYLES {
3440 assert_eq!(
3441 recased(style, heading, crate::config::MarkdownFlavor::MDG),
3442 recased(style, heading, crate::config::MarkdownFlavor::Standard),
3443 "{style:?} treated {heading:?} as a Gherkin structure"
3444 );
3445 }
3446 }
3447 }
3448
3449 #[test]
3450 fn test_mdg_splits_at_the_first_colon_only() {
3451 let mdg = crate::config::MarkdownFlavor::MDG;
3453 let heading = "## Scenario: ratio: two to one";
3454 assert_eq!(
3455 recased(HeadingCapStyle::TitleCase, heading, mdg),
3456 "## Scenario: Ratio: Two to One"
3457 );
3458 assert_eq!(
3459 recased(HeadingCapStyle::SentenceCase, heading, mdg),
3460 "## Scenario: Ratio: two to one"
3461 );
3462 assert_eq!(
3463 recased(HeadingCapStyle::AllCaps, heading, mdg),
3464 "## Scenario: RATIO: TWO TO ONE"
3465 );
3466 }
3467
3468 #[test]
3469 fn test_mdg_leaves_a_colon_behind_a_backtick_to_the_normal_rule() {
3470 for heading in [
3474 "# See `x: y` Notes",
3475 "# `a: b`",
3476 "# `code` Feature: a name",
3477 "# `x: y` Feature: a name",
3478 ] {
3479 for style in STYLES {
3480 assert_eq!(
3481 recased(style, heading, crate::config::MarkdownFlavor::MDG),
3482 recased(style, heading, crate::config::MarkdownFlavor::Standard),
3483 "{style:?} split {heading:?} at a colon inside a code span"
3484 );
3485 }
3486 }
3487 }
3488
3489 #[test]
3490 fn test_mdg_splits_at_a_keyword_colon_that_precedes_a_code_span() {
3491 let mdg = crate::config::MarkdownFlavor::MDG;
3493 let heading = "# Scenario: use `a: b` here";
3494 assert_eq!(
3495 recased(HeadingCapStyle::TitleCase, heading, mdg),
3496 "# Scenario: Use `a: b` Here"
3497 );
3498 assert_eq!(
3499 recased(HeadingCapStyle::SentenceCase, heading, mdg),
3500 "# Scenario: Use `a: b` here"
3501 );
3502 assert_eq!(
3503 recased(HeadingCapStyle::AllCaps, heading, mdg),
3504 "# Scenario: USE `a: b` HERE"
3505 );
3506 }
3507
3508 #[test]
3509 fn test_mdg_splits_at_a_keyword_colon_before_an_unbalanced_backtick() {
3510 let mdg = crate::config::MarkdownFlavor::MDG;
3513 let heading = "# Scenario: a ` b";
3514 assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), "# Scenario: A ` B");
3515 assert_eq!(
3516 recased(HeadingCapStyle::SentenceCase, heading, mdg),
3517 "# Scenario: A ` b"
3518 );
3519 assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), "# Scenario: A ` B");
3520 }
3521
3522 #[test]
3523 fn test_mdg_keeps_a_keyword_with_nothing_left_to_recase() {
3524 for style in STYLES {
3525 assert_eq!(
3526 recased(style, "# Feature:", crate::config::MarkdownFlavor::MDG),
3527 "# Feature:"
3528 );
3529 }
3530 }
3531
3532 #[test]
3533 fn test_mdg_keeps_a_custom_id_after_the_name() {
3534 assert_eq!(
3535 recased(
3536 HeadingCapStyle::TitleCase,
3537 "# Feature: the system {#overview}",
3538 crate::config::MarkdownFlavor::MDG
3539 ),
3540 "# Feature: The System {#overview}"
3541 );
3542 }
3543
3544 #[test]
3545 fn test_mdg_fix_is_idempotent() {
3546 for (heading, ..) in GHERKIN_STRUCTURES {
3547 for style in STYLES {
3548 let rule = create_rule_with_style(style);
3549 let content = format!("{heading}\n");
3550 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
3551 let once = rule.fix(&ctx).unwrap();
3552 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::MDG, None);
3553 assert_eq!(
3554 rule.fix(&ctx).unwrap(),
3555 once,
3556 "fix is not idempotent for {heading:?} ({style:?})"
3557 );
3558 }
3559 }
3560 }
3561}