1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
15use crate::utils::header_id_utils::{HTML_TAG_ATTRIBUTES_PATTERN, HTML_TAG_NAME_PATTERN, is_backslash_escaped};
16use crate::utils::html_elements::is_void_element;
17use crate::utils::mdg;
18use crate::utils::range_utils::byte_to_char_count;
19use regex::Regex;
20use std::collections::HashSet;
21use std::sync::LazyLock;
22
23mod md063_config;
24pub(super) use md063_config::{HeadingCapStyle, MD063Config};
25
26static INLINE_CODE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`+[^`]+`+").unwrap());
28
29static LINK_REGEX: LazyLock<Regex> =
34 LazyLock::new(|| Regex::new(r"\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)|\[([^\]]*)\]\[[^\]]*\]").unwrap());
35
36static HTML_TOKEN_REGEX: LazyLock<Regex> = LazyLock::new(|| {
42 let pattern = format!(
43 r"<!-->|<!--->|<!--.*?-->|</({HTML_TAG_NAME_PATTERN})\s*>|<({HTML_TAG_NAME_PATTERN}){HTML_TAG_ATTRIBUTES_PATTERN}\s*/?>"
44 );
45 Regex::new(&pattern).unwrap()
46});
47
48const SELF_RENDERING_ELEMENTS: &[&str] = &[
53 "img", "video", "audio", "iframe", "embed", "object", "canvas", "svg", "math", "input", "select", "textarea",
54 "button", "meter", "progress",
55];
56
57static CUSTOM_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*\{#[^}]+\}\s*$").unwrap());
59
60#[derive(Debug, Clone)]
62enum HeadingSegment {
63 Text(String),
65 Code(String),
67 Link {
69 full: String,
70 text_start: usize,
71 text_end: usize,
72 },
73 Html(String),
75 Image(String),
78}
79
80impl HeadingSegment {
81 fn renders_nothing(&self) -> bool {
86 match self {
87 HeadingSegment::Html(html) => {
88 let paints_something = HTML_TOKEN_REGEX.captures_iter(html).any(|token| {
89 token.get(2).is_some_and(|name| {
90 let name = name.as_str().to_ascii_lowercase();
91 SELF_RENDERING_ELEMENTS.contains(&name.as_str())
92 })
93 });
94 !paints_something && HTML_TOKEN_REGEX.replace_all(html, "").trim().is_empty()
95 }
96 _ => false,
97 }
98 }
99}
100
101#[derive(Clone)]
103pub struct MD063HeadingCapitalization {
104 config: MD063Config,
105 lowercase_set: HashSet<String>,
106 proper_names: Vec<String>,
109}
110
111impl Default for MD063HeadingCapitalization {
112 fn default() -> Self {
113 Self::new()
114 }
115}
116
117impl MD063HeadingCapitalization {
118 pub fn new() -> Self {
119 let config = MD063Config::default();
120 let lowercase_set = config.lowercase_words.iter().cloned().collect();
121 Self {
122 config,
123 lowercase_set,
124 proper_names: Vec::new(),
125 }
126 }
127
128 pub fn from_config_struct(config: MD063Config) -> Self {
129 let lowercase_set = config.lowercase_words.iter().cloned().collect();
130 Self {
131 config,
132 lowercase_set,
133 proper_names: Vec::new(),
134 }
135 }
136
137 fn match_case_insensitive_at(text: &str, start: usize, pattern_lower: &str) -> Option<usize> {
144 if start > text.len() || !text.is_char_boundary(start) || pattern_lower.is_empty() {
145 return None;
146 }
147
148 let mut matched_bytes = 0;
149
150 for (offset, ch) in text[start..].char_indices() {
151 if matched_bytes >= pattern_lower.len() {
152 break;
153 }
154
155 let lowered: String = ch.to_lowercase().collect();
156 if !pattern_lower[matched_bytes..].starts_with(&lowered) {
157 return None;
158 }
159
160 matched_bytes += lowered.len();
161
162 if matched_bytes == pattern_lower.len() {
163 return Some(start + offset + ch.len_utf8());
164 }
165 }
166
167 None
168 }
169
170 fn find_case_insensitive_match(text: &str, pattern_lower: &str, search_start: usize) -> Option<(usize, usize)> {
173 if pattern_lower.is_empty() || search_start >= text.len() || !text.is_char_boundary(search_start) {
174 return None;
175 }
176
177 for (offset, _) in text[search_start..].char_indices() {
178 let start = search_start + offset;
179 if let Some(end) = Self::match_case_insensitive_at(text, start, pattern_lower) {
180 return Some((start, end));
181 }
182 }
183
184 None
185 }
186
187 fn proper_name_canonical_forms(&self, text: &str) -> std::collections::HashMap<usize, &str> {
193 let mut map = std::collections::HashMap::new();
194
195 for name in &self.proper_names {
196 if name.is_empty() {
197 continue;
198 }
199 let name_lower = name.to_lowercase();
200 let canonical_words: Vec<&str> = name.split_whitespace().collect();
201 if canonical_words.is_empty() {
202 continue;
203 }
204 let mut search_start = 0;
205
206 while search_start < text.len() {
207 let Some((abs_pos, end_pos)) = Self::find_case_insensitive_match(text, &name_lower, search_start)
208 else {
209 break;
210 };
211
212 let before_ok = abs_pos == 0 || !text[..abs_pos].chars().last().is_some_and(char::is_alphanumeric);
214 let after_ok =
215 end_pos >= text.len() || !text[end_pos..].chars().next().is_some_and(char::is_alphanumeric);
216
217 if before_ok && after_ok {
218 let text_slice = &text[abs_pos..end_pos];
222 let mut word_idx = 0;
223 let mut slice_offset = 0;
224
225 for text_word in text_slice.split_whitespace() {
226 if let Some(w_rel) = text_slice[slice_offset..].find(text_word) {
227 let word_abs = abs_pos + slice_offset + w_rel;
228 if let Some(&canonical_word) = canonical_words.get(word_idx) {
229 map.insert(word_abs, canonical_word);
230 }
231 slice_offset += w_rel + text_word.len();
232 word_idx += 1;
233 }
234 }
235 }
236
237 search_start = abs_pos + text[abs_pos..].chars().next().map_or(1, char::len_utf8);
240 }
241 }
242
243 map
244 }
245
246 fn has_internal_capitals(&self, word: &str) -> bool {
248 let chars: Vec<char> = word.chars().collect();
249 if chars.len() < 2 {
250 return false;
251 }
252
253 let first = chars[0];
254 let rest = &chars[1..];
255 let has_upper_in_rest = rest.iter().any(|c| c.is_uppercase());
256 let has_lower_in_rest = rest.iter().any(|c| c.is_lowercase());
257
258 if has_upper_in_rest && has_lower_in_rest {
260 return true;
261 }
262
263 if first.is_lowercase() && has_upper_in_rest {
265 return true;
266 }
267
268 false
269 }
270
271 fn is_all_caps_acronym(&self, word: &str) -> bool {
275 if word.len() < 2 {
277 return false;
278 }
279
280 let mut consecutive_upper = 0;
281 let mut max_consecutive = 0;
282
283 for c in word.chars() {
284 if c.is_uppercase() {
285 consecutive_upper += 1;
286 max_consecutive = max_consecutive.max(consecutive_upper);
287 } else if c.is_lowercase() {
288 return false;
290 } else {
291 consecutive_upper = 0;
293 }
294 }
295
296 max_consecutive >= 2
298 }
299
300 fn should_preserve_word(&self, word: &str) -> bool {
302 if self.is_ignored_word(word) {
304 return true;
305 }
306
307 let is_ordinal = Self::is_numeric_ordinal(word);
313
314 if !is_ordinal {
315 if self.config.preserve_cased_words && self.has_internal_capitals(word) {
317 return true;
318 }
319
320 if self.config.preserve_cased_words && self.is_all_caps_acronym(word) {
322 return true;
323 }
324 }
325
326 if self.is_caret_notation(word) {
328 return true;
329 }
330
331 false
332 }
333
334 fn is_ignored_word(&self, word: &str) -> bool {
340 let mut candidate = word;
341 loop {
342 if self.config.ignore_words.iter().any(|ignored| ignored == candidate) {
343 return true;
344 }
345 match candidate.char_indices().next_back() {
346 Some((i, c)) if Self::is_trailing_punctuation(c) => candidate = &candidate[..i],
347 _ => return false,
348 }
349 }
350 }
351
352 fn is_trailing_punctuation(c: char) -> bool {
357 matches!(
358 c,
359 '.' | ','
360 | ';'
361 | ':'
362 | '!'
363 | '?'
364 | ')'
365 | ']'
366 | '}'
367 | '"'
368 | '\''
369 | '\u{2026}'
370 | '\u{2019}'
371 | '\u{201d}'
372 | '\u{203a}'
373 | '\u{bb}'
374 | '\u{3001}'
375 | '\u{3002}'
376 | '\u{3009}'
377 | '\u{300b}'
378 | '\u{300d}'
379 | '\u{300f}'
380 | '\u{3011}'
381 | '\u{ff01}'
382 | '\u{ff09}'
383 | '\u{ff0c}'
384 | '\u{ff0e}'
385 | '\u{ff1a}'
386 | '\u{ff1b}'
387 | '\u{ff1f}'
388 )
389 }
390
391 fn is_numeric_ordinal(word: &str) -> bool {
399 let core = word.trim_matches(|c: char| !c.is_alphanumeric());
404 let bytes = core.as_bytes();
405
406 let alpha_start = match bytes.iter().position(|&b| !b.is_ascii_digit()) {
408 Some(pos) if pos > 0 => pos,
409 _ => return false,
410 };
411
412 let alpha_end = bytes[alpha_start..]
414 .iter()
415 .position(|b| !b.is_ascii_alphabetic())
416 .map_or(bytes.len(), |p| alpha_start + p);
417
418 let suffix = &core[alpha_start..alpha_end];
419 matches!(suffix.to_ascii_lowercase().as_str(), "st" | "nd" | "rd" | "th")
420 }
421
422 fn is_caret_notation(&self, word: &str) -> bool {
424 let chars: Vec<char> = word.chars().collect();
425 if chars.len() >= 2 && chars[0] == '^' {
427 let second = chars[1];
428 if second.is_ascii_uppercase() || "@[\\]^_".contains(second) {
430 return true;
431 }
432 }
433 false
434 }
435
436 fn is_lowercase_word(&self, word: &str) -> bool {
438 self.lowercase_set.contains(&word.to_lowercase())
439 }
440
441 fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
443 if word.is_empty() {
444 return word.to_string();
445 }
446
447 if self.should_preserve_word(word) {
449 return word.to_string();
450 }
451
452 if is_first || is_last {
454 return self.capitalize_first(word);
455 }
456
457 if self.is_lowercase_word(word) {
459 return Self::lowercase_preserving_composition(word);
460 }
461
462 self.capitalize_first(word)
464 }
465
466 fn apply_canonical_form_to_word(word: &str, canonical: &str) -> String {
469 let canonical_lower = canonical.to_lowercase();
470 if canonical_lower.is_empty() {
471 return canonical.to_string();
472 }
473
474 if let Some(end_pos) = Self::match_case_insensitive_at(word, 0, &canonical_lower) {
475 let mut out = String::with_capacity(canonical.len() + word.len().saturating_sub(end_pos));
476 out.push_str(canonical);
477 out.push_str(&word[end_pos..]);
478 out
479 } else {
480 canonical.to_string()
481 }
482 }
483
484 fn capitalize_first(&self, word: &str) -> String {
486 if word.is_empty() {
487 return String::new();
488 }
489
490 let first_alpha_pos = word.find(|c: char| c.is_alphabetic());
492 let Some(pos) = first_alpha_pos else {
493 return word.to_string();
494 };
495
496 let prefix = &word[..pos];
497 let suffix = &word[pos..];
498
499 if Self::is_numeric_ordinal(word) {
502 let suffix_lower = Self::lowercase_preserving_composition(suffix);
503 return format!("{prefix}{suffix_lower}");
504 }
505
506 let mut chars = suffix.chars();
507 let first = chars.next().unwrap();
508 let first_upper = Self::uppercase_preserving_composition(&first.to_string());
511 let rest: String = chars.collect();
512 let rest_lower = Self::lowercase_preserving_composition(&rest);
513 format!("{prefix}{first_upper}{rest_lower}")
514 }
515
516 fn lowercase_preserving_composition(s: &str) -> String {
519 let mut result = String::with_capacity(s.len());
520 for c in s.chars() {
521 let lower: String = c.to_lowercase().collect();
522 if lower.chars().count() == 1 {
523 result.push_str(&lower);
524 } else {
525 result.push(c);
527 }
528 }
529 result
530 }
531
532 fn sentence_case_first_person_pronouns(word: &str) -> Option<String> {
542 fn is_emphasis_marker(c: char) -> bool {
543 matches!(c, '*' | '_' | '~')
544 }
545
546 fn is_word_connector(c: char) -> bool {
550 matches!(c, '/' | '\\' | '-' | '.' | '+' | '&')
551 }
552
553 fn is_word_boundary(c: char) -> bool {
554 !c.is_alphanumeric() && !is_word_connector(c)
555 }
556
557 fn is_pronoun_at(word: &str, pos: usize) -> bool {
558 let left = word[..pos].trim_end_matches(is_emphasis_marker);
559 if !left.chars().next_back().is_none_or(is_word_boundary) {
560 return false;
561 }
562
563 let after_i = word[pos + 1..].trim_start_matches(is_emphasis_marker);
564 let Some(apostrophe) = after_i.chars().next() else {
565 return true;
566 };
567 if !matches!(apostrophe, '\'' | '’') {
568 return is_word_boundary(apostrophe);
569 }
570
571 let after_apostrophe = after_i[apostrophe.len_utf8()..].trim_start_matches(is_emphasis_marker);
572 let suffix_end = after_apostrophe
573 .find(|c: char| !c.is_ascii_alphabetic())
574 .unwrap_or(after_apostrophe.len());
575 let suffix = &after_apostrophe[..suffix_end];
576 if suffix.is_empty() {
577 return after_apostrophe.chars().next().is_none_or(is_word_boundary);
579 }
580 if !matches!(suffix.to_ascii_lowercase().as_str(), "d" | "ll" | "m" | "ve") {
581 return false;
582 }
583
584 let after_suffix = after_apostrophe[suffix_end..].trim_start_matches(is_emphasis_marker);
585 after_suffix.chars().next().is_none_or(is_word_boundary)
586 }
587
588 let pronoun_positions: Vec<usize> = word
589 .char_indices()
590 .filter_map(|(pos, c)| (c == 'I' && is_pronoun_at(word, pos)).then_some(pos))
591 .collect();
592 if pronoun_positions.is_empty() {
593 return None;
594 }
595
596 let mut result = String::with_capacity(word.len());
597 let mut copied_through = 0;
598 for pos in pronoun_positions {
599 result.push_str(&Self::lowercase_preserving_composition(&word[copied_through..pos]));
600 result.push('I');
601 copied_through = pos + 1;
602 }
603 result.push_str(&Self::lowercase_preserving_composition(&word[copied_through..]));
604 Some(result)
605 }
606
607 fn uppercase_preserving_composition(s: &str) -> String {
612 let mut result = String::with_capacity(s.len());
613 for c in s.chars() {
614 let upper: String = c.to_uppercase().collect();
615 if upper.chars().count() == 1 {
616 result.push_str(&upper);
617 } else {
618 result.push(c);
620 }
621 }
622 result
623 }
624
625 fn apply_title_case(&self, text: &str) -> String {
629 let canonical_forms = self.proper_name_canonical_forms(text);
630
631 let original_words: Vec<&str> = text.split_whitespace().collect();
632 let total_words = original_words.len();
633
634 let mut word_positions: Vec<usize> = Vec::with_capacity(original_words.len());
637 let mut pos = 0;
638 for word in &original_words {
639 if let Some(rel) = text[pos..].find(word) {
640 word_positions.push(pos + rel);
641 pos = pos + rel + word.len();
642 } else {
643 word_positions.push(usize::MAX);
644 }
645 }
646
647 let result_words: Vec<String> = original_words
648 .iter()
649 .enumerate()
650 .map(|(i, word)| {
651 let after_period = i > 0 && original_words[i - 1].ends_with('.');
652 let is_first = i == 0 || after_period;
653 let is_last = i == total_words - 1;
654
655 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
657 return Self::apply_canonical_form_to_word(word, canonical);
658 }
659
660 if self.should_preserve_word(word) {
662 return (*word).to_string();
663 }
664
665 if self.is_ignored_word(word) {
667 return (*word).to_string();
668 }
669
670 if word.contains('-') {
672 return self.handle_hyphenated_word(word, is_first, is_last);
673 }
674
675 self.title_case_word(word, is_first, is_last)
676 })
677 .collect();
678
679 result_words.join(" ")
680 }
681
682 fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
684 let parts: Vec<&str> = word.split('-').collect();
685 let total_parts = parts.len();
686
687 let result_parts: Vec<String> = parts
688 .iter()
689 .enumerate()
690 .map(|(i, part)| {
691 let part_is_first = is_first && i == 0;
693 let part_is_last = is_last && i == total_parts - 1;
694 self.title_case_word(part, part_is_first, part_is_last)
695 })
696 .collect();
697
698 result_parts.join("-")
699 }
700
701 fn ends_sentence(&self, word: &str) -> bool {
707 self.config
708 .sentence_case_restart_after
709 .iter()
710 .any(|boundary| !boundary.is_empty() && word.ends_with(boundary.as_str()))
711 }
712
713 fn apply_sentence_case_from(&self, text: &str, starts_sentence: bool) -> String {
717 if text.is_empty() {
718 return text.to_string();
719 }
720
721 let canonical_forms = self.proper_name_canonical_forms(text);
722 let mut result = String::new();
723 let mut current_pos = 0;
724 let mut at_sentence_start = starts_sentence;
725
726 for word in text.split_whitespace() {
728 if let Some(pos) = text[current_pos..].find(word) {
729 let abs_pos = current_pos + pos;
730
731 result.push_str(&text[current_pos..abs_pos]);
733
734 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
737 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
738 } else if self.is_ignored_word(word) {
739 result.push_str(word);
741 } else if let Some(pronoun) = Self::sentence_case_first_person_pronouns(word) {
742 result.push_str(&pronoun);
745 } else if at_sentence_start {
746 if self.should_preserve_word(word) {
748 result.push_str(word);
750 } else {
751 let mut chars = word.chars();
753 if let Some(first) = chars.next() {
754 result.push_str(&Self::uppercase_preserving_composition(&first.to_string()));
755 let rest: String = chars.collect();
756 result.push_str(&Self::lowercase_preserving_composition(&rest));
757 }
758 }
759 } else {
760 if self.should_preserve_word(word) {
762 result.push_str(word);
763 } else {
764 result.push_str(&Self::lowercase_preserving_composition(word));
765 }
766 }
767
768 at_sentence_start = self.ends_sentence(word);
769 current_pos = abs_pos + word.len();
770 }
771 }
772
773 if current_pos < text.len() {
775 result.push_str(&text[current_pos..]);
776 }
777
778 result
779 }
780
781 fn apply_all_caps(&self, text: &str) -> String {
783 if text.is_empty() {
784 return text.to_string();
785 }
786
787 let canonical_forms = self.proper_name_canonical_forms(text);
788 let mut result = String::new();
789 let mut current_pos = 0;
790
791 for word in text.split_whitespace() {
793 if let Some(pos) = text[current_pos..].find(word) {
794 let abs_pos = current_pos + pos;
795
796 result.push_str(&text[current_pos..abs_pos]);
798
799 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
802 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
803 } else if self.should_preserve_word(word) {
804 result.push_str(word);
805 } else {
806 result.push_str(&Self::uppercase_preserving_composition(word));
807 }
808
809 current_pos = abs_pos + word.len();
810 }
811 }
812
813 if current_pos < text.len() {
815 result.push_str(&text[current_pos..]);
816 }
817
818 result
819 }
820
821 fn parse_segments(&self, text: &str) -> Vec<HeadingSegment> {
823 let mut segments = Vec::new();
824 let mut last_end = 0;
825
826 let mut special_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
828
829 for mat in INLINE_CODE_REGEX.find_iter(text) {
831 special_regions.push((mat.start(), mat.end(), HeadingSegment::Code(mat.as_str().to_string())));
832 }
833
834 for caps in LINK_REGEX.captures_iter(text) {
836 let full_match = caps.get(0).unwrap();
837
838 if full_match.start() >= 1 && text.as_bytes()[full_match.start() - 1] == b'!' {
842 let region_start = full_match.start() - 1;
843 special_regions.push((
844 region_start,
845 full_match.end(),
846 HeadingSegment::Image(text[region_start..full_match.end()].to_string()),
847 ));
848 continue;
849 }
850
851 let text_match = caps.get(1).or_else(|| caps.get(2));
852
853 if let Some(text_m) = text_match {
854 special_regions.push((
855 full_match.start(),
856 full_match.end(),
857 HeadingSegment::Link {
858 full: full_match.as_str().to_string(),
859 text_start: text_m.start() - full_match.start(),
860 text_end: text_m.end() - full_match.start(),
861 },
862 ));
863 }
864 }
865
866 let code_ranges: Vec<(usize, usize)> = special_regions
869 .iter()
870 .filter(|(_, _, segment)| matches!(segment, HeadingSegment::Code(_)))
871 .map(|(start, end, _)| (*start, *end))
872 .collect();
873 for (start, end) in Self::html_regions(text, &code_ranges) {
874 special_regions.push((start, end, HeadingSegment::Html(text[start..end].to_string())));
875 }
876
877 special_regions.sort_by_key(|(start, _, _)| *start);
879
880 let mut filtered_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
883 for region in special_regions {
884 let overlaps = filtered_regions.iter().any(|(s, e, _)| region.0 < *e && region.1 > *s);
885 if !overlaps {
886 filtered_regions.push(region);
887 }
888 }
889
890 for (start, end, segment) in filtered_regions {
892 if start > last_end {
894 let text_segment = &text[last_end..start];
895 if !text_segment.is_empty() {
896 segments.push(HeadingSegment::Text(text_segment.to_string()));
897 }
898 }
899 segments.push(segment);
900 last_end = end;
901 }
902
903 if last_end < text.len() {
905 let remaining = &text[last_end..];
906 if !remaining.is_empty() {
907 segments.push(HeadingSegment::Text(remaining.to_string()));
908 }
909 }
910
911 if segments.is_empty() && !text.is_empty() {
913 segments.push(HeadingSegment::Text(text.to_string()));
914 }
915
916 segments
917 }
918
919 fn html_regions(text: &str, code_ranges: &[(usize, usize)]) -> Vec<(usize, usize)> {
933 let mut regions: Vec<(usize, usize)> = Vec::new();
934 let mut open_elements: Vec<(String, usize)> = Vec::new();
935
936 let mut pos = 0;
937 while let Some(token) = HTML_TOKEN_REGEX.captures_at(text, pos) {
938 let whole = token.get(0).unwrap();
939 if code_ranges
940 .iter()
941 .any(|&(start, end)| start <= whole.start() && whole.start() < end)
942 || is_backslash_escaped(text, whole.start())
943 {
944 pos = whole.start() + 1;
946 continue;
947 }
948 pos = whole.end();
949
950 if let Some(closing) = token.get(1) {
951 let name = closing.as_str().to_ascii_lowercase();
952 if let Some(depth) = open_elements.iter().rposition(|(open_name, _)| *open_name == name) {
953 let element_start = open_elements[depth].1;
954 open_elements.truncate(depth);
955 regions.retain(|&(start, _)| start < element_start);
956 regions.push((element_start, whole.end()));
957 continue;
958 }
959 } else if let Some(opening) = token.get(2) {
960 let name = opening.as_str().to_ascii_lowercase();
961 if !whole.as_str().ends_with("/>") && !is_void_element(&name) {
962 open_elements.push((name, whole.start()));
963 }
964 }
965
966 regions.push((whole.start(), whole.end()));
967 }
968
969 regions
970 }
971
972 fn mdx_html_regions(
976 ctx: &crate::lint_context::LintContext,
977 start: usize,
978 end: usize,
979 ) -> (Vec<(usize, usize)>, bool) {
980 let html = ctx.html_tags();
981 let components = ctx.jsx_component_tags();
982 let mut tags = Vec::new();
983 for cached in [html.as_slice(), components.as_slice()] {
984 let first = cached.partition_point(|tag| tag.byte_end <= start);
985 tags.extend(cached[first..].iter().take_while(|tag| tag.byte_offset < end));
986 }
987 tags.sort_unstable_by_key(|tag| tag.byte_offset);
988 let mut regions = Vec::new();
989 let mut open = Vec::new();
990 let mut requires_source_map = false;
991 for tag in tags {
992 let source = &ctx.content[tag.byte_offset..tag.byte_end];
993 requires_source_map |= !HTML_TOKEN_REGEX
994 .find(source)
995 .is_some_and(|token| token.start() == 0 && token.end() == source.len());
996 if tag.is_closing {
997 if let Some(depth) = open.iter().rposition(|(name, _)| *name == tag.tag_name.as_str()) {
998 let (_, element_start) = open[depth];
999 open.truncate(depth);
1000 regions.push((element_start, tag.byte_end));
1001 }
1002 } else if !tag.is_self_closing && !is_void_element(&tag.tag_name) {
1003 open.push((tag.tag_name.as_str(), tag.byte_offset));
1004 }
1005 regions.push((tag.byte_offset, tag.byte_end));
1006 }
1007 (regions, requires_source_map)
1008 }
1009
1010 fn apply_heading_capitalization(
1012 &self,
1013 ctx: &crate::lint_context::LintContext,
1014 line_index: usize,
1015 heading: &crate::lint_context::HeadingInfo,
1016 ) -> String {
1017 if ctx.flavor != crate::config::MarkdownFlavor::MDX {
1018 return self.apply_capitalization(&heading.raw_text, ctx.flavor);
1019 }
1020 let line_info = &ctx.lines[line_index];
1021 if heading.text_lines > 1 {
1022 let first = line_index.saturating_sub(heading.text_lines - 1);
1025 let start = ctx.lines[first].byte_offset;
1026 let end = line_info.byte_offset + line_info.byte_len;
1027 let (_, requires_source_map) = Self::mdx_html_regions(ctx, start, end);
1028 if ctx.overlaps_mdx_inline_code(start, end) || requires_source_map {
1029 return heading.raw_text.clone();
1030 }
1031 return self.apply_capitalization(&heading.raw_text, ctx.flavor);
1032 }
1033 let line = line_info.content(ctx.content);
1034 let Some(relative_start) = line.find(&heading.raw_text) else {
1035 return heading.raw_text.clone();
1036 };
1037 let start = line_info.byte_offset + relative_start;
1038 let end = start + heading.raw_text.len();
1039 let (mut ranges, _) = Self::mdx_html_regions(ctx, start, end);
1040 ranges.extend(ctx.mdx_inline_code_ranges(start, end));
1041 let ranges = ranges
1042 .into_iter()
1043 .map(|(range_start, range_end)| (range_start.max(start) - start, range_end.min(end) - start))
1044 .collect();
1045 self.apply_capitalization_with_ranges(&heading.raw_text, ctx.flavor, ranges)
1046 }
1047
1048 fn apply_capitalization(&self, text: &str, flavor: crate::config::MarkdownFlavor) -> String {
1050 self.apply_capitalization_with_ranges(text, flavor, Vec::new())
1051 }
1052
1053 fn apply_capitalization_with_ranges(
1054 &self,
1055 text: &str,
1056 flavor: crate::config::MarkdownFlavor,
1057 mut ranges: Vec<(usize, usize)>,
1058 ) -> String {
1059 ranges.extend(crate::utils::jinja_utils::find_jinja_ranges(text));
1060 ranges.extend(crate::utils::shortcode_utils::shortcode_ranges(text));
1061 ranges.sort_unstable();
1062 if ranges.is_empty() {
1063 return self.apply_capitalization_to_segments(text, flavor);
1064 }
1065
1066 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
1069 for (start, end) in ranges {
1070 if let Some(last) = merged.last_mut()
1071 && start <= last.1
1072 {
1073 last.1 = last.1.max(end);
1074 } else {
1075 merged.push((start, end));
1076 }
1077 }
1078
1079 let mut delimiter = String::from("\u{e000}");
1086 while text.contains(&delimiter) {
1087 delimiter.push('\u{e000}');
1088 }
1089 let mut masked = String::with_capacity(text.len());
1090 let mut templates = Vec::with_capacity(merged.len());
1091 let mut last_end = 0;
1092 for (index, (start, end)) in merged.into_iter().enumerate() {
1093 let source = &text[start..end];
1094 let token = if flavor == crate::config::MarkdownFlavor::MDX && source.starts_with('<') {
1095 let body = if HeadingSegment::Html(source.to_string()).renders_nothing() {
1096 ""
1097 } else {
1098 "0"
1099 };
1100 format!("<span data-md063=\"{delimiter}{index}{delimiter}\">{body}</span>")
1101 } else if flavor == crate::config::MarkdownFlavor::MDX
1102 && crate::utils::mdx_comments::is_comment_only_expression(&text[start..end])
1103 {
1104 format!("<!--{delimiter}{index}{delimiter}-->")
1107 } else {
1108 format!("{delimiter}{index}{delimiter} {delimiter}{index}{delimiter}")
1109 };
1110 masked.push_str(&text[last_end..start]);
1111 masked.push_str(&token);
1112 templates.push((token, &text[start..end]));
1113 last_end = end;
1114 }
1115 masked.push_str(&text[last_end..]);
1116
1117 let capitalized = self.apply_capitalization_to_segments(&masked, flavor);
1118 let mut result = String::with_capacity(capitalized.len());
1119 let mut cursor = 0;
1120 for (token, template) in templates {
1123 let Some(offset) = capitalized[cursor..].find(&token) else {
1124 return text.to_string();
1127 };
1128 let start = cursor + offset;
1129 result.push_str(&capitalized[cursor..start]);
1130 result.push_str(template);
1131 cursor = start + token.len();
1132 }
1133 result.push_str(&capitalized[cursor..]);
1134 result
1135 }
1136
1137 fn apply_capitalization_to_segments(&self, text: &str, flavor: crate::config::MarkdownFlavor) -> String {
1139 let (main_text, custom_id) = if let Some(mat) = CUSTOM_ID_REGEX.find(text) {
1141 (&text[..mat.start()], Some(mat.as_str()))
1142 } else {
1143 (text, None)
1144 };
1145
1146 let (keyword, main_text) = if flavor == crate::config::MarkdownFlavor::MDG {
1153 mdg::keyword_split(main_text).unwrap_or(("", main_text))
1154 } else {
1155 ("", main_text)
1156 };
1157
1158 let segments = self.parse_segments(main_text);
1160
1161 let text_segments: Vec<usize> = segments
1163 .iter()
1164 .enumerate()
1165 .filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
1166 .collect();
1167
1168 let first_segment_starts_sentence = segments
1172 .iter()
1173 .find(|s| !s.renders_nothing())
1174 .is_some_and(|s| matches!(s, HeadingSegment::Text(_) | HeadingSegment::Link { .. }));
1175
1176 let last_segment_is_text = segments
1179 .iter()
1180 .rev()
1181 .find(|s| !s.renders_nothing())
1182 .is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
1183
1184 let mut result_parts: Vec<String> = Vec::new();
1186
1187 let mut at_sentence_start = first_segment_starts_sentence;
1190
1191 for (i, segment) in segments.iter().enumerate() {
1192 at_sentence_start = match segment {
1197 HeadingSegment::Text(t) => {
1198 let is_first_text = text_segments.first() == Some(&i);
1199 let is_last_text = text_segments.last() == Some(&i) && last_segment_is_text;
1203
1204 let capitalized = match self.config.style {
1205 HeadingCapStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
1206 HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(t, at_sentence_start),
1207 HeadingCapStyle::AllCaps => self.apply_all_caps(t),
1208 };
1209 let ends_sentence = self.ends_sentence(capitalized.trim_end());
1210 result_parts.push(capitalized);
1211 ends_sentence
1212 }
1213 HeadingSegment::Code(c) => {
1214 result_parts.push(c.clone());
1215 false
1216 }
1217 HeadingSegment::Link {
1218 full,
1219 text_start,
1220 text_end,
1221 } => {
1222 let link_text = &full[*text_start..*text_end];
1224 let capitalized_text = match self.config.style {
1225 HeadingCapStyle::TitleCase => self.apply_title_case(link_text),
1226 HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(link_text, at_sentence_start),
1229 HeadingCapStyle::AllCaps => self.apply_all_caps(link_text),
1230 };
1231 let ends_sentence = self.ends_sentence(capitalized_text.trim_end());
1234
1235 let mut new_link = String::new();
1236 new_link.push_str(&full[..*text_start]);
1237 new_link.push_str(&capitalized_text);
1238 new_link.push_str(&full[*text_end..]);
1239 result_parts.push(new_link);
1240 ends_sentence
1241 }
1242 HeadingSegment::Html(h) => {
1243 result_parts.push(h.clone());
1247 segment.renders_nothing() && at_sentence_start
1248 }
1249 HeadingSegment::Image(img) => {
1250 result_parts.push(img.clone());
1252 false
1253 }
1254 };
1255 }
1256
1257 let mut result = String::with_capacity(text.len());
1258 result.push_str(keyword);
1259 result.push_str(&result_parts.join(""));
1260
1261 if let Some(id) = custom_id {
1263 result.push_str(id);
1264 }
1265
1266 result
1267 }
1268
1269 fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
1271 let canonical_forms = self.proper_name_canonical_forms(text);
1272 let words: Vec<&str> = text.split_whitespace().collect();
1273 let total_words = words.len();
1274
1275 if total_words == 0 {
1276 return text.to_string();
1277 }
1278
1279 let mut word_positions: Vec<usize> = Vec::with_capacity(words.len());
1282 let mut pos = 0;
1283 for word in &words {
1284 if let Some(rel) = text[pos..].find(word) {
1285 word_positions.push(pos + rel);
1286 pos = pos + rel + word.len();
1287 } else {
1288 word_positions.push(usize::MAX);
1289 }
1290 }
1291
1292 let result_words: Vec<String> = words
1293 .iter()
1294 .enumerate()
1295 .map(|(i, word)| {
1296 let after_period = i > 0 && words[i - 1].ends_with('.');
1297 let is_first = (is_first_segment && i == 0) || after_period;
1298 let is_last = is_last_segment && i == total_words - 1;
1299
1300 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
1302 return Self::apply_canonical_form_to_word(word, canonical);
1303 }
1304
1305 if self.is_ignored_word(word) {
1307 return (*word).to_string();
1308 }
1309
1310 if word.contains('-') {
1312 return self.handle_hyphenated_word(word, is_first, is_last);
1313 }
1314
1315 self.title_case_word(word, is_first, is_last)
1316 })
1317 .collect();
1318
1319 let mut result = String::new();
1321 let mut word_iter = result_words.iter();
1322 let mut in_word = false;
1323
1324 for c in text.chars() {
1325 if c.is_whitespace() {
1326 if in_word {
1327 in_word = false;
1328 }
1329 result.push(c);
1330 } else if !in_word {
1331 if let Some(word) = word_iter.next() {
1332 result.push_str(word);
1333 }
1334 in_word = true;
1335 }
1336 }
1337
1338 result
1339 }
1340
1341 fn fix_atx_heading(&self, line: &str, heading: &crate::lint_context::HeadingInfo, fixed_text: &str) -> String {
1343 let indent = &line[..heading.marker_column];
1346 let hashes = "#".repeat(heading.level as usize);
1347
1348 let closing = &heading.closing_sequence;
1350 if heading.has_closing_sequence {
1351 format!("{indent}{hashes} {fixed_text} {closing}")
1352 } else {
1353 format!("{indent}{hashes} {fixed_text}")
1354 }
1355 }
1356
1357 fn fix_setext_heading(&self, line: &str, heading: &crate::lint_context::HeadingInfo, fixed_text: &str) -> String {
1359 let leading_ws = &line[..heading.content_column];
1362
1363 format!("{leading_ws}{fixed_text}")
1364 }
1365
1366 fn rewrite_words<'w>(line: &str, words: &mut impl Iterator<Item = &'w str>) -> String {
1369 let mut result = String::with_capacity(line.len());
1370 let mut in_word = false;
1371 for c in line.chars() {
1372 if c.is_whitespace() {
1373 in_word = false;
1374 result.push(c);
1375 } else if !in_word {
1376 if let Some(word) = words.next() {
1377 result.push_str(word);
1378 }
1379 in_word = true;
1380 }
1381 }
1382 result
1383 }
1384
1385 fn fix_setext_heading_span(
1401 &self,
1402 ctx: &crate::lint_context::LintContext,
1403 first_idx: usize,
1404 last_idx: usize,
1405 heading: &crate::lint_context::HeadingInfo,
1406 quoted: bool,
1407 flavor: crate::config::MarkdownFlavor,
1408 ) -> Option<Vec<String>> {
1409 let quote_depth = if quoted {
1410 ctx.lines
1411 .get(last_idx + 1)
1412 .and_then(|underline| crate::utils::blockquote::parse_blockquote_prefix(underline.content(ctx.content)))
1413 .map_or(0, |prefix| prefix.nesting_level)
1414 } else {
1415 0
1416 };
1417 let bodies: Vec<(&str, &str, &str)> = (first_idx..=last_idx)
1418 .map(|idx| {
1419 let line = ctx.lines[idx].content(ctx.content);
1420 let (prefix, line) = if idx == first_idx {
1424 line.split_at(heading.content_column)
1425 } else if quoted {
1426 let margin = crate::utils::blockquote::quote_margin_len(line, quote_depth).unwrap_or(0);
1429 line.split_at(margin)
1430 } else {
1431 ("", line)
1432 };
1433 if idx < last_idx && ctx.line_ends_with_hard_break(idx + 1) {
1434 let (body, hard_break) = line.split_at(line.len() - 1);
1435 (prefix, body, hard_break)
1436 } else {
1437 (prefix, line, "")
1438 }
1439 })
1440 .collect();
1441 let fixed_text = self.apply_capitalization(&heading.raw_text, flavor);
1442 let fixed_words: Vec<&str> = fixed_text.split_whitespace().collect();
1443 let original_words: usize = bodies.iter().map(|(_, body, _)| body.split_whitespace().count()).sum();
1444 if fixed_words.len() != original_words {
1445 return None;
1446 }
1447
1448 let mut words = fixed_words.into_iter();
1449 Some(
1450 bodies
1451 .iter()
1452 .map(|(prefix, body, hard_break)| {
1453 format!("{prefix}{}{hard_break}", Self::rewrite_words(body, &mut words))
1454 })
1455 .collect(),
1456 )
1457 }
1458}
1459
1460impl Rule for MD063HeadingCapitalization {
1461 fn name(&self) -> &'static str {
1462 "MD063"
1463 }
1464
1465 fn description(&self) -> &'static str {
1466 "Heading capitalization"
1467 }
1468
1469 fn category(&self) -> RuleCategory {
1470 RuleCategory::Heading
1471 }
1472
1473 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1474 !ctx.likely_has_headings() || ctx.headings().next().is_none()
1475 }
1476
1477 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1478 let content = ctx.content;
1479
1480 if content.is_empty() {
1481 return Ok(Vec::new());
1482 }
1483
1484 let mut warnings = Vec::new();
1485
1486 let edges = crate::utils::blockquote::container_edges(ctx);
1487
1488 for parsed in ctx.headings() {
1489 let line_num = parsed.line_num - 1;
1490 let line_info = parsed.line_info;
1491 let heading = parsed.heading;
1492 if heading.level < self.config.min_level || heading.level > self.config.max_level {
1494 continue;
1495 }
1496
1497 if !parsed.is_blockquote()
1499 && crate::utils::blockquote::is_indented_past_edge(&edges, line_num, line_info.content(ctx.content))
1500 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX)
1501 {
1502 continue;
1503 }
1504
1505 let original_text = &heading.raw_text;
1507 let fixed_text = self.apply_heading_capitalization(ctx, line_num, heading);
1508
1509 if original_text != &fixed_text {
1510 let line = line_info.content(ctx.content);
1511 let style_name = match self.config.style {
1512 HeadingCapStyle::TitleCase => "title case",
1513 HeadingCapStyle::SentenceCase => "sentence case",
1514 HeadingCapStyle::AllCaps => "ALL CAPS",
1515 };
1516
1517 if heading.text_lines > 1 {
1521 let first_idx = line_num + 1 - heading.text_lines;
1522 let first_line = ctx.lines[first_idx].content(ctx.content);
1523 let fix = self
1524 .fix_setext_heading_span(ctx, first_idx, line_num, heading, parsed.is_blockquote(), ctx.flavor)
1525 .map(|rewritten| {
1526 let range = ctx.line_content_byte_range(first_idx + 1).start
1527 ..ctx.line_content_byte_range(line_num + 1).end;
1528 Fix::new(range, rewritten.join("\n"))
1529 });
1530 warnings.push(LintWarning {
1531 rule_name: Some(self.name().to_string()),
1532 line: first_idx + 1,
1533 column: byte_to_char_count(first_line, heading.content_column),
1534 end_line: line_num + 1,
1535 end_column: line.trim_end().chars().count() + 1,
1536 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1537 severity: Severity::Warning,
1538 fix,
1539 });
1540 continue;
1541 }
1542
1543 warnings.push(LintWarning {
1544 rule_name: Some(self.name().to_string()),
1545 line: line_num + 1,
1546 column: byte_to_char_count(line, heading.content_column),
1547 end_line: line_num + 1,
1548 end_column: byte_to_char_count(line, heading.content_column) + original_text.chars().count(),
1549 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1550 severity: Severity::Warning,
1551 fix: Some(Fix::new(
1552 ctx.line_content_byte_range(line_num + 1),
1553 match heading.style {
1554 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading, &fixed_text),
1555 _ => self.fix_setext_heading(line, heading, &fixed_text),
1556 },
1557 )),
1558 });
1559 }
1560 }
1561
1562 Ok(warnings)
1563 }
1564
1565 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1566 let content = ctx.content;
1567
1568 if content.is_empty() {
1569 return Ok(content.to_string());
1570 }
1571
1572 let lines = ctx.raw_lines();
1573 let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1574
1575 let edges = crate::utils::blockquote::container_edges(ctx);
1576
1577 for parsed in ctx.headings() {
1578 let line_num = parsed.line_num - 1;
1579 let line_info = parsed.line_info;
1580 let heading = parsed.heading;
1581 if ctx.is_rule_disabled(self.name(), line_num + 1) {
1583 continue;
1584 }
1585
1586 if heading.level < self.config.min_level || heading.level > self.config.max_level {
1588 continue;
1589 }
1590
1591 if !parsed.is_blockquote()
1593 && crate::utils::blockquote::is_indented_past_edge(&edges, line_num, line_info.content(ctx.content))
1594 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX)
1595 {
1596 continue;
1597 }
1598
1599 let original_text = &heading.raw_text;
1600 let fixed_text = self.apply_heading_capitalization(ctx, line_num, heading);
1601
1602 if original_text != &fixed_text {
1603 let line = line_info.content(ctx.content);
1604 if heading.text_lines > 1 {
1607 let first_idx = line_num + 1 - heading.text_lines;
1608 if (first_idx..line_num).any(|idx| ctx.is_rule_disabled(self.name(), idx + 1)) {
1611 continue;
1612 }
1613 if let Some(rewritten) = self.fix_setext_heading_span(
1614 ctx,
1615 first_idx,
1616 line_num,
1617 heading,
1618 parsed.is_blockquote(),
1619 ctx.flavor,
1620 ) {
1621 fixed_lines[first_idx..=line_num].clone_from_slice(&rewritten);
1622 }
1623 continue;
1624 }
1625 fixed_lines[line_num] = match heading.style {
1626 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading, &fixed_text),
1627 _ => self.fix_setext_heading(line, heading, &fixed_text),
1628 };
1629 }
1630 }
1631
1632 let mut result = String::with_capacity(content.len());
1634 for (i, line) in fixed_lines.iter().enumerate() {
1635 result.push_str(line);
1636 if i < fixed_lines.len() - 1 || content.ends_with('\n') {
1637 result.push('\n');
1638 }
1639 }
1640
1641 Ok(result)
1642 }
1643
1644 fn as_any(&self) -> &dyn std::any::Any {
1645 self
1646 }
1647
1648 crate::impl_rule_config_sections!(MD063Config);
1649
1650 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1651 where
1652 Self: Sized,
1653 {
1654 let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
1655 let md044_config =
1656 crate::rule_config_serde::load_rule_config::<crate::rules::md044_proper_names::MD044Config>(config);
1657 let mut rule = Self::from_config_struct(rule_config);
1658 rule.proper_names = md044_config.names;
1659 Box::new(rule)
1660 }
1661}
1662
1663#[cfg(test)]
1664mod tests {
1665 use super::*;
1666 use crate::lint_context::LintContext;
1667
1668 fn create_rule() -> MD063HeadingCapitalization {
1669 let config = MD063Config {
1670 enabled: true,
1671 ..Default::default()
1672 };
1673 MD063HeadingCapitalization::from_config_struct(config)
1674 }
1675
1676 fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
1677 let config = MD063Config {
1678 enabled: true,
1679 style,
1680 ..Default::default()
1681 };
1682 MD063HeadingCapitalization::from_config_struct(config)
1683 }
1684
1685 #[test]
1686 fn test_heading_indent_counts_from_the_list_item_content_column() {
1687 let rule = create_rule();
1688 for (source, expected) in [
1689 ("- x\n\n ## hello world\n", "- x\n\n ## Hello World\n"),
1690 ("- x\n\n ## hello world\n", "- x\n\n ## Hello World\n"),
1691 ("1. x\n\n ## hello world\n", "1. x\n\n ## Hello World\n"),
1692 ("10. x\n\n ## hello world\n", "10. x\n\n ## Hello World\n"),
1693 ("10. x\n\n ## hello world\n", "10. x\n\n ## Hello World\n"),
1694 ("- x\n\n\t## hello world\n", "- x\n\n\t## Hello World\n"),
1695 ] {
1696 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1697 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source:?}");
1698 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source:?}");
1699 }
1700 }
1701
1702 #[test]
1703 fn test_heading_four_columns_past_the_list_item_content_column_is_code() {
1704 let rule = create_rule();
1705 for source in [
1706 "- x\n\n ## hello world\n",
1707 "1. x\n\n ## hello world\n",
1708 "10. x\n\n ## hello world\n",
1709 "- x\n\n\t\t## hello world\n",
1710 " ## hello world\n",
1711 ] {
1712 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1713 assert!(rule.check(&ctx).unwrap().is_empty(), "{source:?}");
1714 assert_eq!(rule.fix(&ctx).unwrap(), source, "{source:?}");
1715 }
1716 }
1717
1718 #[test]
1721 fn test_mdx_sentence_case_distinguishes_comments_from_comments_plus_code() {
1722 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1723 for (source, expected) in [
1724 ("# { /* hidden */ } hello world\n", "# { /* hidden */ } Hello world\n"),
1725 (
1726 "export const name = \"Visible\"\n\n# {/* c */ name} hello world\n",
1727 "export const name = \"Visible\"\n\n# {/* c */ name} hello world\n",
1728 ),
1729 ] {
1730 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1731 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1732 }
1733 }
1734
1735 #[test]
1736 fn test_mdx_leading_comment_does_not_consume_sentence_start() {
1737 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1738 for (source, expected) in [
1739 (
1740 "# {/* literal comment */} hello world\n",
1741 "# {/* literal comment */} Hello world\n",
1742 ),
1743 (
1744 "{/* literal comment */} hello world\n===\n",
1745 "{/* literal comment */} Hello world\n===\n",
1746 ),
1747 ] {
1748 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1749 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1750 }
1751 }
1752
1753 #[test]
1754 fn test_mdx_heading_expression_code_is_preserved() {
1755 for (style, before, after) in [
1756 (HeadingCapStyle::TitleCase, "Hello", "World"),
1757 (HeadingCapStyle::SentenceCase, "Hello", "world"),
1758 (HeadingCapStyle::AllCaps, "HELLO", "WORLD"),
1759 ] {
1760 let rule = create_rule_with_style(style);
1761 for expression in ["name", "data.text", r#""literal value""#, "name.toLowerCase()"] {
1762 let source = format!("# hello {{{expression}}} world\n");
1763 let expected = format!("# {before} {{{expression}}} {after}\n");
1764 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
1765 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1766 assert_eq!(
1767 rule.fix(&ctx).unwrap(),
1768 expected,
1769 "expression code is literal: {source}"
1770 );
1771 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::MDX, None);
1772 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1773 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1774 }
1775 }
1776 }
1777
1778 #[test]
1779 fn test_mdx_heading_link_labels_and_adjacent_expressions_preserve_code() {
1780 let rule = create_rule();
1781 for (source, expected) in [
1782 (
1783 "# hello [{name} world](target) friend\n",
1784 "# Hello [{name} World](target) Friend\n",
1785 ),
1786 ("# hello {name}{data.text} world\n", "# Hello {name}{data.text} World\n"),
1787 (
1788 "# hello {/* literal comment */} world\n",
1789 "# Hello {/* literal comment */} World\n",
1790 ),
1791 ("# 日本語 {name} world\n", "# 日本語 {name} World\n"),
1792 ] {
1793 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1794 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1795 }
1796 }
1797
1798 #[test]
1799 fn test_mdx_expression_protection_is_flavor_specific() {
1800 let rule = create_rule();
1801 let source = "# hello {name} world\n";
1802 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1803 assert_eq!(rule.fix(&ctx).unwrap(), "# Hello {Name} World\n");
1804 }
1805
1806 #[test]
1807 fn test_mdx_heading_attribute_expressions_keep_existing_html_protection() {
1808 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1809 let source = "# hello <span title={name}>world</span> friend\n";
1810 let expected = "# HELLO <span title={name}>world</span> FRIEND\n";
1811 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1812 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1813 }
1814
1815 #[test]
1816 fn test_heading_template_tags_are_preserved() {
1817 for (style, before, after) in [
1818 (HeadingCapStyle::TitleCase, "Hello", "World"),
1819 (HeadingCapStyle::SentenceCase, "Hello", "world"),
1820 (HeadingCapStyle::AllCaps, "HELLO", "WORLD"),
1821 ] {
1822 let rule = create_rule_with_style(style);
1823 for template in [
1824 "{{ name }}",
1825 "{{ data['display_name'] }}",
1826 "{{< note title=\"literal value\" >}}",
1827 "{{% note title=\"literal value\" %}}",
1828 ] {
1829 let source = format!("# hello {template} world\n");
1830 let expected = format!("# {before} {template} {after}\n");
1831 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1832 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1833 assert_eq!(rule.fix(&ctx).unwrap(), expected, "template code is literal: {source}");
1834 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
1835 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1836 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1837 }
1838 }
1839 }
1840
1841 #[test]
1842 fn test_heading_template_control_tags_and_link_labels_are_preserved() {
1843 let rule = create_rule();
1844 for (source, expected) in [
1845 (
1846 "# hello {% if enabled %}yes{% else %}no{% endif %} world\n",
1847 "# Hello {% if enabled %}Yes{% else %}No{% endif %} World\n",
1848 ),
1849 (
1850 "# hello [{{ name }} world](target) friend\n",
1851 "# Hello [{{ name }} World](target) Friend\n",
1852 ),
1853 (
1854 "# hello {{ name }} and {{ data['display_name'] }} world\n",
1855 "# Hello {{ name }} and {{ data['display_name'] }} World\n",
1856 ),
1857 ] {
1858 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1859 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1860 }
1861 }
1862
1863 #[test]
1864 fn test_heading_template_tokens_do_not_collide_with_literal_text() {
1865 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1866 let source = "# 日本語 \u{e000}0\u{e000} {{ name }} world\n";
1867 let expected = "# 日本語 \u{e000}0\u{e000} {{ name }} WORLD\n";
1868 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1869 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1870 }
1871
1872 #[test]
1873 fn test_an_escaped_tag_does_not_hide_the_element_written_inside_it() {
1874 let text = r#"\<span title='<a id="x"></a>'>foo"#;
1877 assert_eq!(MD063HeadingCapitalization::html_regions(text, &[]), vec![(14, 28)]);
1878 }
1879
1880 #[test]
1881 fn test_title_case_basic() {
1882 let rule = create_rule();
1883 let content = "# hello world\n";
1884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1885 let result = rule.check(&ctx).unwrap();
1886 assert_eq!(result.len(), 1);
1887 assert!(result[0].message.contains("Hello World"));
1888 }
1889
1890 #[test]
1891 fn test_title_case_lowercase_words() {
1892 let rule = create_rule();
1893 let content = "# the quick brown fox\n";
1894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1895 let result = rule.check(&ctx).unwrap();
1896 assert_eq!(result.len(), 1);
1897 assert!(result[0].message.contains("The Quick Brown Fox"));
1899 }
1900
1901 #[test]
1902 fn test_title_case_already_correct() {
1903 let rule = create_rule();
1904 let content = "# The Quick Brown Fox\n";
1905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1906 let result = rule.check(&ctx).unwrap();
1907 assert!(result.is_empty(), "Already correct heading should not be flagged");
1908 }
1909
1910 #[test]
1911 fn test_title_case_hyphenated() {
1912 let rule = create_rule();
1913 let content = "# self-documenting code\n";
1914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1915 let result = rule.check(&ctx).unwrap();
1916 assert_eq!(result.len(), 1);
1917 assert!(result[0].message.contains("Self-Documenting Code"));
1918 }
1919
1920 #[test]
1921 fn test_title_case_preserves_url_with_nested_parens() {
1922 let rule = create_rule();
1923 let content = "# guide for [the api](https://example.com/docs/v(2)beta)\n";
1925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1926 let fixed = rule.fix(&ctx).unwrap();
1927 assert!(
1930 fixed.contains("https://example.com/docs/v(2)beta"),
1931 "URL with nested parens was corrupted: {fixed:?}"
1932 );
1933 }
1934
1935 #[test]
1936 fn test_title_case_does_not_recase_image_alt() {
1937 let rule = create_rule();
1938 let content = "# overview \n";
1939 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1940 let fixed = rule.fix(&ctx).unwrap();
1941 assert!(
1943 fixed.contains(""),
1944 "image alt text was modified: {fixed:?}"
1945 );
1946 assert!(
1947 fixed.contains("# Overview"),
1948 "surrounding prose should still be title-cased: {fixed:?}"
1949 );
1950 }
1951
1952 #[test]
1954 fn test_sentence_case_basic() {
1955 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1956 let content = "# The Quick Brown Fox\n";
1957 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1958 let result = rule.check(&ctx).unwrap();
1959 assert_eq!(result.len(), 1);
1960 assert!(result[0].message.contains("The quick brown fox"));
1961 }
1962
1963 #[test]
1964 fn test_sentence_case_already_correct() {
1965 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1966 let content = "# The quick brown fox\n";
1967 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1968 let result = rule.check(&ctx).unwrap();
1969 assert!(result.is_empty());
1970 }
1971
1972 #[test]
1973 fn test_sentence_case_preserves_first_person_pronoun() {
1974 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1976 let content = "# How do I debug playbooks?\n";
1977 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1978
1979 assert!(rule.check(&ctx).unwrap().is_empty());
1980 assert_eq!(rule.fix(&ctx).unwrap(), content);
1981 }
1982
1983 #[test]
1984 fn test_sentence_case_preserves_first_person_contractions() {
1985 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1986
1987 for content in [
1988 "# What I'd change\n",
1989 "# Where I'll look\n",
1990 "# Why I'm here\n",
1991 "# What I've learned\n",
1992 "# What I’d change\n",
1993 "# Where I’ll look\n",
1994 "# Why I’m here\n",
1995 "# What I’ve learned\n",
1996 ] {
1997 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1998 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1999 assert_eq!(rule.fix(&ctx).unwrap(), content);
2000 }
2001 }
2002
2003 #[test]
2004 fn test_sentence_case_pronoun_handles_markup_punctuation_and_suffix_case() {
2005 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2006 let cases = [
2007 ("# What (**I**) would change\n", "# What (**I**) would change\n"),
2008 ("# Why **I**'M changing it\n", "# Why **I**'m changing it\n"),
2009 ("# What I’LL change\n", "# What I’ll change\n"),
2010 ("# What I—really want\n", "# What I—really want\n"),
2011 ("# What I’d—reluctantly change\n", "# What I’d—reluctantly change\n"),
2012 ("# What I—yes—I—would do\n", "# What I—yes—I—would do\n"),
2013 ("# What [I'll change](plan.md)\n", "# What [I'll change](plan.md)\n"),
2014 ("# [What I—really want](plan.md)\n", "# [What I—really want](plan.md)\n"),
2015 ];
2016
2017 for (content, expected) in cases {
2018 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2019 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
2020 }
2021 }
2022
2023 #[test]
2024 fn test_sentence_case_pronoun_is_independent_of_cased_word_preservation() {
2025 let config = MD063Config {
2026 enabled: true,
2027 style: HeadingCapStyle::SentenceCase,
2028 preserve_cased_words: false,
2029 ..Default::default()
2030 };
2031 let rule = MD063HeadingCapitalization::from_config_struct(config);
2032 let content = "# How do I debug what I’LL change?\n";
2033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2034
2035 assert_eq!(rule.fix(&ctx).unwrap(), "# How do I debug what I’ll change?\n");
2036 }
2037
2038 #[test]
2039 fn test_sentence_case_explicit_ignore_wins_over_pronoun_normalization() {
2040 let config = MD063Config {
2041 enabled: true,
2042 style: HeadingCapStyle::SentenceCase,
2043 ignore_words: vec!["I'LL".to_string(), "I’LL".to_string()],
2044 ..Default::default()
2045 };
2046 let rule = MD063HeadingCapitalization::from_config_struct(config);
2047 let content = "# Why I'LL stay and why I’LL leave\n";
2048 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2049
2050 assert!(rule.check(&ctx).unwrap().is_empty());
2051 assert_eq!(rule.fix(&ctx).unwrap(), content);
2052 }
2053
2054 #[test]
2055 fn test_sentence_case_pronoun_does_not_preserve_other_single_letters_or_compounds() {
2056 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2057 let content = "# Compare i, A, I/O, and A.I. values\n";
2058 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2059
2060 assert_eq!(rule.fix(&ctx).unwrap(), "# Compare i, a, i/o, and a.i. values\n");
2061 }
2062
2063 #[test]
2065 fn test_all_caps_basic() {
2066 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
2067 let content = "# hello world\n";
2068 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2069 let result = rule.check(&ctx).unwrap();
2070 assert_eq!(result.len(), 1);
2071 assert!(result[0].message.contains("HELLO WORLD"));
2072 }
2073
2074 #[test]
2076 fn test_preserve_ignore_words() {
2077 let config = MD063Config {
2078 enabled: true,
2079 ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
2080 ..Default::default()
2081 };
2082 let rule = MD063HeadingCapitalization::from_config_struct(config);
2083
2084 let content = "# using iPhone on macOS\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!(result[0].message.contains("iPhone"));
2090 assert!(result[0].message.contains("macOS"));
2091 }
2092
2093 #[test]
2094 fn test_preserve_cased_words() {
2095 let rule = create_rule();
2096 let content = "# using GitHub actions\n";
2097 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2098 let result = rule.check(&ctx).unwrap();
2099 assert_eq!(result.len(), 1);
2100 assert!(result[0].message.contains("GitHub"));
2102 }
2103
2104 #[test]
2106 fn test_inline_code_preserved() {
2107 let rule = create_rule();
2108 let content = "# using `const` in javascript\n";
2109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2110 let result = rule.check(&ctx).unwrap();
2111 assert_eq!(result.len(), 1);
2112 assert!(result[0].message.contains("`const`"));
2114 assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
2115 }
2116
2117 #[test]
2119 fn test_level_filter() {
2120 let config = MD063Config {
2121 enabled: true,
2122 min_level: 2,
2123 max_level: 4,
2124 ..Default::default()
2125 };
2126 let rule = MD063HeadingCapitalization::from_config_struct(config);
2127
2128 let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
2129 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2130 let result = rule.check(&ctx).unwrap();
2131
2132 assert_eq!(result.len(), 2);
2134 assert_eq!(result[0].line, 2); assert_eq!(result[1].line, 3); }
2137
2138 #[test]
2140 fn test_fix_atx_heading() {
2141 let rule = create_rule();
2142 let content = "# hello world\n";
2143 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2144 let fixed = rule.fix(&ctx).unwrap();
2145 assert_eq!(fixed, "# Hello World\n");
2146 }
2147
2148 #[test]
2149 fn test_fix_multiple_headings() {
2150 let rule = create_rule();
2151 let content = "# first heading\n\n## second heading\n";
2152 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2153 let fixed = rule.fix(&ctx).unwrap();
2154 assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
2155 }
2156
2157 #[test]
2159 fn test_setext_heading() {
2160 let rule = create_rule();
2161 let content = "hello world\n============\n";
2162 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2163 let result = rule.check(&ctx).unwrap();
2164 assert_eq!(result.len(), 1);
2165 assert!(result[0].message.contains("Hello World"));
2166 }
2167
2168 #[test]
2169 fn test_multi_line_setext_heading_is_capitalized_in_place() {
2170 let rule = create_rule();
2174 let content = "hello world\nand more words\n==============\n";
2175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2176 let fixed = rule.fix(&ctx).unwrap();
2177 assert_eq!(fixed, "Hello World\nand More Words\n==============\n");
2178
2179 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2180 assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent");
2181 }
2182
2183 #[test]
2184 fn test_multi_line_setext_heading_keeps_a_hard_break_backslash() {
2185 let rule = create_rule();
2188 let content = "foo bar\\\nbaz qux\n=======\n";
2189 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2190 let fixed = rule.fix(&ctx).unwrap();
2191 assert_eq!(fixed, "Foo Bar\\\nBaz Qux\n=======\n");
2192
2193 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2194 assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent");
2195 }
2196
2197 #[test]
2198 fn test_multi_line_setext_heading_keeps_a_backslash_that_is_no_hard_break() {
2199 let rule = create_rule();
2203 for (content, expected) in [
2204 ("foo `a\\\nb` tail\n===\n", "Foo `a\\\nb` Tail\n===\n"),
2205 ("foo bar\nbaz\\\n===\n", "Foo Bar\nBaz\\\n===\n"),
2206 ] {
2207 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2208 let fixed = rule.fix(&ctx).unwrap();
2209 assert_eq!(fixed, expected, "{content:?}");
2210
2211 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2212 assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent: {content:?}");
2213 }
2214 }
2215
2216 #[test]
2217 fn test_multi_line_setext_heading_warning_covers_the_whole_span() {
2218 let rule = create_rule();
2221 let content = "hello world\nand more\n=====\n";
2222 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2223 let result = rule.check(&ctx).unwrap();
2224 assert_eq!(result.len(), 1, "got: {result:?}");
2225 assert_eq!(result[0].line, 1);
2226 assert_eq!(result[0].column, 1);
2227 assert_eq!(result[0].end_line, 2);
2228 assert_eq!(result[0].end_column, 9);
2229 }
2230
2231 #[test]
2232 fn test_multi_line_setext_heading_with_a_multi_byte_word_is_idempotent() {
2233 let rule = create_rule();
2237 let content = "`A`\n| à | |\n| --- | --- |\n---";
2238 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2239 let fixed = rule.fix(&ctx).unwrap();
2240 assert_eq!(fixed, "`A`\n| À | |\n| --- | --- |\n---");
2241
2242 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2243 assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent");
2244 }
2245
2246 #[test]
2247 fn test_multi_line_setext_heading_whose_words_cannot_be_mapped_back_is_reported_without_a_fix() {
2248 let rule = create_rule();
2253 let content = "see [ the guide ](guide.md) first\nand then more\n=====\n";
2254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2255 let result = rule.check(&ctx).unwrap();
2256 assert_eq!(result.len(), 1, "got: {result:?}");
2257 assert_eq!(result[0].line, 1);
2258 assert_eq!(result[0].end_line, 2);
2259 assert!(
2260 result[0].fix.is_none(),
2261 "no rewrite is offered, got: {:?}",
2262 result[0].fix
2263 );
2264 assert_eq!(rule.fix(&ctx).unwrap(), content, "the heading is left as written");
2265
2266 let content = "see [the guide](guide.md) first\nand then more\n=====\n";
2268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2269 let result = rule.check(&ctx).unwrap();
2270 assert_eq!(result.len(), 1, "got: {result:?}");
2271 assert!(result[0].fix.is_some(), "the control heading is fixable");
2272 assert_eq!(
2273 rule.fix(&ctx).unwrap(),
2274 "See [The Guide](guide.md) First\nand Then More\n=====\n"
2275 );
2276 }
2277
2278 #[test]
2279 fn test_fix_honours_a_suppression_on_any_line_of_a_multi_line_setext_heading() {
2280 let rule = create_rule();
2284 let content = "<!-- rumdl-disable-next-line MD063 -->\nhello\nworld\n===\n";
2285 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2286 assert_eq!(
2287 rule.fix(&ctx).unwrap(),
2288 content,
2289 "the suppressed heading is left as written"
2290 );
2291
2292 let content = "hello\nworld\n===\n";
2294 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2295 assert_eq!(rule.fix(&ctx).unwrap(), "Hello\nWorld\n===\n");
2296 }
2297
2298 #[test]
2300 fn test_custom_id_preserved() {
2301 let rule = create_rule();
2302 let content = "# getting started {#intro}\n";
2303 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2304 let result = rule.check(&ctx).unwrap();
2305 assert_eq!(result.len(), 1);
2306 assert!(result[0].message.contains("{#intro}"));
2308 }
2309
2310 #[test]
2312 fn test_skip_obsidian_tags_not_headings() {
2313 let rule = create_rule();
2314
2315 let content = "# H1\n\n#tag\n";
2317 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2318 let result = rule.check(&ctx).unwrap();
2319 assert!(
2320 result.is_empty() || result.iter().all(|w| w.line != 3),
2321 "Obsidian tag #tag should not be treated as a heading: {result:?}"
2322 );
2323 }
2324
2325 #[test]
2326 fn test_skip_invalid_atx_headings_no_space() {
2327 let rule = create_rule();
2328
2329 let content = "#notaheading\n";
2331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2332 let result = rule.check(&ctx).unwrap();
2333 assert!(
2334 result.is_empty(),
2335 "Invalid ATX heading without space should not be flagged: {result:?}"
2336 );
2337 }
2338
2339 #[test]
2340 fn test_fix_skips_obsidian_tags() {
2341 let rule = create_rule();
2342
2343 let content = "# hello world\n\n#tag\n";
2344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2345 let fixed = rule.fix(&ctx).unwrap();
2346 assert!(fixed.contains("#tag"), "Fix should not modify Obsidian tag #tag");
2348 assert!(fixed.contains("# Hello World"), "Fix should still fix real headings");
2349 }
2350
2351 #[test]
2352 fn test_preserve_all_caps_acronyms() {
2353 let rule = create_rule();
2354 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2355
2356 let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
2358 assert_eq!(fixed, "# Using API in Production\n");
2359
2360 let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
2362 assert_eq!(fixed, "# API and GPU Integration\n");
2363
2364 let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
2366 assert_eq!(fixed, "# IO Performance Guide\n");
2367
2368 let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
2370 assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
2371 }
2372
2373 #[test]
2374 fn test_preserve_acronyms_in_hyphenated_words() {
2375 let rule = create_rule();
2376 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2377
2378 let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
2380 assert_eq!(fixed, "# API-Driven Architecture\n");
2381
2382 let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
2384 assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
2385 }
2386
2387 #[test]
2388 fn test_single_letters_not_treated_as_acronyms() {
2389 let rule = create_rule();
2390 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2391
2392 let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
2394 assert_eq!(fixed, "# I Am a Heading\n");
2395 }
2396
2397 #[test]
2398 fn test_lowercase_terms_need_ignore_words() {
2399 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2400
2401 let rule = create_rule();
2403 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
2404 assert_eq!(fixed, "# Using Npm Packages\n");
2405
2406 let config = MD063Config {
2408 enabled: true,
2409 ignore_words: vec!["npm".to_string()],
2410 ..Default::default()
2411 };
2412 let rule = MD063HeadingCapitalization::from_config_struct(config);
2413 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
2414 assert_eq!(fixed, "# Using npm Packages\n");
2415 }
2416
2417 #[test]
2418 fn test_acronyms_with_mixed_case_preserved() {
2419 let rule = create_rule();
2420 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2421
2422 let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
2424 assert_eq!(fixed, "# Using API with GitHub\n");
2425 }
2426
2427 #[test]
2428 fn test_real_world_acronyms() {
2429 let rule = create_rule();
2430 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2431
2432 let content = "# FFI bindings for CPU optimization\n";
2434 let fixed = rule.fix(&ctx(content)).unwrap();
2435 assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
2436
2437 let content = "# DOM manipulation and SSR rendering\n";
2438 let fixed = rule.fix(&ctx(content)).unwrap();
2439 assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
2440
2441 let content = "# CVE security and RNN models\n";
2442 let fixed = rule.fix(&ctx(content)).unwrap();
2443 assert_eq!(fixed, "# CVE Security and RNN Models\n");
2444 }
2445
2446 #[test]
2447 fn test_is_all_caps_acronym() {
2448 let rule = create_rule();
2449
2450 assert!(rule.is_all_caps_acronym("API"));
2452 assert!(rule.is_all_caps_acronym("IO"));
2453 assert!(rule.is_all_caps_acronym("GPU"));
2454 assert!(rule.is_all_caps_acronym("HTTP2")); assert!(!rule.is_all_caps_acronym("A"));
2458 assert!(!rule.is_all_caps_acronym("I"));
2459
2460 assert!(!rule.is_all_caps_acronym("Api"));
2462 assert!(!rule.is_all_caps_acronym("npm"));
2463 assert!(!rule.is_all_caps_acronym("iPhone"));
2464 }
2465
2466 #[test]
2467 fn test_sentence_case_starts_after_a_leading_empty_anchor() {
2468 let config = MD063Config {
2470 enabled: true,
2471 style: HeadingCapStyle::SentenceCase,
2472 ..Default::default()
2473 };
2474 let rule = MD063HeadingCapitalization::from_config_struct(config);
2475
2476 let content = "# <a id=\"top\"></a>the beginning\n";
2477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2478 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2479 assert_eq!(rule.fix(&ctx).unwrap(), "# <a id=\"top\"></a>The beginning\n");
2480
2481 let content = "# <kbd>ctrl</kbd> the key\n";
2483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2484 assert!(rule.check(&ctx).unwrap().is_empty());
2485
2486 for content in ["# <img src=\"x.png\"> the picture\n", "#  the picture\n"] {
2489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2490 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
2491 }
2492 }
2493
2494 #[test]
2495 fn test_sentence_case_ignore_words_first_word() {
2496 let config = MD063Config {
2497 enabled: true,
2498 style: HeadingCapStyle::SentenceCase,
2499 ignore_words: vec!["nvim".to_string()],
2500 ..Default::default()
2501 };
2502 let rule = MD063HeadingCapitalization::from_config_struct(config);
2503
2504 let content = "# nvim config\n";
2506 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2507 let result = rule.check(&ctx).unwrap();
2508 assert!(
2509 result.is_empty(),
2510 "nvim in ignore-words should not be flagged. Got: {result:?}"
2511 );
2512
2513 let fixed = rule.fix(&ctx).unwrap();
2515 assert_eq!(fixed, "# nvim config\n");
2516 }
2517
2518 #[test]
2519 fn test_sentence_case_ignore_words_not_first() {
2520 let config = MD063Config {
2521 enabled: true,
2522 style: HeadingCapStyle::SentenceCase,
2523 ignore_words: vec!["nvim".to_string()],
2524 ..Default::default()
2525 };
2526 let rule = MD063HeadingCapitalization::from_config_struct(config);
2527
2528 let content = "# Using nvim editor\n";
2530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2531 let result = rule.check(&ctx).unwrap();
2532 assert!(
2533 result.is_empty(),
2534 "nvim in ignore-words should be preserved. Got: {result:?}"
2535 );
2536 }
2537
2538 #[test]
2539 fn test_preserve_cased_words_ios() {
2540 let config = MD063Config {
2541 enabled: true,
2542 style: HeadingCapStyle::SentenceCase,
2543 preserve_cased_words: true,
2544 ..Default::default()
2545 };
2546 let rule = MD063HeadingCapitalization::from_config_struct(config);
2547
2548 let content = "## This is iOS\n";
2550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2551 let result = rule.check(&ctx).unwrap();
2552 assert!(
2553 result.is_empty(),
2554 "iOS should be preserved with preserve-cased-words. Got: {result:?}"
2555 );
2556
2557 let fixed = rule.fix(&ctx).unwrap();
2559 assert_eq!(fixed, "## This is iOS\n");
2560 }
2561
2562 #[test]
2563 fn test_preserve_cased_words_ios_title_case() {
2564 let config = MD063Config {
2565 enabled: true,
2566 style: HeadingCapStyle::TitleCase,
2567 preserve_cased_words: true,
2568 ..Default::default()
2569 };
2570 let rule = MD063HeadingCapitalization::from_config_struct(config);
2571
2572 let content = "# developing for iOS\n";
2574 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2575 let fixed = rule.fix(&ctx).unwrap();
2576 assert_eq!(fixed, "# Developing for iOS\n");
2577 }
2578
2579 #[test]
2580 fn test_has_internal_capitals_ios() {
2581 let rule = create_rule();
2582
2583 assert!(
2585 rule.has_internal_capitals("iOS"),
2586 "iOS has mixed case (lowercase i, uppercase OS)"
2587 );
2588
2589 assert!(rule.has_internal_capitals("iPhone"));
2591 assert!(rule.has_internal_capitals("macOS"));
2592 assert!(rule.has_internal_capitals("GitHub"));
2593 assert!(rule.has_internal_capitals("JavaScript"));
2594 assert!(rule.has_internal_capitals("eBay"));
2595
2596 assert!(!rule.has_internal_capitals("API"));
2598 assert!(!rule.has_internal_capitals("GPU"));
2599
2600 assert!(!rule.has_internal_capitals("npm"));
2602 assert!(!rule.has_internal_capitals("config"));
2603
2604 assert!(!rule.has_internal_capitals("The"));
2606 assert!(!rule.has_internal_capitals("Hello"));
2607 }
2608
2609 #[test]
2610 fn test_lowercase_words_before_trailing_code() {
2611 let config = MD063Config {
2612 enabled: true,
2613 style: HeadingCapStyle::TitleCase,
2614 lowercase_words: vec![
2615 "a".to_string(),
2616 "an".to_string(),
2617 "and".to_string(),
2618 "at".to_string(),
2619 "but".to_string(),
2620 "by".to_string(),
2621 "for".to_string(),
2622 "from".to_string(),
2623 "into".to_string(),
2624 "nor".to_string(),
2625 "on".to_string(),
2626 "onto".to_string(),
2627 "or".to_string(),
2628 "the".to_string(),
2629 "to".to_string(),
2630 "upon".to_string(),
2631 "via".to_string(),
2632 "vs".to_string(),
2633 "with".to_string(),
2634 "without".to_string(),
2635 ],
2636 preserve_cased_words: true,
2637 ..Default::default()
2638 };
2639 let rule = MD063HeadingCapitalization::from_config_struct(config);
2640
2641 let content = "## subtitle with a `app`\n";
2646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2647 let result = rule.check(&ctx).unwrap();
2648
2649 assert!(!result.is_empty(), "Should flag incorrect capitalization");
2651 let fixed = rule.fix(&ctx).unwrap();
2652 assert!(
2654 fixed.contains("with a `app`"),
2655 "Expected 'with a `app`' but got: {fixed:?}"
2656 );
2657 assert!(
2658 !fixed.contains("with A `app`"),
2659 "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
2660 );
2661 assert!(
2663 fixed.contains("Subtitle with a `app`"),
2664 "Expected 'Subtitle with a `app`' but got: {fixed:?}"
2665 );
2666 }
2667
2668 #[test]
2669 fn test_lowercase_words_preserved_before_trailing_code_variant() {
2670 let config = MD063Config {
2671 enabled: true,
2672 style: HeadingCapStyle::TitleCase,
2673 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2674 ..Default::default()
2675 };
2676 let rule = MD063HeadingCapitalization::from_config_struct(config);
2677
2678 let content = "## Title with the `code`\n";
2680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2681 let fixed = rule.fix(&ctx).unwrap();
2682 assert!(
2684 fixed.contains("with the `code`"),
2685 "Expected 'with the `code`' but got: {fixed:?}"
2686 );
2687 assert!(
2688 !fixed.contains("with The `code`"),
2689 "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
2690 );
2691 }
2692
2693 #[test]
2694 fn test_last_word_capitalized_when_no_trailing_code() {
2695 let config = MD063Config {
2698 enabled: true,
2699 style: HeadingCapStyle::TitleCase,
2700 lowercase_words: vec!["a".to_string(), "the".to_string()],
2701 ..Default::default()
2702 };
2703 let rule = MD063HeadingCapitalization::from_config_struct(config);
2704
2705 let content = "## title with a word\n";
2708 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2709 let fixed = rule.fix(&ctx).unwrap();
2710 assert!(
2712 fixed.contains("With a Word"),
2713 "Expected 'With a Word' but got: {fixed:?}"
2714 );
2715 }
2716
2717 #[test]
2718 fn test_multiple_lowercase_words_before_code() {
2719 let config = MD063Config {
2720 enabled: true,
2721 style: HeadingCapStyle::TitleCase,
2722 lowercase_words: vec![
2723 "a".to_string(),
2724 "the".to_string(),
2725 "with".to_string(),
2726 "for".to_string(),
2727 ],
2728 ..Default::default()
2729 };
2730 let rule = MD063HeadingCapitalization::from_config_struct(config);
2731
2732 let content = "## Guide for the `user`\n";
2734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2735 let fixed = rule.fix(&ctx).unwrap();
2736 assert!(
2737 fixed.contains("for the `user`"),
2738 "Expected 'for the `user`' but got: {fixed:?}"
2739 );
2740 assert!(
2741 !fixed.contains("For The `user`"),
2742 "Should not capitalize lowercase words before code. Got: {fixed:?}"
2743 );
2744 }
2745
2746 #[test]
2747 fn test_code_in_middle_normal_rules_apply() {
2748 let config = MD063Config {
2749 enabled: true,
2750 style: HeadingCapStyle::TitleCase,
2751 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2752 ..Default::default()
2753 };
2754 let rule = MD063HeadingCapitalization::from_config_struct(config);
2755
2756 let content = "## Using `const` for the code\n";
2758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2759 let fixed = rule.fix(&ctx).unwrap();
2760 assert!(
2762 fixed.contains("for the Code"),
2763 "Expected 'for the Code' but got: {fixed:?}"
2764 );
2765 }
2766
2767 #[test]
2768 fn test_link_at_end_same_as_code() {
2769 let config = MD063Config {
2770 enabled: true,
2771 style: HeadingCapStyle::TitleCase,
2772 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2773 ..Default::default()
2774 };
2775 let rule = MD063HeadingCapitalization::from_config_struct(config);
2776
2777 let content = "## Guide for the [link](./page.md)\n";
2779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2780 let fixed = rule.fix(&ctx).unwrap();
2781 assert!(
2783 fixed.contains("for the [Link]"),
2784 "Expected 'for the [Link]' but got: {fixed:?}"
2785 );
2786 assert!(
2787 !fixed.contains("for The [Link]"),
2788 "Should not capitalize 'the' before link. Got: {fixed:?}"
2789 );
2790 }
2791
2792 #[test]
2793 fn test_multiple_code_segments() {
2794 let config = MD063Config {
2795 enabled: true,
2796 style: HeadingCapStyle::TitleCase,
2797 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2798 ..Default::default()
2799 };
2800 let rule = MD063HeadingCapitalization::from_config_struct(config);
2801
2802 let content = "## Using `const` with a `variable`\n";
2804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2805 let fixed = rule.fix(&ctx).unwrap();
2806 assert!(
2808 fixed.contains("with a `variable`"),
2809 "Expected 'with a `variable`' but got: {fixed:?}"
2810 );
2811 assert!(
2812 !fixed.contains("with A `variable`"),
2813 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2814 );
2815 }
2816
2817 #[test]
2818 fn test_code_and_link_combination() {
2819 let config = MD063Config {
2820 enabled: true,
2821 style: HeadingCapStyle::TitleCase,
2822 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2823 ..Default::default()
2824 };
2825 let rule = MD063HeadingCapitalization::from_config_struct(config);
2826
2827 let content = "## Guide for the `code` [link](./page.md)\n";
2829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2830 let fixed = rule.fix(&ctx).unwrap();
2831 assert!(
2833 fixed.contains("for the `code`"),
2834 "Expected 'for the `code`' but got: {fixed:?}"
2835 );
2836 }
2837
2838 #[test]
2839 fn test_text_after_code_capitalizes_last() {
2840 let config = MD063Config {
2841 enabled: true,
2842 style: HeadingCapStyle::TitleCase,
2843 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2844 ..Default::default()
2845 };
2846 let rule = MD063HeadingCapitalization::from_config_struct(config);
2847
2848 let content = "## Using `const` for the code\n";
2850 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2851 let fixed = rule.fix(&ctx).unwrap();
2852 assert!(
2854 fixed.contains("for the Code"),
2855 "Expected 'for the Code' but got: {fixed:?}"
2856 );
2857 }
2858
2859 #[test]
2860 fn test_preserve_cased_words_with_trailing_code() {
2861 let config = MD063Config {
2862 enabled: true,
2863 style: HeadingCapStyle::TitleCase,
2864 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2865 preserve_cased_words: true,
2866 ..Default::default()
2867 };
2868 let rule = MD063HeadingCapitalization::from_config_struct(config);
2869
2870 let content = "## Guide for iOS `app`\n";
2872 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2873 let fixed = rule.fix(&ctx).unwrap();
2874 assert!(
2876 fixed.contains("for iOS `app`"),
2877 "Expected 'for iOS `app`' but got: {fixed:?}"
2878 );
2879 assert!(
2880 !fixed.contains("For iOS `app`"),
2881 "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
2882 );
2883 }
2884
2885 #[test]
2886 fn test_ignore_words_with_trailing_code() {
2887 let config = MD063Config {
2888 enabled: true,
2889 style: HeadingCapStyle::TitleCase,
2890 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2891 ignore_words: vec!["npm".to_string()],
2892 ..Default::default()
2893 };
2894 let rule = MD063HeadingCapitalization::from_config_struct(config);
2895
2896 let content = "## Using npm with a `script`\n";
2898 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2899 let fixed = rule.fix(&ctx).unwrap();
2900 assert!(
2902 fixed.contains("npm with a `script`"),
2903 "Expected 'npm with a `script`' but got: {fixed:?}"
2904 );
2905 assert!(
2906 !fixed.contains("with A `script`"),
2907 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2908 );
2909 }
2910
2911 #[test]
2912 fn test_empty_text_segment_edge_case() {
2913 let config = MD063Config {
2914 enabled: true,
2915 style: HeadingCapStyle::TitleCase,
2916 lowercase_words: vec!["a".to_string(), "with".to_string()],
2917 ..Default::default()
2918 };
2919 let rule = MD063HeadingCapitalization::from_config_struct(config);
2920
2921 let content = "## `start` with a `end`\n";
2923 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2924 let fixed = rule.fix(&ctx).unwrap();
2925 assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
2928 assert!(
2929 !fixed.contains("A `end`"),
2930 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2931 );
2932 }
2933
2934 #[test]
2935 fn test_sentence_case_with_trailing_code() {
2936 let config = MD063Config {
2937 enabled: true,
2938 style: HeadingCapStyle::SentenceCase,
2939 lowercase_words: vec!["a".to_string(), "the".to_string()],
2940 ..Default::default()
2941 };
2942 let rule = MD063HeadingCapitalization::from_config_struct(config);
2943
2944 let content = "## guide for the `user`\n";
2946 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2947 let fixed = rule.fix(&ctx).unwrap();
2948 assert!(
2950 fixed.contains("Guide for the `user`"),
2951 "Expected 'Guide for the `user`' but got: {fixed:?}"
2952 );
2953 }
2954
2955 #[test]
2956 fn test_hyphenated_word_before_code() {
2957 let config = MD063Config {
2958 enabled: true,
2959 style: HeadingCapStyle::TitleCase,
2960 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2961 ..Default::default()
2962 };
2963 let rule = MD063HeadingCapitalization::from_config_struct(config);
2964
2965 let content = "## Self-contained with a `feature`\n";
2967 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2968 let fixed = rule.fix(&ctx).unwrap();
2969 assert!(
2971 fixed.contains("with a `feature`"),
2972 "Expected 'with a `feature`' but got: {fixed:?}"
2973 );
2974 }
2975
2976 #[test]
2981 fn test_sentence_case_code_at_start_basic() {
2982 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2984 let content = "# `rumdl` is a linter\n";
2985 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2986 let result = rule.check(&ctx).unwrap();
2987 assert!(
2989 result.is_empty(),
2990 "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
2991 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2992 );
2993 }
2994
2995 #[test]
2996 fn test_sentence_case_code_at_start_incorrect_capitalization() {
2997 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2999 let content = "# `rumdl` Is a Linter\n";
3000 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3001 let result = rule.check(&ctx).unwrap();
3002 assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
3004 assert!(
3005 result[0].message.contains("`rumdl` is a linter"),
3006 "Should suggest lowercase after code. Got: {:?}",
3007 result[0].message
3008 );
3009 }
3010
3011 #[test]
3012 fn test_sentence_case_code_at_start_fix() {
3013 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3014 let content = "# `rumdl` Is A Linter\n";
3015 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3016 let fixed = rule.fix(&ctx).unwrap();
3017 assert!(
3018 fixed.contains("# `rumdl` is a linter"),
3019 "Should fix to lowercase after code. Got: {fixed:?}"
3020 );
3021 }
3022
3023 #[test]
3024 fn test_sentence_case_text_at_start_still_capitalizes() {
3025 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3027 let content = "# the quick brown fox\n";
3028 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3029 let result = rule.check(&ctx).unwrap();
3030 assert_eq!(result.len(), 1);
3031 assert!(
3032 result[0].message.contains("The quick brown fox"),
3033 "Text-first heading should capitalize first word. Got: {:?}",
3034 result[0].message
3035 );
3036 }
3037
3038 #[test]
3039 fn test_sentence_case_link_at_start() {
3040 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3042 let content = "# [api](api.md) reference guide\n";
3043 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3044 let result = rule.check(&ctx).unwrap();
3045 assert_eq!(result.len(), 1);
3046 assert!(result[0].message.contains("[Api](api.md) reference guide"));
3047 }
3048
3049 #[test]
3050 fn test_sentence_case_link_preserves_acronyms() {
3051 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3053 let content = "# [API](api.md) Reference Guide\n";
3054 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3055 let result = rule.check(&ctx).unwrap();
3056 assert_eq!(result.len(), 1);
3057 assert!(
3059 result[0].message.contains("[API](api.md) reference guide"),
3060 "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
3061 result[0].message
3062 );
3063 }
3064
3065 #[test]
3066 fn test_sentence_case_link_preserves_brand_names() {
3067 let config = MD063Config {
3069 enabled: true,
3070 style: HeadingCapStyle::SentenceCase,
3071 preserve_cased_words: true,
3072 ..Default::default()
3073 };
3074 let rule = MD063HeadingCapitalization::from_config_struct(config);
3075 let content = "# [iPhone](iphone.md) Features Guide\n";
3076 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3077 let result = rule.check(&ctx).unwrap();
3078 assert_eq!(result.len(), 1);
3079 assert!(
3081 result[0].message.contains("[iPhone](iphone.md) features guide"),
3082 "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
3083 result[0].message
3084 );
3085 }
3086
3087 #[test]
3088 fn test_sentence_case_link_lowercases_regular_words() {
3089 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3091 let content = "# [Documentation](docs.md) Reference\n";
3092 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3093 let result = rule.check(&ctx).unwrap();
3094 assert_eq!(result.len(), 1);
3095 assert!(
3096 result[0].message.contains("[Documentation](docs.md) reference"),
3097 "Should preserve the sentence-initial capital. Got: {:?}",
3098 result[0].message
3099 );
3100 }
3101
3102 #[test]
3103 fn test_sentence_case_opening_link_label_is_sentence_initial() {
3104 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3106 let content = "# [Foo bar](https://example.com)\n";
3107 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3108
3109 assert!(rule.check(&ctx).unwrap().is_empty());
3110 assert_eq!(rule.fix(&ctx).unwrap(), content);
3111 }
3112
3113 #[test]
3114 fn test_sentence_case_link_at_start_correct_already() {
3115 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3117 let content = "# [API](api.md) reference guide\n";
3118 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3119 let result = rule.check(&ctx).unwrap();
3120 assert!(
3121 result.is_empty(),
3122 "Correctly cased heading with link should not be flagged. Got: {:?}",
3123 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3124 );
3125 }
3126
3127 #[test]
3128 fn test_sentence_case_link_github_preserved() {
3129 let config = MD063Config {
3131 enabled: true,
3132 style: HeadingCapStyle::SentenceCase,
3133 preserve_cased_words: true,
3134 ..Default::default()
3135 };
3136 let rule = MD063HeadingCapitalization::from_config_struct(config);
3137 let content = "# [GitHub](gh.md) Repository Setup\n";
3138 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3139 let result = rule.check(&ctx).unwrap();
3140 assert_eq!(result.len(), 1);
3141 assert!(
3142 result[0].message.contains("[GitHub](gh.md) repository setup"),
3143 "Should preserve 'GitHub'. Got: {:?}",
3144 result[0].message
3145 );
3146 }
3147
3148 #[test]
3149 fn test_sentence_case_multiple_code_spans() {
3150 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3151 let content = "# `foo` and `bar` are methods\n";
3152 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3153 let result = rule.check(&ctx).unwrap();
3154 assert!(
3156 result.is_empty(),
3157 "Should not capitalize words between/after code spans. Got: {:?}",
3158 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3159 );
3160 }
3161
3162 #[test]
3163 fn test_sentence_case_code_only_heading() {
3164 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3166 let content = "# `rumdl`\n";
3167 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3168 let result = rule.check(&ctx).unwrap();
3169 assert!(
3170 result.is_empty(),
3171 "Code-only heading should be fine. Got: {:?}",
3172 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3173 );
3174 }
3175
3176 #[test]
3177 fn test_sentence_case_code_at_end() {
3178 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3180 let content = "# install the `rumdl` tool\n";
3181 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3182 let result = rule.check(&ctx).unwrap();
3183 assert_eq!(result.len(), 1);
3185 assert!(
3186 result[0].message.contains("Install the `rumdl` tool"),
3187 "First word should still be capitalized when text comes first. Got: {:?}",
3188 result[0].message
3189 );
3190 }
3191
3192 #[test]
3193 fn test_sentence_case_code_in_middle() {
3194 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3196 let content = "# using the `rumdl` linter for markdown\n";
3197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3198 let result = rule.check(&ctx).unwrap();
3199 assert_eq!(result.len(), 1);
3201 assert!(
3202 result[0].message.contains("Using the `rumdl` linter for markdown"),
3203 "First word should be capitalized. Got: {:?}",
3204 result[0].message
3205 );
3206 }
3207
3208 #[test]
3209 fn test_sentence_case_preserved_word_after_code() {
3210 let config = MD063Config {
3212 enabled: true,
3213 style: HeadingCapStyle::SentenceCase,
3214 preserve_cased_words: true,
3215 ..Default::default()
3216 };
3217 let rule = MD063HeadingCapitalization::from_config_struct(config);
3218 let content = "# `swift` iPhone development\n";
3219 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3220 let result = rule.check(&ctx).unwrap();
3221 assert!(
3223 result.is_empty(),
3224 "Preserved words after code should stay. Got: {:?}",
3225 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3226 );
3227 }
3228
3229 #[test]
3230 fn test_title_case_code_at_start_still_capitalizes() {
3231 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3233 let content = "# `api` quick start guide\n";
3234 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3235 let result = rule.check(&ctx).unwrap();
3236 assert_eq!(result.len(), 1);
3238 assert!(
3239 result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
3240 "Title case should capitalize major words after code. Got: {:?}",
3241 result[0].message
3242 );
3243 }
3244
3245 #[test]
3248 fn test_sentence_case_html_tag_at_start() {
3249 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3251 let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
3252 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3253 let result = rule.check(&ctx).unwrap();
3254 assert_eq!(result.len(), 1);
3256 let fixed = rule.fix(&ctx).unwrap();
3257 assert_eq!(
3258 fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
3259 "Text after HTML at start should be lowercase"
3260 );
3261 }
3262
3263 #[test]
3264 fn test_sentence_case_html_tag_preserves_content() {
3265 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3267 let content = "# The <abbr>API</abbr> documentation guide\n";
3268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3269 let result = rule.check(&ctx).unwrap();
3270 assert!(
3272 result.is_empty(),
3273 "HTML tag content should be preserved. Got: {:?}",
3274 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3275 );
3276 }
3277
3278 #[test]
3279 fn test_sentence_case_html_tag_at_start_with_acronym() {
3280 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3282 let content = "# <abbr>API</abbr> Documentation Guide\n";
3283 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3284 let result = rule.check(&ctx).unwrap();
3285 assert_eq!(result.len(), 1);
3286 let fixed = rule.fix(&ctx).unwrap();
3287 assert_eq!(
3288 fixed, "# <abbr>API</abbr> documentation guide\n",
3289 "Text after HTML at start should be lowercase, HTML content preserved"
3290 );
3291 }
3292
3293 #[test]
3294 fn test_sentence_case_html_tag_in_middle() {
3295 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3297 let content = "# using the <code>config</code> File\n";
3298 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3299 let result = rule.check(&ctx).unwrap();
3300 assert_eq!(result.len(), 1);
3301 let fixed = rule.fix(&ctx).unwrap();
3302 assert_eq!(
3303 fixed, "# Using the <code>config</code> file\n",
3304 "First word capitalized, HTML preserved, rest lowercase"
3305 );
3306 }
3307
3308 #[test]
3309 fn test_html_tag_strong_emphasis() {
3310 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3312 let content = "# The <strong>Bold</strong> Way\n";
3313 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3314 let result = rule.check(&ctx).unwrap();
3315 assert_eq!(result.len(), 1);
3316 let fixed = rule.fix(&ctx).unwrap();
3317 assert_eq!(
3318 fixed, "# The <strong>Bold</strong> way\n",
3319 "<strong> tag content should be preserved"
3320 );
3321 }
3322
3323 #[test]
3324 fn test_html_tag_with_attributes() {
3325 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3327 let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
3328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3329 let result = rule.check(&ctx).unwrap();
3330 assert_eq!(result.len(), 1);
3331 let fixed = rule.fix(&ctx).unwrap();
3332 assert_eq!(
3333 fixed, "# <span class=\"highlight\">Important</span> notice here\n",
3334 "HTML tag with attributes should be preserved"
3335 );
3336 }
3337
3338 #[test]
3339 fn test_multiple_html_tags() {
3340 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3342 let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
3343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3344 let result = rule.check(&ctx).unwrap();
3345 assert_eq!(result.len(), 1);
3346 let fixed = rule.fix(&ctx).unwrap();
3347 assert_eq!(
3348 fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
3349 "Multiple HTML tags should all be preserved"
3350 );
3351 }
3352
3353 #[test]
3354 fn test_html_and_code_mixed() {
3355 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3357 let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
3358 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3359 let result = rule.check(&ctx).unwrap();
3360 assert_eq!(result.len(), 1);
3361 let fixed = rule.fix(&ctx).unwrap();
3362 assert_eq!(
3363 fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
3364 "HTML and code should both be preserved"
3365 );
3366 }
3367
3368 #[test]
3369 fn test_self_closing_html_tag() {
3370 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3372 let content = "# Line one<br/>Line Two Here\n";
3373 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3374 let result = rule.check(&ctx).unwrap();
3375 assert_eq!(result.len(), 1);
3376 let fixed = rule.fix(&ctx).unwrap();
3377 assert_eq!(
3378 fixed, "# Line one<br/>line two here\n",
3379 "Self-closing HTML tags should be preserved"
3380 );
3381 }
3382
3383 #[test]
3384 fn test_title_case_with_html_tags() {
3385 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3387 let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
3388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3389 let result = rule.check(&ctx).unwrap();
3390 assert_eq!(result.len(), 1);
3391 let fixed = rule.fix(&ctx).unwrap();
3392 assert!(
3394 fixed.contains("<kbd>ctrl</kbd>"),
3395 "HTML tag content should be preserved in title case. Got: {fixed}"
3396 );
3397 assert!(
3398 fixed.starts_with("# The ") || fixed.starts_with("# the "),
3399 "Title case should work with HTML. Got: {fixed}"
3400 );
3401 }
3402
3403 #[test]
3406 fn test_sentence_case_preserves_caret_notation() {
3407 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3409 let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
3410 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3411 let result = rule.check(&ctx).unwrap();
3412 assert!(
3414 result.is_empty(),
3415 "Caret notation should be preserved. Got: {:?}",
3416 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3417 );
3418 }
3419
3420 #[test]
3421 fn test_sentence_case_caret_notation_various() {
3422 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3424
3425 let content = "## Press ^C to cancel\n";
3427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3428 let result = rule.check(&ctx).unwrap();
3429 assert!(
3430 result.is_empty(),
3431 "^C should be preserved. Got: {:?}",
3432 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3433 );
3434
3435 let content = "## Use ^Z for background\n";
3437 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3438 let result = rule.check(&ctx).unwrap();
3439 assert!(
3440 result.is_empty(),
3441 "^Z should be preserved. Got: {:?}",
3442 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3443 );
3444
3445 let content = "## Press ^[ for escape\n";
3447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3448 let result = rule.check(&ctx).unwrap();
3449 assert!(
3450 result.is_empty(),
3451 "^[ should be preserved. Got: {:?}",
3452 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3453 );
3454 }
3455
3456 #[test]
3457 fn test_caret_notation_detection() {
3458 let rule = create_rule();
3459
3460 assert!(rule.is_caret_notation("^A"));
3462 assert!(rule.is_caret_notation("^Z"));
3463 assert!(rule.is_caret_notation("^C"));
3464 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")); }
3476
3477 fn create_sentence_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
3484 let config = MD063Config {
3485 enabled: true,
3486 style: HeadingCapStyle::SentenceCase,
3487 ..Default::default()
3488 };
3489 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3490 rule.proper_names = names;
3491 rule
3492 }
3493
3494 #[test]
3495 fn test_sentence_case_preserves_single_word_proper_name() {
3496 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
3497 let content = "# installing javascript\n";
3499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3500 let result = rule.check(&ctx).unwrap();
3501 assert_eq!(result.len(), 1, "Should flag the heading");
3502 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3503 assert!(
3504 fix_text.contains("JavaScript"),
3505 "Fix should preserve proper name 'JavaScript', got: {fix_text:?}"
3506 );
3507 assert!(
3508 !fix_text.contains("javascript"),
3509 "Fix should not have lowercase 'javascript', got: {fix_text:?}"
3510 );
3511 }
3512
3513 #[test]
3514 fn test_sentence_case_preserves_multi_word_proper_name() {
3515 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3516 let content = "# using good application features\n";
3518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3519 let result = rule.check(&ctx).unwrap();
3520 assert_eq!(result.len(), 1, "Should flag the heading");
3521 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3522 assert!(
3523 fix_text.contains("Good Application"),
3524 "Fix should preserve 'Good Application' as a phrase, got: {fix_text:?}"
3525 );
3526 }
3527
3528 #[test]
3529 fn test_sentence_case_proper_name_at_start_of_heading() {
3530 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3531 let content = "# good application overview\n";
3533 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3534 let result = rule.check(&ctx).unwrap();
3535 assert_eq!(result.len(), 1, "Should flag the heading");
3536 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3537 assert!(
3538 fix_text.contains("Good Application"),
3539 "Fix should produce 'Good Application' at start of heading, got: {fix_text:?}"
3540 );
3541 assert!(
3542 fix_text.contains("overview"),
3543 "Non-proper-name word 'overview' should be lowercase, got: {fix_text:?}"
3544 );
3545 }
3546
3547 #[test]
3548 fn test_sentence_case_with_proper_names_no_oscillation() {
3549 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3552
3553 let content = "# installing good application on your system\n";
3555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3556 let result = rule.check(&ctx).unwrap();
3557 assert_eq!(result.len(), 1);
3558 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
3559
3560 assert!(
3562 fixed_heading.contains("Good Application"),
3563 "After fix, proper name must be preserved: {fixed_heading:?}"
3564 );
3565
3566 let fixed_line = format!("{fixed_heading}\n");
3568 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
3569 let result2 = rule.check(&ctx2).unwrap();
3570 assert!(
3571 result2.is_empty(),
3572 "After one fix, heading must already satisfy both MD063 and MD044 - no oscillation. \
3573 Second pass warnings: {result2:?}"
3574 );
3575 }
3576
3577 #[test]
3578 fn test_sentence_case_proper_names_already_correct() {
3579 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3580 let content = "# Installing Good Application\n";
3582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3583 let result = rule.check(&ctx).unwrap();
3584 assert!(
3585 result.is_empty(),
3586 "Correct sentence-case heading with proper name should not be flagged, got: {result:?}"
3587 );
3588 }
3589
3590 #[test]
3591 fn test_sentence_case_multiple_proper_names_in_heading() {
3592 let rule = create_sentence_case_rule_with_proper_names(vec!["TypeScript".to_string(), "React".to_string()]);
3593 let content = "# using typescript with react\n";
3594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3595 let result = rule.check(&ctx).unwrap();
3596 assert_eq!(result.len(), 1);
3597 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3598 assert!(
3599 fix_text.contains("TypeScript"),
3600 "Fix should preserve 'TypeScript', got: {fix_text:?}"
3601 );
3602 assert!(
3603 fix_text.contains("React"),
3604 "Fix should preserve 'React', got: {fix_text:?}"
3605 );
3606 }
3607
3608 #[test]
3609 fn test_sentence_case_unicode_casefold_expansion_before_proper_name() {
3610 let rule = create_sentence_case_rule_with_proper_names(vec!["Österreich".to_string()]);
3613 let content = "# İ österreich guide\n";
3614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3615
3616 let result = rule.check(&ctx).unwrap();
3618 assert_eq!(result.len(), 1, "Should flag heading for canonical proper-name casing");
3619 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3620 assert!(
3621 fix_text.contains("Österreich"),
3622 "Fix should preserve canonical 'Österreich', got: {fix_text:?}"
3623 );
3624 }
3625
3626 #[test]
3627 fn test_sentence_case_preserves_trailing_punctuation_on_proper_name() {
3628 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
3629 let content = "# using javascript, today\n";
3630 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3631 let result = rule.check(&ctx).unwrap();
3632 assert_eq!(result.len(), 1, "Should flag heading");
3633 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3634 assert!(
3635 fix_text.contains("JavaScript,"),
3636 "Fix should preserve trailing punctuation, got: {fix_text:?}"
3637 );
3638 }
3639
3640 fn create_title_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
3647 let config = MD063Config {
3648 enabled: true,
3649 style: HeadingCapStyle::TitleCase,
3650 ..Default::default()
3651 };
3652 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3653 rule.proper_names = names;
3654 rule
3655 }
3656
3657 #[test]
3658 fn test_title_case_preserves_proper_name_with_lowercase_article() {
3659 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
3663 let content = "# listening to the rolling stones today\n";
3664 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3665 let result = rule.check(&ctx).unwrap();
3666 assert_eq!(result.len(), 1, "Should flag the heading");
3667 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3668 assert!(
3669 fix_text.contains("The Rolling Stones"),
3670 "Fix should preserve proper name 'The Rolling Stones', got: {fix_text:?}"
3671 );
3672 }
3673
3674 #[test]
3675 fn test_title_case_proper_name_no_oscillation() {
3676 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
3678 let content = "# listening to the rolling stones today\n";
3679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3680 let result = rule.check(&ctx).unwrap();
3681 assert_eq!(result.len(), 1);
3682 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
3683
3684 let fixed_line = format!("{fixed_heading}\n");
3685 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
3686 let result2 = rule.check(&ctx2).unwrap();
3687 assert!(
3688 result2.is_empty(),
3689 "After one title-case fix, heading must already satisfy both rules. \
3690 Second pass warnings: {result2:?}"
3691 );
3692 }
3693
3694 #[test]
3695 fn test_title_case_unicode_casefold_expansion_before_proper_name() {
3696 let rule = create_title_case_rule_with_proper_names(vec!["Österreich".to_string()]);
3697 let content = "# İ österreich guide\n";
3698 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3699 let result = rule.check(&ctx).unwrap();
3700 assert_eq!(result.len(), 1, "Should flag the heading");
3701 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3702 assert!(
3703 fix_text.contains("Österreich"),
3704 "Fix should preserve canonical proper-name casing, got: {fix_text:?}"
3705 );
3706 }
3707
3708 #[test]
3714 fn test_from_config_loads_md044_names_into_md063() {
3715 use crate::config::{Config, RuleConfig};
3716 use crate::rule::Rule;
3717 use std::collections::BTreeMap;
3718
3719 let mut config = Config::default();
3720
3721 let mut md063_values = BTreeMap::new();
3723 md063_values.insert("style".to_string(), toml::Value::String("sentence_case".to_string()));
3724 md063_values.insert("enabled".to_string(), toml::Value::Boolean(true));
3725 config.rules.insert(
3726 "MD063".to_string(),
3727 RuleConfig {
3728 values: md063_values,
3729 severity: None,
3730 },
3731 );
3732
3733 let mut md044_values = BTreeMap::new();
3735 md044_values.insert(
3736 "names".to_string(),
3737 toml::Value::Array(vec![toml::Value::String("Good Application".to_string())]),
3738 );
3739 config.rules.insert(
3740 "MD044".to_string(),
3741 RuleConfig {
3742 values: md044_values,
3743 severity: None,
3744 },
3745 );
3746
3747 let rule = MD063HeadingCapitalization::from_config(&config);
3749
3750 let content = "# using good application features\n";
3752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3753 let result = rule.check(&ctx).unwrap();
3754 assert_eq!(result.len(), 1, "Should flag the heading");
3755 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3756 assert!(
3757 fix_text.contains("Good Application"),
3758 "from_config should wire MD044 names into MD063; fix should preserve \
3759 'Good Application', got: {fix_text:?}"
3760 );
3761 }
3762
3763 #[test]
3764 fn test_title_case_short_word_not_confused_with_substring() {
3765 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3769
3770 let content = "# in the insert\n";
3773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3774 let result = rule.check(&ctx).unwrap();
3775 assert_eq!(result.len(), 1, "Should flag the heading");
3776 let fix = result[0].fix.as_ref().expect("Fix should be present");
3777 assert!(
3779 fix.replacement.contains("In the Insert"),
3780 "Expected 'In the Insert', got: {:?}",
3781 fix.replacement
3782 );
3783 }
3784
3785 #[test]
3786 fn test_title_case_or_not_confused_with_orchestra() {
3787 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3788
3789 let content = "# or the orchestra\n";
3792 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3793 let result = rule.check(&ctx).unwrap();
3794 assert_eq!(result.len(), 1, "Should flag the heading");
3795 let fix = result[0].fix.as_ref().expect("Fix should be present");
3796 assert!(
3798 fix.replacement.contains("Or the Orchestra"),
3799 "Expected 'Or the Orchestra', got: {:?}",
3800 fix.replacement
3801 );
3802 }
3803
3804 #[test]
3805 fn test_all_caps_preserves_all_words() {
3806 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
3807
3808 let content = "# in the insert\n";
3809 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3810 let result = rule.check(&ctx).unwrap();
3811 assert_eq!(result.len(), 1, "Should flag the heading");
3812 let fix = result[0].fix.as_ref().expect("Fix should be present");
3813 assert!(
3814 fix.replacement.contains("IN THE INSERT"),
3815 "All caps should uppercase all words, got: {:?}",
3816 fix.replacement
3817 );
3818 }
3819
3820 #[test]
3822 fn test_title_case_numbered_prefix_lowercase_word() {
3823 let rule = create_rule();
3825 let content = "## 1. To Be a Thing\n";
3826 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3827 let result = rule.check(&ctx).unwrap();
3828 assert!(
3829 result.is_empty(),
3830 "Should not flag '## 1. To Be a Thing', got: {result:?}"
3831 );
3832
3833 let content_lower = "## 1. to be a thing\n";
3834 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3835 let result2 = rule.check(&ctx2).unwrap();
3836 assert!(!result2.is_empty(), "Should flag '## 1. to be a thing'");
3837 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3838 assert!(
3839 fix.replacement.contains("1. To Be a Thing"),
3840 "Fix should capitalize 'To', got: {:?}",
3841 fix.replacement
3842 );
3843 }
3844
3845 #[test]
3846 fn test_title_case_numbered_prefix_article() {
3847 let rule = create_rule();
3849 let content = "## 2. A Guide to the Galaxy\n";
3850 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3851 let result = rule.check(&ctx).unwrap();
3852 assert!(
3853 result.is_empty(),
3854 "Should not flag '## 2. A Guide to the Galaxy', got: {result:?}"
3855 );
3856
3857 let content_lower = "## 2. a guide to the galaxy\n";
3858 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3859 let result2 = rule.check(&ctx2).unwrap();
3860 assert!(!result2.is_empty(), "Should flag '## 2. a guide to the galaxy'");
3861 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3862 assert!(
3863 fix.replacement.contains("2. A Guide to the Galaxy"),
3864 "Fix should capitalize 'A', got: {:?}",
3865 fix.replacement
3866 );
3867 }
3868
3869 #[test]
3870 fn test_title_case_mid_sentence_period_word() {
3871 let rule = create_rule();
3873 let content = "## Step 1. Introduction to the Problem\n";
3874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3875 let result = rule.check(&ctx).unwrap();
3876 assert!(
3877 result.is_empty(),
3878 "Should not flag '## Step 1. Introduction to the Problem', got: {result:?}"
3879 );
3880
3881 let content_lower = "## Step 1. introduction to the problem\n";
3882 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3883 let result2 = rule.check(&ctx2).unwrap();
3884 assert!(
3885 !result2.is_empty(),
3886 "Should flag '## Step 1. introduction to the problem'"
3887 );
3888 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3889 assert!(
3890 fix.replacement.contains("Step 1. Introduction to the Problem"),
3891 "Fix should capitalize 'Introduction', got: {:?}",
3892 fix.replacement
3893 );
3894 }
3895
3896 #[test]
3897 fn test_title_case_numbered_prefix_in_link_text() {
3898 let config = MD063Config {
3901 enabled: true,
3902 style: HeadingCapStyle::TitleCase,
3903 ..Default::default()
3904 };
3905 let rule = MD063HeadingCapitalization::from_config_struct(config);
3906
3907 let content = "## [1. To Be a Thing](https://example.com)\n";
3909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3910 let result = rule.check(&ctx).unwrap();
3911 assert!(
3912 result.is_empty(),
3913 "Should not flag '## [1. To Be a Thing](url)', got: {result:?}"
3914 );
3915
3916 let content_lower = "## [1. to be a thing](https://example.com)\n";
3918 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3919 let result2 = rule.check(&ctx2).unwrap();
3920 assert!(!result2.is_empty(), "Should flag '## [1. to be a thing](url)'");
3921 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3922 assert!(
3923 fix.replacement.contains("1. To Be a Thing"),
3924 "Fix should capitalize 'To' in link text, got: {:?}",
3925 fix.replacement
3926 );
3927 }
3928
3929 #[test]
3934 fn test_is_numeric_ordinal_recognises_canonical_forms() {
3935 for word in &[
3936 "1st", "2nd", "3rd", "4th", "5th", "11th", "21st", "22nd", "23rd", "100th", "1ST", "5Th", "21St", "21sT",
3937 ] {
3938 assert!(
3939 MD063HeadingCapitalization::is_numeric_ordinal(word),
3940 "expected `{word}` to be detected as a numeric ordinal"
3941 );
3942 }
3943 }
3944
3945 #[test]
3946 fn test_is_numeric_ordinal_rejects_non_ordinals() {
3947 for word in &[
3952 "first", "1stop", "ist", "5", "th", "abc", "4G", "4K", "30s", "100k", "5x", "1.5", "iPhone6S",
3953 ] {
3954 assert!(
3955 !MD063HeadingCapitalization::is_numeric_ordinal(word),
3956 "expected `{word}` NOT to be detected as a numeric ordinal"
3957 );
3958 }
3959 }
3960
3961 #[test]
3962 fn test_is_numeric_ordinal_strips_trailing_punctuation() {
3963 for word in &["5th.", "1st,", "21st!", "3rd:", "4th)", "5th's"] {
3964 assert!(
3965 MD063HeadingCapitalization::is_numeric_ordinal(word),
3966 "expected `{word}` to be detected as a numeric ordinal (with punctuation)"
3967 );
3968 }
3969 }
3970
3971 #[test]
3972 fn test_is_numeric_ordinal_ignores_wrapping_punctuation() {
3973 for word in &[
3978 "(2nd", "[2nd", "\"2nd", "'2nd", "*2nd", "(21st)", "\"3rd\"", "**5th**", "_1st_",
3979 ] {
3980 assert!(
3981 MD063HeadingCapitalization::is_numeric_ordinal(word),
3982 "expected `{word}` to be detected as a numeric ordinal"
3983 );
3984 }
3985
3986 for word in &["2-nd", "2 nd", "(", "\"\"", "()", "(nd", "(2"] {
3990 assert!(
3991 !MD063HeadingCapitalization::is_numeric_ordinal(word),
3992 "expected `{word}` NOT to be detected as a numeric ordinal"
3993 );
3994 }
3995 }
3996
3997 #[test]
3998 fn test_ordinal_wrapped_in_punctuation_survives_a_fix() {
3999 for (style, content) in [
4002 (HeadingCapStyle::SentenceCase, "# The second (2nd) attempt\n"),
4003 (HeadingCapStyle::SentenceCase, "# Ranked \"3rd\" overall\n"),
4004 (HeadingCapStyle::SentenceCase, "# Plain 2nd place\n"),
4005 (HeadingCapStyle::TitleCase, "# The Second (2nd) Attempt\n"),
4006 (HeadingCapStyle::TitleCase, "# Ranked \"3rd\" Overall\n"),
4007 ] {
4008 let rule = create_rule_with_style(style);
4009 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4010 let result = rule.check(&ctx).unwrap();
4011 assert!(
4012 result.is_empty(),
4013 "{style:?} should not flag {content:?}, got: {result:?}"
4014 );
4015 assert_eq!(
4016 rule.fix(&ctx).unwrap(),
4017 content,
4018 "{style:?} must leave {content:?} alone"
4019 );
4020 }
4021
4022 for (style, content, expected) in [
4026 (
4027 HeadingCapStyle::SentenceCase,
4028 "# The Second (2nd) Attempt\n",
4029 "# The second (2nd) attempt\n",
4030 ),
4031 (
4032 HeadingCapStyle::TitleCase,
4033 "# the second (2nd) attempt\n",
4034 "# The Second (2nd) Attempt\n",
4035 ),
4036 ] {
4037 let rule = create_rule_with_style(style);
4038 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4039 assert!(
4040 !rule.check(&ctx).unwrap().is_empty(),
4041 "{style:?} should flag {content:?}"
4042 );
4043 let fixed = rule.fix(&ctx).unwrap();
4044 assert_eq!(fixed, expected, "{style:?} fix of {content:?}");
4045
4046 let refixed = rule
4047 .fix(&LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None))
4048 .unwrap();
4049 assert_eq!(refixed, expected, "{style:?} second pass over {fixed:?}");
4050 }
4051 }
4052
4053 #[test]
4054 fn test_wrapped_ordinal_corrupted_by_the_old_fix_is_repaired() {
4055 for (style, content, expected) in [
4059 (
4060 HeadingCapStyle::SentenceCase,
4061 "# The second (2Nd) attempt\n",
4062 "# The second (2nd) attempt\n",
4063 ),
4064 (
4065 HeadingCapStyle::TitleCase,
4066 "# The Second (2Nd) Attempt\n",
4067 "# The Second (2nd) Attempt\n",
4068 ),
4069 (
4070 HeadingCapStyle::SentenceCase,
4071 "# Ranked \"3Rd\" overall\n",
4072 "# Ranked \"3rd\" overall\n",
4073 ),
4074 ] {
4075 let rule = create_rule_with_style(style);
4076 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4077 assert!(
4078 !rule.check(&ctx).unwrap().is_empty(),
4079 "{style:?} should flag {content:?}"
4080 );
4081 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{style:?} fix of {content:?}");
4082 }
4083 }
4084
4085 #[test]
4086 fn test_title_case_ordinal_first_word_not_flagged() {
4087 let rule = create_rule();
4088 for content in &[
4089 "# 1st Place\n",
4090 "# 2nd Edition\n",
4091 "# 3rd Time\n",
4092 "# 5th Avenue\n",
4093 "# 21st Century Skills\n",
4094 "# 100th Customer\n",
4095 ] {
4096 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4097 let result = rule.check(&ctx).unwrap();
4098 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4099 }
4100 }
4101
4102 #[test]
4103 fn test_title_case_ordinal_mid_heading_not_flagged() {
4104 let rule = create_rule();
4105 for content in &[
4106 "# May 3rd Notes\n",
4107 "# Top 100th Customer\n",
4108 "# Notes for the 5th of May\n",
4109 ] {
4110 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4111 let result = rule.check(&ctx).unwrap();
4112 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4113 }
4114 }
4115
4116 #[test]
4117 fn test_title_case_ordinal_corrupted_form_is_fixed() {
4118 let rule = create_rule();
4121 for (input, expected) in &[
4122 ("# 1St Place\n", "1st Place"),
4123 ("# 5Th Avenue\n", "5th Avenue"),
4124 ("# 21St Century Skills\n", "21st Century Skills"),
4125 ("# May 3Rd Notes\n", "May 3rd Notes"),
4126 ("# 22Nd Edition\n", "22nd Edition"),
4127 ] {
4128 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
4129 let result = rule.check(&ctx).unwrap();
4130 assert!(!result.is_empty(), "Should flag {input:?}");
4131 let fix = result[0].fix.as_ref().expect("should have a fix");
4132 assert!(
4133 fix.replacement.contains(expected),
4134 "Fix for {input:?} should contain {expected:?}, got: {:?}",
4135 fix.replacement
4136 );
4137 }
4138 }
4139
4140 #[test]
4141 fn test_title_case_ordinal_lowercase_other_words_capitalised() {
4142 let rule = create_rule();
4144 let content = "# 5th avenue\n";
4145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4146 let result = rule.check(&ctx).unwrap();
4147 assert_eq!(result.len(), 1);
4148 let fix = result[0].fix.as_ref().expect("should have a fix");
4149 assert!(
4150 fix.replacement.contains("5th Avenue"),
4151 "Fix should produce '5th Avenue', got: {:?}",
4152 fix.replacement
4153 );
4154 }
4155
4156 #[test]
4157 fn test_title_case_ordinal_with_trailing_punctuation() {
4158 let rule = create_rule();
4159 let content = "# Released on the 5th.\n";
4160 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4161 let result = rule.check(&ctx).unwrap();
4162 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4163 }
4164
4165 #[test]
4166 fn test_title_case_ordinal_hyphenated() {
4167 let rule = create_rule();
4168 for content in &["# 21st-Century Skills\n", "# A 19th-Century Novel\n"] {
4169 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4170 let result = rule.check(&ctx).unwrap();
4171 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4172 }
4173 }
4174
4175 #[test]
4176 fn test_sentence_case_ordinal_corrupted_form_is_fixed() {
4177 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
4178 let content = "# 5Th avenue\n";
4179 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4180 let result = rule.check(&ctx).unwrap();
4181 assert_eq!(result.len(), 1);
4182 let fix = result[0].fix.as_ref().expect("should have a fix");
4183 assert!(
4184 fix.replacement.contains("5th avenue"),
4185 "Fix should produce '5th avenue', got: {:?}",
4186 fix.replacement
4187 );
4188 }
4189
4190 #[test]
4191 fn test_title_case_digit_acronym_unchanged() {
4192 let rule = create_rule();
4195 for content in &["# 4G Networks\n", "# 4K Streaming\n"] {
4196 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4197 let result = rule.check(&ctx).unwrap();
4198 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4199 }
4200 }
4201
4202 fn restart_rule(boundaries: &[&str]) -> MD063HeadingCapitalization {
4205 let config = MD063Config {
4206 enabled: true,
4207 style: HeadingCapStyle::SentenceCase,
4208 sentence_case_restart_after: boundaries.iter().copied().map(String::from).collect(),
4209 ..Default::default()
4210 };
4211 MD063HeadingCapitalization::from_config_struct(config)
4212 }
4213
4214 fn suggested(rule: &MD063HeadingCapitalization, content: &str) -> Option<String> {
4217 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4218 let warnings = rule.check(&ctx).unwrap();
4219 let fixed = rule.fix(&ctx).unwrap();
4220 assert_eq!(
4221 warnings.is_empty(),
4222 fixed == content,
4223 "a warning and a rewrite must agree for {content:?}"
4224 );
4225 (!warnings.is_empty()).then(|| fixed.trim_start_matches('#').trim().to_string())
4226 }
4227
4228 #[test]
4229 fn test_restart_after_capitalizes_the_word_following_a_boundary() {
4230 let rule = restart_rule(&[":"]);
4231 assert_eq!(
4232 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
4233 Some("Requirement 1: Struct to logger slice conversion")
4234 );
4235 }
4236
4237 #[test]
4238 fn test_restart_after_defaults_to_no_boundaries() {
4239 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
4241 assert_eq!(
4242 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
4243 Some("Requirement 1: struct to logger slice conversion")
4244 );
4245 }
4246
4247 #[test]
4248 fn test_restart_after_only_honors_configured_punctuation() {
4249 let rule = restart_rule(&[":"]);
4251 assert_eq!(
4252 suggested(&rule, "# Design - Data Model Overview\n").as_deref(),
4253 Some("Design - data model overview")
4254 );
4255 assert_eq!(
4256 suggested(&rule, "# Setup; Then Run\n").as_deref(),
4257 Some("Setup; then run")
4258 );
4259
4260 let rule = restart_rule(&[";", "\u{2014}"]);
4261 assert_eq!(
4262 suggested(&rule, "# Setup; Then Run\n").as_deref(),
4263 Some("Setup; Then run")
4264 );
4265 assert_eq!(
4266 suggested(&rule, "# Part One \u{2014} The Big Idea\n").as_deref(),
4267 Some("Part one \u{2014} The big idea")
4268 );
4269 }
4270
4271 #[test]
4272 fn test_restart_after_matches_only_at_the_end_of_a_word() {
4273 let rule = restart_rule(&["-", ":"]);
4276 assert_eq!(
4277 suggested(&rule, "# Ports: Well-Known Ports Explained\n").as_deref(),
4278 Some("Ports: Well-Known ports explained")
4279 );
4280 assert_eq!(
4281 suggested(&rule, "# See https://example.com/A/B For Details\n").as_deref(),
4282 Some("See https://example.com/A/B for details")
4283 );
4284 }
4285
4286 #[test]
4287 fn test_restart_after_a_trailing_boundary_is_a_no_op() {
4288 let rule = restart_rule(&[":"]);
4289 assert_eq!(suggested(&rule, "# Setup:\n"), None);
4290 }
4291
4292 #[test]
4293 fn test_restart_after_does_not_override_preserved_words() {
4294 let rule = restart_rule(&[":"]);
4297 assert_eq!(
4298 suggested(&rule, "# Devices: iPhone And Android\n").as_deref(),
4299 Some("Devices: iPhone and android")
4300 );
4301
4302 let config = MD063Config {
4303 enabled: true,
4304 style: HeadingCapStyle::SentenceCase,
4305 sentence_case_restart_after: vec![":".to_string()],
4306 ignore_words: vec!["kubectl".to_string()],
4307 preserve_cased_words: false,
4308 ..Default::default()
4309 };
4310 let rule = MD063HeadingCapitalization::from_config_struct(config);
4311 assert_eq!(
4312 suggested(&rule, "# Tools: kubectl And Helm\n").as_deref(),
4313 Some("Tools: kubectl and helm")
4314 );
4315 }
4316
4317 #[test]
4318 fn test_restart_after_keeps_md044_canonical_forms() {
4319 let config = MD063Config {
4320 enabled: true,
4321 style: HeadingCapStyle::SentenceCase,
4322 sentence_case_restart_after: vec![":".to_string()],
4323 ..Default::default()
4324 };
4325 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
4326 rule.proper_names = vec!["GitHub".to_string()];
4327
4328 assert_eq!(
4330 suggested(&rule, "# Docs: github Actions Guide\n").as_deref(),
4331 Some("Docs: GitHub actions guide")
4332 );
4333 assert_eq!(suggested(&rule, "# Docs: GitHub actions guide\n"), None);
4334 }
4335
4336 #[test]
4337 fn test_restart_after_carries_across_segments() {
4338 let rule = restart_rule(&[":"]);
4341 assert_eq!(
4342 suggested(
4343 &rule,
4344 "# Overview: [Some Link Here](https://example.com) Trailing Words\n"
4345 )
4346 .as_deref(),
4347 Some("Overview: [Some link here](https://example.com) trailing words")
4348 );
4349 assert_eq!(
4350 suggested(&rule, "# Overview: `code` Then More Words\n").as_deref(),
4351 Some("Overview: `code` then more words")
4352 );
4353 }
4354
4355 #[test]
4356 fn test_restart_after_ends_a_sentence_at_the_end_of_link_text() {
4357 let rule = restart_rule(&[":"]);
4361 assert_eq!(
4362 suggested(&rule, "# Topic [See:](https://example.com) More Words\n").as_deref(),
4363 Some("Topic [see:](https://example.com) More words")
4364 );
4365
4366 assert_eq!(
4368 suggested(&rule, "# Topic [See](https://example.com) More Words\n").as_deref(),
4369 Some("Topic [see](https://example.com) more words")
4370 );
4371 }
4372
4373 #[test]
4374 fn test_restart_after_ignores_boundaries_inside_opaque_segments() {
4375 let rule = restart_rule(&[":"]);
4378 for content in [
4379 "# Topic `see:` More Words\n",
4380 "# Topic  More Words\n",
4381 "# Topic <span title=\"x:\">y</span> More Words\n",
4382 ] {
4383 let fixed = suggested(&rule, content).expect("heading should be rewritten");
4384 assert!(
4385 fixed.ends_with("more words"),
4386 "opaque segment restarted the sentence in {content:?}: {fixed}"
4387 );
4388 }
4389 }
4390
4391 #[test]
4392 fn test_restart_after_treats_a_leading_link_as_sentence_initial() {
4393 for rule in [restart_rule(&[]), restart_rule(&[":"])] {
4396 assert_eq!(
4397 suggested(&rule, "# [Some Link Here](https://example.com) Trailing Words\n").as_deref(),
4398 Some("[Some link here](https://example.com) trailing words")
4399 );
4400 }
4401 }
4402
4403 #[test]
4404 fn test_restart_after_fix_is_idempotent() {
4405 let rule = restart_rule(&[":", ";", "-", "\u{2014}"]);
4406 for content in [
4407 "# Requirement 1: Struct to Logger Slice Conversion\n",
4408 "# Ports: Well-Known Ports Explained\n",
4409 "# Devices: iPhone And Android\n",
4410 "# Overview: [Some Link Here](https://example.com) Trailing Words\n",
4411 "# Setup:\n",
4412 ] {
4413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4414 let once = rule.fix(&ctx).unwrap();
4415 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
4416 assert_eq!(rule.fix(&ctx).unwrap(), once, "fix is not idempotent for {content:?}");
4417 }
4418 }
4419
4420 #[test]
4421 fn test_restart_after_ignores_empty_boundary_entries() {
4422 let rule = restart_rule(&[""]);
4424 assert_eq!(
4425 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
4426 Some("Requirement 1: struct to logger slice conversion")
4427 );
4428 }
4429
4430 const STYLES: [HeadingCapStyle; 3] = [
4433 HeadingCapStyle::TitleCase,
4434 HeadingCapStyle::SentenceCase,
4435 HeadingCapStyle::AllCaps,
4436 ];
4437
4438 const GHERKIN_STRUCTURES: [(&str, &str, &str, &str); 6] = [
4441 (
4442 "# Feature: the system under test",
4443 "# Feature: The System Under Test",
4444 "# Feature: The system under test",
4445 "# Feature: THE SYSTEM UNDER TEST",
4446 ),
4447 (
4448 "## Background: a shared setup",
4449 "## Background: A Shared Setup",
4450 "## Background: A shared setup",
4451 "## Background: A SHARED SETUP",
4452 ),
4453 (
4454 "## Rule: money is never lost",
4455 "## Rule: Money Is Never Lost",
4456 "## Rule: Money is never lost",
4457 "## Rule: MONEY IS NEVER LOST",
4458 ),
4459 (
4460 "### Scenario: add two numbers",
4461 "### Scenario: Add Two Numbers",
4462 "### Scenario: Add two numbers",
4463 "### Scenario: ADD TWO NUMBERS",
4464 ),
4465 (
4466 "### Scenario Outline: add two numbers",
4467 "### Scenario Outline: Add Two Numbers",
4468 "### Scenario Outline: Add two numbers",
4469 "### Scenario Outline: ADD TWO NUMBERS",
4470 ),
4471 (
4472 "#### Examples: happy path",
4473 "#### Examples: Happy Path",
4474 "#### Examples: Happy path",
4475 "#### Examples: HAPPY PATH",
4476 ),
4477 ];
4478
4479 fn recased(style: HeadingCapStyle, heading: &str, flavor: crate::config::MarkdownFlavor) -> String {
4481 let rule = create_rule_with_style(style);
4482 let content = format!("{heading}\n");
4483 let ctx = LintContext::new(&content, flavor, None);
4484 let warnings = rule.check(&ctx).unwrap();
4485 let fixed = rule.fix(&ctx).unwrap();
4486 assert_eq!(
4487 warnings.is_empty(),
4488 fixed == content,
4489 "a warning and a rewrite must agree for {content:?} under {flavor:?}"
4490 );
4491 fixed.trim_end().to_string()
4492 }
4493
4494 #[test]
4495 fn test_mdg_keeps_the_keyword_of_every_structure() {
4496 for (heading, ..) in GHERKIN_STRUCTURES {
4499 let keyword = &heading[..=heading.find(':').unwrap()];
4500 for style in STYLES {
4501 let fixed = recased(style, heading, crate::config::MarkdownFlavor::MDG);
4502 assert!(
4503 fixed.starts_with(keyword),
4504 "{style:?} lost the keyword of {heading:?}: {fixed}"
4505 );
4506 }
4507 }
4508 }
4509
4510 #[test]
4511 fn test_mdg_recases_only_the_name_of_a_structure() {
4512 for (heading, title, sentence, caps) in GHERKIN_STRUCTURES {
4513 let mdg = crate::config::MarkdownFlavor::MDG;
4514 assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), title);
4515 assert_eq!(recased(HeadingCapStyle::SentenceCase, heading, mdg), sentence);
4516 assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), caps);
4517 }
4518 }
4519
4520 #[test]
4521 fn test_standard_flavor_recases_a_keyword_like_any_other_word() {
4522 let standard = crate::config::MarkdownFlavor::Standard;
4524 assert_eq!(
4525 recased(HeadingCapStyle::TitleCase, "# Feature: the system under test", standard),
4526 "# Feature: the System Under Test"
4527 );
4528 assert_eq!(
4529 recased(
4530 HeadingCapStyle::SentenceCase,
4531 "### Scenario Outline: add two numbers",
4532 standard
4533 ),
4534 "### Scenario outline: add two numbers"
4535 );
4536 assert_eq!(
4537 recased(HeadingCapStyle::AllCaps, "# Feature: the system under test", standard),
4538 "# FEATURE: THE SYSTEM UNDER TEST"
4539 );
4540 }
4541
4542 #[test]
4543 fn test_mdg_leaves_a_heading_without_a_colon_to_the_normal_rule() {
4544 for heading in ["## notes about the system", "## Notes", "# THE SYSTEM"] {
4545 for style in STYLES {
4546 assert_eq!(
4547 recased(style, heading, crate::config::MarkdownFlavor::MDG),
4548 recased(style, heading, crate::config::MarkdownFlavor::Standard),
4549 "{style:?} treated {heading:?} as a Gherkin structure"
4550 );
4551 }
4552 }
4553 }
4554
4555 #[test]
4556 fn test_mdg_splits_at_the_first_colon_only() {
4557 let mdg = crate::config::MarkdownFlavor::MDG;
4559 let heading = "## Scenario: ratio: two to one";
4560 assert_eq!(
4561 recased(HeadingCapStyle::TitleCase, heading, mdg),
4562 "## Scenario: Ratio: Two to One"
4563 );
4564 assert_eq!(
4565 recased(HeadingCapStyle::SentenceCase, heading, mdg),
4566 "## Scenario: Ratio: two to one"
4567 );
4568 assert_eq!(
4569 recased(HeadingCapStyle::AllCaps, heading, mdg),
4570 "## Scenario: RATIO: TWO TO ONE"
4571 );
4572 }
4573
4574 #[test]
4575 fn test_mdg_leaves_a_colon_behind_a_backtick_to_the_normal_rule() {
4576 for heading in [
4580 "# See `x: y` Notes",
4581 "# `a: b`",
4582 "# `code` Feature: a name",
4583 "# `x: y` Feature: a name",
4584 ] {
4585 for style in STYLES {
4586 assert_eq!(
4587 recased(style, heading, crate::config::MarkdownFlavor::MDG),
4588 recased(style, heading, crate::config::MarkdownFlavor::Standard),
4589 "{style:?} split {heading:?} at a colon inside a code span"
4590 );
4591 }
4592 }
4593 }
4594
4595 #[test]
4596 fn test_mdg_splits_at_a_keyword_colon_that_precedes_a_code_span() {
4597 let mdg = crate::config::MarkdownFlavor::MDG;
4599 let heading = "# Scenario: use `a: b` here";
4600 assert_eq!(
4601 recased(HeadingCapStyle::TitleCase, heading, mdg),
4602 "# Scenario: Use `a: b` Here"
4603 );
4604 assert_eq!(
4605 recased(HeadingCapStyle::SentenceCase, heading, mdg),
4606 "# Scenario: Use `a: b` here"
4607 );
4608 assert_eq!(
4609 recased(HeadingCapStyle::AllCaps, heading, mdg),
4610 "# Scenario: USE `a: b` HERE"
4611 );
4612 }
4613
4614 #[test]
4615 fn test_mdg_splits_at_a_keyword_colon_before_an_unbalanced_backtick() {
4616 let mdg = crate::config::MarkdownFlavor::MDG;
4619 let heading = "# Scenario: a ` b";
4620 assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), "# Scenario: A ` B");
4621 assert_eq!(
4622 recased(HeadingCapStyle::SentenceCase, heading, mdg),
4623 "# Scenario: A ` b"
4624 );
4625 assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), "# Scenario: A ` B");
4626 }
4627
4628 #[test]
4629 fn test_mdg_keeps_a_keyword_with_nothing_left_to_recase() {
4630 for style in STYLES {
4631 assert_eq!(
4632 recased(style, "# Feature:", crate::config::MarkdownFlavor::MDG),
4633 "# Feature:"
4634 );
4635 }
4636 }
4637
4638 #[test]
4639 fn test_mdg_keeps_a_custom_id_after_the_name() {
4640 assert_eq!(
4641 recased(
4642 HeadingCapStyle::TitleCase,
4643 "# Feature: the system {#overview}",
4644 crate::config::MarkdownFlavor::MDG
4645 ),
4646 "# Feature: The System {#overview}"
4647 );
4648 }
4649
4650 #[test]
4651 fn test_mdg_fix_is_idempotent() {
4652 for (heading, ..) in GHERKIN_STRUCTURES {
4653 for style in STYLES {
4654 let rule = create_rule_with_style(style);
4655 let content = format!("{heading}\n");
4656 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
4657 let once = rule.fix(&ctx).unwrap();
4658 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::MDG, None);
4659 assert_eq!(
4660 rule.fix(&ctx).unwrap(),
4661 once,
4662 "fix is not idempotent for {heading:?} ({style:?})"
4663 );
4664 }
4665 }
4666 }
4667}
4668
4669#[cfg(test)]
4670mod shortcode_delimiter_tests {
4671 use super::*;
4672
4673 #[test]
4674 fn heading_capitalization_preserves_shortcode_raw_arguments() {
4675 let rule = MD063HeadingCapitalization::from_config_struct(MD063Config {
4676 enabled: true,
4677 style: HeadingCapStyle::TitleCase,
4678 ..Default::default()
4679 });
4680 for (open, close) in [("{{<", ">}}"), ("{{%", "%}}")] {
4681 let tag = format!("{open} note title=`literal {close} friend` {close}");
4682 assert_eq!(
4683 rule.apply_capitalization(&format!("hello {tag} world"), crate::config::MarkdownFlavor::Hugo),
4684 format!("Hello {tag} World")
4685 );
4686 }
4687 }
4688}
4689
4690#[cfg(test)]
4691mod mdx_tag_capitalization_tests {
4692 use super::*;
4693 use crate::rule::Rule;
4694
4695 fn fixed(style: HeadingCapStyle, source: &str) -> String {
4696 let rule = MD063HeadingCapitalization::from_config_struct(MD063Config {
4697 enabled: true,
4698 style,
4699 ..Default::default()
4700 });
4701 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
4702 rule.fix(&ctx).unwrap()
4703 }
4704
4705 #[test]
4706 fn mdx_heading_capitalization_preserves_native_jsx_elements() {
4707 for name in ["span", "Card", "ui.Card", "svg:path"] {
4708 let element = format!("<{name} onClick={{() => null}} title=\"literal friend\">world</{name}>");
4709 let source = format!("# hello {element} after\n");
4710 for (style, before, after) in [
4711 (HeadingCapStyle::TitleCase, "Hello", "After"),
4712 (HeadingCapStyle::SentenceCase, "Hello", "after"),
4713 (HeadingCapStyle::AllCaps, "HELLO", "AFTER"),
4714 ] {
4715 assert_eq!(fixed(style, &source), format!("# {before} {element} {after}\n"));
4716 }
4717 }
4718 }
4719
4720 #[test]
4721 fn mdx_heading_capitalization_preserves_nested_member_elements() {
4722 let element = "<ui.Card title='literal friend'>world <span>inner</span></ui.Card>";
4723 let source = format!("# hello {element} after\n");
4724 assert_eq!(
4725 fixed(HeadingCapStyle::TitleCase, &source),
4726 format!("# Hello {element} After\n")
4727 );
4728 }
4729
4730 #[test]
4731 fn mdx_heading_tag_protection_preserves_existing_visible_word_context() {
4732 assert_eq!(
4733 fixed(HeadingCapStyle::SentenceCase, "# <span></span> hello world\n"),
4734 "# <span></span> Hello world\n"
4735 );
4736 assert_eq!(
4737 fixed(HeadingCapStyle::SentenceCase, "# <img alt='friend'/> hello world\n"),
4738 "# <img alt='friend'/> hello world\n"
4739 );
4740 assert_eq!(
4741 fixed(HeadingCapStyle::TitleCase, "# <span>visible</span> and the world\n"),
4742 "# <span>visible</span> And the World\n"
4743 );
4744 }
4745
4746 #[test]
4747 fn mdx_setext_member_attributes_preserve_their_source_map() {
4748 let member = "hello <ui.Card title=\"literal\nfriend\">world</ui.Card> after\n===\n";
4749 assert_eq!(fixed(HeadingCapStyle::TitleCase, member), member);
4750 let html = "hello <span title=\"literal\nfriend\">world</span> after\n===\n";
4751 assert_eq!(
4752 fixed(HeadingCapStyle::TitleCase, html),
4753 "Hello <span title=\"literal\nfriend\">world</span> After\n===\n"
4754 );
4755 }
4756}