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 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1090 let json_value = serde_json::to_value(&self.config).ok()?;
1091 Some((
1092 self.name().to_string(),
1093 crate::rule_config_serde::json_to_toml_value(&json_value)?,
1094 ))
1095 }
1096
1097 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1098 where
1099 Self: Sized,
1100 {
1101 let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
1102 let md044_config =
1103 crate::rule_config_serde::load_rule_config::<crate::rules::md044_proper_names::MD044Config>(config);
1104 let mut rule = Self::from_config_struct(rule_config);
1105 rule.proper_names = md044_config.names;
1106 Box::new(rule)
1107 }
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112 use super::*;
1113 use crate::lint_context::LintContext;
1114
1115 fn create_rule() -> MD063HeadingCapitalization {
1116 let config = MD063Config {
1117 enabled: true,
1118 ..Default::default()
1119 };
1120 MD063HeadingCapitalization::from_config_struct(config)
1121 }
1122
1123 fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
1124 let config = MD063Config {
1125 enabled: true,
1126 style,
1127 ..Default::default()
1128 };
1129 MD063HeadingCapitalization::from_config_struct(config)
1130 }
1131
1132 #[test]
1134 fn test_title_case_basic() {
1135 let rule = create_rule();
1136 let content = "# hello world\n";
1137 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1138 let result = rule.check(&ctx).unwrap();
1139 assert_eq!(result.len(), 1);
1140 assert!(result[0].message.contains("Hello World"));
1141 }
1142
1143 #[test]
1144 fn test_title_case_lowercase_words() {
1145 let rule = create_rule();
1146 let content = "# the quick brown fox\n";
1147 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1148 let result = rule.check(&ctx).unwrap();
1149 assert_eq!(result.len(), 1);
1150 assert!(result[0].message.contains("The Quick Brown Fox"));
1152 }
1153
1154 #[test]
1155 fn test_title_case_already_correct() {
1156 let rule = create_rule();
1157 let content = "# The Quick Brown Fox\n";
1158 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1159 let result = rule.check(&ctx).unwrap();
1160 assert!(result.is_empty(), "Already correct heading should not be flagged");
1161 }
1162
1163 #[test]
1164 fn test_title_case_hyphenated() {
1165 let rule = create_rule();
1166 let content = "# self-documenting code\n";
1167 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1168 let result = rule.check(&ctx).unwrap();
1169 assert_eq!(result.len(), 1);
1170 assert!(result[0].message.contains("Self-Documenting Code"));
1171 }
1172
1173 #[test]
1174 fn test_title_case_preserves_url_with_nested_parens() {
1175 let rule = create_rule();
1176 let content = "# guide for [the api](https://example.com/docs/v(2)beta)\n";
1178 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1179 let fixed = rule.fix(&ctx).unwrap();
1180 assert!(
1183 fixed.contains("https://example.com/docs/v(2)beta"),
1184 "URL with nested parens was corrupted: {fixed:?}"
1185 );
1186 }
1187
1188 #[test]
1189 fn test_title_case_does_not_recase_image_alt() {
1190 let rule = create_rule();
1191 let content = "# overview \n";
1192 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1193 let fixed = rule.fix(&ctx).unwrap();
1194 assert!(
1196 fixed.contains(""),
1197 "image alt text was modified: {fixed:?}"
1198 );
1199 assert!(
1200 fixed.contains("# Overview"),
1201 "surrounding prose should still be title-cased: {fixed:?}"
1202 );
1203 }
1204
1205 #[test]
1207 fn test_sentence_case_basic() {
1208 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1209 let content = "# The Quick Brown Fox\n";
1210 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1211 let result = rule.check(&ctx).unwrap();
1212 assert_eq!(result.len(), 1);
1213 assert!(result[0].message.contains("The quick brown fox"));
1214 }
1215
1216 #[test]
1217 fn test_sentence_case_already_correct() {
1218 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1219 let content = "# The quick brown fox\n";
1220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221 let result = rule.check(&ctx).unwrap();
1222 assert!(result.is_empty());
1223 }
1224
1225 #[test]
1227 fn test_all_caps_basic() {
1228 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1229 let content = "# hello world\n";
1230 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1231 let result = rule.check(&ctx).unwrap();
1232 assert_eq!(result.len(), 1);
1233 assert!(result[0].message.contains("HELLO WORLD"));
1234 }
1235
1236 #[test]
1238 fn test_preserve_ignore_words() {
1239 let config = MD063Config {
1240 enabled: true,
1241 ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
1242 ..Default::default()
1243 };
1244 let rule = MD063HeadingCapitalization::from_config_struct(config);
1245
1246 let content = "# using iPhone on macOS\n";
1247 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1248 let result = rule.check(&ctx).unwrap();
1249 assert_eq!(result.len(), 1);
1250 assert!(result[0].message.contains("iPhone"));
1252 assert!(result[0].message.contains("macOS"));
1253 }
1254
1255 #[test]
1256 fn test_preserve_cased_words() {
1257 let rule = create_rule();
1258 let content = "# using GitHub actions\n";
1259 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1260 let result = rule.check(&ctx).unwrap();
1261 assert_eq!(result.len(), 1);
1262 assert!(result[0].message.contains("GitHub"));
1264 }
1265
1266 #[test]
1268 fn test_inline_code_preserved() {
1269 let rule = create_rule();
1270 let content = "# using `const` in javascript\n";
1271 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1272 let result = rule.check(&ctx).unwrap();
1273 assert_eq!(result.len(), 1);
1274 assert!(result[0].message.contains("`const`"));
1276 assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
1277 }
1278
1279 #[test]
1281 fn test_level_filter() {
1282 let config = MD063Config {
1283 enabled: true,
1284 min_level: 2,
1285 max_level: 4,
1286 ..Default::default()
1287 };
1288 let rule = MD063HeadingCapitalization::from_config_struct(config);
1289
1290 let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
1291 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1292 let result = rule.check(&ctx).unwrap();
1293
1294 assert_eq!(result.len(), 2);
1296 assert_eq!(result[0].line, 2); assert_eq!(result[1].line, 3); }
1299
1300 #[test]
1302 fn test_fix_atx_heading() {
1303 let rule = create_rule();
1304 let content = "# hello world\n";
1305 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1306 let fixed = rule.fix(&ctx).unwrap();
1307 assert_eq!(fixed, "# Hello World\n");
1308 }
1309
1310 #[test]
1311 fn test_fix_multiple_headings() {
1312 let rule = create_rule();
1313 let content = "# first heading\n\n## second heading\n";
1314 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1315 let fixed = rule.fix(&ctx).unwrap();
1316 assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
1317 }
1318
1319 #[test]
1321 fn test_setext_heading() {
1322 let rule = create_rule();
1323 let content = "hello world\n============\n";
1324 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1325 let result = rule.check(&ctx).unwrap();
1326 assert_eq!(result.len(), 1);
1327 assert!(result[0].message.contains("Hello World"));
1328 }
1329
1330 #[test]
1332 fn test_custom_id_preserved() {
1333 let rule = create_rule();
1334 let content = "# getting started {#intro}\n";
1335 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1336 let result = rule.check(&ctx).unwrap();
1337 assert_eq!(result.len(), 1);
1338 assert!(result[0].message.contains("{#intro}"));
1340 }
1341
1342 #[test]
1344 fn test_skip_obsidian_tags_not_headings() {
1345 let rule = create_rule();
1346
1347 let content = "# H1\n\n#tag\n";
1349 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1350 let result = rule.check(&ctx).unwrap();
1351 assert!(
1352 result.is_empty() || result.iter().all(|w| w.line != 3),
1353 "Obsidian tag #tag should not be treated as a heading: {result:?}"
1354 );
1355 }
1356
1357 #[test]
1358 fn test_skip_invalid_atx_headings_no_space() {
1359 let rule = create_rule();
1360
1361 let content = "#notaheading\n";
1363 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1364 let result = rule.check(&ctx).unwrap();
1365 assert!(
1366 result.is_empty(),
1367 "Invalid ATX heading without space should not be flagged: {result:?}"
1368 );
1369 }
1370
1371 #[test]
1372 fn test_fix_skips_obsidian_tags() {
1373 let rule = create_rule();
1374
1375 let content = "# hello world\n\n#tag\n";
1376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1377 let fixed = rule.fix(&ctx).unwrap();
1378 assert!(fixed.contains("#tag"), "Fix should not modify Obsidian tag #tag");
1380 assert!(fixed.contains("# Hello World"), "Fix should still fix real headings");
1381 }
1382
1383 #[test]
1384 fn test_preserve_all_caps_acronyms() {
1385 let rule = create_rule();
1386 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1387
1388 let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
1390 assert_eq!(fixed, "# Using API in Production\n");
1391
1392 let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
1394 assert_eq!(fixed, "# API and GPU Integration\n");
1395
1396 let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
1398 assert_eq!(fixed, "# IO Performance Guide\n");
1399
1400 let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
1402 assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
1403 }
1404
1405 #[test]
1406 fn test_preserve_acronyms_in_hyphenated_words() {
1407 let rule = create_rule();
1408 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1409
1410 let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
1412 assert_eq!(fixed, "# API-Driven Architecture\n");
1413
1414 let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
1416 assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
1417 }
1418
1419 #[test]
1420 fn test_single_letters_not_treated_as_acronyms() {
1421 let rule = create_rule();
1422 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1423
1424 let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
1426 assert_eq!(fixed, "# I Am a Heading\n");
1427 }
1428
1429 #[test]
1430 fn test_lowercase_terms_need_ignore_words() {
1431 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1432
1433 let rule = create_rule();
1435 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1436 assert_eq!(fixed, "# Using Npm Packages\n");
1437
1438 let config = MD063Config {
1440 enabled: true,
1441 ignore_words: vec!["npm".to_string()],
1442 ..Default::default()
1443 };
1444 let rule = MD063HeadingCapitalization::from_config_struct(config);
1445 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1446 assert_eq!(fixed, "# Using npm Packages\n");
1447 }
1448
1449 #[test]
1450 fn test_acronyms_with_mixed_case_preserved() {
1451 let rule = create_rule();
1452 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1453
1454 let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
1456 assert_eq!(fixed, "# Using API with GitHub\n");
1457 }
1458
1459 #[test]
1460 fn test_real_world_acronyms() {
1461 let rule = create_rule();
1462 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1463
1464 let content = "# FFI bindings for CPU optimization\n";
1466 let fixed = rule.fix(&ctx(content)).unwrap();
1467 assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
1468
1469 let content = "# DOM manipulation and SSR rendering\n";
1470 let fixed = rule.fix(&ctx(content)).unwrap();
1471 assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
1472
1473 let content = "# CVE security and RNN models\n";
1474 let fixed = rule.fix(&ctx(content)).unwrap();
1475 assert_eq!(fixed, "# CVE Security and RNN Models\n");
1476 }
1477
1478 #[test]
1479 fn test_is_all_caps_acronym() {
1480 let rule = create_rule();
1481
1482 assert!(rule.is_all_caps_acronym("API"));
1484 assert!(rule.is_all_caps_acronym("IO"));
1485 assert!(rule.is_all_caps_acronym("GPU"));
1486 assert!(rule.is_all_caps_acronym("HTTP2")); assert!(!rule.is_all_caps_acronym("A"));
1490 assert!(!rule.is_all_caps_acronym("I"));
1491
1492 assert!(!rule.is_all_caps_acronym("Api"));
1494 assert!(!rule.is_all_caps_acronym("npm"));
1495 assert!(!rule.is_all_caps_acronym("iPhone"));
1496 }
1497
1498 #[test]
1499 fn test_sentence_case_ignore_words_first_word() {
1500 let config = MD063Config {
1501 enabled: true,
1502 style: HeadingCapStyle::SentenceCase,
1503 ignore_words: vec!["nvim".to_string()],
1504 ..Default::default()
1505 };
1506 let rule = MD063HeadingCapitalization::from_config_struct(config);
1507
1508 let content = "# nvim config\n";
1510 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1511 let result = rule.check(&ctx).unwrap();
1512 assert!(
1513 result.is_empty(),
1514 "nvim in ignore-words should not be flagged. Got: {result:?}"
1515 );
1516
1517 let fixed = rule.fix(&ctx).unwrap();
1519 assert_eq!(fixed, "# nvim config\n");
1520 }
1521
1522 #[test]
1523 fn test_sentence_case_ignore_words_not_first() {
1524 let config = MD063Config {
1525 enabled: true,
1526 style: HeadingCapStyle::SentenceCase,
1527 ignore_words: vec!["nvim".to_string()],
1528 ..Default::default()
1529 };
1530 let rule = MD063HeadingCapitalization::from_config_struct(config);
1531
1532 let content = "# Using nvim editor\n";
1534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1535 let result = rule.check(&ctx).unwrap();
1536 assert!(
1537 result.is_empty(),
1538 "nvim in ignore-words should be preserved. Got: {result:?}"
1539 );
1540 }
1541
1542 #[test]
1543 fn test_preserve_cased_words_ios() {
1544 let config = MD063Config {
1545 enabled: true,
1546 style: HeadingCapStyle::SentenceCase,
1547 preserve_cased_words: true,
1548 ..Default::default()
1549 };
1550 let rule = MD063HeadingCapitalization::from_config_struct(config);
1551
1552 let content = "## This is iOS\n";
1554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1555 let result = rule.check(&ctx).unwrap();
1556 assert!(
1557 result.is_empty(),
1558 "iOS should be preserved with preserve-cased-words. Got: {result:?}"
1559 );
1560
1561 let fixed = rule.fix(&ctx).unwrap();
1563 assert_eq!(fixed, "## This is iOS\n");
1564 }
1565
1566 #[test]
1567 fn test_preserve_cased_words_ios_title_case() {
1568 let config = MD063Config {
1569 enabled: true,
1570 style: HeadingCapStyle::TitleCase,
1571 preserve_cased_words: true,
1572 ..Default::default()
1573 };
1574 let rule = MD063HeadingCapitalization::from_config_struct(config);
1575
1576 let content = "# developing for iOS\n";
1578 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1579 let fixed = rule.fix(&ctx).unwrap();
1580 assert_eq!(fixed, "# Developing for iOS\n");
1581 }
1582
1583 #[test]
1584 fn test_has_internal_capitals_ios() {
1585 let rule = create_rule();
1586
1587 assert!(
1589 rule.has_internal_capitals("iOS"),
1590 "iOS has mixed case (lowercase i, uppercase OS)"
1591 );
1592
1593 assert!(rule.has_internal_capitals("iPhone"));
1595 assert!(rule.has_internal_capitals("macOS"));
1596 assert!(rule.has_internal_capitals("GitHub"));
1597 assert!(rule.has_internal_capitals("JavaScript"));
1598 assert!(rule.has_internal_capitals("eBay"));
1599
1600 assert!(!rule.has_internal_capitals("API"));
1602 assert!(!rule.has_internal_capitals("GPU"));
1603
1604 assert!(!rule.has_internal_capitals("npm"));
1606 assert!(!rule.has_internal_capitals("config"));
1607
1608 assert!(!rule.has_internal_capitals("The"));
1610 assert!(!rule.has_internal_capitals("Hello"));
1611 }
1612
1613 #[test]
1614 fn test_lowercase_words_before_trailing_code() {
1615 let config = MD063Config {
1616 enabled: true,
1617 style: HeadingCapStyle::TitleCase,
1618 lowercase_words: vec![
1619 "a".to_string(),
1620 "an".to_string(),
1621 "and".to_string(),
1622 "at".to_string(),
1623 "but".to_string(),
1624 "by".to_string(),
1625 "for".to_string(),
1626 "from".to_string(),
1627 "into".to_string(),
1628 "nor".to_string(),
1629 "on".to_string(),
1630 "onto".to_string(),
1631 "or".to_string(),
1632 "the".to_string(),
1633 "to".to_string(),
1634 "upon".to_string(),
1635 "via".to_string(),
1636 "vs".to_string(),
1637 "with".to_string(),
1638 "without".to_string(),
1639 ],
1640 preserve_cased_words: true,
1641 ..Default::default()
1642 };
1643 let rule = MD063HeadingCapitalization::from_config_struct(config);
1644
1645 let content = "## subtitle with a `app`\n";
1650 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1651 let result = rule.check(&ctx).unwrap();
1652
1653 assert!(!result.is_empty(), "Should flag incorrect capitalization");
1655 let fixed = rule.fix(&ctx).unwrap();
1656 assert!(
1658 fixed.contains("with a `app`"),
1659 "Expected 'with a `app`' but got: {fixed:?}"
1660 );
1661 assert!(
1662 !fixed.contains("with A `app`"),
1663 "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
1664 );
1665 assert!(
1667 fixed.contains("Subtitle with a `app`"),
1668 "Expected 'Subtitle with a `app`' but got: {fixed:?}"
1669 );
1670 }
1671
1672 #[test]
1673 fn test_lowercase_words_preserved_before_trailing_code_variant() {
1674 let config = MD063Config {
1675 enabled: true,
1676 style: HeadingCapStyle::TitleCase,
1677 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1678 ..Default::default()
1679 };
1680 let rule = MD063HeadingCapitalization::from_config_struct(config);
1681
1682 let content = "## Title with the `code`\n";
1684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1685 let fixed = rule.fix(&ctx).unwrap();
1686 assert!(
1688 fixed.contains("with the `code`"),
1689 "Expected 'with the `code`' but got: {fixed:?}"
1690 );
1691 assert!(
1692 !fixed.contains("with The `code`"),
1693 "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
1694 );
1695 }
1696
1697 #[test]
1698 fn test_last_word_capitalized_when_no_trailing_code() {
1699 let config = MD063Config {
1702 enabled: true,
1703 style: HeadingCapStyle::TitleCase,
1704 lowercase_words: vec!["a".to_string(), "the".to_string()],
1705 ..Default::default()
1706 };
1707 let rule = MD063HeadingCapitalization::from_config_struct(config);
1708
1709 let content = "## title with a word\n";
1712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1713 let fixed = rule.fix(&ctx).unwrap();
1714 assert!(
1716 fixed.contains("With a Word"),
1717 "Expected 'With a Word' but got: {fixed:?}"
1718 );
1719 }
1720
1721 #[test]
1722 fn test_multiple_lowercase_words_before_code() {
1723 let config = MD063Config {
1724 enabled: true,
1725 style: HeadingCapStyle::TitleCase,
1726 lowercase_words: vec![
1727 "a".to_string(),
1728 "the".to_string(),
1729 "with".to_string(),
1730 "for".to_string(),
1731 ],
1732 ..Default::default()
1733 };
1734 let rule = MD063HeadingCapitalization::from_config_struct(config);
1735
1736 let content = "## Guide for the `user`\n";
1738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1739 let fixed = rule.fix(&ctx).unwrap();
1740 assert!(
1741 fixed.contains("for the `user`"),
1742 "Expected 'for the `user`' but got: {fixed:?}"
1743 );
1744 assert!(
1745 !fixed.contains("For The `user`"),
1746 "Should not capitalize lowercase words before code. Got: {fixed:?}"
1747 );
1748 }
1749
1750 #[test]
1751 fn test_code_in_middle_normal_rules_apply() {
1752 let config = MD063Config {
1753 enabled: true,
1754 style: HeadingCapStyle::TitleCase,
1755 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1756 ..Default::default()
1757 };
1758 let rule = MD063HeadingCapitalization::from_config_struct(config);
1759
1760 let content = "## Using `const` for the code\n";
1762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1763 let fixed = rule.fix(&ctx).unwrap();
1764 assert!(
1766 fixed.contains("for the Code"),
1767 "Expected 'for the Code' but got: {fixed:?}"
1768 );
1769 }
1770
1771 #[test]
1772 fn test_link_at_end_same_as_code() {
1773 let config = MD063Config {
1774 enabled: true,
1775 style: HeadingCapStyle::TitleCase,
1776 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1777 ..Default::default()
1778 };
1779 let rule = MD063HeadingCapitalization::from_config_struct(config);
1780
1781 let content = "## Guide for the [link](./page.md)\n";
1783 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1784 let fixed = rule.fix(&ctx).unwrap();
1785 assert!(
1787 fixed.contains("for the [Link]"),
1788 "Expected 'for the [Link]' but got: {fixed:?}"
1789 );
1790 assert!(
1791 !fixed.contains("for The [Link]"),
1792 "Should not capitalize 'the' before link. Got: {fixed:?}"
1793 );
1794 }
1795
1796 #[test]
1797 fn test_multiple_code_segments() {
1798 let config = MD063Config {
1799 enabled: true,
1800 style: HeadingCapStyle::TitleCase,
1801 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1802 ..Default::default()
1803 };
1804 let rule = MD063HeadingCapitalization::from_config_struct(config);
1805
1806 let content = "## Using `const` with a `variable`\n";
1808 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1809 let fixed = rule.fix(&ctx).unwrap();
1810 assert!(
1812 fixed.contains("with a `variable`"),
1813 "Expected 'with a `variable`' but got: {fixed:?}"
1814 );
1815 assert!(
1816 !fixed.contains("with A `variable`"),
1817 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1818 );
1819 }
1820
1821 #[test]
1822 fn test_code_and_link_combination() {
1823 let config = MD063Config {
1824 enabled: true,
1825 style: HeadingCapStyle::TitleCase,
1826 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1827 ..Default::default()
1828 };
1829 let rule = MD063HeadingCapitalization::from_config_struct(config);
1830
1831 let content = "## Guide for the `code` [link](./page.md)\n";
1833 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1834 let fixed = rule.fix(&ctx).unwrap();
1835 assert!(
1837 fixed.contains("for the `code`"),
1838 "Expected 'for the `code`' but got: {fixed:?}"
1839 );
1840 }
1841
1842 #[test]
1843 fn test_text_after_code_capitalizes_last() {
1844 let config = MD063Config {
1845 enabled: true,
1846 style: HeadingCapStyle::TitleCase,
1847 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1848 ..Default::default()
1849 };
1850 let rule = MD063HeadingCapitalization::from_config_struct(config);
1851
1852 let content = "## Using `const` for the code\n";
1854 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1855 let fixed = rule.fix(&ctx).unwrap();
1856 assert!(
1858 fixed.contains("for the Code"),
1859 "Expected 'for the Code' but got: {fixed:?}"
1860 );
1861 }
1862
1863 #[test]
1864 fn test_preserve_cased_words_with_trailing_code() {
1865 let config = MD063Config {
1866 enabled: true,
1867 style: HeadingCapStyle::TitleCase,
1868 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1869 preserve_cased_words: true,
1870 ..Default::default()
1871 };
1872 let rule = MD063HeadingCapitalization::from_config_struct(config);
1873
1874 let content = "## Guide for iOS `app`\n";
1876 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1877 let fixed = rule.fix(&ctx).unwrap();
1878 assert!(
1880 fixed.contains("for iOS `app`"),
1881 "Expected 'for iOS `app`' but got: {fixed:?}"
1882 );
1883 assert!(
1884 !fixed.contains("For iOS `app`"),
1885 "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
1886 );
1887 }
1888
1889 #[test]
1890 fn test_ignore_words_with_trailing_code() {
1891 let config = MD063Config {
1892 enabled: true,
1893 style: HeadingCapStyle::TitleCase,
1894 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1895 ignore_words: vec!["npm".to_string()],
1896 ..Default::default()
1897 };
1898 let rule = MD063HeadingCapitalization::from_config_struct(config);
1899
1900 let content = "## Using npm with a `script`\n";
1902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1903 let fixed = rule.fix(&ctx).unwrap();
1904 assert!(
1906 fixed.contains("npm with a `script`"),
1907 "Expected 'npm with a `script`' but got: {fixed:?}"
1908 );
1909 assert!(
1910 !fixed.contains("with A `script`"),
1911 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1912 );
1913 }
1914
1915 #[test]
1916 fn test_empty_text_segment_edge_case() {
1917 let config = MD063Config {
1918 enabled: true,
1919 style: HeadingCapStyle::TitleCase,
1920 lowercase_words: vec!["a".to_string(), "with".to_string()],
1921 ..Default::default()
1922 };
1923 let rule = MD063HeadingCapitalization::from_config_struct(config);
1924
1925 let content = "## `start` with a `end`\n";
1927 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1928 let fixed = rule.fix(&ctx).unwrap();
1929 assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
1932 assert!(
1933 !fixed.contains("A `end`"),
1934 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1935 );
1936 }
1937
1938 #[test]
1939 fn test_sentence_case_with_trailing_code() {
1940 let config = MD063Config {
1941 enabled: true,
1942 style: HeadingCapStyle::SentenceCase,
1943 lowercase_words: vec!["a".to_string(), "the".to_string()],
1944 ..Default::default()
1945 };
1946 let rule = MD063HeadingCapitalization::from_config_struct(config);
1947
1948 let content = "## guide for the `user`\n";
1950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1951 let fixed = rule.fix(&ctx).unwrap();
1952 assert!(
1954 fixed.contains("Guide for the `user`"),
1955 "Expected 'Guide for the `user`' but got: {fixed:?}"
1956 );
1957 }
1958
1959 #[test]
1960 fn test_hyphenated_word_before_code() {
1961 let config = MD063Config {
1962 enabled: true,
1963 style: HeadingCapStyle::TitleCase,
1964 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1965 ..Default::default()
1966 };
1967 let rule = MD063HeadingCapitalization::from_config_struct(config);
1968
1969 let content = "## Self-contained with a `feature`\n";
1971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1972 let fixed = rule.fix(&ctx).unwrap();
1973 assert!(
1975 fixed.contains("with a `feature`"),
1976 "Expected 'with a `feature`' but got: {fixed:?}"
1977 );
1978 }
1979
1980 #[test]
1985 fn test_sentence_case_code_at_start_basic() {
1986 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1988 let content = "# `rumdl` is a linter\n";
1989 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1990 let result = rule.check(&ctx).unwrap();
1991 assert!(
1993 result.is_empty(),
1994 "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
1995 result.iter().map(|w| &w.message).collect::<Vec<_>>()
1996 );
1997 }
1998
1999 #[test]
2000 fn test_sentence_case_code_at_start_incorrect_capitalization() {
2001 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2003 let content = "# `rumdl` Is a Linter\n";
2004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2005 let result = rule.check(&ctx).unwrap();
2006 assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
2008 assert!(
2009 result[0].message.contains("`rumdl` is a linter"),
2010 "Should suggest lowercase after code. Got: {:?}",
2011 result[0].message
2012 );
2013 }
2014
2015 #[test]
2016 fn test_sentence_case_code_at_start_fix() {
2017 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2018 let content = "# `rumdl` Is A Linter\n";
2019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2020 let fixed = rule.fix(&ctx).unwrap();
2021 assert!(
2022 fixed.contains("# `rumdl` is a linter"),
2023 "Should fix to lowercase after code. Got: {fixed:?}"
2024 );
2025 }
2026
2027 #[test]
2028 fn test_sentence_case_text_at_start_still_capitalizes() {
2029 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2031 let content = "# the quick brown fox\n";
2032 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2033 let result = rule.check(&ctx).unwrap();
2034 assert_eq!(result.len(), 1);
2035 assert!(
2036 result[0].message.contains("The quick brown fox"),
2037 "Text-first heading should capitalize first word. Got: {:?}",
2038 result[0].message
2039 );
2040 }
2041
2042 #[test]
2043 fn test_sentence_case_link_at_start() {
2044 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2046 let content = "# [api](api.md) reference guide\n";
2048 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2049 let result = rule.check(&ctx).unwrap();
2050 assert!(
2052 result.is_empty(),
2053 "Heading with link at start should not capitalize 'reference'. Got: {:?}",
2054 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2055 );
2056 }
2057
2058 #[test]
2059 fn test_sentence_case_link_preserves_acronyms() {
2060 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2062 let content = "# [API](api.md) Reference Guide\n";
2063 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2064 let result = rule.check(&ctx).unwrap();
2065 assert_eq!(result.len(), 1);
2066 assert!(
2068 result[0].message.contains("[API](api.md) reference guide"),
2069 "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
2070 result[0].message
2071 );
2072 }
2073
2074 #[test]
2075 fn test_sentence_case_link_preserves_brand_names() {
2076 let config = MD063Config {
2078 enabled: true,
2079 style: HeadingCapStyle::SentenceCase,
2080 preserve_cased_words: true,
2081 ..Default::default()
2082 };
2083 let rule = MD063HeadingCapitalization::from_config_struct(config);
2084 let content = "# [iPhone](iphone.md) Features Guide\n";
2085 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2086 let result = rule.check(&ctx).unwrap();
2087 assert_eq!(result.len(), 1);
2088 assert!(
2090 result[0].message.contains("[iPhone](iphone.md) features guide"),
2091 "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
2092 result[0].message
2093 );
2094 }
2095
2096 #[test]
2097 fn test_sentence_case_link_lowercases_regular_words() {
2098 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2100 let content = "# [Documentation](docs.md) Reference\n";
2101 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2102 let result = rule.check(&ctx).unwrap();
2103 assert_eq!(result.len(), 1);
2104 assert!(
2106 result[0].message.contains("[documentation](docs.md) reference"),
2107 "Should lowercase regular link text. Got: {:?}",
2108 result[0].message
2109 );
2110 }
2111
2112 #[test]
2113 fn test_sentence_case_link_at_start_correct_already() {
2114 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2116 let content = "# [API](api.md) reference guide\n";
2117 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2118 let result = rule.check(&ctx).unwrap();
2119 assert!(
2120 result.is_empty(),
2121 "Correctly cased heading with link should not be flagged. Got: {:?}",
2122 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2123 );
2124 }
2125
2126 #[test]
2127 fn test_sentence_case_link_github_preserved() {
2128 let config = MD063Config {
2130 enabled: true,
2131 style: HeadingCapStyle::SentenceCase,
2132 preserve_cased_words: true,
2133 ..Default::default()
2134 };
2135 let rule = MD063HeadingCapitalization::from_config_struct(config);
2136 let content = "# [GitHub](gh.md) Repository Setup\n";
2137 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2138 let result = rule.check(&ctx).unwrap();
2139 assert_eq!(result.len(), 1);
2140 assert!(
2141 result[0].message.contains("[GitHub](gh.md) repository setup"),
2142 "Should preserve 'GitHub'. Got: {:?}",
2143 result[0].message
2144 );
2145 }
2146
2147 #[test]
2148 fn test_sentence_case_multiple_code_spans() {
2149 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2150 let content = "# `foo` and `bar` are methods\n";
2151 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2152 let result = rule.check(&ctx).unwrap();
2153 assert!(
2155 result.is_empty(),
2156 "Should not capitalize words between/after code spans. Got: {:?}",
2157 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2158 );
2159 }
2160
2161 #[test]
2162 fn test_sentence_case_code_only_heading() {
2163 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2165 let content = "# `rumdl`\n";
2166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2167 let result = rule.check(&ctx).unwrap();
2168 assert!(
2169 result.is_empty(),
2170 "Code-only heading should be fine. Got: {:?}",
2171 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2172 );
2173 }
2174
2175 #[test]
2176 fn test_sentence_case_code_at_end() {
2177 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2179 let content = "# install the `rumdl` tool\n";
2180 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2181 let result = rule.check(&ctx).unwrap();
2182 assert_eq!(result.len(), 1);
2184 assert!(
2185 result[0].message.contains("Install the `rumdl` tool"),
2186 "First word should still be capitalized when text comes first. Got: {:?}",
2187 result[0].message
2188 );
2189 }
2190
2191 #[test]
2192 fn test_sentence_case_code_in_middle() {
2193 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2195 let content = "# using the `rumdl` linter for markdown\n";
2196 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2197 let result = rule.check(&ctx).unwrap();
2198 assert_eq!(result.len(), 1);
2200 assert!(
2201 result[0].message.contains("Using the `rumdl` linter for markdown"),
2202 "First word should be capitalized. Got: {:?}",
2203 result[0].message
2204 );
2205 }
2206
2207 #[test]
2208 fn test_sentence_case_preserved_word_after_code() {
2209 let config = MD063Config {
2211 enabled: true,
2212 style: HeadingCapStyle::SentenceCase,
2213 preserve_cased_words: true,
2214 ..Default::default()
2215 };
2216 let rule = MD063HeadingCapitalization::from_config_struct(config);
2217 let content = "# `swift` iPhone development\n";
2218 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2219 let result = rule.check(&ctx).unwrap();
2220 assert!(
2222 result.is_empty(),
2223 "Preserved words after code should stay. Got: {:?}",
2224 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2225 );
2226 }
2227
2228 #[test]
2229 fn test_title_case_code_at_start_still_capitalizes() {
2230 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2232 let content = "# `api` quick start guide\n";
2233 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2234 let result = rule.check(&ctx).unwrap();
2235 assert_eq!(result.len(), 1);
2237 assert!(
2238 result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
2239 "Title case should capitalize major words after code. Got: {:?}",
2240 result[0].message
2241 );
2242 }
2243
2244 #[test]
2247 fn test_sentence_case_html_tag_at_start() {
2248 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2250 let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
2251 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2252 let result = rule.check(&ctx).unwrap();
2253 assert_eq!(result.len(), 1);
2255 let fixed = rule.fix(&ctx).unwrap();
2256 assert_eq!(
2257 fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
2258 "Text after HTML at start should be lowercase"
2259 );
2260 }
2261
2262 #[test]
2263 fn test_sentence_case_html_tag_preserves_content() {
2264 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2266 let content = "# The <abbr>API</abbr> documentation guide\n";
2267 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2268 let result = rule.check(&ctx).unwrap();
2269 assert!(
2271 result.is_empty(),
2272 "HTML tag content should be preserved. Got: {:?}",
2273 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2274 );
2275 }
2276
2277 #[test]
2278 fn test_sentence_case_html_tag_at_start_with_acronym() {
2279 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2281 let content = "# <abbr>API</abbr> Documentation Guide\n";
2282 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2283 let result = rule.check(&ctx).unwrap();
2284 assert_eq!(result.len(), 1);
2285 let fixed = rule.fix(&ctx).unwrap();
2286 assert_eq!(
2287 fixed, "# <abbr>API</abbr> documentation guide\n",
2288 "Text after HTML at start should be lowercase, HTML content preserved"
2289 );
2290 }
2291
2292 #[test]
2293 fn test_sentence_case_html_tag_in_middle() {
2294 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2296 let content = "# using the <code>config</code> File\n";
2297 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2298 let result = rule.check(&ctx).unwrap();
2299 assert_eq!(result.len(), 1);
2300 let fixed = rule.fix(&ctx).unwrap();
2301 assert_eq!(
2302 fixed, "# Using the <code>config</code> file\n",
2303 "First word capitalized, HTML preserved, rest lowercase"
2304 );
2305 }
2306
2307 #[test]
2308 fn test_html_tag_strong_emphasis() {
2309 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2311 let content = "# The <strong>Bold</strong> Way\n";
2312 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2313 let result = rule.check(&ctx).unwrap();
2314 assert_eq!(result.len(), 1);
2315 let fixed = rule.fix(&ctx).unwrap();
2316 assert_eq!(
2317 fixed, "# The <strong>Bold</strong> way\n",
2318 "<strong> tag content should be preserved"
2319 );
2320 }
2321
2322 #[test]
2323 fn test_html_tag_with_attributes() {
2324 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2326 let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
2327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2328 let result = rule.check(&ctx).unwrap();
2329 assert_eq!(result.len(), 1);
2330 let fixed = rule.fix(&ctx).unwrap();
2331 assert_eq!(
2332 fixed, "# <span class=\"highlight\">Important</span> notice here\n",
2333 "HTML tag with attributes should be preserved"
2334 );
2335 }
2336
2337 #[test]
2338 fn test_multiple_html_tags() {
2339 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2341 let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
2342 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2343 let result = rule.check(&ctx).unwrap();
2344 assert_eq!(result.len(), 1);
2345 let fixed = rule.fix(&ctx).unwrap();
2346 assert_eq!(
2347 fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
2348 "Multiple HTML tags should all be preserved"
2349 );
2350 }
2351
2352 #[test]
2353 fn test_html_and_code_mixed() {
2354 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2356 let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
2357 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2358 let result = rule.check(&ctx).unwrap();
2359 assert_eq!(result.len(), 1);
2360 let fixed = rule.fix(&ctx).unwrap();
2361 assert_eq!(
2362 fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
2363 "HTML and code should both be preserved"
2364 );
2365 }
2366
2367 #[test]
2368 fn test_self_closing_html_tag() {
2369 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2371 let content = "# Line one<br/>Line Two Here\n";
2372 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2373 let result = rule.check(&ctx).unwrap();
2374 assert_eq!(result.len(), 1);
2375 let fixed = rule.fix(&ctx).unwrap();
2376 assert_eq!(
2377 fixed, "# Line one<br/>line two here\n",
2378 "Self-closing HTML tags should be preserved"
2379 );
2380 }
2381
2382 #[test]
2383 fn test_title_case_with_html_tags() {
2384 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2386 let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
2387 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2388 let result = rule.check(&ctx).unwrap();
2389 assert_eq!(result.len(), 1);
2390 let fixed = rule.fix(&ctx).unwrap();
2391 assert!(
2393 fixed.contains("<kbd>ctrl</kbd>"),
2394 "HTML tag content should be preserved in title case. Got: {fixed}"
2395 );
2396 assert!(
2397 fixed.starts_with("# The ") || fixed.starts_with("# the "),
2398 "Title case should work with HTML. Got: {fixed}"
2399 );
2400 }
2401
2402 #[test]
2405 fn test_sentence_case_preserves_caret_notation() {
2406 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2408 let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
2409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2410 let result = rule.check(&ctx).unwrap();
2411 assert!(
2413 result.is_empty(),
2414 "Caret notation should be preserved. Got: {:?}",
2415 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2416 );
2417 }
2418
2419 #[test]
2420 fn test_sentence_case_caret_notation_various() {
2421 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2423
2424 let content = "## Press ^C to cancel\n";
2426 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2427 let result = rule.check(&ctx).unwrap();
2428 assert!(
2429 result.is_empty(),
2430 "^C should be preserved. Got: {:?}",
2431 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2432 );
2433
2434 let content = "## Use ^Z for background\n";
2436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2437 let result = rule.check(&ctx).unwrap();
2438 assert!(
2439 result.is_empty(),
2440 "^Z should be preserved. Got: {:?}",
2441 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2442 );
2443
2444 let content = "## Press ^[ for escape\n";
2446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2447 let result = rule.check(&ctx).unwrap();
2448 assert!(
2449 result.is_empty(),
2450 "^[ should be preserved. Got: {:?}",
2451 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2452 );
2453 }
2454
2455 #[test]
2456 fn test_caret_notation_detection() {
2457 let rule = create_rule();
2458
2459 assert!(rule.is_caret_notation("^A"));
2461 assert!(rule.is_caret_notation("^Z"));
2462 assert!(rule.is_caret_notation("^C"));
2463 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")); }
2475
2476 fn create_sentence_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2483 let config = MD063Config {
2484 enabled: true,
2485 style: HeadingCapStyle::SentenceCase,
2486 ..Default::default()
2487 };
2488 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2489 rule.proper_names = names;
2490 rule
2491 }
2492
2493 #[test]
2494 fn test_sentence_case_preserves_single_word_proper_name() {
2495 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2496 let content = "# installing javascript\n";
2498 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2499 let result = rule.check(&ctx).unwrap();
2500 assert_eq!(result.len(), 1, "Should flag the heading");
2501 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2502 assert!(
2503 fix_text.contains("JavaScript"),
2504 "Fix should preserve proper name 'JavaScript', got: {fix_text:?}"
2505 );
2506 assert!(
2507 !fix_text.contains("javascript"),
2508 "Fix should not have lowercase 'javascript', got: {fix_text:?}"
2509 );
2510 }
2511
2512 #[test]
2513 fn test_sentence_case_preserves_multi_word_proper_name() {
2514 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2515 let content = "# using good application features\n";
2517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2518 let result = rule.check(&ctx).unwrap();
2519 assert_eq!(result.len(), 1, "Should flag the heading");
2520 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2521 assert!(
2522 fix_text.contains("Good Application"),
2523 "Fix should preserve 'Good Application' as a phrase, got: {fix_text:?}"
2524 );
2525 }
2526
2527 #[test]
2528 fn test_sentence_case_proper_name_at_start_of_heading() {
2529 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2530 let content = "# good application overview\n";
2532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2533 let result = rule.check(&ctx).unwrap();
2534 assert_eq!(result.len(), 1, "Should flag the heading");
2535 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2536 assert!(
2537 fix_text.contains("Good Application"),
2538 "Fix should produce 'Good Application' at start of heading, got: {fix_text:?}"
2539 );
2540 assert!(
2541 fix_text.contains("overview"),
2542 "Non-proper-name word 'overview' should be lowercase, got: {fix_text:?}"
2543 );
2544 }
2545
2546 #[test]
2547 fn test_sentence_case_with_proper_names_no_oscillation() {
2548 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2551
2552 let content = "# installing good application on your system\n";
2554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2555 let result = rule.check(&ctx).unwrap();
2556 assert_eq!(result.len(), 1);
2557 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
2558
2559 assert!(
2561 fixed_heading.contains("Good Application"),
2562 "After fix, proper name must be preserved: {fixed_heading:?}"
2563 );
2564
2565 let fixed_line = format!("{fixed_heading}\n");
2567 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
2568 let result2 = rule.check(&ctx2).unwrap();
2569 assert!(
2570 result2.is_empty(),
2571 "After one fix, heading must already satisfy both MD063 and MD044 - no oscillation. \
2572 Second pass warnings: {result2:?}"
2573 );
2574 }
2575
2576 #[test]
2577 fn test_sentence_case_proper_names_already_correct() {
2578 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2579 let content = "# Installing Good Application\n";
2581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2582 let result = rule.check(&ctx).unwrap();
2583 assert!(
2584 result.is_empty(),
2585 "Correct sentence-case heading with proper name should not be flagged, got: {result:?}"
2586 );
2587 }
2588
2589 #[test]
2590 fn test_sentence_case_multiple_proper_names_in_heading() {
2591 let rule = create_sentence_case_rule_with_proper_names(vec!["TypeScript".to_string(), "React".to_string()]);
2592 let content = "# using typescript with react\n";
2593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2594 let result = rule.check(&ctx).unwrap();
2595 assert_eq!(result.len(), 1);
2596 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2597 assert!(
2598 fix_text.contains("TypeScript"),
2599 "Fix should preserve 'TypeScript', got: {fix_text:?}"
2600 );
2601 assert!(
2602 fix_text.contains("React"),
2603 "Fix should preserve 'React', got: {fix_text:?}"
2604 );
2605 }
2606
2607 #[test]
2608 fn test_sentence_case_unicode_casefold_expansion_before_proper_name() {
2609 let rule = create_sentence_case_rule_with_proper_names(vec!["Österreich".to_string()]);
2612 let content = "# İ österreich guide\n";
2613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2614
2615 let result = rule.check(&ctx).unwrap();
2617 assert_eq!(result.len(), 1, "Should flag heading for canonical proper-name casing");
2618 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2619 assert!(
2620 fix_text.contains("Österreich"),
2621 "Fix should preserve canonical 'Österreich', got: {fix_text:?}"
2622 );
2623 }
2624
2625 #[test]
2626 fn test_sentence_case_preserves_trailing_punctuation_on_proper_name() {
2627 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2628 let content = "# using javascript, today\n";
2629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2630 let result = rule.check(&ctx).unwrap();
2631 assert_eq!(result.len(), 1, "Should flag heading");
2632 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2633 assert!(
2634 fix_text.contains("JavaScript,"),
2635 "Fix should preserve trailing punctuation, got: {fix_text:?}"
2636 );
2637 }
2638
2639 fn create_title_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2646 let config = MD063Config {
2647 enabled: true,
2648 style: HeadingCapStyle::TitleCase,
2649 ..Default::default()
2650 };
2651 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2652 rule.proper_names = names;
2653 rule
2654 }
2655
2656 #[test]
2657 fn test_title_case_preserves_proper_name_with_lowercase_article() {
2658 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
2662 let content = "# listening to the rolling stones today\n";
2663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2664 let result = rule.check(&ctx).unwrap();
2665 assert_eq!(result.len(), 1, "Should flag the heading");
2666 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2667 assert!(
2668 fix_text.contains("The Rolling Stones"),
2669 "Fix should preserve proper name 'The Rolling Stones', got: {fix_text:?}"
2670 );
2671 }
2672
2673 #[test]
2674 fn test_title_case_proper_name_no_oscillation() {
2675 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
2677 let content = "# listening to the rolling stones today\n";
2678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2679 let result = rule.check(&ctx).unwrap();
2680 assert_eq!(result.len(), 1);
2681 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
2682
2683 let fixed_line = format!("{fixed_heading}\n");
2684 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
2685 let result2 = rule.check(&ctx2).unwrap();
2686 assert!(
2687 result2.is_empty(),
2688 "After one title-case fix, heading must already satisfy both rules. \
2689 Second pass warnings: {result2:?}"
2690 );
2691 }
2692
2693 #[test]
2694 fn test_title_case_unicode_casefold_expansion_before_proper_name() {
2695 let rule = create_title_case_rule_with_proper_names(vec!["Österreich".to_string()]);
2696 let content = "# İ österreich guide\n";
2697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2698 let result = rule.check(&ctx).unwrap();
2699 assert_eq!(result.len(), 1, "Should flag the heading");
2700 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2701 assert!(
2702 fix_text.contains("Österreich"),
2703 "Fix should preserve canonical proper-name casing, got: {fix_text:?}"
2704 );
2705 }
2706
2707 #[test]
2713 fn test_from_config_loads_md044_names_into_md063() {
2714 use crate::config::{Config, RuleConfig};
2715 use crate::rule::Rule;
2716 use std::collections::BTreeMap;
2717
2718 let mut config = Config::default();
2719
2720 let mut md063_values = BTreeMap::new();
2722 md063_values.insert("style".to_string(), toml::Value::String("sentence_case".to_string()));
2723 md063_values.insert("enabled".to_string(), toml::Value::Boolean(true));
2724 config.rules.insert(
2725 "MD063".to_string(),
2726 RuleConfig {
2727 values: md063_values,
2728 severity: None,
2729 },
2730 );
2731
2732 let mut md044_values = BTreeMap::new();
2734 md044_values.insert(
2735 "names".to_string(),
2736 toml::Value::Array(vec![toml::Value::String("Good Application".to_string())]),
2737 );
2738 config.rules.insert(
2739 "MD044".to_string(),
2740 RuleConfig {
2741 values: md044_values,
2742 severity: None,
2743 },
2744 );
2745
2746 let rule = MD063HeadingCapitalization::from_config(&config);
2748
2749 let content = "# using good application features\n";
2751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2752 let result = rule.check(&ctx).unwrap();
2753 assert_eq!(result.len(), 1, "Should flag the heading");
2754 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2755 assert!(
2756 fix_text.contains("Good Application"),
2757 "from_config should wire MD044 names into MD063; fix should preserve \
2758 'Good Application', got: {fix_text:?}"
2759 );
2760 }
2761
2762 #[test]
2763 fn test_title_case_short_word_not_confused_with_substring() {
2764 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2768
2769 let content = "# in the insert\n";
2772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2773 let result = rule.check(&ctx).unwrap();
2774 assert_eq!(result.len(), 1, "Should flag the heading");
2775 let fix = result[0].fix.as_ref().expect("Fix should be present");
2776 assert!(
2778 fix.replacement.contains("In the Insert"),
2779 "Expected 'In the Insert', got: {:?}",
2780 fix.replacement
2781 );
2782 }
2783
2784 #[test]
2785 fn test_title_case_or_not_confused_with_orchestra() {
2786 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2787
2788 let content = "# or the orchestra\n";
2791 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2792 let result = rule.check(&ctx).unwrap();
2793 assert_eq!(result.len(), 1, "Should flag the heading");
2794 let fix = result[0].fix.as_ref().expect("Fix should be present");
2795 assert!(
2797 fix.replacement.contains("Or the Orchestra"),
2798 "Expected 'Or the Orchestra', got: {:?}",
2799 fix.replacement
2800 );
2801 }
2802
2803 #[test]
2804 fn test_all_caps_preserves_all_words() {
2805 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
2806
2807 let content = "# in the insert\n";
2808 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2809 let result = rule.check(&ctx).unwrap();
2810 assert_eq!(result.len(), 1, "Should flag the heading");
2811 let fix = result[0].fix.as_ref().expect("Fix should be present");
2812 assert!(
2813 fix.replacement.contains("IN THE INSERT"),
2814 "All caps should uppercase all words, got: {:?}",
2815 fix.replacement
2816 );
2817 }
2818
2819 #[test]
2821 fn test_title_case_numbered_prefix_lowercase_word() {
2822 let rule = create_rule();
2824 let content = "## 1. To Be a Thing\n";
2825 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2826 let result = rule.check(&ctx).unwrap();
2827 assert!(
2828 result.is_empty(),
2829 "Should not flag '## 1. To Be a Thing', got: {result:?}"
2830 );
2831
2832 let content_lower = "## 1. to be a thing\n";
2833 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2834 let result2 = rule.check(&ctx2).unwrap();
2835 assert!(!result2.is_empty(), "Should flag '## 1. to be a thing'");
2836 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2837 assert!(
2838 fix.replacement.contains("1. To Be a Thing"),
2839 "Fix should capitalize 'To', got: {:?}",
2840 fix.replacement
2841 );
2842 }
2843
2844 #[test]
2845 fn test_title_case_numbered_prefix_article() {
2846 let rule = create_rule();
2848 let content = "## 2. A Guide to the Galaxy\n";
2849 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2850 let result = rule.check(&ctx).unwrap();
2851 assert!(
2852 result.is_empty(),
2853 "Should not flag '## 2. A Guide to the Galaxy', got: {result:?}"
2854 );
2855
2856 let content_lower = "## 2. a guide to the galaxy\n";
2857 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2858 let result2 = rule.check(&ctx2).unwrap();
2859 assert!(!result2.is_empty(), "Should flag '## 2. a guide to the galaxy'");
2860 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2861 assert!(
2862 fix.replacement.contains("2. A Guide to the Galaxy"),
2863 "Fix should capitalize 'A', got: {:?}",
2864 fix.replacement
2865 );
2866 }
2867
2868 #[test]
2869 fn test_title_case_mid_sentence_period_word() {
2870 let rule = create_rule();
2872 let content = "## Step 1. Introduction to the Problem\n";
2873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2874 let result = rule.check(&ctx).unwrap();
2875 assert!(
2876 result.is_empty(),
2877 "Should not flag '## Step 1. Introduction to the Problem', got: {result:?}"
2878 );
2879
2880 let content_lower = "## Step 1. introduction to the problem\n";
2881 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2882 let result2 = rule.check(&ctx2).unwrap();
2883 assert!(
2884 !result2.is_empty(),
2885 "Should flag '## Step 1. introduction to the problem'"
2886 );
2887 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2888 assert!(
2889 fix.replacement.contains("Step 1. Introduction to the Problem"),
2890 "Fix should capitalize 'Introduction', got: {:?}",
2891 fix.replacement
2892 );
2893 }
2894
2895 #[test]
2896 fn test_title_case_numbered_prefix_in_link_text() {
2897 let config = MD063Config {
2900 enabled: true,
2901 style: HeadingCapStyle::TitleCase,
2902 ..Default::default()
2903 };
2904 let rule = MD063HeadingCapitalization::from_config_struct(config);
2905
2906 let content = "## [1. To Be a Thing](https://example.com)\n";
2908 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2909 let result = rule.check(&ctx).unwrap();
2910 assert!(
2911 result.is_empty(),
2912 "Should not flag '## [1. To Be a Thing](url)', got: {result:?}"
2913 );
2914
2915 let content_lower = "## [1. to be a thing](https://example.com)\n";
2917 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2918 let result2 = rule.check(&ctx2).unwrap();
2919 assert!(!result2.is_empty(), "Should flag '## [1. to be a thing](url)'");
2920 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2921 assert!(
2922 fix.replacement.contains("1. To Be a Thing"),
2923 "Fix should capitalize 'To' in link text, got: {:?}",
2924 fix.replacement
2925 );
2926 }
2927
2928 #[test]
2933 fn test_is_numeric_ordinal_recognises_canonical_forms() {
2934 for word in &[
2935 "1st", "2nd", "3rd", "4th", "5th", "11th", "21st", "22nd", "23rd", "100th", "1ST", "5Th", "21St", "21sT",
2936 ] {
2937 assert!(
2938 MD063HeadingCapitalization::is_numeric_ordinal(word),
2939 "expected `{word}` to be detected as a numeric ordinal"
2940 );
2941 }
2942 }
2943
2944 #[test]
2945 fn test_is_numeric_ordinal_rejects_non_ordinals() {
2946 for word in &[
2951 "first", "1stop", "ist", "5", "th", "abc", "4G", "4K", "30s", "100k", "5x", "1.5", "iPhone6S",
2952 ] {
2953 assert!(
2954 !MD063HeadingCapitalization::is_numeric_ordinal(word),
2955 "expected `{word}` NOT to be detected as a numeric ordinal"
2956 );
2957 }
2958 }
2959
2960 #[test]
2961 fn test_is_numeric_ordinal_strips_trailing_punctuation() {
2962 for word in &["5th.", "1st,", "21st!", "3rd:", "4th)", "5th's"] {
2963 assert!(
2964 MD063HeadingCapitalization::is_numeric_ordinal(word),
2965 "expected `{word}` to be detected as a numeric ordinal (with punctuation)"
2966 );
2967 }
2968 }
2969
2970 #[test]
2971 fn test_title_case_ordinal_first_word_not_flagged() {
2972 let rule = create_rule();
2973 for content in &[
2974 "# 1st Place\n",
2975 "# 2nd Edition\n",
2976 "# 3rd Time\n",
2977 "# 5th Avenue\n",
2978 "# 21st Century Skills\n",
2979 "# 100th Customer\n",
2980 ] {
2981 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2982 let result = rule.check(&ctx).unwrap();
2983 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
2984 }
2985 }
2986
2987 #[test]
2988 fn test_title_case_ordinal_mid_heading_not_flagged() {
2989 let rule = create_rule();
2990 for content in &[
2991 "# May 3rd Notes\n",
2992 "# Top 100th Customer\n",
2993 "# Notes for the 5th of May\n",
2994 ] {
2995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2996 let result = rule.check(&ctx).unwrap();
2997 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
2998 }
2999 }
3000
3001 #[test]
3002 fn test_title_case_ordinal_corrupted_form_is_fixed() {
3003 let rule = create_rule();
3006 for (input, expected) in &[
3007 ("# 1St Place\n", "1st Place"),
3008 ("# 5Th Avenue\n", "5th Avenue"),
3009 ("# 21St Century Skills\n", "21st Century Skills"),
3010 ("# May 3Rd Notes\n", "May 3rd Notes"),
3011 ("# 22Nd Edition\n", "22nd Edition"),
3012 ] {
3013 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
3014 let result = rule.check(&ctx).unwrap();
3015 assert!(!result.is_empty(), "Should flag {input:?}");
3016 let fix = result[0].fix.as_ref().expect("should have a fix");
3017 assert!(
3018 fix.replacement.contains(expected),
3019 "Fix for {input:?} should contain {expected:?}, got: {:?}",
3020 fix.replacement
3021 );
3022 }
3023 }
3024
3025 #[test]
3026 fn test_title_case_ordinal_lowercase_other_words_capitalised() {
3027 let rule = create_rule();
3029 let content = "# 5th avenue\n";
3030 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3031 let result = rule.check(&ctx).unwrap();
3032 assert_eq!(result.len(), 1);
3033 let fix = result[0].fix.as_ref().expect("should have a fix");
3034 assert!(
3035 fix.replacement.contains("5th Avenue"),
3036 "Fix should produce '5th Avenue', got: {:?}",
3037 fix.replacement
3038 );
3039 }
3040
3041 #[test]
3042 fn test_title_case_ordinal_with_trailing_punctuation() {
3043 let rule = create_rule();
3044 let content = "# Released on the 5th.\n";
3045 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3046 let result = rule.check(&ctx).unwrap();
3047 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3048 }
3049
3050 #[test]
3051 fn test_title_case_ordinal_hyphenated() {
3052 let rule = create_rule();
3053 for content in &["# 21st-Century Skills\n", "# A 19th-Century Novel\n"] {
3054 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3055 let result = rule.check(&ctx).unwrap();
3056 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3057 }
3058 }
3059
3060 #[test]
3061 fn test_sentence_case_ordinal_corrupted_form_is_fixed() {
3062 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3063 let content = "# 5Th avenue\n";
3064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3065 let result = rule.check(&ctx).unwrap();
3066 assert_eq!(result.len(), 1);
3067 let fix = result[0].fix.as_ref().expect("should have a fix");
3068 assert!(
3069 fix.replacement.contains("5th avenue"),
3070 "Fix should produce '5th avenue', got: {:?}",
3071 fix.replacement
3072 );
3073 }
3074
3075 #[test]
3076 fn test_title_case_digit_acronym_unchanged() {
3077 let rule = create_rule();
3080 for content in &["# 4G Networks\n", "# 4K Streaming\n"] {
3081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3082 let result = rule.check(&ctx).unwrap();
3083 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3084 }
3085 }
3086
3087 fn restart_rule(boundaries: &[&str]) -> MD063HeadingCapitalization {
3090 let config = MD063Config {
3091 enabled: true,
3092 style: HeadingCapStyle::SentenceCase,
3093 sentence_case_restart_after: boundaries.iter().copied().map(String::from).collect(),
3094 ..Default::default()
3095 };
3096 MD063HeadingCapitalization::from_config_struct(config)
3097 }
3098
3099 fn suggested(rule: &MD063HeadingCapitalization, content: &str) -> Option<String> {
3102 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3103 let warnings = rule.check(&ctx).unwrap();
3104 let fixed = rule.fix(&ctx).unwrap();
3105 assert_eq!(
3106 warnings.is_empty(),
3107 fixed == content,
3108 "a warning and a rewrite must agree for {content:?}"
3109 );
3110 (!warnings.is_empty()).then(|| fixed.trim_start_matches('#').trim().to_string())
3111 }
3112
3113 #[test]
3114 fn test_restart_after_capitalizes_the_word_following_a_boundary() {
3115 let rule = restart_rule(&[":"]);
3116 assert_eq!(
3117 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3118 Some("Requirement 1: Struct to logger slice conversion")
3119 );
3120 }
3121
3122 #[test]
3123 fn test_restart_after_defaults_to_no_boundaries() {
3124 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3126 assert_eq!(
3127 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3128 Some("Requirement 1: struct to logger slice conversion")
3129 );
3130 }
3131
3132 #[test]
3133 fn test_restart_after_only_honors_configured_punctuation() {
3134 let rule = restart_rule(&[":"]);
3136 assert_eq!(
3137 suggested(&rule, "# Design - Data Model Overview\n").as_deref(),
3138 Some("Design - data model overview")
3139 );
3140 assert_eq!(
3141 suggested(&rule, "# Setup; Then Run\n").as_deref(),
3142 Some("Setup; then run")
3143 );
3144
3145 let rule = restart_rule(&[";", "\u{2014}"]);
3146 assert_eq!(
3147 suggested(&rule, "# Setup; Then Run\n").as_deref(),
3148 Some("Setup; Then run")
3149 );
3150 assert_eq!(
3151 suggested(&rule, "# Part One \u{2014} The Big Idea\n").as_deref(),
3152 Some("Part one \u{2014} The big idea")
3153 );
3154 }
3155
3156 #[test]
3157 fn test_restart_after_matches_only_at_the_end_of_a_word() {
3158 let rule = restart_rule(&["-", ":"]);
3161 assert_eq!(
3162 suggested(&rule, "# Ports: Well-Known Ports Explained\n").as_deref(),
3163 Some("Ports: Well-Known ports explained")
3164 );
3165 assert_eq!(
3166 suggested(&rule, "# See https://example.com/A/B For Details\n").as_deref(),
3167 Some("See https://example.com/A/B for details")
3168 );
3169 }
3170
3171 #[test]
3172 fn test_restart_after_a_trailing_boundary_is_a_no_op() {
3173 let rule = restart_rule(&[":"]);
3174 assert_eq!(suggested(&rule, "# Setup:\n"), None);
3175 }
3176
3177 #[test]
3178 fn test_restart_after_does_not_override_preserved_words() {
3179 let rule = restart_rule(&[":"]);
3182 assert_eq!(
3183 suggested(&rule, "# Devices: iPhone And Android\n").as_deref(),
3184 Some("Devices: iPhone and android")
3185 );
3186
3187 let config = MD063Config {
3188 enabled: true,
3189 style: HeadingCapStyle::SentenceCase,
3190 sentence_case_restart_after: vec![":".to_string()],
3191 ignore_words: vec!["kubectl".to_string()],
3192 preserve_cased_words: false,
3193 ..Default::default()
3194 };
3195 let rule = MD063HeadingCapitalization::from_config_struct(config);
3196 assert_eq!(
3197 suggested(&rule, "# Tools: kubectl And Helm\n").as_deref(),
3198 Some("Tools: kubectl and helm")
3199 );
3200 }
3201
3202 #[test]
3203 fn test_restart_after_keeps_md044_canonical_forms() {
3204 let config = MD063Config {
3205 enabled: true,
3206 style: HeadingCapStyle::SentenceCase,
3207 sentence_case_restart_after: vec![":".to_string()],
3208 ..Default::default()
3209 };
3210 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3211 rule.proper_names = vec!["GitHub".to_string()];
3212
3213 assert_eq!(
3215 suggested(&rule, "# Docs: github Actions Guide\n").as_deref(),
3216 Some("Docs: GitHub actions guide")
3217 );
3218 assert_eq!(suggested(&rule, "# Docs: GitHub actions guide\n"), None);
3219 }
3220
3221 #[test]
3222 fn test_restart_after_carries_across_segments() {
3223 let rule = restart_rule(&[":"]);
3226 assert_eq!(
3227 suggested(
3228 &rule,
3229 "# Overview: [Some Link Here](https://example.com) Trailing Words\n"
3230 )
3231 .as_deref(),
3232 Some("Overview: [Some link here](https://example.com) trailing words")
3233 );
3234 assert_eq!(
3235 suggested(&rule, "# Overview: `code` Then More Words\n").as_deref(),
3236 Some("Overview: `code` then more words")
3237 );
3238 }
3239
3240 #[test]
3241 fn test_restart_after_ends_a_sentence_at_the_end_of_link_text() {
3242 let rule = restart_rule(&[":"]);
3246 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 assert_eq!(
3253 suggested(&rule, "# Topic [See](https://example.com) More Words\n").as_deref(),
3254 Some("Topic [see](https://example.com) more words")
3255 );
3256 }
3257
3258 #[test]
3259 fn test_restart_after_ignores_boundaries_inside_opaque_segments() {
3260 let rule = restart_rule(&[":"]);
3263 for content in [
3264 "# Topic `see:` More Words\n",
3265 "# Topic  More Words\n",
3266 "# Topic <span title=\"x:\">y</span> More Words\n",
3267 ] {
3268 let fixed = suggested(&rule, content).expect("heading should be rewritten");
3269 assert!(
3270 fixed.ends_with("more words"),
3271 "opaque segment restarted the sentence in {content:?}: {fixed}"
3272 );
3273 }
3274 }
3275
3276 #[test]
3277 fn test_restart_after_leaves_a_leading_link_mid_sentence() {
3278 for rule in [restart_rule(&[]), restart_rule(&[":"])] {
3281 assert_eq!(
3282 suggested(&rule, "# [Some Link Here](https://example.com) Trailing Words\n").as_deref(),
3283 Some("[some link here](https://example.com) trailing words")
3284 );
3285 }
3286 }
3287
3288 #[test]
3289 fn test_restart_after_fix_is_idempotent() {
3290 let rule = restart_rule(&[":", ";", "-", "\u{2014}"]);
3291 for content in [
3292 "# Requirement 1: Struct to Logger Slice Conversion\n",
3293 "# Ports: Well-Known Ports Explained\n",
3294 "# Devices: iPhone And Android\n",
3295 "# Overview: [Some Link Here](https://example.com) Trailing Words\n",
3296 "# Setup:\n",
3297 ] {
3298 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3299 let once = rule.fix(&ctx).unwrap();
3300 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
3301 assert_eq!(rule.fix(&ctx).unwrap(), once, "fix is not idempotent for {content:?}");
3302 }
3303 }
3304
3305 #[test]
3306 fn test_restart_after_ignores_empty_boundary_entries() {
3307 let rule = restart_rule(&[""]);
3309 assert_eq!(
3310 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3311 Some("Requirement 1: struct to logger slice conversion")
3312 );
3313 }
3314}