1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
15use crate::utils::range_utils::{LineIndex, byte_to_char_count};
16use regex::Regex;
17use std::collections::HashSet;
18use std::ops::Range;
19use std::sync::LazyLock;
20
21mod md063_config;
22pub(super) use md063_config::{HeadingCapStyle, MD063Config};
23
24static INLINE_CODE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`+[^`]+`+").unwrap());
26
27static LINK_REGEX: LazyLock<Regex> =
32 LazyLock::new(|| Regex::new(r"\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)|\[([^\]]*)\]\[[^\]]*\]").unwrap());
33
34static HTML_TAG_REGEX: LazyLock<Regex> = LazyLock::new(|| {
39 let tags = "kbd|abbr|code|span|sub|sup|mark|cite|dfn|var|samp|small|strong|em|b|i|u|s|q|br|wbr";
41 let pattern = format!(r"<({tags})(?:\s[^>]*)?>.*?</({tags})>|<({tags})(?:\s[^>]*)?\s*/?>");
42 Regex::new(&pattern).unwrap()
43});
44
45static CUSTOM_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*\{#[^}]+\}\s*$").unwrap());
47
48#[derive(Debug, Clone)]
50enum HeadingSegment {
51 Text(String),
53 Code(String),
55 Link {
57 full: String,
58 text_start: usize,
59 text_end: usize,
60 },
61 Html(String),
63 Image(String),
66}
67
68#[derive(Clone)]
70pub struct MD063HeadingCapitalization {
71 config: MD063Config,
72 lowercase_set: HashSet<String>,
73 proper_names: Vec<String>,
76}
77
78impl Default for MD063HeadingCapitalization {
79 fn default() -> Self {
80 Self::new()
81 }
82}
83
84impl MD063HeadingCapitalization {
85 pub fn new() -> Self {
86 let config = MD063Config::default();
87 let lowercase_set = config.lowercase_words.iter().cloned().collect();
88 Self {
89 config,
90 lowercase_set,
91 proper_names: Vec::new(),
92 }
93 }
94
95 pub fn from_config_struct(config: MD063Config) -> Self {
96 let lowercase_set = config.lowercase_words.iter().cloned().collect();
97 Self {
98 config,
99 lowercase_set,
100 proper_names: Vec::new(),
101 }
102 }
103
104 fn match_case_insensitive_at(text: &str, start: usize, pattern_lower: &str) -> Option<usize> {
111 if start > text.len() || !text.is_char_boundary(start) || pattern_lower.is_empty() {
112 return None;
113 }
114
115 let mut matched_bytes = 0;
116
117 for (offset, ch) in text[start..].char_indices() {
118 if matched_bytes >= pattern_lower.len() {
119 break;
120 }
121
122 let lowered: String = ch.to_lowercase().collect();
123 if !pattern_lower[matched_bytes..].starts_with(&lowered) {
124 return None;
125 }
126
127 matched_bytes += lowered.len();
128
129 if matched_bytes == pattern_lower.len() {
130 return Some(start + offset + ch.len_utf8());
131 }
132 }
133
134 None
135 }
136
137 fn find_case_insensitive_match(text: &str, pattern_lower: &str, search_start: usize) -> Option<(usize, usize)> {
140 if pattern_lower.is_empty() || search_start >= text.len() || !text.is_char_boundary(search_start) {
141 return None;
142 }
143
144 for (offset, _) in text[search_start..].char_indices() {
145 let start = search_start + offset;
146 if let Some(end) = Self::match_case_insensitive_at(text, start, pattern_lower) {
147 return Some((start, end));
148 }
149 }
150
151 None
152 }
153
154 fn proper_name_canonical_forms(&self, text: &str) -> std::collections::HashMap<usize, &str> {
160 let mut map = std::collections::HashMap::new();
161
162 for name in &self.proper_names {
163 if name.is_empty() {
164 continue;
165 }
166 let name_lower = name.to_lowercase();
167 let canonical_words: Vec<&str> = name.split_whitespace().collect();
168 if canonical_words.is_empty() {
169 continue;
170 }
171 let mut search_start = 0;
172
173 while search_start < text.len() {
174 let Some((abs_pos, end_pos)) = Self::find_case_insensitive_match(text, &name_lower, search_start)
175 else {
176 break;
177 };
178
179 let before_ok = abs_pos == 0 || !text[..abs_pos].chars().last().is_some_and(char::is_alphanumeric);
181 let after_ok =
182 end_pos >= text.len() || !text[end_pos..].chars().next().is_some_and(char::is_alphanumeric);
183
184 if before_ok && after_ok {
185 let text_slice = &text[abs_pos..end_pos];
189 let mut word_idx = 0;
190 let mut slice_offset = 0;
191
192 for text_word in text_slice.split_whitespace() {
193 if let Some(w_rel) = text_slice[slice_offset..].find(text_word) {
194 let word_abs = abs_pos + slice_offset + w_rel;
195 if let Some(&canonical_word) = canonical_words.get(word_idx) {
196 map.insert(word_abs, canonical_word);
197 }
198 slice_offset += w_rel + text_word.len();
199 word_idx += 1;
200 }
201 }
202 }
203
204 search_start = abs_pos + text[abs_pos..].chars().next().map_or(1, char::len_utf8);
207 }
208 }
209
210 map
211 }
212
213 fn has_internal_capitals(&self, word: &str) -> bool {
215 let chars: Vec<char> = word.chars().collect();
216 if chars.len() < 2 {
217 return false;
218 }
219
220 let first = chars[0];
221 let rest = &chars[1..];
222 let has_upper_in_rest = rest.iter().any(|c| c.is_uppercase());
223 let has_lower_in_rest = rest.iter().any(|c| c.is_lowercase());
224
225 if has_upper_in_rest && has_lower_in_rest {
227 return true;
228 }
229
230 if first.is_lowercase() && has_upper_in_rest {
232 return true;
233 }
234
235 false
236 }
237
238 fn is_all_caps_acronym(&self, word: &str) -> bool {
242 if word.len() < 2 {
244 return false;
245 }
246
247 let mut consecutive_upper = 0;
248 let mut max_consecutive = 0;
249
250 for c in word.chars() {
251 if c.is_uppercase() {
252 consecutive_upper += 1;
253 max_consecutive = max_consecutive.max(consecutive_upper);
254 } else if c.is_lowercase() {
255 return false;
257 } else {
258 consecutive_upper = 0;
260 }
261 }
262
263 max_consecutive >= 2
265 }
266
267 fn should_preserve_word(&self, word: &str) -> bool {
269 if self.config.ignore_words.iter().any(|w| w == word) {
271 return true;
272 }
273
274 let is_ordinal = Self::is_numeric_ordinal(word);
280
281 if !is_ordinal {
282 if self.config.preserve_cased_words && self.has_internal_capitals(word) {
284 return true;
285 }
286
287 if self.config.preserve_cased_words && self.is_all_caps_acronym(word) {
289 return true;
290 }
291 }
292
293 if self.is_caret_notation(word) {
295 return true;
296 }
297
298 false
299 }
300
301 fn is_numeric_ordinal(word: &str) -> bool {
309 let bytes = word.as_bytes();
310
311 let alpha_start = match bytes.iter().position(|&b| !b.is_ascii_digit()) {
313 Some(pos) if pos > 0 => pos,
314 _ => return false,
315 };
316
317 let alpha_end = bytes[alpha_start..]
319 .iter()
320 .position(|b| !b.is_ascii_alphabetic())
321 .map_or(bytes.len(), |p| alpha_start + p);
322
323 let suffix = &word[alpha_start..alpha_end];
324 matches!(suffix.to_ascii_lowercase().as_str(), "st" | "nd" | "rd" | "th")
325 }
326
327 fn is_caret_notation(&self, word: &str) -> bool {
329 let chars: Vec<char> = word.chars().collect();
330 if chars.len() >= 2 && chars[0] == '^' {
332 let second = chars[1];
333 if second.is_ascii_uppercase() || "@[\\]^_".contains(second) {
335 return true;
336 }
337 }
338 false
339 }
340
341 fn is_lowercase_word(&self, word: &str) -> bool {
343 self.lowercase_set.contains(&word.to_lowercase())
344 }
345
346 fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
348 if word.is_empty() {
349 return word.to_string();
350 }
351
352 if self.should_preserve_word(word) {
354 return word.to_string();
355 }
356
357 if is_first || is_last {
359 return self.capitalize_first(word);
360 }
361
362 if self.is_lowercase_word(word) {
364 return Self::lowercase_preserving_composition(word);
365 }
366
367 self.capitalize_first(word)
369 }
370
371 fn apply_canonical_form_to_word(word: &str, canonical: &str) -> String {
374 let canonical_lower = canonical.to_lowercase();
375 if canonical_lower.is_empty() {
376 return canonical.to_string();
377 }
378
379 if let Some(end_pos) = Self::match_case_insensitive_at(word, 0, &canonical_lower) {
380 let mut out = String::with_capacity(canonical.len() + word.len().saturating_sub(end_pos));
381 out.push_str(canonical);
382 out.push_str(&word[end_pos..]);
383 out
384 } else {
385 canonical.to_string()
386 }
387 }
388
389 fn capitalize_first(&self, word: &str) -> String {
391 if word.is_empty() {
392 return String::new();
393 }
394
395 let first_alpha_pos = word.find(|c: char| c.is_alphabetic());
397 let Some(pos) = first_alpha_pos else {
398 return word.to_string();
399 };
400
401 let prefix = &word[..pos];
402 let suffix = &word[pos..];
403
404 if Self::is_numeric_ordinal(word) {
407 let suffix_lower = Self::lowercase_preserving_composition(suffix);
408 return format!("{prefix}{suffix_lower}");
409 }
410
411 let mut chars = suffix.chars();
412 let first = chars.next().unwrap();
413 let first_upper = Self::uppercase_preserving_composition(&first.to_string());
416 let rest: String = chars.collect();
417 let rest_lower = Self::lowercase_preserving_composition(&rest);
418 format!("{prefix}{first_upper}{rest_lower}")
419 }
420
421 fn lowercase_preserving_composition(s: &str) -> String {
424 let mut result = String::with_capacity(s.len());
425 for c in s.chars() {
426 let lower: String = c.to_lowercase().collect();
427 if lower.chars().count() == 1 {
428 result.push_str(&lower);
429 } else {
430 result.push(c);
432 }
433 }
434 result
435 }
436
437 fn uppercase_preserving_composition(s: &str) -> String {
442 let mut result = String::with_capacity(s.len());
443 for c in s.chars() {
444 let upper: String = c.to_uppercase().collect();
445 if upper.chars().count() == 1 {
446 result.push_str(&upper);
447 } else {
448 result.push(c);
450 }
451 }
452 result
453 }
454
455 fn apply_title_case(&self, text: &str) -> String {
459 let canonical_forms = self.proper_name_canonical_forms(text);
460
461 let original_words: Vec<&str> = text.split_whitespace().collect();
462 let total_words = original_words.len();
463
464 let mut word_positions: Vec<usize> = Vec::with_capacity(original_words.len());
467 let mut pos = 0;
468 for word in &original_words {
469 if let Some(rel) = text[pos..].find(word) {
470 word_positions.push(pos + rel);
471 pos = pos + rel + word.len();
472 } else {
473 word_positions.push(usize::MAX);
474 }
475 }
476
477 let result_words: Vec<String> = original_words
478 .iter()
479 .enumerate()
480 .map(|(i, word)| {
481 let after_period = i > 0 && original_words[i - 1].ends_with('.');
482 let is_first = i == 0 || after_period;
483 let is_last = i == total_words - 1;
484
485 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
487 return Self::apply_canonical_form_to_word(word, canonical);
488 }
489
490 if self.should_preserve_word(word) {
492 return (*word).to_string();
493 }
494
495 if word.contains('-') {
497 return self.handle_hyphenated_word(word, is_first, is_last);
498 }
499
500 self.title_case_word(word, is_first, is_last)
501 })
502 .collect();
503
504 result_words.join(" ")
505 }
506
507 fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
509 let parts: Vec<&str> = word.split('-').collect();
510 let total_parts = parts.len();
511
512 let result_parts: Vec<String> = parts
513 .iter()
514 .enumerate()
515 .map(|(i, part)| {
516 let part_is_first = is_first && i == 0;
518 let part_is_last = is_last && i == total_parts - 1;
519 self.title_case_word(part, part_is_first, part_is_last)
520 })
521 .collect();
522
523 result_parts.join("-")
524 }
525
526 fn 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) -> 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 segments = self.parse_segments(main_text);
742
743 let text_segments: Vec<usize> = segments
745 .iter()
746 .enumerate()
747 .filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
748 .collect();
749
750 let first_segment_is_text = segments.first().is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
754
755 let last_segment_is_text = segments.last().is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
759
760 let mut result_parts: Vec<String> = Vec::new();
762
763 let mut at_sentence_start = first_segment_is_text;
767
768 for (i, segment) in segments.iter().enumerate() {
769 at_sentence_start = match segment {
774 HeadingSegment::Text(t) => {
775 let is_first_text = text_segments.first() == Some(&i);
776 let is_last_text = text_segments.last() == Some(&i) && last_segment_is_text;
780
781 let capitalized = match self.config.style {
782 HeadingCapStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
783 HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(t, at_sentence_start),
784 HeadingCapStyle::AllCaps => self.apply_all_caps(t),
785 };
786 let ends_sentence = self.ends_sentence(capitalized.trim_end());
787 result_parts.push(capitalized);
788 ends_sentence
789 }
790 HeadingSegment::Code(c) => {
791 result_parts.push(c.clone());
792 false
793 }
794 HeadingSegment::Link {
795 full,
796 text_start,
797 text_end,
798 } => {
799 let link_text = &full[*text_start..*text_end];
801 let capitalized_text = match self.config.style {
802 HeadingCapStyle::TitleCase => self.apply_title_case(link_text),
803 HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(link_text, at_sentence_start),
806 HeadingCapStyle::AllCaps => self.apply_all_caps(link_text),
807 };
808 let ends_sentence = self.ends_sentence(capitalized_text.trim_end());
811
812 let mut new_link = String::new();
813 new_link.push_str(&full[..*text_start]);
814 new_link.push_str(&capitalized_text);
815 new_link.push_str(&full[*text_end..]);
816 result_parts.push(new_link);
817 ends_sentence
818 }
819 HeadingSegment::Html(h) => {
820 result_parts.push(h.clone());
822 false
823 }
824 HeadingSegment::Image(img) => {
825 result_parts.push(img.clone());
827 false
828 }
829 };
830 }
831
832 let mut result = result_parts.join("");
833
834 if let Some(id) = custom_id {
836 result.push_str(id);
837 }
838
839 result
840 }
841
842 fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
844 let canonical_forms = self.proper_name_canonical_forms(text);
845 let words: Vec<&str> = text.split_whitespace().collect();
846 let total_words = words.len();
847
848 if total_words == 0 {
849 return text.to_string();
850 }
851
852 let mut word_positions: Vec<usize> = Vec::with_capacity(words.len());
855 let mut pos = 0;
856 for word in &words {
857 if let Some(rel) = text[pos..].find(word) {
858 word_positions.push(pos + rel);
859 pos = pos + rel + word.len();
860 } else {
861 word_positions.push(usize::MAX);
862 }
863 }
864
865 let result_words: Vec<String> = words
866 .iter()
867 .enumerate()
868 .map(|(i, word)| {
869 let after_period = i > 0 && words[i - 1].ends_with('.');
870 let is_first = (is_first_segment && i == 0) || after_period;
871 let is_last = is_last_segment && i == total_words - 1;
872
873 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
875 return Self::apply_canonical_form_to_word(word, canonical);
876 }
877
878 if word.contains('-') {
880 return self.handle_hyphenated_word(word, is_first, is_last);
881 }
882
883 self.title_case_word(word, is_first, is_last)
884 })
885 .collect();
886
887 let mut result = String::new();
889 let mut word_iter = result_words.iter();
890 let mut in_word = false;
891
892 for c in text.chars() {
893 if c.is_whitespace() {
894 if in_word {
895 in_word = false;
896 }
897 result.push(c);
898 } else if !in_word {
899 if let Some(word) = word_iter.next() {
900 result.push_str(word);
901 }
902 in_word = true;
903 }
904 }
905
906 result
907 }
908
909 fn get_line_byte_range(&self, content: &str, line_num: usize, line_index: &LineIndex) -> Range<usize> {
911 let start_pos = line_index.get_line_start_byte(line_num).unwrap_or(content.len());
912 let line = content.lines().nth(line_num - 1).unwrap_or("");
913 Range {
914 start: start_pos,
915 end: start_pos + line.len(),
916 }
917 }
918
919 fn fix_atx_heading(&self, _line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
921 let indent = " ".repeat(heading.marker_column);
923 let hashes = "#".repeat(heading.level as usize);
924
925 let fixed_text = self.apply_capitalization(&heading.raw_text);
927
928 let closing = &heading.closing_sequence;
930 if heading.has_closing_sequence {
931 format!("{indent}{hashes} {fixed_text} {closing}")
932 } else {
933 format!("{indent}{hashes} {fixed_text}")
934 }
935 }
936
937 fn fix_setext_heading(&self, line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
939 let fixed_text = self.apply_capitalization(&heading.raw_text);
941
942 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
944
945 format!("{leading_ws}{fixed_text}")
946 }
947}
948
949impl Rule for MD063HeadingCapitalization {
950 fn name(&self) -> &'static str {
951 "MD063"
952 }
953
954 fn description(&self) -> &'static str {
955 "Heading capitalization"
956 }
957
958 fn category(&self) -> RuleCategory {
959 RuleCategory::Heading
960 }
961
962 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
963 !ctx.likely_has_headings() || !ctx.lines.iter().any(|line| line.heading.is_some())
964 }
965
966 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
967 let content = ctx.content;
968
969 if content.is_empty() {
970 return Ok(Vec::new());
971 }
972
973 let mut warnings = Vec::new();
974 let line_index = &ctx.line_index;
975
976 for (line_num, line_info) in ctx.lines.iter().enumerate() {
977 if let Some(heading) = &line_info.heading {
978 if heading.level < self.config.min_level || heading.level > self.config.max_level {
980 continue;
981 }
982
983 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
985 continue;
986 }
987
988 if !heading.is_valid {
990 continue;
991 }
992
993 let original_text = &heading.raw_text;
995 let fixed_text = self.apply_capitalization(original_text);
996
997 if original_text != &fixed_text {
998 let line = line_info.content(ctx.content);
999 let style_name = match self.config.style {
1000 HeadingCapStyle::TitleCase => "title case",
1001 HeadingCapStyle::SentenceCase => "sentence case",
1002 HeadingCapStyle::AllCaps => "ALL CAPS",
1003 };
1004
1005 warnings.push(LintWarning {
1006 rule_name: Some(self.name().to_string()),
1007 line: line_num + 1,
1008 column: byte_to_char_count(line, heading.content_column),
1009 end_line: line_num + 1,
1010 end_column: byte_to_char_count(line, heading.content_column) + original_text.chars().count(),
1011 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1012 severity: Severity::Warning,
1013 fix: Some(Fix::new(
1014 self.get_line_byte_range(content, line_num + 1, line_index),
1015 match heading.style {
1016 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
1017 _ => self.fix_setext_heading(line, heading),
1018 },
1019 )),
1020 });
1021 }
1022 }
1023 }
1024
1025 Ok(warnings)
1026 }
1027
1028 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1029 let content = ctx.content;
1030
1031 if content.is_empty() {
1032 return Ok(content.to_string());
1033 }
1034
1035 let lines = ctx.raw_lines();
1036 let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1037
1038 for (line_num, line_info) in ctx.lines.iter().enumerate() {
1039 if ctx.is_rule_disabled(self.name(), line_num + 1) {
1041 continue;
1042 }
1043
1044 if let Some(heading) = &line_info.heading {
1045 if heading.level < self.config.min_level || heading.level > self.config.max_level {
1047 continue;
1048 }
1049
1050 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
1052 continue;
1053 }
1054
1055 if !heading.is_valid {
1057 continue;
1058 }
1059
1060 let original_text = &heading.raw_text;
1061 let fixed_text = self.apply_capitalization(original_text);
1062
1063 if original_text != &fixed_text {
1064 let line = line_info.content(ctx.content);
1065 fixed_lines[line_num] = match heading.style {
1066 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
1067 _ => self.fix_setext_heading(line, heading),
1068 };
1069 }
1070 }
1071 }
1072
1073 let mut result = String::with_capacity(content.len());
1075 for (i, line) in fixed_lines.iter().enumerate() {
1076 result.push_str(line);
1077 if i < fixed_lines.len() - 1 || content.ends_with('\n') {
1078 result.push('\n');
1079 }
1080 }
1081
1082 Ok(result)
1083 }
1084
1085 fn as_any(&self) -> &dyn std::any::Any {
1086 self
1087 }
1088
1089 crate::impl_rule_config_sections!(MD063Config);
1090
1091 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1092 where
1093 Self: Sized,
1094 {
1095 let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
1096 let md044_config =
1097 crate::rule_config_serde::load_rule_config::<crate::rules::md044_proper_names::MD044Config>(config);
1098 let mut rule = Self::from_config_struct(rule_config);
1099 rule.proper_names = md044_config.names;
1100 Box::new(rule)
1101 }
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106 use super::*;
1107 use crate::lint_context::LintContext;
1108
1109 fn create_rule() -> MD063HeadingCapitalization {
1110 let config = MD063Config {
1111 enabled: true,
1112 ..Default::default()
1113 };
1114 MD063HeadingCapitalization::from_config_struct(config)
1115 }
1116
1117 fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
1118 let config = MD063Config {
1119 enabled: true,
1120 style,
1121 ..Default::default()
1122 };
1123 MD063HeadingCapitalization::from_config_struct(config)
1124 }
1125
1126 #[test]
1128 fn test_title_case_basic() {
1129 let rule = create_rule();
1130 let content = "# hello world\n";
1131 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1132 let result = rule.check(&ctx).unwrap();
1133 assert_eq!(result.len(), 1);
1134 assert!(result[0].message.contains("Hello World"));
1135 }
1136
1137 #[test]
1138 fn test_title_case_lowercase_words() {
1139 let rule = create_rule();
1140 let content = "# the quick brown fox\n";
1141 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1142 let result = rule.check(&ctx).unwrap();
1143 assert_eq!(result.len(), 1);
1144 assert!(result[0].message.contains("The Quick Brown Fox"));
1146 }
1147
1148 #[test]
1149 fn test_title_case_already_correct() {
1150 let rule = create_rule();
1151 let content = "# The Quick Brown Fox\n";
1152 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1153 let result = rule.check(&ctx).unwrap();
1154 assert!(result.is_empty(), "Already correct heading should not be flagged");
1155 }
1156
1157 #[test]
1158 fn test_title_case_hyphenated() {
1159 let rule = create_rule();
1160 let content = "# self-documenting code\n";
1161 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1162 let result = rule.check(&ctx).unwrap();
1163 assert_eq!(result.len(), 1);
1164 assert!(result[0].message.contains("Self-Documenting Code"));
1165 }
1166
1167 #[test]
1168 fn test_title_case_preserves_url_with_nested_parens() {
1169 let rule = create_rule();
1170 let content = "# guide for [the api](https://example.com/docs/v(2)beta)\n";
1172 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1173 let fixed = rule.fix(&ctx).unwrap();
1174 assert!(
1177 fixed.contains("https://example.com/docs/v(2)beta"),
1178 "URL with nested parens was corrupted: {fixed:?}"
1179 );
1180 }
1181
1182 #[test]
1183 fn test_title_case_does_not_recase_image_alt() {
1184 let rule = create_rule();
1185 let content = "# overview \n";
1186 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1187 let fixed = rule.fix(&ctx).unwrap();
1188 assert!(
1190 fixed.contains(""),
1191 "image alt text was modified: {fixed:?}"
1192 );
1193 assert!(
1194 fixed.contains("# Overview"),
1195 "surrounding prose should still be title-cased: {fixed:?}"
1196 );
1197 }
1198
1199 #[test]
1201 fn test_sentence_case_basic() {
1202 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1203 let content = "# The Quick Brown Fox\n";
1204 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1205 let result = rule.check(&ctx).unwrap();
1206 assert_eq!(result.len(), 1);
1207 assert!(result[0].message.contains("The quick brown fox"));
1208 }
1209
1210 #[test]
1211 fn test_sentence_case_already_correct() {
1212 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1213 let content = "# The quick brown fox\n";
1214 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1215 let result = rule.check(&ctx).unwrap();
1216 assert!(result.is_empty());
1217 }
1218
1219 #[test]
1221 fn test_all_caps_basic() {
1222 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1223 let content = "# hello world\n";
1224 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1225 let result = rule.check(&ctx).unwrap();
1226 assert_eq!(result.len(), 1);
1227 assert!(result[0].message.contains("HELLO WORLD"));
1228 }
1229
1230 #[test]
1232 fn test_preserve_ignore_words() {
1233 let config = MD063Config {
1234 enabled: true,
1235 ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
1236 ..Default::default()
1237 };
1238 let rule = MD063HeadingCapitalization::from_config_struct(config);
1239
1240 let content = "# using iPhone on macOS\n";
1241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1242 let result = rule.check(&ctx).unwrap();
1243 assert_eq!(result.len(), 1);
1244 assert!(result[0].message.contains("iPhone"));
1246 assert!(result[0].message.contains("macOS"));
1247 }
1248
1249 #[test]
1250 fn test_preserve_cased_words() {
1251 let rule = create_rule();
1252 let content = "# using GitHub actions\n";
1253 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1254 let result = rule.check(&ctx).unwrap();
1255 assert_eq!(result.len(), 1);
1256 assert!(result[0].message.contains("GitHub"));
1258 }
1259
1260 #[test]
1262 fn test_inline_code_preserved() {
1263 let rule = create_rule();
1264 let content = "# using `const` in javascript\n";
1265 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1266 let result = rule.check(&ctx).unwrap();
1267 assert_eq!(result.len(), 1);
1268 assert!(result[0].message.contains("`const`"));
1270 assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
1271 }
1272
1273 #[test]
1275 fn test_level_filter() {
1276 let config = MD063Config {
1277 enabled: true,
1278 min_level: 2,
1279 max_level: 4,
1280 ..Default::default()
1281 };
1282 let rule = MD063HeadingCapitalization::from_config_struct(config);
1283
1284 let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
1285 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1286 let result = rule.check(&ctx).unwrap();
1287
1288 assert_eq!(result.len(), 2);
1290 assert_eq!(result[0].line, 2); assert_eq!(result[1].line, 3); }
1293
1294 #[test]
1296 fn test_fix_atx_heading() {
1297 let rule = create_rule();
1298 let content = "# hello world\n";
1299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1300 let fixed = rule.fix(&ctx).unwrap();
1301 assert_eq!(fixed, "# Hello World\n");
1302 }
1303
1304 #[test]
1305 fn test_fix_multiple_headings() {
1306 let rule = create_rule();
1307 let content = "# first heading\n\n## second heading\n";
1308 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1309 let fixed = rule.fix(&ctx).unwrap();
1310 assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
1311 }
1312
1313 #[test]
1315 fn test_setext_heading() {
1316 let rule = create_rule();
1317 let content = "hello world\n============\n";
1318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1319 let result = rule.check(&ctx).unwrap();
1320 assert_eq!(result.len(), 1);
1321 assert!(result[0].message.contains("Hello World"));
1322 }
1323
1324 #[test]
1326 fn test_custom_id_preserved() {
1327 let rule = create_rule();
1328 let content = "# getting started {#intro}\n";
1329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1330 let result = rule.check(&ctx).unwrap();
1331 assert_eq!(result.len(), 1);
1332 assert!(result[0].message.contains("{#intro}"));
1334 }
1335
1336 #[test]
1338 fn test_skip_obsidian_tags_not_headings() {
1339 let rule = create_rule();
1340
1341 let content = "# H1\n\n#tag\n";
1343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1344 let result = rule.check(&ctx).unwrap();
1345 assert!(
1346 result.is_empty() || result.iter().all(|w| w.line != 3),
1347 "Obsidian tag #tag should not be treated as a heading: {result:?}"
1348 );
1349 }
1350
1351 #[test]
1352 fn test_skip_invalid_atx_headings_no_space() {
1353 let rule = create_rule();
1354
1355 let content = "#notaheading\n";
1357 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1358 let result = rule.check(&ctx).unwrap();
1359 assert!(
1360 result.is_empty(),
1361 "Invalid ATX heading without space should not be flagged: {result:?}"
1362 );
1363 }
1364
1365 #[test]
1366 fn test_fix_skips_obsidian_tags() {
1367 let rule = create_rule();
1368
1369 let content = "# hello world\n\n#tag\n";
1370 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1371 let fixed = rule.fix(&ctx).unwrap();
1372 assert!(fixed.contains("#tag"), "Fix should not modify Obsidian tag #tag");
1374 assert!(fixed.contains("# Hello World"), "Fix should still fix real headings");
1375 }
1376
1377 #[test]
1378 fn test_preserve_all_caps_acronyms() {
1379 let rule = create_rule();
1380 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1381
1382 let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
1384 assert_eq!(fixed, "# Using API in Production\n");
1385
1386 let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
1388 assert_eq!(fixed, "# API and GPU Integration\n");
1389
1390 let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
1392 assert_eq!(fixed, "# IO Performance Guide\n");
1393
1394 let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
1396 assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
1397 }
1398
1399 #[test]
1400 fn test_preserve_acronyms_in_hyphenated_words() {
1401 let rule = create_rule();
1402 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1403
1404 let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
1406 assert_eq!(fixed, "# API-Driven Architecture\n");
1407
1408 let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
1410 assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
1411 }
1412
1413 #[test]
1414 fn test_single_letters_not_treated_as_acronyms() {
1415 let rule = create_rule();
1416 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1417
1418 let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
1420 assert_eq!(fixed, "# I Am a Heading\n");
1421 }
1422
1423 #[test]
1424 fn test_lowercase_terms_need_ignore_words() {
1425 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1426
1427 let rule = create_rule();
1429 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1430 assert_eq!(fixed, "# Using Npm Packages\n");
1431
1432 let config = MD063Config {
1434 enabled: true,
1435 ignore_words: vec!["npm".to_string()],
1436 ..Default::default()
1437 };
1438 let rule = MD063HeadingCapitalization::from_config_struct(config);
1439 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1440 assert_eq!(fixed, "# Using npm Packages\n");
1441 }
1442
1443 #[test]
1444 fn test_acronyms_with_mixed_case_preserved() {
1445 let rule = create_rule();
1446 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1447
1448 let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
1450 assert_eq!(fixed, "# Using API with GitHub\n");
1451 }
1452
1453 #[test]
1454 fn test_real_world_acronyms() {
1455 let rule = create_rule();
1456 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1457
1458 let content = "# FFI bindings for CPU optimization\n";
1460 let fixed = rule.fix(&ctx(content)).unwrap();
1461 assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
1462
1463 let content = "# DOM manipulation and SSR rendering\n";
1464 let fixed = rule.fix(&ctx(content)).unwrap();
1465 assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
1466
1467 let content = "# CVE security and RNN models\n";
1468 let fixed = rule.fix(&ctx(content)).unwrap();
1469 assert_eq!(fixed, "# CVE Security and RNN Models\n");
1470 }
1471
1472 #[test]
1473 fn test_is_all_caps_acronym() {
1474 let rule = create_rule();
1475
1476 assert!(rule.is_all_caps_acronym("API"));
1478 assert!(rule.is_all_caps_acronym("IO"));
1479 assert!(rule.is_all_caps_acronym("GPU"));
1480 assert!(rule.is_all_caps_acronym("HTTP2")); assert!(!rule.is_all_caps_acronym("A"));
1484 assert!(!rule.is_all_caps_acronym("I"));
1485
1486 assert!(!rule.is_all_caps_acronym("Api"));
1488 assert!(!rule.is_all_caps_acronym("npm"));
1489 assert!(!rule.is_all_caps_acronym("iPhone"));
1490 }
1491
1492 #[test]
1493 fn test_sentence_case_ignore_words_first_word() {
1494 let config = MD063Config {
1495 enabled: true,
1496 style: HeadingCapStyle::SentenceCase,
1497 ignore_words: vec!["nvim".to_string()],
1498 ..Default::default()
1499 };
1500 let rule = MD063HeadingCapitalization::from_config_struct(config);
1501
1502 let content = "# nvim config\n";
1504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1505 let result = rule.check(&ctx).unwrap();
1506 assert!(
1507 result.is_empty(),
1508 "nvim in ignore-words should not be flagged. Got: {result:?}"
1509 );
1510
1511 let fixed = rule.fix(&ctx).unwrap();
1513 assert_eq!(fixed, "# nvim config\n");
1514 }
1515
1516 #[test]
1517 fn test_sentence_case_ignore_words_not_first() {
1518 let config = MD063Config {
1519 enabled: true,
1520 style: HeadingCapStyle::SentenceCase,
1521 ignore_words: vec!["nvim".to_string()],
1522 ..Default::default()
1523 };
1524 let rule = MD063HeadingCapitalization::from_config_struct(config);
1525
1526 let content = "# Using nvim editor\n";
1528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1529 let result = rule.check(&ctx).unwrap();
1530 assert!(
1531 result.is_empty(),
1532 "nvim in ignore-words should be preserved. Got: {result:?}"
1533 );
1534 }
1535
1536 #[test]
1537 fn test_preserve_cased_words_ios() {
1538 let config = MD063Config {
1539 enabled: true,
1540 style: HeadingCapStyle::SentenceCase,
1541 preserve_cased_words: true,
1542 ..Default::default()
1543 };
1544 let rule = MD063HeadingCapitalization::from_config_struct(config);
1545
1546 let content = "## This is iOS\n";
1548 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1549 let result = rule.check(&ctx).unwrap();
1550 assert!(
1551 result.is_empty(),
1552 "iOS should be preserved with preserve-cased-words. Got: {result:?}"
1553 );
1554
1555 let fixed = rule.fix(&ctx).unwrap();
1557 assert_eq!(fixed, "## This is iOS\n");
1558 }
1559
1560 #[test]
1561 fn test_preserve_cased_words_ios_title_case() {
1562 let config = MD063Config {
1563 enabled: true,
1564 style: HeadingCapStyle::TitleCase,
1565 preserve_cased_words: true,
1566 ..Default::default()
1567 };
1568 let rule = MD063HeadingCapitalization::from_config_struct(config);
1569
1570 let content = "# developing for iOS\n";
1572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1573 let fixed = rule.fix(&ctx).unwrap();
1574 assert_eq!(fixed, "# Developing for iOS\n");
1575 }
1576
1577 #[test]
1578 fn test_has_internal_capitals_ios() {
1579 let rule = create_rule();
1580
1581 assert!(
1583 rule.has_internal_capitals("iOS"),
1584 "iOS has mixed case (lowercase i, uppercase OS)"
1585 );
1586
1587 assert!(rule.has_internal_capitals("iPhone"));
1589 assert!(rule.has_internal_capitals("macOS"));
1590 assert!(rule.has_internal_capitals("GitHub"));
1591 assert!(rule.has_internal_capitals("JavaScript"));
1592 assert!(rule.has_internal_capitals("eBay"));
1593
1594 assert!(!rule.has_internal_capitals("API"));
1596 assert!(!rule.has_internal_capitals("GPU"));
1597
1598 assert!(!rule.has_internal_capitals("npm"));
1600 assert!(!rule.has_internal_capitals("config"));
1601
1602 assert!(!rule.has_internal_capitals("The"));
1604 assert!(!rule.has_internal_capitals("Hello"));
1605 }
1606
1607 #[test]
1608 fn test_lowercase_words_before_trailing_code() {
1609 let config = MD063Config {
1610 enabled: true,
1611 style: HeadingCapStyle::TitleCase,
1612 lowercase_words: vec![
1613 "a".to_string(),
1614 "an".to_string(),
1615 "and".to_string(),
1616 "at".to_string(),
1617 "but".to_string(),
1618 "by".to_string(),
1619 "for".to_string(),
1620 "from".to_string(),
1621 "into".to_string(),
1622 "nor".to_string(),
1623 "on".to_string(),
1624 "onto".to_string(),
1625 "or".to_string(),
1626 "the".to_string(),
1627 "to".to_string(),
1628 "upon".to_string(),
1629 "via".to_string(),
1630 "vs".to_string(),
1631 "with".to_string(),
1632 "without".to_string(),
1633 ],
1634 preserve_cased_words: true,
1635 ..Default::default()
1636 };
1637 let rule = MD063HeadingCapitalization::from_config_struct(config);
1638
1639 let content = "## subtitle with a `app`\n";
1644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1645 let result = rule.check(&ctx).unwrap();
1646
1647 assert!(!result.is_empty(), "Should flag incorrect capitalization");
1649 let fixed = rule.fix(&ctx).unwrap();
1650 assert!(
1652 fixed.contains("with a `app`"),
1653 "Expected 'with a `app`' but got: {fixed:?}"
1654 );
1655 assert!(
1656 !fixed.contains("with A `app`"),
1657 "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
1658 );
1659 assert!(
1661 fixed.contains("Subtitle with a `app`"),
1662 "Expected 'Subtitle with a `app`' but got: {fixed:?}"
1663 );
1664 }
1665
1666 #[test]
1667 fn test_lowercase_words_preserved_before_trailing_code_variant() {
1668 let config = MD063Config {
1669 enabled: true,
1670 style: HeadingCapStyle::TitleCase,
1671 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1672 ..Default::default()
1673 };
1674 let rule = MD063HeadingCapitalization::from_config_struct(config);
1675
1676 let content = "## Title with the `code`\n";
1678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1679 let fixed = rule.fix(&ctx).unwrap();
1680 assert!(
1682 fixed.contains("with the `code`"),
1683 "Expected 'with the `code`' but got: {fixed:?}"
1684 );
1685 assert!(
1686 !fixed.contains("with The `code`"),
1687 "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
1688 );
1689 }
1690
1691 #[test]
1692 fn test_last_word_capitalized_when_no_trailing_code() {
1693 let config = MD063Config {
1696 enabled: true,
1697 style: HeadingCapStyle::TitleCase,
1698 lowercase_words: vec!["a".to_string(), "the".to_string()],
1699 ..Default::default()
1700 };
1701 let rule = MD063HeadingCapitalization::from_config_struct(config);
1702
1703 let content = "## title with a word\n";
1706 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1707 let fixed = rule.fix(&ctx).unwrap();
1708 assert!(
1710 fixed.contains("With a Word"),
1711 "Expected 'With a Word' but got: {fixed:?}"
1712 );
1713 }
1714
1715 #[test]
1716 fn test_multiple_lowercase_words_before_code() {
1717 let config = MD063Config {
1718 enabled: true,
1719 style: HeadingCapStyle::TitleCase,
1720 lowercase_words: vec![
1721 "a".to_string(),
1722 "the".to_string(),
1723 "with".to_string(),
1724 "for".to_string(),
1725 ],
1726 ..Default::default()
1727 };
1728 let rule = MD063HeadingCapitalization::from_config_struct(config);
1729
1730 let content = "## Guide for the `user`\n";
1732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1733 let fixed = rule.fix(&ctx).unwrap();
1734 assert!(
1735 fixed.contains("for the `user`"),
1736 "Expected 'for the `user`' but got: {fixed:?}"
1737 );
1738 assert!(
1739 !fixed.contains("For The `user`"),
1740 "Should not capitalize lowercase words before code. Got: {fixed:?}"
1741 );
1742 }
1743
1744 #[test]
1745 fn test_code_in_middle_normal_rules_apply() {
1746 let config = MD063Config {
1747 enabled: true,
1748 style: HeadingCapStyle::TitleCase,
1749 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1750 ..Default::default()
1751 };
1752 let rule = MD063HeadingCapitalization::from_config_struct(config);
1753
1754 let content = "## Using `const` for the code\n";
1756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1757 let fixed = rule.fix(&ctx).unwrap();
1758 assert!(
1760 fixed.contains("for the Code"),
1761 "Expected 'for the Code' but got: {fixed:?}"
1762 );
1763 }
1764
1765 #[test]
1766 fn test_link_at_end_same_as_code() {
1767 let config = MD063Config {
1768 enabled: true,
1769 style: HeadingCapStyle::TitleCase,
1770 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1771 ..Default::default()
1772 };
1773 let rule = MD063HeadingCapitalization::from_config_struct(config);
1774
1775 let content = "## Guide for the [link](./page.md)\n";
1777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1778 let fixed = rule.fix(&ctx).unwrap();
1779 assert!(
1781 fixed.contains("for the [Link]"),
1782 "Expected 'for the [Link]' but got: {fixed:?}"
1783 );
1784 assert!(
1785 !fixed.contains("for The [Link]"),
1786 "Should not capitalize 'the' before link. Got: {fixed:?}"
1787 );
1788 }
1789
1790 #[test]
1791 fn test_multiple_code_segments() {
1792 let config = MD063Config {
1793 enabled: true,
1794 style: HeadingCapStyle::TitleCase,
1795 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1796 ..Default::default()
1797 };
1798 let rule = MD063HeadingCapitalization::from_config_struct(config);
1799
1800 let content = "## Using `const` with a `variable`\n";
1802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1803 let fixed = rule.fix(&ctx).unwrap();
1804 assert!(
1806 fixed.contains("with a `variable`"),
1807 "Expected 'with a `variable`' but got: {fixed:?}"
1808 );
1809 assert!(
1810 !fixed.contains("with A `variable`"),
1811 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1812 );
1813 }
1814
1815 #[test]
1816 fn test_code_and_link_combination() {
1817 let config = MD063Config {
1818 enabled: true,
1819 style: HeadingCapStyle::TitleCase,
1820 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1821 ..Default::default()
1822 };
1823 let rule = MD063HeadingCapitalization::from_config_struct(config);
1824
1825 let content = "## Guide for the `code` [link](./page.md)\n";
1827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1828 let fixed = rule.fix(&ctx).unwrap();
1829 assert!(
1831 fixed.contains("for the `code`"),
1832 "Expected 'for the `code`' but got: {fixed:?}"
1833 );
1834 }
1835
1836 #[test]
1837 fn test_text_after_code_capitalizes_last() {
1838 let config = MD063Config {
1839 enabled: true,
1840 style: HeadingCapStyle::TitleCase,
1841 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1842 ..Default::default()
1843 };
1844 let rule = MD063HeadingCapitalization::from_config_struct(config);
1845
1846 let content = "## Using `const` for the code\n";
1848 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1849 let fixed = rule.fix(&ctx).unwrap();
1850 assert!(
1852 fixed.contains("for the Code"),
1853 "Expected 'for the Code' but got: {fixed:?}"
1854 );
1855 }
1856
1857 #[test]
1858 fn test_preserve_cased_words_with_trailing_code() {
1859 let config = MD063Config {
1860 enabled: true,
1861 style: HeadingCapStyle::TitleCase,
1862 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1863 preserve_cased_words: true,
1864 ..Default::default()
1865 };
1866 let rule = MD063HeadingCapitalization::from_config_struct(config);
1867
1868 let content = "## Guide for iOS `app`\n";
1870 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1871 let fixed = rule.fix(&ctx).unwrap();
1872 assert!(
1874 fixed.contains("for iOS `app`"),
1875 "Expected 'for iOS `app`' but got: {fixed:?}"
1876 );
1877 assert!(
1878 !fixed.contains("For iOS `app`"),
1879 "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
1880 );
1881 }
1882
1883 #[test]
1884 fn test_ignore_words_with_trailing_code() {
1885 let config = MD063Config {
1886 enabled: true,
1887 style: HeadingCapStyle::TitleCase,
1888 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1889 ignore_words: vec!["npm".to_string()],
1890 ..Default::default()
1891 };
1892 let rule = MD063HeadingCapitalization::from_config_struct(config);
1893
1894 let content = "## Using npm with a `script`\n";
1896 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1897 let fixed = rule.fix(&ctx).unwrap();
1898 assert!(
1900 fixed.contains("npm with a `script`"),
1901 "Expected 'npm with a `script`' but got: {fixed:?}"
1902 );
1903 assert!(
1904 !fixed.contains("with A `script`"),
1905 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1906 );
1907 }
1908
1909 #[test]
1910 fn test_empty_text_segment_edge_case() {
1911 let config = MD063Config {
1912 enabled: true,
1913 style: HeadingCapStyle::TitleCase,
1914 lowercase_words: vec!["a".to_string(), "with".to_string()],
1915 ..Default::default()
1916 };
1917 let rule = MD063HeadingCapitalization::from_config_struct(config);
1918
1919 let content = "## `start` with a `end`\n";
1921 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1922 let fixed = rule.fix(&ctx).unwrap();
1923 assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
1926 assert!(
1927 !fixed.contains("A `end`"),
1928 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1929 );
1930 }
1931
1932 #[test]
1933 fn test_sentence_case_with_trailing_code() {
1934 let config = MD063Config {
1935 enabled: true,
1936 style: HeadingCapStyle::SentenceCase,
1937 lowercase_words: vec!["a".to_string(), "the".to_string()],
1938 ..Default::default()
1939 };
1940 let rule = MD063HeadingCapitalization::from_config_struct(config);
1941
1942 let content = "## guide for the `user`\n";
1944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1945 let fixed = rule.fix(&ctx).unwrap();
1946 assert!(
1948 fixed.contains("Guide for the `user`"),
1949 "Expected 'Guide for the `user`' but got: {fixed:?}"
1950 );
1951 }
1952
1953 #[test]
1954 fn test_hyphenated_word_before_code() {
1955 let config = MD063Config {
1956 enabled: true,
1957 style: HeadingCapStyle::TitleCase,
1958 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1959 ..Default::default()
1960 };
1961 let rule = MD063HeadingCapitalization::from_config_struct(config);
1962
1963 let content = "## Self-contained with a `feature`\n";
1965 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1966 let fixed = rule.fix(&ctx).unwrap();
1967 assert!(
1969 fixed.contains("with a `feature`"),
1970 "Expected 'with a `feature`' but got: {fixed:?}"
1971 );
1972 }
1973
1974 #[test]
1979 fn test_sentence_case_code_at_start_basic() {
1980 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1982 let content = "# `rumdl` is a linter\n";
1983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1984 let result = rule.check(&ctx).unwrap();
1985 assert!(
1987 result.is_empty(),
1988 "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
1989 result.iter().map(|w| &w.message).collect::<Vec<_>>()
1990 );
1991 }
1992
1993 #[test]
1994 fn test_sentence_case_code_at_start_incorrect_capitalization() {
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_eq!(result.len(), 1, "Should detect incorrect capitalization");
2002 assert!(
2003 result[0].message.contains("`rumdl` is a linter"),
2004 "Should suggest lowercase after code. Got: {:?}",
2005 result[0].message
2006 );
2007 }
2008
2009 #[test]
2010 fn test_sentence_case_code_at_start_fix() {
2011 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 fixed = rule.fix(&ctx).unwrap();
2015 assert!(
2016 fixed.contains("# `rumdl` is a linter"),
2017 "Should fix to lowercase after code. Got: {fixed:?}"
2018 );
2019 }
2020
2021 #[test]
2022 fn test_sentence_case_text_at_start_still_capitalizes() {
2023 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2025 let content = "# the quick brown fox\n";
2026 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2027 let result = rule.check(&ctx).unwrap();
2028 assert_eq!(result.len(), 1);
2029 assert!(
2030 result[0].message.contains("The quick brown fox"),
2031 "Text-first heading should capitalize first word. Got: {:?}",
2032 result[0].message
2033 );
2034 }
2035
2036 #[test]
2037 fn test_sentence_case_link_at_start() {
2038 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2040 let content = "# [api](api.md) reference guide\n";
2042 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2043 let result = rule.check(&ctx).unwrap();
2044 assert!(
2046 result.is_empty(),
2047 "Heading with link at start should not capitalize 'reference'. Got: {:?}",
2048 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2049 );
2050 }
2051
2052 #[test]
2053 fn test_sentence_case_link_preserves_acronyms() {
2054 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2056 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_eq!(result.len(), 1);
2060 assert!(
2062 result[0].message.contains("[API](api.md) reference guide"),
2063 "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
2064 result[0].message
2065 );
2066 }
2067
2068 #[test]
2069 fn test_sentence_case_link_preserves_brand_names() {
2070 let config = MD063Config {
2072 enabled: true,
2073 style: HeadingCapStyle::SentenceCase,
2074 preserve_cased_words: true,
2075 ..Default::default()
2076 };
2077 let rule = MD063HeadingCapitalization::from_config_struct(config);
2078 let content = "# [iPhone](iphone.md) Features Guide\n";
2079 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2080 let result = rule.check(&ctx).unwrap();
2081 assert_eq!(result.len(), 1);
2082 assert!(
2084 result[0].message.contains("[iPhone](iphone.md) features guide"),
2085 "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
2086 result[0].message
2087 );
2088 }
2089
2090 #[test]
2091 fn test_sentence_case_link_lowercases_regular_words() {
2092 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2094 let content = "# [Documentation](docs.md) Reference\n";
2095 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2096 let result = rule.check(&ctx).unwrap();
2097 assert_eq!(result.len(), 1);
2098 assert!(
2100 result[0].message.contains("[documentation](docs.md) reference"),
2101 "Should lowercase regular link text. Got: {:?}",
2102 result[0].message
2103 );
2104 }
2105
2106 #[test]
2107 fn test_sentence_case_link_at_start_correct_already() {
2108 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2110 let content = "# [API](api.md) reference guide\n";
2111 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2112 let result = rule.check(&ctx).unwrap();
2113 assert!(
2114 result.is_empty(),
2115 "Correctly cased heading with link should not be flagged. Got: {:?}",
2116 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2117 );
2118 }
2119
2120 #[test]
2121 fn test_sentence_case_link_github_preserved() {
2122 let config = MD063Config {
2124 enabled: true,
2125 style: HeadingCapStyle::SentenceCase,
2126 preserve_cased_words: true,
2127 ..Default::default()
2128 };
2129 let rule = MD063HeadingCapitalization::from_config_struct(config);
2130 let content = "# [GitHub](gh.md) Repository Setup\n";
2131 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2132 let result = rule.check(&ctx).unwrap();
2133 assert_eq!(result.len(), 1);
2134 assert!(
2135 result[0].message.contains("[GitHub](gh.md) repository setup"),
2136 "Should preserve 'GitHub'. Got: {:?}",
2137 result[0].message
2138 );
2139 }
2140
2141 #[test]
2142 fn test_sentence_case_multiple_code_spans() {
2143 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2144 let content = "# `foo` and `bar` are methods\n";
2145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2146 let result = rule.check(&ctx).unwrap();
2147 assert!(
2149 result.is_empty(),
2150 "Should not capitalize words between/after code spans. Got: {:?}",
2151 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2152 );
2153 }
2154
2155 #[test]
2156 fn test_sentence_case_code_only_heading() {
2157 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2159 let content = "# `rumdl`\n";
2160 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2161 let result = rule.check(&ctx).unwrap();
2162 assert!(
2163 result.is_empty(),
2164 "Code-only heading should be fine. Got: {:?}",
2165 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2166 );
2167 }
2168
2169 #[test]
2170 fn test_sentence_case_code_at_end() {
2171 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2173 let content = "# install the `rumdl` tool\n";
2174 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2175 let result = rule.check(&ctx).unwrap();
2176 assert_eq!(result.len(), 1);
2178 assert!(
2179 result[0].message.contains("Install the `rumdl` tool"),
2180 "First word should still be capitalized when text comes first. Got: {:?}",
2181 result[0].message
2182 );
2183 }
2184
2185 #[test]
2186 fn test_sentence_case_code_in_middle() {
2187 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2189 let content = "# using the `rumdl` linter for markdown\n";
2190 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2191 let result = rule.check(&ctx).unwrap();
2192 assert_eq!(result.len(), 1);
2194 assert!(
2195 result[0].message.contains("Using the `rumdl` linter for markdown"),
2196 "First word should be capitalized. Got: {:?}",
2197 result[0].message
2198 );
2199 }
2200
2201 #[test]
2202 fn test_sentence_case_preserved_word_after_code() {
2203 let config = MD063Config {
2205 enabled: true,
2206 style: HeadingCapStyle::SentenceCase,
2207 preserve_cased_words: true,
2208 ..Default::default()
2209 };
2210 let rule = MD063HeadingCapitalization::from_config_struct(config);
2211 let content = "# `swift` iPhone development\n";
2212 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2213 let result = rule.check(&ctx).unwrap();
2214 assert!(
2216 result.is_empty(),
2217 "Preserved words after code should stay. Got: {:?}",
2218 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2219 );
2220 }
2221
2222 #[test]
2223 fn test_title_case_code_at_start_still_capitalizes() {
2224 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2226 let content = "# `api` quick start guide\n";
2227 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2228 let result = rule.check(&ctx).unwrap();
2229 assert_eq!(result.len(), 1);
2231 assert!(
2232 result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
2233 "Title case should capitalize major words after code. Got: {:?}",
2234 result[0].message
2235 );
2236 }
2237
2238 #[test]
2241 fn test_sentence_case_html_tag_at_start() {
2242 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2244 let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
2245 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2246 let result = rule.check(&ctx).unwrap();
2247 assert_eq!(result.len(), 1);
2249 let fixed = rule.fix(&ctx).unwrap();
2250 assert_eq!(
2251 fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
2252 "Text after HTML at start should be lowercase"
2253 );
2254 }
2255
2256 #[test]
2257 fn test_sentence_case_html_tag_preserves_content() {
2258 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2260 let content = "# The <abbr>API</abbr> documentation guide\n";
2261 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2262 let result = rule.check(&ctx).unwrap();
2263 assert!(
2265 result.is_empty(),
2266 "HTML tag content should be preserved. Got: {:?}",
2267 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2268 );
2269 }
2270
2271 #[test]
2272 fn test_sentence_case_html_tag_at_start_with_acronym() {
2273 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2275 let content = "# <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_eq!(result.len(), 1);
2279 let fixed = rule.fix(&ctx).unwrap();
2280 assert_eq!(
2281 fixed, "# <abbr>API</abbr> documentation guide\n",
2282 "Text after HTML at start should be lowercase, HTML content preserved"
2283 );
2284 }
2285
2286 #[test]
2287 fn test_sentence_case_html_tag_in_middle() {
2288 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2290 let content = "# using the <code>config</code> File\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, "# Using the <code>config</code> file\n",
2297 "First word capitalized, HTML preserved, rest lowercase"
2298 );
2299 }
2300
2301 #[test]
2302 fn test_html_tag_strong_emphasis() {
2303 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2305 let content = "# The <strong>Bold</strong> Way\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, "# The <strong>Bold</strong> way\n",
2312 "<strong> tag content should be preserved"
2313 );
2314 }
2315
2316 #[test]
2317 fn test_html_tag_with_attributes() {
2318 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2320 let content = "# <span class=\"highlight\">Important</span> Notice Here\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, "# <span class=\"highlight\">Important</span> notice here\n",
2327 "HTML tag with attributes should be preserved"
2328 );
2329 }
2330
2331 #[test]
2332 fn test_multiple_html_tags() {
2333 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2335 let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\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, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
2342 "Multiple HTML tags should all be preserved"
2343 );
2344 }
2345
2346 #[test]
2347 fn test_html_and_code_mixed() {
2348 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2350 let content = "# <kbd>Ctrl</kbd>+`v` Paste command\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>+`v` paste command\n",
2357 "HTML and code should both be preserved"
2358 );
2359 }
2360
2361 #[test]
2362 fn test_self_closing_html_tag() {
2363 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2365 let content = "# Line one<br/>Line Two Here\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, "# Line one<br/>line two here\n",
2372 "Self-closing HTML tags should be preserved"
2373 );
2374 }
2375
2376 #[test]
2377 fn test_title_case_with_html_tags() {
2378 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2380 let content = "# the <kbd>ctrl</kbd> key is a modifier\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!(
2387 fixed.contains("<kbd>ctrl</kbd>"),
2388 "HTML tag content should be preserved in title case. Got: {fixed}"
2389 );
2390 assert!(
2391 fixed.starts_with("# The ") || fixed.starts_with("# the "),
2392 "Title case should work with HTML. Got: {fixed}"
2393 );
2394 }
2395
2396 #[test]
2399 fn test_sentence_case_preserves_caret_notation() {
2400 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2402 let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
2403 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2404 let result = rule.check(&ctx).unwrap();
2405 assert!(
2407 result.is_empty(),
2408 "Caret notation should be preserved. Got: {:?}",
2409 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2410 );
2411 }
2412
2413 #[test]
2414 fn test_sentence_case_caret_notation_various() {
2415 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2417
2418 let content = "## Press ^C to cancel\n";
2420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2421 let result = rule.check(&ctx).unwrap();
2422 assert!(
2423 result.is_empty(),
2424 "^C should be preserved. Got: {:?}",
2425 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2426 );
2427
2428 let content = "## Use ^Z for background\n";
2430 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2431 let result = rule.check(&ctx).unwrap();
2432 assert!(
2433 result.is_empty(),
2434 "^Z should be preserved. Got: {:?}",
2435 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2436 );
2437
2438 let content = "## Press ^[ for escape\n";
2440 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2441 let result = rule.check(&ctx).unwrap();
2442 assert!(
2443 result.is_empty(),
2444 "^[ should be preserved. Got: {:?}",
2445 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2446 );
2447 }
2448
2449 #[test]
2450 fn test_caret_notation_detection() {
2451 let rule = create_rule();
2452
2453 assert!(rule.is_caret_notation("^A"));
2455 assert!(rule.is_caret_notation("^Z"));
2456 assert!(rule.is_caret_notation("^C"));
2457 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")); }
2469
2470 fn create_sentence_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2477 let config = MD063Config {
2478 enabled: true,
2479 style: HeadingCapStyle::SentenceCase,
2480 ..Default::default()
2481 };
2482 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2483 rule.proper_names = names;
2484 rule
2485 }
2486
2487 #[test]
2488 fn test_sentence_case_preserves_single_word_proper_name() {
2489 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2490 let content = "# installing javascript\n";
2492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2493 let result = rule.check(&ctx).unwrap();
2494 assert_eq!(result.len(), 1, "Should flag the heading");
2495 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2496 assert!(
2497 fix_text.contains("JavaScript"),
2498 "Fix should preserve proper name 'JavaScript', got: {fix_text:?}"
2499 );
2500 assert!(
2501 !fix_text.contains("javascript"),
2502 "Fix should not have lowercase 'javascript', got: {fix_text:?}"
2503 );
2504 }
2505
2506 #[test]
2507 fn test_sentence_case_preserves_multi_word_proper_name() {
2508 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2509 let content = "# using good application features\n";
2511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2512 let result = rule.check(&ctx).unwrap();
2513 assert_eq!(result.len(), 1, "Should flag the heading");
2514 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2515 assert!(
2516 fix_text.contains("Good Application"),
2517 "Fix should preserve 'Good Application' as a phrase, got: {fix_text:?}"
2518 );
2519 }
2520
2521 #[test]
2522 fn test_sentence_case_proper_name_at_start_of_heading() {
2523 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2524 let content = "# good application overview\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 produce 'Good Application' at start of heading, got: {fix_text:?}"
2533 );
2534 assert!(
2535 fix_text.contains("overview"),
2536 "Non-proper-name word 'overview' should be lowercase, got: {fix_text:?}"
2537 );
2538 }
2539
2540 #[test]
2541 fn test_sentence_case_with_proper_names_no_oscillation() {
2542 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2545
2546 let content = "# installing good application on your system\n";
2548 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2549 let result = rule.check(&ctx).unwrap();
2550 assert_eq!(result.len(), 1);
2551 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
2552
2553 assert!(
2555 fixed_heading.contains("Good Application"),
2556 "After fix, proper name must be preserved: {fixed_heading:?}"
2557 );
2558
2559 let fixed_line = format!("{fixed_heading}\n");
2561 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
2562 let result2 = rule.check(&ctx2).unwrap();
2563 assert!(
2564 result2.is_empty(),
2565 "After one fix, heading must already satisfy both MD063 and MD044 - no oscillation. \
2566 Second pass warnings: {result2:?}"
2567 );
2568 }
2569
2570 #[test]
2571 fn test_sentence_case_proper_names_already_correct() {
2572 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2573 let content = "# Installing Good Application\n";
2575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2576 let result = rule.check(&ctx).unwrap();
2577 assert!(
2578 result.is_empty(),
2579 "Correct sentence-case heading with proper name should not be flagged, got: {result:?}"
2580 );
2581 }
2582
2583 #[test]
2584 fn test_sentence_case_multiple_proper_names_in_heading() {
2585 let rule = create_sentence_case_rule_with_proper_names(vec!["TypeScript".to_string(), "React".to_string()]);
2586 let content = "# using typescript with react\n";
2587 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2588 let result = rule.check(&ctx).unwrap();
2589 assert_eq!(result.len(), 1);
2590 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2591 assert!(
2592 fix_text.contains("TypeScript"),
2593 "Fix should preserve 'TypeScript', got: {fix_text:?}"
2594 );
2595 assert!(
2596 fix_text.contains("React"),
2597 "Fix should preserve 'React', got: {fix_text:?}"
2598 );
2599 }
2600
2601 #[test]
2602 fn test_sentence_case_unicode_casefold_expansion_before_proper_name() {
2603 let rule = create_sentence_case_rule_with_proper_names(vec!["Österreich".to_string()]);
2606 let content = "# İ österreich guide\n";
2607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2608
2609 let result = rule.check(&ctx).unwrap();
2611 assert_eq!(result.len(), 1, "Should flag heading for canonical proper-name casing");
2612 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2613 assert!(
2614 fix_text.contains("Österreich"),
2615 "Fix should preserve canonical 'Österreich', got: {fix_text:?}"
2616 );
2617 }
2618
2619 #[test]
2620 fn test_sentence_case_preserves_trailing_punctuation_on_proper_name() {
2621 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2622 let content = "# using javascript, today\n";
2623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2624 let result = rule.check(&ctx).unwrap();
2625 assert_eq!(result.len(), 1, "Should flag heading");
2626 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2627 assert!(
2628 fix_text.contains("JavaScript,"),
2629 "Fix should preserve trailing punctuation, got: {fix_text:?}"
2630 );
2631 }
2632
2633 fn create_title_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2640 let config = MD063Config {
2641 enabled: true,
2642 style: HeadingCapStyle::TitleCase,
2643 ..Default::default()
2644 };
2645 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2646 rule.proper_names = names;
2647 rule
2648 }
2649
2650 #[test]
2651 fn test_title_case_preserves_proper_name_with_lowercase_article() {
2652 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
2656 let content = "# listening to the rolling stones today\n";
2657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2658 let result = rule.check(&ctx).unwrap();
2659 assert_eq!(result.len(), 1, "Should flag the heading");
2660 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2661 assert!(
2662 fix_text.contains("The Rolling Stones"),
2663 "Fix should preserve proper name 'The Rolling Stones', got: {fix_text:?}"
2664 );
2665 }
2666
2667 #[test]
2668 fn test_title_case_proper_name_no_oscillation() {
2669 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);
2675 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
2676
2677 let fixed_line = format!("{fixed_heading}\n");
2678 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
2679 let result2 = rule.check(&ctx2).unwrap();
2680 assert!(
2681 result2.is_empty(),
2682 "After one title-case fix, heading must already satisfy both rules. \
2683 Second pass warnings: {result2:?}"
2684 );
2685 }
2686
2687 #[test]
2688 fn test_title_case_unicode_casefold_expansion_before_proper_name() {
2689 let rule = create_title_case_rule_with_proper_names(vec!["Österreich".to_string()]);
2690 let content = "# İ österreich guide\n";
2691 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2692 let result = rule.check(&ctx).unwrap();
2693 assert_eq!(result.len(), 1, "Should flag the heading");
2694 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2695 assert!(
2696 fix_text.contains("Österreich"),
2697 "Fix should preserve canonical proper-name casing, got: {fix_text:?}"
2698 );
2699 }
2700
2701 #[test]
2707 fn test_from_config_loads_md044_names_into_md063() {
2708 use crate::config::{Config, RuleConfig};
2709 use crate::rule::Rule;
2710 use std::collections::BTreeMap;
2711
2712 let mut config = Config::default();
2713
2714 let mut md063_values = BTreeMap::new();
2716 md063_values.insert("style".to_string(), toml::Value::String("sentence_case".to_string()));
2717 md063_values.insert("enabled".to_string(), toml::Value::Boolean(true));
2718 config.rules.insert(
2719 "MD063".to_string(),
2720 RuleConfig {
2721 values: md063_values,
2722 severity: None,
2723 },
2724 );
2725
2726 let mut md044_values = BTreeMap::new();
2728 md044_values.insert(
2729 "names".to_string(),
2730 toml::Value::Array(vec![toml::Value::String("Good Application".to_string())]),
2731 );
2732 config.rules.insert(
2733 "MD044".to_string(),
2734 RuleConfig {
2735 values: md044_values,
2736 severity: None,
2737 },
2738 );
2739
2740 let rule = MD063HeadingCapitalization::from_config(&config);
2742
2743 let content = "# using good application features\n";
2745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2746 let result = rule.check(&ctx).unwrap();
2747 assert_eq!(result.len(), 1, "Should flag the heading");
2748 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2749 assert!(
2750 fix_text.contains("Good Application"),
2751 "from_config should wire MD044 names into MD063; fix should preserve \
2752 'Good Application', got: {fix_text:?}"
2753 );
2754 }
2755
2756 #[test]
2757 fn test_title_case_short_word_not_confused_with_substring() {
2758 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2762
2763 let content = "# in the insert\n";
2766 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2767 let result = rule.check(&ctx).unwrap();
2768 assert_eq!(result.len(), 1, "Should flag the heading");
2769 let fix = result[0].fix.as_ref().expect("Fix should be present");
2770 assert!(
2772 fix.replacement.contains("In the Insert"),
2773 "Expected 'In the Insert', got: {:?}",
2774 fix.replacement
2775 );
2776 }
2777
2778 #[test]
2779 fn test_title_case_or_not_confused_with_orchestra() {
2780 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2781
2782 let content = "# or the orchestra\n";
2785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2786 let result = rule.check(&ctx).unwrap();
2787 assert_eq!(result.len(), 1, "Should flag the heading");
2788 let fix = result[0].fix.as_ref().expect("Fix should be present");
2789 assert!(
2791 fix.replacement.contains("Or the Orchestra"),
2792 "Expected 'Or the Orchestra', got: {:?}",
2793 fix.replacement
2794 );
2795 }
2796
2797 #[test]
2798 fn test_all_caps_preserves_all_words() {
2799 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
2800
2801 let content = "# in the insert\n";
2802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2803 let result = rule.check(&ctx).unwrap();
2804 assert_eq!(result.len(), 1, "Should flag the heading");
2805 let fix = result[0].fix.as_ref().expect("Fix should be present");
2806 assert!(
2807 fix.replacement.contains("IN THE INSERT"),
2808 "All caps should uppercase all words, got: {:?}",
2809 fix.replacement
2810 );
2811 }
2812
2813 #[test]
2815 fn test_title_case_numbered_prefix_lowercase_word() {
2816 let rule = create_rule();
2818 let content = "## 1. To Be a Thing\n";
2819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2820 let result = rule.check(&ctx).unwrap();
2821 assert!(
2822 result.is_empty(),
2823 "Should not flag '## 1. To Be a Thing', got: {result:?}"
2824 );
2825
2826 let content_lower = "## 1. to be a thing\n";
2827 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2828 let result2 = rule.check(&ctx2).unwrap();
2829 assert!(!result2.is_empty(), "Should flag '## 1. to be a thing'");
2830 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2831 assert!(
2832 fix.replacement.contains("1. To Be a Thing"),
2833 "Fix should capitalize 'To', got: {:?}",
2834 fix.replacement
2835 );
2836 }
2837
2838 #[test]
2839 fn test_title_case_numbered_prefix_article() {
2840 let rule = create_rule();
2842 let content = "## 2. A Guide to the Galaxy\n";
2843 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2844 let result = rule.check(&ctx).unwrap();
2845 assert!(
2846 result.is_empty(),
2847 "Should not flag '## 2. A Guide to the Galaxy', got: {result:?}"
2848 );
2849
2850 let content_lower = "## 2. a guide to the galaxy\n";
2851 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2852 let result2 = rule.check(&ctx2).unwrap();
2853 assert!(!result2.is_empty(), "Should flag '## 2. a guide to the galaxy'");
2854 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2855 assert!(
2856 fix.replacement.contains("2. A Guide to the Galaxy"),
2857 "Fix should capitalize 'A', got: {:?}",
2858 fix.replacement
2859 );
2860 }
2861
2862 #[test]
2863 fn test_title_case_mid_sentence_period_word() {
2864 let rule = create_rule();
2866 let content = "## Step 1. Introduction to the Problem\n";
2867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2868 let result = rule.check(&ctx).unwrap();
2869 assert!(
2870 result.is_empty(),
2871 "Should not flag '## Step 1. Introduction to the Problem', got: {result:?}"
2872 );
2873
2874 let content_lower = "## Step 1. introduction to the problem\n";
2875 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2876 let result2 = rule.check(&ctx2).unwrap();
2877 assert!(
2878 !result2.is_empty(),
2879 "Should flag '## Step 1. introduction to the problem'"
2880 );
2881 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2882 assert!(
2883 fix.replacement.contains("Step 1. Introduction to the Problem"),
2884 "Fix should capitalize 'Introduction', got: {:?}",
2885 fix.replacement
2886 );
2887 }
2888
2889 #[test]
2890 fn test_title_case_numbered_prefix_in_link_text() {
2891 let config = MD063Config {
2894 enabled: true,
2895 style: HeadingCapStyle::TitleCase,
2896 ..Default::default()
2897 };
2898 let rule = MD063HeadingCapitalization::from_config_struct(config);
2899
2900 let content = "## [1. To Be a Thing](https://example.com)\n";
2902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2903 let result = rule.check(&ctx).unwrap();
2904 assert!(
2905 result.is_empty(),
2906 "Should not flag '## [1. To Be a Thing](url)', got: {result:?}"
2907 );
2908
2909 let content_lower = "## [1. to be a thing](https://example.com)\n";
2911 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2912 let result2 = rule.check(&ctx2).unwrap();
2913 assert!(!result2.is_empty(), "Should flag '## [1. to be a thing](url)'");
2914 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2915 assert!(
2916 fix.replacement.contains("1. To Be a Thing"),
2917 "Fix should capitalize 'To' in link text, got: {:?}",
2918 fix.replacement
2919 );
2920 }
2921
2922 #[test]
2927 fn test_is_numeric_ordinal_recognises_canonical_forms() {
2928 for word in &[
2929 "1st", "2nd", "3rd", "4th", "5th", "11th", "21st", "22nd", "23rd", "100th", "1ST", "5Th", "21St", "21sT",
2930 ] {
2931 assert!(
2932 MD063HeadingCapitalization::is_numeric_ordinal(word),
2933 "expected `{word}` to be detected as a numeric ordinal"
2934 );
2935 }
2936 }
2937
2938 #[test]
2939 fn test_is_numeric_ordinal_rejects_non_ordinals() {
2940 for word in &[
2945 "first", "1stop", "ist", "5", "th", "abc", "4G", "4K", "30s", "100k", "5x", "1.5", "iPhone6S",
2946 ] {
2947 assert!(
2948 !MD063HeadingCapitalization::is_numeric_ordinal(word),
2949 "expected `{word}` NOT to be detected as a numeric ordinal"
2950 );
2951 }
2952 }
2953
2954 #[test]
2955 fn test_is_numeric_ordinal_strips_trailing_punctuation() {
2956 for word in &["5th.", "1st,", "21st!", "3rd:", "4th)", "5th's"] {
2957 assert!(
2958 MD063HeadingCapitalization::is_numeric_ordinal(word),
2959 "expected `{word}` to be detected as a numeric ordinal (with punctuation)"
2960 );
2961 }
2962 }
2963
2964 #[test]
2965 fn test_title_case_ordinal_first_word_not_flagged() {
2966 let rule = create_rule();
2967 for content in &[
2968 "# 1st Place\n",
2969 "# 2nd Edition\n",
2970 "# 3rd Time\n",
2971 "# 5th Avenue\n",
2972 "# 21st Century Skills\n",
2973 "# 100th Customer\n",
2974 ] {
2975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2976 let result = rule.check(&ctx).unwrap();
2977 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
2978 }
2979 }
2980
2981 #[test]
2982 fn test_title_case_ordinal_mid_heading_not_flagged() {
2983 let rule = create_rule();
2984 for content in &[
2985 "# May 3rd Notes\n",
2986 "# Top 100th Customer\n",
2987 "# Notes for the 5th of May\n",
2988 ] {
2989 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2990 let result = rule.check(&ctx).unwrap();
2991 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
2992 }
2993 }
2994
2995 #[test]
2996 fn test_title_case_ordinal_corrupted_form_is_fixed() {
2997 let rule = create_rule();
3000 for (input, expected) in &[
3001 ("# 1St Place\n", "1st Place"),
3002 ("# 5Th Avenue\n", "5th Avenue"),
3003 ("# 21St Century Skills\n", "21st Century Skills"),
3004 ("# May 3Rd Notes\n", "May 3rd Notes"),
3005 ("# 22Nd Edition\n", "22nd Edition"),
3006 ] {
3007 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
3008 let result = rule.check(&ctx).unwrap();
3009 assert!(!result.is_empty(), "Should flag {input:?}");
3010 let fix = result[0].fix.as_ref().expect("should have a fix");
3011 assert!(
3012 fix.replacement.contains(expected),
3013 "Fix for {input:?} should contain {expected:?}, got: {:?}",
3014 fix.replacement
3015 );
3016 }
3017 }
3018
3019 #[test]
3020 fn test_title_case_ordinal_lowercase_other_words_capitalised() {
3021 let rule = create_rule();
3023 let content = "# 5th avenue\n";
3024 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3025 let result = rule.check(&ctx).unwrap();
3026 assert_eq!(result.len(), 1);
3027 let fix = result[0].fix.as_ref().expect("should have a fix");
3028 assert!(
3029 fix.replacement.contains("5th Avenue"),
3030 "Fix should produce '5th Avenue', got: {:?}",
3031 fix.replacement
3032 );
3033 }
3034
3035 #[test]
3036 fn test_title_case_ordinal_with_trailing_punctuation() {
3037 let rule = create_rule();
3038 let content = "# Released on the 5th.\n";
3039 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3040 let result = rule.check(&ctx).unwrap();
3041 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3042 }
3043
3044 #[test]
3045 fn test_title_case_ordinal_hyphenated() {
3046 let rule = create_rule();
3047 for content in &["# 21st-Century Skills\n", "# A 19th-Century Novel\n"] {
3048 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3049 let result = rule.check(&ctx).unwrap();
3050 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3051 }
3052 }
3053
3054 #[test]
3055 fn test_sentence_case_ordinal_corrupted_form_is_fixed() {
3056 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3057 let content = "# 5Th avenue\n";
3058 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3059 let result = rule.check(&ctx).unwrap();
3060 assert_eq!(result.len(), 1);
3061 let fix = result[0].fix.as_ref().expect("should have a fix");
3062 assert!(
3063 fix.replacement.contains("5th avenue"),
3064 "Fix should produce '5th avenue', got: {:?}",
3065 fix.replacement
3066 );
3067 }
3068
3069 #[test]
3070 fn test_title_case_digit_acronym_unchanged() {
3071 let rule = create_rule();
3074 for content in &["# 4G Networks\n", "# 4K Streaming\n"] {
3075 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3076 let result = rule.check(&ctx).unwrap();
3077 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3078 }
3079 }
3080
3081 fn restart_rule(boundaries: &[&str]) -> MD063HeadingCapitalization {
3084 let config = MD063Config {
3085 enabled: true,
3086 style: HeadingCapStyle::SentenceCase,
3087 sentence_case_restart_after: boundaries.iter().copied().map(String::from).collect(),
3088 ..Default::default()
3089 };
3090 MD063HeadingCapitalization::from_config_struct(config)
3091 }
3092
3093 fn suggested(rule: &MD063HeadingCapitalization, content: &str) -> Option<String> {
3096 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3097 let warnings = rule.check(&ctx).unwrap();
3098 let fixed = rule.fix(&ctx).unwrap();
3099 assert_eq!(
3100 warnings.is_empty(),
3101 fixed == content,
3102 "a warning and a rewrite must agree for {content:?}"
3103 );
3104 (!warnings.is_empty()).then(|| fixed.trim_start_matches('#').trim().to_string())
3105 }
3106
3107 #[test]
3108 fn test_restart_after_capitalizes_the_word_following_a_boundary() {
3109 let rule = restart_rule(&[":"]);
3110 assert_eq!(
3111 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3112 Some("Requirement 1: Struct to logger slice conversion")
3113 );
3114 }
3115
3116 #[test]
3117 fn test_restart_after_defaults_to_no_boundaries() {
3118 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3120 assert_eq!(
3121 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3122 Some("Requirement 1: struct to logger slice conversion")
3123 );
3124 }
3125
3126 #[test]
3127 fn test_restart_after_only_honors_configured_punctuation() {
3128 let rule = restart_rule(&[":"]);
3130 assert_eq!(
3131 suggested(&rule, "# Design - Data Model Overview\n").as_deref(),
3132 Some("Design - data model overview")
3133 );
3134 assert_eq!(
3135 suggested(&rule, "# Setup; Then Run\n").as_deref(),
3136 Some("Setup; then run")
3137 );
3138
3139 let rule = restart_rule(&[";", "\u{2014}"]);
3140 assert_eq!(
3141 suggested(&rule, "# Setup; Then Run\n").as_deref(),
3142 Some("Setup; Then run")
3143 );
3144 assert_eq!(
3145 suggested(&rule, "# Part One \u{2014} The Big Idea\n").as_deref(),
3146 Some("Part one \u{2014} The big idea")
3147 );
3148 }
3149
3150 #[test]
3151 fn test_restart_after_matches_only_at_the_end_of_a_word() {
3152 let rule = restart_rule(&["-", ":"]);
3155 assert_eq!(
3156 suggested(&rule, "# Ports: Well-Known Ports Explained\n").as_deref(),
3157 Some("Ports: Well-Known ports explained")
3158 );
3159 assert_eq!(
3160 suggested(&rule, "# See https://example.com/A/B For Details\n").as_deref(),
3161 Some("See https://example.com/A/B for details")
3162 );
3163 }
3164
3165 #[test]
3166 fn test_restart_after_a_trailing_boundary_is_a_no_op() {
3167 let rule = restart_rule(&[":"]);
3168 assert_eq!(suggested(&rule, "# Setup:\n"), None);
3169 }
3170
3171 #[test]
3172 fn test_restart_after_does_not_override_preserved_words() {
3173 let rule = restart_rule(&[":"]);
3176 assert_eq!(
3177 suggested(&rule, "# Devices: iPhone And Android\n").as_deref(),
3178 Some("Devices: iPhone and android")
3179 );
3180
3181 let config = MD063Config {
3182 enabled: true,
3183 style: HeadingCapStyle::SentenceCase,
3184 sentence_case_restart_after: vec![":".to_string()],
3185 ignore_words: vec!["kubectl".to_string()],
3186 preserve_cased_words: false,
3187 ..Default::default()
3188 };
3189 let rule = MD063HeadingCapitalization::from_config_struct(config);
3190 assert_eq!(
3191 suggested(&rule, "# Tools: kubectl And Helm\n").as_deref(),
3192 Some("Tools: kubectl and helm")
3193 );
3194 }
3195
3196 #[test]
3197 fn test_restart_after_keeps_md044_canonical_forms() {
3198 let config = MD063Config {
3199 enabled: true,
3200 style: HeadingCapStyle::SentenceCase,
3201 sentence_case_restart_after: vec![":".to_string()],
3202 ..Default::default()
3203 };
3204 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3205 rule.proper_names = vec!["GitHub".to_string()];
3206
3207 assert_eq!(
3209 suggested(&rule, "# Docs: github Actions Guide\n").as_deref(),
3210 Some("Docs: GitHub actions guide")
3211 );
3212 assert_eq!(suggested(&rule, "# Docs: GitHub actions guide\n"), None);
3213 }
3214
3215 #[test]
3216 fn test_restart_after_carries_across_segments() {
3217 let rule = restart_rule(&[":"]);
3220 assert_eq!(
3221 suggested(
3222 &rule,
3223 "# Overview: [Some Link Here](https://example.com) Trailing Words\n"
3224 )
3225 .as_deref(),
3226 Some("Overview: [Some link here](https://example.com) trailing words")
3227 );
3228 assert_eq!(
3229 suggested(&rule, "# Overview: `code` Then More Words\n").as_deref(),
3230 Some("Overview: `code` then more words")
3231 );
3232 }
3233
3234 #[test]
3235 fn test_restart_after_ends_a_sentence_at_the_end_of_link_text() {
3236 let rule = restart_rule(&[":"]);
3240 assert_eq!(
3241 suggested(&rule, "# Topic [See:](https://example.com) More Words\n").as_deref(),
3242 Some("Topic [see:](https://example.com) More words")
3243 );
3244
3245 assert_eq!(
3247 suggested(&rule, "# Topic [See](https://example.com) More Words\n").as_deref(),
3248 Some("Topic [see](https://example.com) more words")
3249 );
3250 }
3251
3252 #[test]
3253 fn test_restart_after_ignores_boundaries_inside_opaque_segments() {
3254 let rule = restart_rule(&[":"]);
3257 for content in [
3258 "# Topic `see:` More Words\n",
3259 "# Topic  More Words\n",
3260 "# Topic <span title=\"x:\">y</span> More Words\n",
3261 ] {
3262 let fixed = suggested(&rule, content).expect("heading should be rewritten");
3263 assert!(
3264 fixed.ends_with("more words"),
3265 "opaque segment restarted the sentence in {content:?}: {fixed}"
3266 );
3267 }
3268 }
3269
3270 #[test]
3271 fn test_restart_after_leaves_a_leading_link_mid_sentence() {
3272 for rule in [restart_rule(&[]), restart_rule(&[":"])] {
3275 assert_eq!(
3276 suggested(&rule, "# [Some Link Here](https://example.com) Trailing Words\n").as_deref(),
3277 Some("[some link here](https://example.com) trailing words")
3278 );
3279 }
3280 }
3281
3282 #[test]
3283 fn test_restart_after_fix_is_idempotent() {
3284 let rule = restart_rule(&[":", ";", "-", "\u{2014}"]);
3285 for content in [
3286 "# Requirement 1: Struct to Logger Slice Conversion\n",
3287 "# Ports: Well-Known Ports Explained\n",
3288 "# Devices: iPhone And Android\n",
3289 "# Overview: [Some Link Here](https://example.com) Trailing Words\n",
3290 "# Setup:\n",
3291 ] {
3292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3293 let once = rule.fix(&ctx).unwrap();
3294 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
3295 assert_eq!(rule.fix(&ctx).unwrap(), once, "fix is not idempotent for {content:?}");
3296 }
3297 }
3298
3299 #[test]
3300 fn test_restart_after_ignores_empty_boundary_entries() {
3301 let rule = restart_rule(&[""]);
3303 assert_eq!(
3304 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3305 Some("Requirement 1: struct to logger slice conversion")
3306 );
3307 }
3308}