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 {
335 self.config.ignore_words.iter().any(|ignored| ignored == word)
336 }
337
338 fn is_numeric_ordinal(word: &str) -> bool {
346 let core = word.trim_matches(|c: char| !c.is_alphanumeric());
351 let bytes = core.as_bytes();
352
353 let alpha_start = match bytes.iter().position(|&b| !b.is_ascii_digit()) {
355 Some(pos) if pos > 0 => pos,
356 _ => return false,
357 };
358
359 let alpha_end = bytes[alpha_start..]
361 .iter()
362 .position(|b| !b.is_ascii_alphabetic())
363 .map_or(bytes.len(), |p| alpha_start + p);
364
365 let suffix = &core[alpha_start..alpha_end];
366 matches!(suffix.to_ascii_lowercase().as_str(), "st" | "nd" | "rd" | "th")
367 }
368
369 fn is_caret_notation(&self, word: &str) -> bool {
371 let chars: Vec<char> = word.chars().collect();
372 if chars.len() >= 2 && chars[0] == '^' {
374 let second = chars[1];
375 if second.is_ascii_uppercase() || "@[\\]^_".contains(second) {
377 return true;
378 }
379 }
380 false
381 }
382
383 fn is_lowercase_word(&self, word: &str) -> bool {
385 self.lowercase_set.contains(&word.to_lowercase())
386 }
387
388 fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
390 if word.is_empty() {
391 return word.to_string();
392 }
393
394 if self.should_preserve_word(word) {
396 return word.to_string();
397 }
398
399 if is_first || is_last {
401 return self.capitalize_first(word);
402 }
403
404 if self.is_lowercase_word(word) {
406 return Self::lowercase_preserving_composition(word);
407 }
408
409 self.capitalize_first(word)
411 }
412
413 fn apply_canonical_form_to_word(word: &str, canonical: &str) -> String {
416 let canonical_lower = canonical.to_lowercase();
417 if canonical_lower.is_empty() {
418 return canonical.to_string();
419 }
420
421 if let Some(end_pos) = Self::match_case_insensitive_at(word, 0, &canonical_lower) {
422 let mut out = String::with_capacity(canonical.len() + word.len().saturating_sub(end_pos));
423 out.push_str(canonical);
424 out.push_str(&word[end_pos..]);
425 out
426 } else {
427 canonical.to_string()
428 }
429 }
430
431 fn capitalize_first(&self, word: &str) -> String {
433 if word.is_empty() {
434 return String::new();
435 }
436
437 let first_alpha_pos = word.find(|c: char| c.is_alphabetic());
439 let Some(pos) = first_alpha_pos else {
440 return word.to_string();
441 };
442
443 let prefix = &word[..pos];
444 let suffix = &word[pos..];
445
446 if Self::is_numeric_ordinal(word) {
449 let suffix_lower = Self::lowercase_preserving_composition(suffix);
450 return format!("{prefix}{suffix_lower}");
451 }
452
453 let mut chars = suffix.chars();
454 let first = chars.next().unwrap();
455 let first_upper = Self::uppercase_preserving_composition(&first.to_string());
458 let rest: String = chars.collect();
459 let rest_lower = Self::lowercase_preserving_composition(&rest);
460 format!("{prefix}{first_upper}{rest_lower}")
461 }
462
463 fn lowercase_preserving_composition(s: &str) -> String {
466 let mut result = String::with_capacity(s.len());
467 for c in s.chars() {
468 let lower: String = c.to_lowercase().collect();
469 if lower.chars().count() == 1 {
470 result.push_str(&lower);
471 } else {
472 result.push(c);
474 }
475 }
476 result
477 }
478
479 fn sentence_case_first_person_pronouns(word: &str) -> Option<String> {
489 fn is_emphasis_marker(c: char) -> bool {
490 matches!(c, '*' | '_' | '~')
491 }
492
493 fn is_word_connector(c: char) -> bool {
497 matches!(c, '/' | '\\' | '-' | '.' | '+' | '&')
498 }
499
500 fn is_word_boundary(c: char) -> bool {
501 !c.is_alphanumeric() && !is_word_connector(c)
502 }
503
504 fn is_pronoun_at(word: &str, pos: usize) -> bool {
505 let left = word[..pos].trim_end_matches(is_emphasis_marker);
506 if !left.chars().next_back().is_none_or(is_word_boundary) {
507 return false;
508 }
509
510 let after_i = word[pos + 1..].trim_start_matches(is_emphasis_marker);
511 let Some(apostrophe) = after_i.chars().next() else {
512 return true;
513 };
514 if !matches!(apostrophe, '\'' | '’') {
515 return is_word_boundary(apostrophe);
516 }
517
518 let after_apostrophe = after_i[apostrophe.len_utf8()..].trim_start_matches(is_emphasis_marker);
519 let suffix_end = after_apostrophe
520 .find(|c: char| !c.is_ascii_alphabetic())
521 .unwrap_or(after_apostrophe.len());
522 let suffix = &after_apostrophe[..suffix_end];
523 if suffix.is_empty() {
524 return after_apostrophe.chars().next().is_none_or(is_word_boundary);
526 }
527 if !matches!(suffix.to_ascii_lowercase().as_str(), "d" | "ll" | "m" | "ve") {
528 return false;
529 }
530
531 let after_suffix = after_apostrophe[suffix_end..].trim_start_matches(is_emphasis_marker);
532 after_suffix.chars().next().is_none_or(is_word_boundary)
533 }
534
535 let pronoun_positions: Vec<usize> = word
536 .char_indices()
537 .filter_map(|(pos, c)| (c == 'I' && is_pronoun_at(word, pos)).then_some(pos))
538 .collect();
539 if pronoun_positions.is_empty() {
540 return None;
541 }
542
543 let mut result = String::with_capacity(word.len());
544 let mut copied_through = 0;
545 for pos in pronoun_positions {
546 result.push_str(&Self::lowercase_preserving_composition(&word[copied_through..pos]));
547 result.push('I');
548 copied_through = pos + 1;
549 }
550 result.push_str(&Self::lowercase_preserving_composition(&word[copied_through..]));
551 Some(result)
552 }
553
554 fn uppercase_preserving_composition(s: &str) -> String {
559 let mut result = String::with_capacity(s.len());
560 for c in s.chars() {
561 let upper: String = c.to_uppercase().collect();
562 if upper.chars().count() == 1 {
563 result.push_str(&upper);
564 } else {
565 result.push(c);
567 }
568 }
569 result
570 }
571
572 fn apply_title_case(&self, text: &str) -> String {
576 let canonical_forms = self.proper_name_canonical_forms(text);
577
578 let original_words: Vec<&str> = text.split_whitespace().collect();
579 let total_words = original_words.len();
580
581 let mut word_positions: Vec<usize> = Vec::with_capacity(original_words.len());
584 let mut pos = 0;
585 for word in &original_words {
586 if let Some(rel) = text[pos..].find(word) {
587 word_positions.push(pos + rel);
588 pos = pos + rel + word.len();
589 } else {
590 word_positions.push(usize::MAX);
591 }
592 }
593
594 let result_words: Vec<String> = original_words
595 .iter()
596 .enumerate()
597 .map(|(i, word)| {
598 let after_period = i > 0 && original_words[i - 1].ends_with('.');
599 let is_first = i == 0 || after_period;
600 let is_last = i == total_words - 1;
601
602 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
604 return Self::apply_canonical_form_to_word(word, canonical);
605 }
606
607 if self.should_preserve_word(word) {
609 return (*word).to_string();
610 }
611
612 if word.contains('-') {
614 return self.handle_hyphenated_word(word, is_first, is_last);
615 }
616
617 self.title_case_word(word, is_first, is_last)
618 })
619 .collect();
620
621 result_words.join(" ")
622 }
623
624 fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
626 let parts: Vec<&str> = word.split('-').collect();
627 let total_parts = parts.len();
628
629 let result_parts: Vec<String> = parts
630 .iter()
631 .enumerate()
632 .map(|(i, part)| {
633 let part_is_first = is_first && i == 0;
635 let part_is_last = is_last && i == total_parts - 1;
636 self.title_case_word(part, part_is_first, part_is_last)
637 })
638 .collect();
639
640 result_parts.join("-")
641 }
642
643 fn ends_sentence(&self, word: &str) -> bool {
649 self.config
650 .sentence_case_restart_after
651 .iter()
652 .any(|boundary| !boundary.is_empty() && word.ends_with(boundary.as_str()))
653 }
654
655 fn apply_sentence_case_from(&self, text: &str, starts_sentence: bool) -> String {
659 if text.is_empty() {
660 return text.to_string();
661 }
662
663 let canonical_forms = self.proper_name_canonical_forms(text);
664 let mut result = String::new();
665 let mut current_pos = 0;
666 let mut at_sentence_start = starts_sentence;
667
668 for word in text.split_whitespace() {
670 if let Some(pos) = text[current_pos..].find(word) {
671 let abs_pos = current_pos + pos;
672
673 result.push_str(&text[current_pos..abs_pos]);
675
676 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
679 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
680 } else if self.is_ignored_word(word) {
681 result.push_str(word);
683 } else if let Some(pronoun) = Self::sentence_case_first_person_pronouns(word) {
684 result.push_str(&pronoun);
687 } else if at_sentence_start {
688 if self.should_preserve_word(word) {
690 result.push_str(word);
692 } else {
693 let mut chars = word.chars();
695 if let Some(first) = chars.next() {
696 result.push_str(&Self::uppercase_preserving_composition(&first.to_string()));
697 let rest: String = chars.collect();
698 result.push_str(&Self::lowercase_preserving_composition(&rest));
699 }
700 }
701 } else {
702 if self.should_preserve_word(word) {
704 result.push_str(word);
705 } else {
706 result.push_str(&Self::lowercase_preserving_composition(word));
707 }
708 }
709
710 at_sentence_start = self.ends_sentence(word);
711 current_pos = abs_pos + word.len();
712 }
713 }
714
715 if current_pos < text.len() {
717 result.push_str(&text[current_pos..]);
718 }
719
720 result
721 }
722
723 fn apply_all_caps(&self, text: &str) -> String {
725 if text.is_empty() {
726 return text.to_string();
727 }
728
729 let canonical_forms = self.proper_name_canonical_forms(text);
730 let mut result = String::new();
731 let mut current_pos = 0;
732
733 for word in text.split_whitespace() {
735 if let Some(pos) = text[current_pos..].find(word) {
736 let abs_pos = current_pos + pos;
737
738 result.push_str(&text[current_pos..abs_pos]);
740
741 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
744 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
745 } else if self.should_preserve_word(word) {
746 result.push_str(word);
747 } else {
748 result.push_str(&Self::uppercase_preserving_composition(word));
749 }
750
751 current_pos = abs_pos + word.len();
752 }
753 }
754
755 if current_pos < text.len() {
757 result.push_str(&text[current_pos..]);
758 }
759
760 result
761 }
762
763 fn parse_segments(&self, text: &str) -> Vec<HeadingSegment> {
765 let mut segments = Vec::new();
766 let mut last_end = 0;
767
768 let mut special_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
770
771 for mat in INLINE_CODE_REGEX.find_iter(text) {
773 special_regions.push((mat.start(), mat.end(), HeadingSegment::Code(mat.as_str().to_string())));
774 }
775
776 for caps in LINK_REGEX.captures_iter(text) {
778 let full_match = caps.get(0).unwrap();
779
780 if full_match.start() >= 1 && text.as_bytes()[full_match.start() - 1] == b'!' {
784 let region_start = full_match.start() - 1;
785 special_regions.push((
786 region_start,
787 full_match.end(),
788 HeadingSegment::Image(text[region_start..full_match.end()].to_string()),
789 ));
790 continue;
791 }
792
793 let text_match = caps.get(1).or_else(|| caps.get(2));
794
795 if let Some(text_m) = text_match {
796 special_regions.push((
797 full_match.start(),
798 full_match.end(),
799 HeadingSegment::Link {
800 full: full_match.as_str().to_string(),
801 text_start: text_m.start() - full_match.start(),
802 text_end: text_m.end() - full_match.start(),
803 },
804 ));
805 }
806 }
807
808 let code_ranges: Vec<(usize, usize)> = special_regions
811 .iter()
812 .filter(|(_, _, segment)| matches!(segment, HeadingSegment::Code(_)))
813 .map(|(start, end, _)| (*start, *end))
814 .collect();
815 for (start, end) in Self::html_regions(text, &code_ranges) {
816 special_regions.push((start, end, HeadingSegment::Html(text[start..end].to_string())));
817 }
818
819 special_regions.sort_by_key(|(start, _, _)| *start);
821
822 let mut filtered_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
825 for region in special_regions {
826 let overlaps = filtered_regions.iter().any(|(s, e, _)| region.0 < *e && region.1 > *s);
827 if !overlaps {
828 filtered_regions.push(region);
829 }
830 }
831
832 for (start, end, segment) in filtered_regions {
834 if start > last_end {
836 let text_segment = &text[last_end..start];
837 if !text_segment.is_empty() {
838 segments.push(HeadingSegment::Text(text_segment.to_string()));
839 }
840 }
841 segments.push(segment);
842 last_end = end;
843 }
844
845 if last_end < text.len() {
847 let remaining = &text[last_end..];
848 if !remaining.is_empty() {
849 segments.push(HeadingSegment::Text(remaining.to_string()));
850 }
851 }
852
853 if segments.is_empty() && !text.is_empty() {
855 segments.push(HeadingSegment::Text(text.to_string()));
856 }
857
858 segments
859 }
860
861 fn html_regions(text: &str, code_ranges: &[(usize, usize)]) -> Vec<(usize, usize)> {
875 let mut regions: Vec<(usize, usize)> = Vec::new();
876 let mut open_elements: Vec<(String, usize)> = Vec::new();
877
878 let mut pos = 0;
879 while let Some(token) = HTML_TOKEN_REGEX.captures_at(text, pos) {
880 let whole = token.get(0).unwrap();
881 if code_ranges
882 .iter()
883 .any(|&(start, end)| start <= whole.start() && whole.start() < end)
884 || is_backslash_escaped(text, whole.start())
885 {
886 pos = whole.start() + 1;
888 continue;
889 }
890 pos = whole.end();
891
892 if let Some(closing) = token.get(1) {
893 let name = closing.as_str().to_ascii_lowercase();
894 if let Some(depth) = open_elements.iter().rposition(|(open_name, _)| *open_name == name) {
895 let element_start = open_elements[depth].1;
896 open_elements.truncate(depth);
897 regions.retain(|&(start, _)| start < element_start);
898 regions.push((element_start, whole.end()));
899 continue;
900 }
901 } else if let Some(opening) = token.get(2) {
902 let name = opening.as_str().to_ascii_lowercase();
903 if !whole.as_str().ends_with("/>") && !is_void_element(&name) {
904 open_elements.push((name, whole.start()));
905 }
906 }
907
908 regions.push((whole.start(), whole.end()));
909 }
910
911 regions
912 }
913
914 fn apply_capitalization(&self, text: &str, flavor: crate::config::MarkdownFlavor) -> String {
916 let (main_text, custom_id) = if let Some(mat) = CUSTOM_ID_REGEX.find(text) {
918 (&text[..mat.start()], Some(mat.as_str()))
919 } else {
920 (text, None)
921 };
922
923 let (keyword, main_text) = if flavor == crate::config::MarkdownFlavor::MDG {
930 mdg::keyword_split(main_text).unwrap_or(("", main_text))
931 } else {
932 ("", main_text)
933 };
934
935 let segments = self.parse_segments(main_text);
937
938 let text_segments: Vec<usize> = segments
940 .iter()
941 .enumerate()
942 .filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
943 .collect();
944
945 let first_segment_starts_sentence = segments
949 .iter()
950 .find(|s| !s.renders_nothing())
951 .is_some_and(|s| matches!(s, HeadingSegment::Text(_) | HeadingSegment::Link { .. }));
952
953 let last_segment_is_text = segments
956 .iter()
957 .rev()
958 .find(|s| !s.renders_nothing())
959 .is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
960
961 let mut result_parts: Vec<String> = Vec::new();
963
964 let mut at_sentence_start = first_segment_starts_sentence;
967
968 for (i, segment) in segments.iter().enumerate() {
969 at_sentence_start = match segment {
974 HeadingSegment::Text(t) => {
975 let is_first_text = text_segments.first() == Some(&i);
976 let is_last_text = text_segments.last() == Some(&i) && last_segment_is_text;
980
981 let capitalized = match self.config.style {
982 HeadingCapStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
983 HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(t, at_sentence_start),
984 HeadingCapStyle::AllCaps => self.apply_all_caps(t),
985 };
986 let ends_sentence = self.ends_sentence(capitalized.trim_end());
987 result_parts.push(capitalized);
988 ends_sentence
989 }
990 HeadingSegment::Code(c) => {
991 result_parts.push(c.clone());
992 false
993 }
994 HeadingSegment::Link {
995 full,
996 text_start,
997 text_end,
998 } => {
999 let link_text = &full[*text_start..*text_end];
1001 let capitalized_text = match self.config.style {
1002 HeadingCapStyle::TitleCase => self.apply_title_case(link_text),
1003 HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(link_text, at_sentence_start),
1006 HeadingCapStyle::AllCaps => self.apply_all_caps(link_text),
1007 };
1008 let ends_sentence = self.ends_sentence(capitalized_text.trim_end());
1011
1012 let mut new_link = String::new();
1013 new_link.push_str(&full[..*text_start]);
1014 new_link.push_str(&capitalized_text);
1015 new_link.push_str(&full[*text_end..]);
1016 result_parts.push(new_link);
1017 ends_sentence
1018 }
1019 HeadingSegment::Html(h) => {
1020 result_parts.push(h.clone());
1024 segment.renders_nothing() && at_sentence_start
1025 }
1026 HeadingSegment::Image(img) => {
1027 result_parts.push(img.clone());
1029 false
1030 }
1031 };
1032 }
1033
1034 let mut result = String::with_capacity(text.len());
1035 result.push_str(keyword);
1036 result.push_str(&result_parts.join(""));
1037
1038 if let Some(id) = custom_id {
1040 result.push_str(id);
1041 }
1042
1043 result
1044 }
1045
1046 fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
1048 let canonical_forms = self.proper_name_canonical_forms(text);
1049 let words: Vec<&str> = text.split_whitespace().collect();
1050 let total_words = words.len();
1051
1052 if total_words == 0 {
1053 return text.to_string();
1054 }
1055
1056 let mut word_positions: Vec<usize> = Vec::with_capacity(words.len());
1059 let mut pos = 0;
1060 for word in &words {
1061 if let Some(rel) = text[pos..].find(word) {
1062 word_positions.push(pos + rel);
1063 pos = pos + rel + word.len();
1064 } else {
1065 word_positions.push(usize::MAX);
1066 }
1067 }
1068
1069 let result_words: Vec<String> = words
1070 .iter()
1071 .enumerate()
1072 .map(|(i, word)| {
1073 let after_period = i > 0 && words[i - 1].ends_with('.');
1074 let is_first = (is_first_segment && i == 0) || after_period;
1075 let is_last = is_last_segment && i == total_words - 1;
1076
1077 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
1079 return Self::apply_canonical_form_to_word(word, canonical);
1080 }
1081
1082 if word.contains('-') {
1084 return self.handle_hyphenated_word(word, is_first, is_last);
1085 }
1086
1087 self.title_case_word(word, is_first, is_last)
1088 })
1089 .collect();
1090
1091 let mut result = String::new();
1093 let mut word_iter = result_words.iter();
1094 let mut in_word = false;
1095
1096 for c in text.chars() {
1097 if c.is_whitespace() {
1098 if in_word {
1099 in_word = false;
1100 }
1101 result.push(c);
1102 } else if !in_word {
1103 if let Some(word) = word_iter.next() {
1104 result.push_str(word);
1105 }
1106 in_word = true;
1107 }
1108 }
1109
1110 result
1111 }
1112
1113 fn fix_atx_heading(
1115 &self,
1116 _line: &str,
1117 heading: &crate::lint_context::HeadingInfo,
1118 flavor: crate::config::MarkdownFlavor,
1119 ) -> String {
1120 let indent = " ".repeat(heading.marker_column);
1122 let hashes = "#".repeat(heading.level as usize);
1123
1124 let fixed_text = self.apply_capitalization(&heading.raw_text, flavor);
1126
1127 let closing = &heading.closing_sequence;
1129 if heading.has_closing_sequence {
1130 format!("{indent}{hashes} {fixed_text} {closing}")
1131 } else {
1132 format!("{indent}{hashes} {fixed_text}")
1133 }
1134 }
1135
1136 fn fix_setext_heading(
1138 &self,
1139 line: &str,
1140 heading: &crate::lint_context::HeadingInfo,
1141 flavor: crate::config::MarkdownFlavor,
1142 ) -> String {
1143 let fixed_text = self.apply_capitalization(&heading.raw_text, flavor);
1145
1146 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
1148
1149 format!("{leading_ws}{fixed_text}")
1150 }
1151
1152 fn rewrite_words<'w>(line: &str, words: &mut impl Iterator<Item = &'w str>) -> String {
1155 let mut result = String::with_capacity(line.len());
1156 let mut in_word = false;
1157 for c in line.chars() {
1158 if c.is_whitespace() {
1159 in_word = false;
1160 result.push(c);
1161 } else if !in_word {
1162 if let Some(word) = words.next() {
1163 result.push_str(word);
1164 }
1165 in_word = true;
1166 }
1167 }
1168 result
1169 }
1170
1171 fn fix_setext_heading_span(
1187 &self,
1188 ctx: &crate::lint_context::LintContext,
1189 first_idx: usize,
1190 last_idx: usize,
1191 heading: &crate::lint_context::HeadingInfo,
1192 flavor: crate::config::MarkdownFlavor,
1193 ) -> Option<Vec<String>> {
1194 let bodies: Vec<(&str, &str)> = (first_idx..=last_idx)
1195 .map(|idx| {
1196 let line = ctx.lines[idx].content(ctx.content);
1197 if idx < last_idx && ctx.line_ends_with_hard_break(idx + 1) {
1198 line.split_at(line.len() - 1)
1199 } else {
1200 (line, "")
1201 }
1202 })
1203 .collect();
1204 let fixed_text = self.apply_capitalization(&heading.raw_text, flavor);
1205 let fixed_words: Vec<&str> = fixed_text.split_whitespace().collect();
1206 let original_words: usize = bodies.iter().map(|(body, _)| body.split_whitespace().count()).sum();
1207 if fixed_words.len() != original_words {
1208 return None;
1209 }
1210
1211 let mut words = fixed_words.into_iter();
1212 Some(
1213 bodies
1214 .iter()
1215 .map(|(body, hard_break)| format!("{}{hard_break}", Self::rewrite_words(body, &mut words)))
1216 .collect(),
1217 )
1218 }
1219}
1220
1221impl Rule for MD063HeadingCapitalization {
1222 fn name(&self) -> &'static str {
1223 "MD063"
1224 }
1225
1226 fn description(&self) -> &'static str {
1227 "Heading capitalization"
1228 }
1229
1230 fn category(&self) -> RuleCategory {
1231 RuleCategory::Heading
1232 }
1233
1234 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1235 !ctx.likely_has_headings() || !ctx.lines.iter().any(|line| line.heading.is_some())
1236 }
1237
1238 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1239 let content = ctx.content;
1240
1241 if content.is_empty() {
1242 return Ok(Vec::new());
1243 }
1244
1245 let mut warnings = Vec::new();
1246
1247 for (line_num, line_info) in ctx.lines.iter().enumerate() {
1248 if let Some(heading) = &line_info.heading {
1249 if heading.level < self.config.min_level || heading.level > self.config.max_level {
1251 continue;
1252 }
1253
1254 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
1256 continue;
1257 }
1258
1259 let original_text = &heading.raw_text;
1261 let fixed_text = self.apply_capitalization(original_text, ctx.flavor);
1262
1263 if original_text != &fixed_text {
1264 let line = line_info.content(ctx.content);
1265 let style_name = match self.config.style {
1266 HeadingCapStyle::TitleCase => "title case",
1267 HeadingCapStyle::SentenceCase => "sentence case",
1268 HeadingCapStyle::AllCaps => "ALL CAPS",
1269 };
1270
1271 if heading.text_lines > 1 {
1275 let first_idx = line_num + 1 - heading.text_lines;
1276 let first_line = ctx.lines[first_idx].content(ctx.content);
1277 let fix = self
1278 .fix_setext_heading_span(ctx, first_idx, line_num, heading, ctx.flavor)
1279 .map(|rewritten| {
1280 let range = ctx.line_content_byte_range(first_idx + 1).start
1281 ..ctx.line_content_byte_range(line_num + 1).end;
1282 Fix::new(range, rewritten.join("\n"))
1283 });
1284 warnings.push(LintWarning {
1285 rule_name: Some(self.name().to_string()),
1286 line: first_idx + 1,
1287 column: byte_to_char_count(first_line, heading.content_column),
1288 end_line: line_num + 1,
1289 end_column: line.trim_end().chars().count() + 1,
1290 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1291 severity: Severity::Warning,
1292 fix,
1293 });
1294 continue;
1295 }
1296
1297 warnings.push(LintWarning {
1298 rule_name: Some(self.name().to_string()),
1299 line: line_num + 1,
1300 column: byte_to_char_count(line, heading.content_column),
1301 end_line: line_num + 1,
1302 end_column: byte_to_char_count(line, heading.content_column) + original_text.chars().count(),
1303 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1304 severity: Severity::Warning,
1305 fix: Some(Fix::new(
1306 ctx.line_content_byte_range(line_num + 1),
1307 match heading.style {
1308 crate::lint_context::HeadingStyle::ATX => {
1309 self.fix_atx_heading(line, heading, ctx.flavor)
1310 }
1311 _ => self.fix_setext_heading(line, heading, ctx.flavor),
1312 },
1313 )),
1314 });
1315 }
1316 }
1317 }
1318
1319 Ok(warnings)
1320 }
1321
1322 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1323 let content = ctx.content;
1324
1325 if content.is_empty() {
1326 return Ok(content.to_string());
1327 }
1328
1329 let lines = ctx.raw_lines();
1330 let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1331
1332 for (line_num, line_info) in ctx.lines.iter().enumerate() {
1333 if ctx.is_rule_disabled(self.name(), line_num + 1) {
1335 continue;
1336 }
1337
1338 if let Some(heading) = &line_info.heading {
1339 if heading.level < self.config.min_level || heading.level > self.config.max_level {
1341 continue;
1342 }
1343
1344 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
1346 continue;
1347 }
1348
1349 let original_text = &heading.raw_text;
1350 let fixed_text = self.apply_capitalization(original_text, ctx.flavor);
1351
1352 if original_text != &fixed_text {
1353 let line = line_info.content(ctx.content);
1354 if heading.text_lines > 1 {
1357 let first_idx = line_num + 1 - heading.text_lines;
1358 if (first_idx..line_num).any(|idx| ctx.is_rule_disabled(self.name(), idx + 1)) {
1361 continue;
1362 }
1363 if let Some(rewritten) =
1364 self.fix_setext_heading_span(ctx, first_idx, line_num, heading, ctx.flavor)
1365 {
1366 fixed_lines[first_idx..=line_num].clone_from_slice(&rewritten);
1367 }
1368 continue;
1369 }
1370 fixed_lines[line_num] = match heading.style {
1371 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading, ctx.flavor),
1372 _ => self.fix_setext_heading(line, heading, ctx.flavor),
1373 };
1374 }
1375 }
1376 }
1377
1378 let mut result = String::with_capacity(content.len());
1380 for (i, line) in fixed_lines.iter().enumerate() {
1381 result.push_str(line);
1382 if i < fixed_lines.len() - 1 || content.ends_with('\n') {
1383 result.push('\n');
1384 }
1385 }
1386
1387 Ok(result)
1388 }
1389
1390 fn as_any(&self) -> &dyn std::any::Any {
1391 self
1392 }
1393
1394 crate::impl_rule_config_sections!(MD063Config);
1395
1396 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1397 where
1398 Self: Sized,
1399 {
1400 let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
1401 let md044_config =
1402 crate::rule_config_serde::load_rule_config::<crate::rules::md044_proper_names::MD044Config>(config);
1403 let mut rule = Self::from_config_struct(rule_config);
1404 rule.proper_names = md044_config.names;
1405 Box::new(rule)
1406 }
1407}
1408
1409#[cfg(test)]
1410mod tests {
1411 use super::*;
1412 use crate::lint_context::LintContext;
1413
1414 fn create_rule() -> MD063HeadingCapitalization {
1415 let config = MD063Config {
1416 enabled: true,
1417 ..Default::default()
1418 };
1419 MD063HeadingCapitalization::from_config_struct(config)
1420 }
1421
1422 fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
1423 let config = MD063Config {
1424 enabled: true,
1425 style,
1426 ..Default::default()
1427 };
1428 MD063HeadingCapitalization::from_config_struct(config)
1429 }
1430
1431 #[test]
1433 fn test_an_escaped_tag_does_not_hide_the_element_written_inside_it() {
1434 let text = r#"\<span title='<a id="x"></a>'>foo"#;
1437 assert_eq!(MD063HeadingCapitalization::html_regions(text, &[]), vec![(14, 28)]);
1438 }
1439
1440 #[test]
1441 fn test_title_case_basic() {
1442 let rule = create_rule();
1443 let content = "# hello world\n";
1444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1445 let result = rule.check(&ctx).unwrap();
1446 assert_eq!(result.len(), 1);
1447 assert!(result[0].message.contains("Hello World"));
1448 }
1449
1450 #[test]
1451 fn test_title_case_lowercase_words() {
1452 let rule = create_rule();
1453 let content = "# the quick brown fox\n";
1454 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1455 let result = rule.check(&ctx).unwrap();
1456 assert_eq!(result.len(), 1);
1457 assert!(result[0].message.contains("The Quick Brown Fox"));
1459 }
1460
1461 #[test]
1462 fn test_title_case_already_correct() {
1463 let rule = create_rule();
1464 let content = "# The Quick Brown Fox\n";
1465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1466 let result = rule.check(&ctx).unwrap();
1467 assert!(result.is_empty(), "Already correct heading should not be flagged");
1468 }
1469
1470 #[test]
1471 fn test_title_case_hyphenated() {
1472 let rule = create_rule();
1473 let content = "# self-documenting code\n";
1474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1475 let result = rule.check(&ctx).unwrap();
1476 assert_eq!(result.len(), 1);
1477 assert!(result[0].message.contains("Self-Documenting Code"));
1478 }
1479
1480 #[test]
1481 fn test_title_case_preserves_url_with_nested_parens() {
1482 let rule = create_rule();
1483 let content = "# guide for [the api](https://example.com/docs/v(2)beta)\n";
1485 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1486 let fixed = rule.fix(&ctx).unwrap();
1487 assert!(
1490 fixed.contains("https://example.com/docs/v(2)beta"),
1491 "URL with nested parens was corrupted: {fixed:?}"
1492 );
1493 }
1494
1495 #[test]
1496 fn test_title_case_does_not_recase_image_alt() {
1497 let rule = create_rule();
1498 let content = "# overview \n";
1499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1500 let fixed = rule.fix(&ctx).unwrap();
1501 assert!(
1503 fixed.contains(""),
1504 "image alt text was modified: {fixed:?}"
1505 );
1506 assert!(
1507 fixed.contains("# Overview"),
1508 "surrounding prose should still be title-cased: {fixed:?}"
1509 );
1510 }
1511
1512 #[test]
1514 fn test_sentence_case_basic() {
1515 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1516 let content = "# The Quick Brown Fox\n";
1517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1518 let result = rule.check(&ctx).unwrap();
1519 assert_eq!(result.len(), 1);
1520 assert!(result[0].message.contains("The quick brown fox"));
1521 }
1522
1523 #[test]
1524 fn test_sentence_case_already_correct() {
1525 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1526 let content = "# The quick brown fox\n";
1527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1528 let result = rule.check(&ctx).unwrap();
1529 assert!(result.is_empty());
1530 }
1531
1532 #[test]
1533 fn test_sentence_case_preserves_first_person_pronoun() {
1534 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1536 let content = "# How do I debug playbooks?\n";
1537 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1538
1539 assert!(rule.check(&ctx).unwrap().is_empty());
1540 assert_eq!(rule.fix(&ctx).unwrap(), content);
1541 }
1542
1543 #[test]
1544 fn test_sentence_case_preserves_first_person_contractions() {
1545 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1546
1547 for content in [
1548 "# What I'd change\n",
1549 "# Where I'll look\n",
1550 "# Why I'm here\n",
1551 "# What I've learned\n",
1552 "# What I’d change\n",
1553 "# Where I’ll look\n",
1554 "# Why I’m here\n",
1555 "# What I’ve learned\n",
1556 ] {
1557 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1558 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1559 assert_eq!(rule.fix(&ctx).unwrap(), content);
1560 }
1561 }
1562
1563 #[test]
1564 fn test_sentence_case_pronoun_handles_markup_punctuation_and_suffix_case() {
1565 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1566 let cases = [
1567 ("# What (**I**) would change\n", "# What (**I**) would change\n"),
1568 ("# Why **I**'M changing it\n", "# Why **I**'m changing it\n"),
1569 ("# What I’LL change\n", "# What I’ll change\n"),
1570 ("# What I—really want\n", "# What I—really want\n"),
1571 ("# What I’d—reluctantly change\n", "# What I’d—reluctantly change\n"),
1572 ("# What I—yes—I—would do\n", "# What I—yes—I—would do\n"),
1573 ("# What [I'll change](plan.md)\n", "# What [I'll change](plan.md)\n"),
1574 ("# [What I—really want](plan.md)\n", "# [What I—really want](plan.md)\n"),
1575 ];
1576
1577 for (content, expected) in cases {
1578 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1579 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
1580 }
1581 }
1582
1583 #[test]
1584 fn test_sentence_case_pronoun_is_independent_of_cased_word_preservation() {
1585 let config = MD063Config {
1586 enabled: true,
1587 style: HeadingCapStyle::SentenceCase,
1588 preserve_cased_words: false,
1589 ..Default::default()
1590 };
1591 let rule = MD063HeadingCapitalization::from_config_struct(config);
1592 let content = "# How do I debug what I’LL change?\n";
1593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1594
1595 assert_eq!(rule.fix(&ctx).unwrap(), "# How do I debug what I’ll change?\n");
1596 }
1597
1598 #[test]
1599 fn test_sentence_case_explicit_ignore_wins_over_pronoun_normalization() {
1600 let config = MD063Config {
1601 enabled: true,
1602 style: HeadingCapStyle::SentenceCase,
1603 ignore_words: vec!["I'LL".to_string(), "I’LL".to_string()],
1604 ..Default::default()
1605 };
1606 let rule = MD063HeadingCapitalization::from_config_struct(config);
1607 let content = "# Why I'LL stay and why I’LL leave\n";
1608 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1609
1610 assert!(rule.check(&ctx).unwrap().is_empty());
1611 assert_eq!(rule.fix(&ctx).unwrap(), content);
1612 }
1613
1614 #[test]
1615 fn test_sentence_case_pronoun_does_not_preserve_other_single_letters_or_compounds() {
1616 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1617 let content = "# Compare i, A, I/O, and A.I. values\n";
1618 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1619
1620 assert_eq!(rule.fix(&ctx).unwrap(), "# Compare i, a, i/o, and a.i. values\n");
1621 }
1622
1623 #[test]
1625 fn test_all_caps_basic() {
1626 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1627 let content = "# hello world\n";
1628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1629 let result = rule.check(&ctx).unwrap();
1630 assert_eq!(result.len(), 1);
1631 assert!(result[0].message.contains("HELLO WORLD"));
1632 }
1633
1634 #[test]
1636 fn test_preserve_ignore_words() {
1637 let config = MD063Config {
1638 enabled: true,
1639 ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
1640 ..Default::default()
1641 };
1642 let rule = MD063HeadingCapitalization::from_config_struct(config);
1643
1644 let content = "# using iPhone on macOS\n";
1645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1646 let result = rule.check(&ctx).unwrap();
1647 assert_eq!(result.len(), 1);
1648 assert!(result[0].message.contains("iPhone"));
1650 assert!(result[0].message.contains("macOS"));
1651 }
1652
1653 #[test]
1654 fn test_preserve_cased_words() {
1655 let rule = create_rule();
1656 let content = "# using GitHub actions\n";
1657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1658 let result = rule.check(&ctx).unwrap();
1659 assert_eq!(result.len(), 1);
1660 assert!(result[0].message.contains("GitHub"));
1662 }
1663
1664 #[test]
1666 fn test_inline_code_preserved() {
1667 let rule = create_rule();
1668 let content = "# using `const` in javascript\n";
1669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1670 let result = rule.check(&ctx).unwrap();
1671 assert_eq!(result.len(), 1);
1672 assert!(result[0].message.contains("`const`"));
1674 assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
1675 }
1676
1677 #[test]
1679 fn test_level_filter() {
1680 let config = MD063Config {
1681 enabled: true,
1682 min_level: 2,
1683 max_level: 4,
1684 ..Default::default()
1685 };
1686 let rule = MD063HeadingCapitalization::from_config_struct(config);
1687
1688 let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
1689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1690 let result = rule.check(&ctx).unwrap();
1691
1692 assert_eq!(result.len(), 2);
1694 assert_eq!(result[0].line, 2); assert_eq!(result[1].line, 3); }
1697
1698 #[test]
1700 fn test_fix_atx_heading() {
1701 let rule = create_rule();
1702 let content = "# hello world\n";
1703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1704 let fixed = rule.fix(&ctx).unwrap();
1705 assert_eq!(fixed, "# Hello World\n");
1706 }
1707
1708 #[test]
1709 fn test_fix_multiple_headings() {
1710 let rule = create_rule();
1711 let content = "# first heading\n\n## second heading\n";
1712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1713 let fixed = rule.fix(&ctx).unwrap();
1714 assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
1715 }
1716
1717 #[test]
1719 fn test_setext_heading() {
1720 let rule = create_rule();
1721 let content = "hello world\n============\n";
1722 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1723 let result = rule.check(&ctx).unwrap();
1724 assert_eq!(result.len(), 1);
1725 assert!(result[0].message.contains("Hello World"));
1726 }
1727
1728 #[test]
1729 fn test_multi_line_setext_heading_is_capitalized_in_place() {
1730 let rule = create_rule();
1734 let content = "hello world\nand more words\n==============\n";
1735 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1736 let fixed = rule.fix(&ctx).unwrap();
1737 assert_eq!(fixed, "Hello World\nand More Words\n==============\n");
1738
1739 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1740 assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent");
1741 }
1742
1743 #[test]
1744 fn test_multi_line_setext_heading_keeps_a_hard_break_backslash() {
1745 let rule = create_rule();
1748 let content = "foo bar\\\nbaz qux\n=======\n";
1749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1750 let fixed = rule.fix(&ctx).unwrap();
1751 assert_eq!(fixed, "Foo Bar\\\nBaz Qux\n=======\n");
1752
1753 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1754 assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent");
1755 }
1756
1757 #[test]
1758 fn test_multi_line_setext_heading_keeps_a_backslash_that_is_no_hard_break() {
1759 let rule = create_rule();
1763 for (content, expected) in [
1764 ("foo `a\\\nb` tail\n===\n", "Foo `a\\\nb` Tail\n===\n"),
1765 ("foo bar\nbaz\\\n===\n", "Foo Bar\nBaz\\\n===\n"),
1766 ] {
1767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1768 let fixed = rule.fix(&ctx).unwrap();
1769 assert_eq!(fixed, expected, "{content:?}");
1770
1771 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1772 assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent: {content:?}");
1773 }
1774 }
1775
1776 #[test]
1777 fn test_multi_line_setext_heading_warning_covers_the_whole_span() {
1778 let rule = create_rule();
1781 let content = "hello world\nand more\n=====\n";
1782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1783 let result = rule.check(&ctx).unwrap();
1784 assert_eq!(result.len(), 1, "got: {result:?}");
1785 assert_eq!(result[0].line, 1);
1786 assert_eq!(result[0].column, 1);
1787 assert_eq!(result[0].end_line, 2);
1788 assert_eq!(result[0].end_column, 9);
1789 }
1790
1791 #[test]
1792 fn test_multi_line_setext_heading_with_a_multi_byte_word_is_idempotent() {
1793 let rule = create_rule();
1797 let content = "`A`\n| à | |\n| --- | --- |\n---";
1798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1799 let fixed = rule.fix(&ctx).unwrap();
1800 assert_eq!(fixed, "`A`\n| À | |\n| --- | --- |\n---");
1801
1802 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1803 assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent");
1804 }
1805
1806 #[test]
1807 fn test_multi_line_setext_heading_whose_words_cannot_be_mapped_back_is_reported_without_a_fix() {
1808 let rule = create_rule();
1813 let content = "see [ the guide ](guide.md) first\nand then more\n=====\n";
1814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1815 let result = rule.check(&ctx).unwrap();
1816 assert_eq!(result.len(), 1, "got: {result:?}");
1817 assert_eq!(result[0].line, 1);
1818 assert_eq!(result[0].end_line, 2);
1819 assert!(
1820 result[0].fix.is_none(),
1821 "no rewrite is offered, got: {:?}",
1822 result[0].fix
1823 );
1824 assert_eq!(rule.fix(&ctx).unwrap(), content, "the heading is left as written");
1825
1826 let content = "see [the guide](guide.md) first\nand then more\n=====\n";
1828 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1829 let result = rule.check(&ctx).unwrap();
1830 assert_eq!(result.len(), 1, "got: {result:?}");
1831 assert!(result[0].fix.is_some(), "the control heading is fixable");
1832 assert_eq!(
1833 rule.fix(&ctx).unwrap(),
1834 "See [The Guide](guide.md) First\nand Then More\n=====\n"
1835 );
1836 }
1837
1838 #[test]
1839 fn test_fix_honours_a_suppression_on_any_line_of_a_multi_line_setext_heading() {
1840 let rule = create_rule();
1844 let content = "<!-- rumdl-disable-next-line MD063 -->\nhello\nworld\n===\n";
1845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1846 assert_eq!(
1847 rule.fix(&ctx).unwrap(),
1848 content,
1849 "the suppressed heading is left as written"
1850 );
1851
1852 let content = "hello\nworld\n===\n";
1854 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1855 assert_eq!(rule.fix(&ctx).unwrap(), "Hello\nWorld\n===\n");
1856 }
1857
1858 #[test]
1860 fn test_custom_id_preserved() {
1861 let rule = create_rule();
1862 let content = "# getting started {#intro}\n";
1863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1864 let result = rule.check(&ctx).unwrap();
1865 assert_eq!(result.len(), 1);
1866 assert!(result[0].message.contains("{#intro}"));
1868 }
1869
1870 #[test]
1872 fn test_skip_obsidian_tags_not_headings() {
1873 let rule = create_rule();
1874
1875 let content = "# H1\n\n#tag\n";
1877 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1878 let result = rule.check(&ctx).unwrap();
1879 assert!(
1880 result.is_empty() || result.iter().all(|w| w.line != 3),
1881 "Obsidian tag #tag should not be treated as a heading: {result:?}"
1882 );
1883 }
1884
1885 #[test]
1886 fn test_skip_invalid_atx_headings_no_space() {
1887 let rule = create_rule();
1888
1889 let content = "#notaheading\n";
1891 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1892 let result = rule.check(&ctx).unwrap();
1893 assert!(
1894 result.is_empty(),
1895 "Invalid ATX heading without space should not be flagged: {result:?}"
1896 );
1897 }
1898
1899 #[test]
1900 fn test_fix_skips_obsidian_tags() {
1901 let rule = create_rule();
1902
1903 let content = "# hello world\n\n#tag\n";
1904 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1905 let fixed = rule.fix(&ctx).unwrap();
1906 assert!(fixed.contains("#tag"), "Fix should not modify Obsidian tag #tag");
1908 assert!(fixed.contains("# Hello World"), "Fix should still fix real headings");
1909 }
1910
1911 #[test]
1912 fn test_preserve_all_caps_acronyms() {
1913 let rule = create_rule();
1914 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1915
1916 let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
1918 assert_eq!(fixed, "# Using API in Production\n");
1919
1920 let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
1922 assert_eq!(fixed, "# API and GPU Integration\n");
1923
1924 let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
1926 assert_eq!(fixed, "# IO Performance Guide\n");
1927
1928 let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
1930 assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
1931 }
1932
1933 #[test]
1934 fn test_preserve_acronyms_in_hyphenated_words() {
1935 let rule = create_rule();
1936 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1937
1938 let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
1940 assert_eq!(fixed, "# API-Driven Architecture\n");
1941
1942 let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
1944 assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
1945 }
1946
1947 #[test]
1948 fn test_single_letters_not_treated_as_acronyms() {
1949 let rule = create_rule();
1950 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1951
1952 let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
1954 assert_eq!(fixed, "# I Am a Heading\n");
1955 }
1956
1957 #[test]
1958 fn test_lowercase_terms_need_ignore_words() {
1959 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1960
1961 let rule = create_rule();
1963 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1964 assert_eq!(fixed, "# Using Npm Packages\n");
1965
1966 let config = MD063Config {
1968 enabled: true,
1969 ignore_words: vec!["npm".to_string()],
1970 ..Default::default()
1971 };
1972 let rule = MD063HeadingCapitalization::from_config_struct(config);
1973 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1974 assert_eq!(fixed, "# Using npm Packages\n");
1975 }
1976
1977 #[test]
1978 fn test_acronyms_with_mixed_case_preserved() {
1979 let rule = create_rule();
1980 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1981
1982 let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
1984 assert_eq!(fixed, "# Using API with GitHub\n");
1985 }
1986
1987 #[test]
1988 fn test_real_world_acronyms() {
1989 let rule = create_rule();
1990 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1991
1992 let content = "# FFI bindings for CPU optimization\n";
1994 let fixed = rule.fix(&ctx(content)).unwrap();
1995 assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
1996
1997 let content = "# DOM manipulation and SSR rendering\n";
1998 let fixed = rule.fix(&ctx(content)).unwrap();
1999 assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
2000
2001 let content = "# CVE security and RNN models\n";
2002 let fixed = rule.fix(&ctx(content)).unwrap();
2003 assert_eq!(fixed, "# CVE Security and RNN Models\n");
2004 }
2005
2006 #[test]
2007 fn test_is_all_caps_acronym() {
2008 let rule = create_rule();
2009
2010 assert!(rule.is_all_caps_acronym("API"));
2012 assert!(rule.is_all_caps_acronym("IO"));
2013 assert!(rule.is_all_caps_acronym("GPU"));
2014 assert!(rule.is_all_caps_acronym("HTTP2")); assert!(!rule.is_all_caps_acronym("A"));
2018 assert!(!rule.is_all_caps_acronym("I"));
2019
2020 assert!(!rule.is_all_caps_acronym("Api"));
2022 assert!(!rule.is_all_caps_acronym("npm"));
2023 assert!(!rule.is_all_caps_acronym("iPhone"));
2024 }
2025
2026 #[test]
2027 fn test_sentence_case_starts_after_a_leading_empty_anchor() {
2028 let config = MD063Config {
2030 enabled: true,
2031 style: HeadingCapStyle::SentenceCase,
2032 ..Default::default()
2033 };
2034 let rule = MD063HeadingCapitalization::from_config_struct(config);
2035
2036 let content = "# <a id=\"top\"></a>the beginning\n";
2037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2038 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2039 assert_eq!(rule.fix(&ctx).unwrap(), "# <a id=\"top\"></a>The beginning\n");
2040
2041 let content = "# <kbd>ctrl</kbd> the key\n";
2043 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2044 assert!(rule.check(&ctx).unwrap().is_empty());
2045
2046 for content in ["# <img src=\"x.png\"> the picture\n", "#  the picture\n"] {
2049 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2050 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
2051 }
2052 }
2053
2054 #[test]
2055 fn test_sentence_case_ignore_words_first_word() {
2056 let config = MD063Config {
2057 enabled: true,
2058 style: HeadingCapStyle::SentenceCase,
2059 ignore_words: vec!["nvim".to_string()],
2060 ..Default::default()
2061 };
2062 let rule = MD063HeadingCapitalization::from_config_struct(config);
2063
2064 let content = "# nvim config\n";
2066 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2067 let result = rule.check(&ctx).unwrap();
2068 assert!(
2069 result.is_empty(),
2070 "nvim in ignore-words should not be flagged. Got: {result:?}"
2071 );
2072
2073 let fixed = rule.fix(&ctx).unwrap();
2075 assert_eq!(fixed, "# nvim config\n");
2076 }
2077
2078 #[test]
2079 fn test_sentence_case_ignore_words_not_first() {
2080 let config = MD063Config {
2081 enabled: true,
2082 style: HeadingCapStyle::SentenceCase,
2083 ignore_words: vec!["nvim".to_string()],
2084 ..Default::default()
2085 };
2086 let rule = MD063HeadingCapitalization::from_config_struct(config);
2087
2088 let content = "# Using nvim editor\n";
2090 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2091 let result = rule.check(&ctx).unwrap();
2092 assert!(
2093 result.is_empty(),
2094 "nvim in ignore-words should be preserved. Got: {result:?}"
2095 );
2096 }
2097
2098 #[test]
2099 fn test_preserve_cased_words_ios() {
2100 let config = MD063Config {
2101 enabled: true,
2102 style: HeadingCapStyle::SentenceCase,
2103 preserve_cased_words: true,
2104 ..Default::default()
2105 };
2106 let rule = MD063HeadingCapitalization::from_config_struct(config);
2107
2108 let content = "## This is iOS\n";
2110 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2111 let result = rule.check(&ctx).unwrap();
2112 assert!(
2113 result.is_empty(),
2114 "iOS should be preserved with preserve-cased-words. Got: {result:?}"
2115 );
2116
2117 let fixed = rule.fix(&ctx).unwrap();
2119 assert_eq!(fixed, "## This is iOS\n");
2120 }
2121
2122 #[test]
2123 fn test_preserve_cased_words_ios_title_case() {
2124 let config = MD063Config {
2125 enabled: true,
2126 style: HeadingCapStyle::TitleCase,
2127 preserve_cased_words: true,
2128 ..Default::default()
2129 };
2130 let rule = MD063HeadingCapitalization::from_config_struct(config);
2131
2132 let content = "# developing for iOS\n";
2134 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2135 let fixed = rule.fix(&ctx).unwrap();
2136 assert_eq!(fixed, "# Developing for iOS\n");
2137 }
2138
2139 #[test]
2140 fn test_has_internal_capitals_ios() {
2141 let rule = create_rule();
2142
2143 assert!(
2145 rule.has_internal_capitals("iOS"),
2146 "iOS has mixed case (lowercase i, uppercase OS)"
2147 );
2148
2149 assert!(rule.has_internal_capitals("iPhone"));
2151 assert!(rule.has_internal_capitals("macOS"));
2152 assert!(rule.has_internal_capitals("GitHub"));
2153 assert!(rule.has_internal_capitals("JavaScript"));
2154 assert!(rule.has_internal_capitals("eBay"));
2155
2156 assert!(!rule.has_internal_capitals("API"));
2158 assert!(!rule.has_internal_capitals("GPU"));
2159
2160 assert!(!rule.has_internal_capitals("npm"));
2162 assert!(!rule.has_internal_capitals("config"));
2163
2164 assert!(!rule.has_internal_capitals("The"));
2166 assert!(!rule.has_internal_capitals("Hello"));
2167 }
2168
2169 #[test]
2170 fn test_lowercase_words_before_trailing_code() {
2171 let config = MD063Config {
2172 enabled: true,
2173 style: HeadingCapStyle::TitleCase,
2174 lowercase_words: vec![
2175 "a".to_string(),
2176 "an".to_string(),
2177 "and".to_string(),
2178 "at".to_string(),
2179 "but".to_string(),
2180 "by".to_string(),
2181 "for".to_string(),
2182 "from".to_string(),
2183 "into".to_string(),
2184 "nor".to_string(),
2185 "on".to_string(),
2186 "onto".to_string(),
2187 "or".to_string(),
2188 "the".to_string(),
2189 "to".to_string(),
2190 "upon".to_string(),
2191 "via".to_string(),
2192 "vs".to_string(),
2193 "with".to_string(),
2194 "without".to_string(),
2195 ],
2196 preserve_cased_words: true,
2197 ..Default::default()
2198 };
2199 let rule = MD063HeadingCapitalization::from_config_struct(config);
2200
2201 let content = "## subtitle with a `app`\n";
2206 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2207 let result = rule.check(&ctx).unwrap();
2208
2209 assert!(!result.is_empty(), "Should flag incorrect capitalization");
2211 let fixed = rule.fix(&ctx).unwrap();
2212 assert!(
2214 fixed.contains("with a `app`"),
2215 "Expected 'with a `app`' but got: {fixed:?}"
2216 );
2217 assert!(
2218 !fixed.contains("with A `app`"),
2219 "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
2220 );
2221 assert!(
2223 fixed.contains("Subtitle with a `app`"),
2224 "Expected 'Subtitle with a `app`' but got: {fixed:?}"
2225 );
2226 }
2227
2228 #[test]
2229 fn test_lowercase_words_preserved_before_trailing_code_variant() {
2230 let config = MD063Config {
2231 enabled: true,
2232 style: HeadingCapStyle::TitleCase,
2233 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2234 ..Default::default()
2235 };
2236 let rule = MD063HeadingCapitalization::from_config_struct(config);
2237
2238 let content = "## Title with the `code`\n";
2240 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2241 let fixed = rule.fix(&ctx).unwrap();
2242 assert!(
2244 fixed.contains("with the `code`"),
2245 "Expected 'with the `code`' but got: {fixed:?}"
2246 );
2247 assert!(
2248 !fixed.contains("with The `code`"),
2249 "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
2250 );
2251 }
2252
2253 #[test]
2254 fn test_last_word_capitalized_when_no_trailing_code() {
2255 let config = MD063Config {
2258 enabled: true,
2259 style: HeadingCapStyle::TitleCase,
2260 lowercase_words: vec!["a".to_string(), "the".to_string()],
2261 ..Default::default()
2262 };
2263 let rule = MD063HeadingCapitalization::from_config_struct(config);
2264
2265 let content = "## title with a word\n";
2268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2269 let fixed = rule.fix(&ctx).unwrap();
2270 assert!(
2272 fixed.contains("With a Word"),
2273 "Expected 'With a Word' but got: {fixed:?}"
2274 );
2275 }
2276
2277 #[test]
2278 fn test_multiple_lowercase_words_before_code() {
2279 let config = MD063Config {
2280 enabled: true,
2281 style: HeadingCapStyle::TitleCase,
2282 lowercase_words: vec![
2283 "a".to_string(),
2284 "the".to_string(),
2285 "with".to_string(),
2286 "for".to_string(),
2287 ],
2288 ..Default::default()
2289 };
2290 let rule = MD063HeadingCapitalization::from_config_struct(config);
2291
2292 let content = "## Guide for the `user`\n";
2294 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2295 let fixed = rule.fix(&ctx).unwrap();
2296 assert!(
2297 fixed.contains("for the `user`"),
2298 "Expected 'for the `user`' but got: {fixed:?}"
2299 );
2300 assert!(
2301 !fixed.contains("For The `user`"),
2302 "Should not capitalize lowercase words before code. Got: {fixed:?}"
2303 );
2304 }
2305
2306 #[test]
2307 fn test_code_in_middle_normal_rules_apply() {
2308 let config = MD063Config {
2309 enabled: true,
2310 style: HeadingCapStyle::TitleCase,
2311 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2312 ..Default::default()
2313 };
2314 let rule = MD063HeadingCapitalization::from_config_struct(config);
2315
2316 let content = "## Using `const` for the code\n";
2318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2319 let fixed = rule.fix(&ctx).unwrap();
2320 assert!(
2322 fixed.contains("for the Code"),
2323 "Expected 'for the Code' but got: {fixed:?}"
2324 );
2325 }
2326
2327 #[test]
2328 fn test_link_at_end_same_as_code() {
2329 let config = MD063Config {
2330 enabled: true,
2331 style: HeadingCapStyle::TitleCase,
2332 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2333 ..Default::default()
2334 };
2335 let rule = MD063HeadingCapitalization::from_config_struct(config);
2336
2337 let content = "## Guide for the [link](./page.md)\n";
2339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2340 let fixed = rule.fix(&ctx).unwrap();
2341 assert!(
2343 fixed.contains("for the [Link]"),
2344 "Expected 'for the [Link]' but got: {fixed:?}"
2345 );
2346 assert!(
2347 !fixed.contains("for The [Link]"),
2348 "Should not capitalize 'the' before link. Got: {fixed:?}"
2349 );
2350 }
2351
2352 #[test]
2353 fn test_multiple_code_segments() {
2354 let config = MD063Config {
2355 enabled: true,
2356 style: HeadingCapStyle::TitleCase,
2357 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2358 ..Default::default()
2359 };
2360 let rule = MD063HeadingCapitalization::from_config_struct(config);
2361
2362 let content = "## Using `const` with a `variable`\n";
2364 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2365 let fixed = rule.fix(&ctx).unwrap();
2366 assert!(
2368 fixed.contains("with a `variable`"),
2369 "Expected 'with a `variable`' but got: {fixed:?}"
2370 );
2371 assert!(
2372 !fixed.contains("with A `variable`"),
2373 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2374 );
2375 }
2376
2377 #[test]
2378 fn test_code_and_link_combination() {
2379 let config = MD063Config {
2380 enabled: true,
2381 style: HeadingCapStyle::TitleCase,
2382 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2383 ..Default::default()
2384 };
2385 let rule = MD063HeadingCapitalization::from_config_struct(config);
2386
2387 let content = "## Guide for the `code` [link](./page.md)\n";
2389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2390 let fixed = rule.fix(&ctx).unwrap();
2391 assert!(
2393 fixed.contains("for the `code`"),
2394 "Expected 'for the `code`' but got: {fixed:?}"
2395 );
2396 }
2397
2398 #[test]
2399 fn test_text_after_code_capitalizes_last() {
2400 let config = MD063Config {
2401 enabled: true,
2402 style: HeadingCapStyle::TitleCase,
2403 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2404 ..Default::default()
2405 };
2406 let rule = MD063HeadingCapitalization::from_config_struct(config);
2407
2408 let content = "## Using `const` for the code\n";
2410 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2411 let fixed = rule.fix(&ctx).unwrap();
2412 assert!(
2414 fixed.contains("for the Code"),
2415 "Expected 'for the Code' but got: {fixed:?}"
2416 );
2417 }
2418
2419 #[test]
2420 fn test_preserve_cased_words_with_trailing_code() {
2421 let config = MD063Config {
2422 enabled: true,
2423 style: HeadingCapStyle::TitleCase,
2424 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2425 preserve_cased_words: true,
2426 ..Default::default()
2427 };
2428 let rule = MD063HeadingCapitalization::from_config_struct(config);
2429
2430 let content = "## Guide for iOS `app`\n";
2432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2433 let fixed = rule.fix(&ctx).unwrap();
2434 assert!(
2436 fixed.contains("for iOS `app`"),
2437 "Expected 'for iOS `app`' but got: {fixed:?}"
2438 );
2439 assert!(
2440 !fixed.contains("For iOS `app`"),
2441 "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
2442 );
2443 }
2444
2445 #[test]
2446 fn test_ignore_words_with_trailing_code() {
2447 let config = MD063Config {
2448 enabled: true,
2449 style: HeadingCapStyle::TitleCase,
2450 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2451 ignore_words: vec!["npm".to_string()],
2452 ..Default::default()
2453 };
2454 let rule = MD063HeadingCapitalization::from_config_struct(config);
2455
2456 let content = "## Using npm with a `script`\n";
2458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2459 let fixed = rule.fix(&ctx).unwrap();
2460 assert!(
2462 fixed.contains("npm with a `script`"),
2463 "Expected 'npm with a `script`' but got: {fixed:?}"
2464 );
2465 assert!(
2466 !fixed.contains("with A `script`"),
2467 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2468 );
2469 }
2470
2471 #[test]
2472 fn test_empty_text_segment_edge_case() {
2473 let config = MD063Config {
2474 enabled: true,
2475 style: HeadingCapStyle::TitleCase,
2476 lowercase_words: vec!["a".to_string(), "with".to_string()],
2477 ..Default::default()
2478 };
2479 let rule = MD063HeadingCapitalization::from_config_struct(config);
2480
2481 let content = "## `start` with a `end`\n";
2483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2484 let fixed = rule.fix(&ctx).unwrap();
2485 assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
2488 assert!(
2489 !fixed.contains("A `end`"),
2490 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2491 );
2492 }
2493
2494 #[test]
2495 fn test_sentence_case_with_trailing_code() {
2496 let config = MD063Config {
2497 enabled: true,
2498 style: HeadingCapStyle::SentenceCase,
2499 lowercase_words: vec!["a".to_string(), "the".to_string()],
2500 ..Default::default()
2501 };
2502 let rule = MD063HeadingCapitalization::from_config_struct(config);
2503
2504 let content = "## guide for the `user`\n";
2506 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2507 let fixed = rule.fix(&ctx).unwrap();
2508 assert!(
2510 fixed.contains("Guide for the `user`"),
2511 "Expected 'Guide for the `user`' but got: {fixed:?}"
2512 );
2513 }
2514
2515 #[test]
2516 fn test_hyphenated_word_before_code() {
2517 let config = MD063Config {
2518 enabled: true,
2519 style: HeadingCapStyle::TitleCase,
2520 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2521 ..Default::default()
2522 };
2523 let rule = MD063HeadingCapitalization::from_config_struct(config);
2524
2525 let content = "## Self-contained with a `feature`\n";
2527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2528 let fixed = rule.fix(&ctx).unwrap();
2529 assert!(
2531 fixed.contains("with a `feature`"),
2532 "Expected 'with a `feature`' but got: {fixed:?}"
2533 );
2534 }
2535
2536 #[test]
2541 fn test_sentence_case_code_at_start_basic() {
2542 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2544 let content = "# `rumdl` is a linter\n";
2545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2546 let result = rule.check(&ctx).unwrap();
2547 assert!(
2549 result.is_empty(),
2550 "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
2551 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2552 );
2553 }
2554
2555 #[test]
2556 fn test_sentence_case_code_at_start_incorrect_capitalization() {
2557 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2559 let content = "# `rumdl` Is a Linter\n";
2560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2561 let result = rule.check(&ctx).unwrap();
2562 assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
2564 assert!(
2565 result[0].message.contains("`rumdl` is a linter"),
2566 "Should suggest lowercase after code. Got: {:?}",
2567 result[0].message
2568 );
2569 }
2570
2571 #[test]
2572 fn test_sentence_case_code_at_start_fix() {
2573 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2574 let content = "# `rumdl` Is A Linter\n";
2575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2576 let fixed = rule.fix(&ctx).unwrap();
2577 assert!(
2578 fixed.contains("# `rumdl` is a linter"),
2579 "Should fix to lowercase after code. Got: {fixed:?}"
2580 );
2581 }
2582
2583 #[test]
2584 fn test_sentence_case_text_at_start_still_capitalizes() {
2585 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2587 let content = "# the quick brown fox\n";
2588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2589 let result = rule.check(&ctx).unwrap();
2590 assert_eq!(result.len(), 1);
2591 assert!(
2592 result[0].message.contains("The quick brown fox"),
2593 "Text-first heading should capitalize first word. Got: {:?}",
2594 result[0].message
2595 );
2596 }
2597
2598 #[test]
2599 fn test_sentence_case_link_at_start() {
2600 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2602 let content = "# [api](api.md) reference guide\n";
2603 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2604 let result = rule.check(&ctx).unwrap();
2605 assert_eq!(result.len(), 1);
2606 assert!(result[0].message.contains("[Api](api.md) reference guide"));
2607 }
2608
2609 #[test]
2610 fn test_sentence_case_link_preserves_acronyms() {
2611 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2613 let content = "# [API](api.md) Reference Guide\n";
2614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2615 let result = rule.check(&ctx).unwrap();
2616 assert_eq!(result.len(), 1);
2617 assert!(
2619 result[0].message.contains("[API](api.md) reference guide"),
2620 "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
2621 result[0].message
2622 );
2623 }
2624
2625 #[test]
2626 fn test_sentence_case_link_preserves_brand_names() {
2627 let config = MD063Config {
2629 enabled: true,
2630 style: HeadingCapStyle::SentenceCase,
2631 preserve_cased_words: true,
2632 ..Default::default()
2633 };
2634 let rule = MD063HeadingCapitalization::from_config_struct(config);
2635 let content = "# [iPhone](iphone.md) Features Guide\n";
2636 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2637 let result = rule.check(&ctx).unwrap();
2638 assert_eq!(result.len(), 1);
2639 assert!(
2641 result[0].message.contains("[iPhone](iphone.md) features guide"),
2642 "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
2643 result[0].message
2644 );
2645 }
2646
2647 #[test]
2648 fn test_sentence_case_link_lowercases_regular_words() {
2649 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2651 let content = "# [Documentation](docs.md) Reference\n";
2652 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2653 let result = rule.check(&ctx).unwrap();
2654 assert_eq!(result.len(), 1);
2655 assert!(
2656 result[0].message.contains("[Documentation](docs.md) reference"),
2657 "Should preserve the sentence-initial capital. Got: {:?}",
2658 result[0].message
2659 );
2660 }
2661
2662 #[test]
2663 fn test_sentence_case_opening_link_label_is_sentence_initial() {
2664 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2666 let content = "# [Foo bar](https://example.com)\n";
2667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2668
2669 assert!(rule.check(&ctx).unwrap().is_empty());
2670 assert_eq!(rule.fix(&ctx).unwrap(), content);
2671 }
2672
2673 #[test]
2674 fn test_sentence_case_link_at_start_correct_already() {
2675 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2677 let content = "# [API](api.md) reference guide\n";
2678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2679 let result = rule.check(&ctx).unwrap();
2680 assert!(
2681 result.is_empty(),
2682 "Correctly cased heading with link should not be flagged. Got: {:?}",
2683 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2684 );
2685 }
2686
2687 #[test]
2688 fn test_sentence_case_link_github_preserved() {
2689 let config = MD063Config {
2691 enabled: true,
2692 style: HeadingCapStyle::SentenceCase,
2693 preserve_cased_words: true,
2694 ..Default::default()
2695 };
2696 let rule = MD063HeadingCapitalization::from_config_struct(config);
2697 let content = "# [GitHub](gh.md) Repository Setup\n";
2698 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2699 let result = rule.check(&ctx).unwrap();
2700 assert_eq!(result.len(), 1);
2701 assert!(
2702 result[0].message.contains("[GitHub](gh.md) repository setup"),
2703 "Should preserve 'GitHub'. Got: {:?}",
2704 result[0].message
2705 );
2706 }
2707
2708 #[test]
2709 fn test_sentence_case_multiple_code_spans() {
2710 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2711 let content = "# `foo` and `bar` are methods\n";
2712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2713 let result = rule.check(&ctx).unwrap();
2714 assert!(
2716 result.is_empty(),
2717 "Should not capitalize words between/after code spans. Got: {:?}",
2718 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2719 );
2720 }
2721
2722 #[test]
2723 fn test_sentence_case_code_only_heading() {
2724 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2726 let content = "# `rumdl`\n";
2727 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2728 let result = rule.check(&ctx).unwrap();
2729 assert!(
2730 result.is_empty(),
2731 "Code-only heading should be fine. Got: {:?}",
2732 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2733 );
2734 }
2735
2736 #[test]
2737 fn test_sentence_case_code_at_end() {
2738 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2740 let content = "# install the `rumdl` tool\n";
2741 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2742 let result = rule.check(&ctx).unwrap();
2743 assert_eq!(result.len(), 1);
2745 assert!(
2746 result[0].message.contains("Install the `rumdl` tool"),
2747 "First word should still be capitalized when text comes first. Got: {:?}",
2748 result[0].message
2749 );
2750 }
2751
2752 #[test]
2753 fn test_sentence_case_code_in_middle() {
2754 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2756 let content = "# using the `rumdl` linter for markdown\n";
2757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2758 let result = rule.check(&ctx).unwrap();
2759 assert_eq!(result.len(), 1);
2761 assert!(
2762 result[0].message.contains("Using the `rumdl` linter for markdown"),
2763 "First word should be capitalized. Got: {:?}",
2764 result[0].message
2765 );
2766 }
2767
2768 #[test]
2769 fn test_sentence_case_preserved_word_after_code() {
2770 let config = MD063Config {
2772 enabled: true,
2773 style: HeadingCapStyle::SentenceCase,
2774 preserve_cased_words: true,
2775 ..Default::default()
2776 };
2777 let rule = MD063HeadingCapitalization::from_config_struct(config);
2778 let content = "# `swift` iPhone development\n";
2779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2780 let result = rule.check(&ctx).unwrap();
2781 assert!(
2783 result.is_empty(),
2784 "Preserved words after code should stay. Got: {:?}",
2785 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2786 );
2787 }
2788
2789 #[test]
2790 fn test_title_case_code_at_start_still_capitalizes() {
2791 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2793 let content = "# `api` quick start guide\n";
2794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2795 let result = rule.check(&ctx).unwrap();
2796 assert_eq!(result.len(), 1);
2798 assert!(
2799 result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
2800 "Title case should capitalize major words after code. Got: {:?}",
2801 result[0].message
2802 );
2803 }
2804
2805 #[test]
2808 fn test_sentence_case_html_tag_at_start() {
2809 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2811 let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
2812 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2813 let result = rule.check(&ctx).unwrap();
2814 assert_eq!(result.len(), 1);
2816 let fixed = rule.fix(&ctx).unwrap();
2817 assert_eq!(
2818 fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
2819 "Text after HTML at start should be lowercase"
2820 );
2821 }
2822
2823 #[test]
2824 fn test_sentence_case_html_tag_preserves_content() {
2825 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2827 let content = "# The <abbr>API</abbr> documentation guide\n";
2828 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2829 let result = rule.check(&ctx).unwrap();
2830 assert!(
2832 result.is_empty(),
2833 "HTML tag content should be preserved. Got: {:?}",
2834 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2835 );
2836 }
2837
2838 #[test]
2839 fn test_sentence_case_html_tag_at_start_with_acronym() {
2840 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2842 let content = "# <abbr>API</abbr> Documentation Guide\n";
2843 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2844 let result = rule.check(&ctx).unwrap();
2845 assert_eq!(result.len(), 1);
2846 let fixed = rule.fix(&ctx).unwrap();
2847 assert_eq!(
2848 fixed, "# <abbr>API</abbr> documentation guide\n",
2849 "Text after HTML at start should be lowercase, HTML content preserved"
2850 );
2851 }
2852
2853 #[test]
2854 fn test_sentence_case_html_tag_in_middle() {
2855 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2857 let content = "# using the <code>config</code> File\n";
2858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2859 let result = rule.check(&ctx).unwrap();
2860 assert_eq!(result.len(), 1);
2861 let fixed = rule.fix(&ctx).unwrap();
2862 assert_eq!(
2863 fixed, "# Using the <code>config</code> file\n",
2864 "First word capitalized, HTML preserved, rest lowercase"
2865 );
2866 }
2867
2868 #[test]
2869 fn test_html_tag_strong_emphasis() {
2870 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2872 let content = "# The <strong>Bold</strong> Way\n";
2873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2874 let result = rule.check(&ctx).unwrap();
2875 assert_eq!(result.len(), 1);
2876 let fixed = rule.fix(&ctx).unwrap();
2877 assert_eq!(
2878 fixed, "# The <strong>Bold</strong> way\n",
2879 "<strong> tag content should be preserved"
2880 );
2881 }
2882
2883 #[test]
2884 fn test_html_tag_with_attributes() {
2885 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2887 let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
2888 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2889 let result = rule.check(&ctx).unwrap();
2890 assert_eq!(result.len(), 1);
2891 let fixed = rule.fix(&ctx).unwrap();
2892 assert_eq!(
2893 fixed, "# <span class=\"highlight\">Important</span> notice here\n",
2894 "HTML tag with attributes should be preserved"
2895 );
2896 }
2897
2898 #[test]
2899 fn test_multiple_html_tags() {
2900 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2902 let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
2903 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2904 let result = rule.check(&ctx).unwrap();
2905 assert_eq!(result.len(), 1);
2906 let fixed = rule.fix(&ctx).unwrap();
2907 assert_eq!(
2908 fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
2909 "Multiple HTML tags should all be preserved"
2910 );
2911 }
2912
2913 #[test]
2914 fn test_html_and_code_mixed() {
2915 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2917 let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
2918 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2919 let result = rule.check(&ctx).unwrap();
2920 assert_eq!(result.len(), 1);
2921 let fixed = rule.fix(&ctx).unwrap();
2922 assert_eq!(
2923 fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
2924 "HTML and code should both be preserved"
2925 );
2926 }
2927
2928 #[test]
2929 fn test_self_closing_html_tag() {
2930 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2932 let content = "# Line one<br/>Line Two Here\n";
2933 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2934 let result = rule.check(&ctx).unwrap();
2935 assert_eq!(result.len(), 1);
2936 let fixed = rule.fix(&ctx).unwrap();
2937 assert_eq!(
2938 fixed, "# Line one<br/>line two here\n",
2939 "Self-closing HTML tags should be preserved"
2940 );
2941 }
2942
2943 #[test]
2944 fn test_title_case_with_html_tags() {
2945 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2947 let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
2948 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2949 let result = rule.check(&ctx).unwrap();
2950 assert_eq!(result.len(), 1);
2951 let fixed = rule.fix(&ctx).unwrap();
2952 assert!(
2954 fixed.contains("<kbd>ctrl</kbd>"),
2955 "HTML tag content should be preserved in title case. Got: {fixed}"
2956 );
2957 assert!(
2958 fixed.starts_with("# The ") || fixed.starts_with("# the "),
2959 "Title case should work with HTML. Got: {fixed}"
2960 );
2961 }
2962
2963 #[test]
2966 fn test_sentence_case_preserves_caret_notation() {
2967 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2969 let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
2970 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2971 let result = rule.check(&ctx).unwrap();
2972 assert!(
2974 result.is_empty(),
2975 "Caret notation should be preserved. Got: {:?}",
2976 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2977 );
2978 }
2979
2980 #[test]
2981 fn test_sentence_case_caret_notation_various() {
2982 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2984
2985 let content = "## Press ^C to cancel\n";
2987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2988 let result = rule.check(&ctx).unwrap();
2989 assert!(
2990 result.is_empty(),
2991 "^C should be preserved. Got: {:?}",
2992 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2993 );
2994
2995 let content = "## Use ^Z for background\n";
2997 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2998 let result = rule.check(&ctx).unwrap();
2999 assert!(
3000 result.is_empty(),
3001 "^Z should be preserved. Got: {:?}",
3002 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3003 );
3004
3005 let content = "## Press ^[ for escape\n";
3007 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3008 let result = rule.check(&ctx).unwrap();
3009 assert!(
3010 result.is_empty(),
3011 "^[ should be preserved. Got: {:?}",
3012 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3013 );
3014 }
3015
3016 #[test]
3017 fn test_caret_notation_detection() {
3018 let rule = create_rule();
3019
3020 assert!(rule.is_caret_notation("^A"));
3022 assert!(rule.is_caret_notation("^Z"));
3023 assert!(rule.is_caret_notation("^C"));
3024 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")); }
3036
3037 fn create_sentence_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
3044 let config = MD063Config {
3045 enabled: true,
3046 style: HeadingCapStyle::SentenceCase,
3047 ..Default::default()
3048 };
3049 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3050 rule.proper_names = names;
3051 rule
3052 }
3053
3054 #[test]
3055 fn test_sentence_case_preserves_single_word_proper_name() {
3056 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
3057 let content = "# installing javascript\n";
3059 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3060 let result = rule.check(&ctx).unwrap();
3061 assert_eq!(result.len(), 1, "Should flag the heading");
3062 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3063 assert!(
3064 fix_text.contains("JavaScript"),
3065 "Fix should preserve proper name 'JavaScript', got: {fix_text:?}"
3066 );
3067 assert!(
3068 !fix_text.contains("javascript"),
3069 "Fix should not have lowercase 'javascript', got: {fix_text:?}"
3070 );
3071 }
3072
3073 #[test]
3074 fn test_sentence_case_preserves_multi_word_proper_name() {
3075 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3076 let content = "# using good application features\n";
3078 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3079 let result = rule.check(&ctx).unwrap();
3080 assert_eq!(result.len(), 1, "Should flag the heading");
3081 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3082 assert!(
3083 fix_text.contains("Good Application"),
3084 "Fix should preserve 'Good Application' as a phrase, got: {fix_text:?}"
3085 );
3086 }
3087
3088 #[test]
3089 fn test_sentence_case_proper_name_at_start_of_heading() {
3090 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3091 let content = "# good application overview\n";
3093 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3094 let result = rule.check(&ctx).unwrap();
3095 assert_eq!(result.len(), 1, "Should flag the heading");
3096 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3097 assert!(
3098 fix_text.contains("Good Application"),
3099 "Fix should produce 'Good Application' at start of heading, got: {fix_text:?}"
3100 );
3101 assert!(
3102 fix_text.contains("overview"),
3103 "Non-proper-name word 'overview' should be lowercase, got: {fix_text:?}"
3104 );
3105 }
3106
3107 #[test]
3108 fn test_sentence_case_with_proper_names_no_oscillation() {
3109 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3112
3113 let content = "# installing good application on your system\n";
3115 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3116 let result = rule.check(&ctx).unwrap();
3117 assert_eq!(result.len(), 1);
3118 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
3119
3120 assert!(
3122 fixed_heading.contains("Good Application"),
3123 "After fix, proper name must be preserved: {fixed_heading:?}"
3124 );
3125
3126 let fixed_line = format!("{fixed_heading}\n");
3128 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
3129 let result2 = rule.check(&ctx2).unwrap();
3130 assert!(
3131 result2.is_empty(),
3132 "After one fix, heading must already satisfy both MD063 and MD044 - no oscillation. \
3133 Second pass warnings: {result2:?}"
3134 );
3135 }
3136
3137 #[test]
3138 fn test_sentence_case_proper_names_already_correct() {
3139 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3140 let content = "# Installing Good Application\n";
3142 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3143 let result = rule.check(&ctx).unwrap();
3144 assert!(
3145 result.is_empty(),
3146 "Correct sentence-case heading with proper name should not be flagged, got: {result:?}"
3147 );
3148 }
3149
3150 #[test]
3151 fn test_sentence_case_multiple_proper_names_in_heading() {
3152 let rule = create_sentence_case_rule_with_proper_names(vec!["TypeScript".to_string(), "React".to_string()]);
3153 let content = "# using typescript with react\n";
3154 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3155 let result = rule.check(&ctx).unwrap();
3156 assert_eq!(result.len(), 1);
3157 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3158 assert!(
3159 fix_text.contains("TypeScript"),
3160 "Fix should preserve 'TypeScript', got: {fix_text:?}"
3161 );
3162 assert!(
3163 fix_text.contains("React"),
3164 "Fix should preserve 'React', got: {fix_text:?}"
3165 );
3166 }
3167
3168 #[test]
3169 fn test_sentence_case_unicode_casefold_expansion_before_proper_name() {
3170 let rule = create_sentence_case_rule_with_proper_names(vec!["Österreich".to_string()]);
3173 let content = "# İ österreich guide\n";
3174 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3175
3176 let result = rule.check(&ctx).unwrap();
3178 assert_eq!(result.len(), 1, "Should flag heading for canonical proper-name casing");
3179 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3180 assert!(
3181 fix_text.contains("Österreich"),
3182 "Fix should preserve canonical 'Österreich', got: {fix_text:?}"
3183 );
3184 }
3185
3186 #[test]
3187 fn test_sentence_case_preserves_trailing_punctuation_on_proper_name() {
3188 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
3189 let content = "# using javascript, today\n";
3190 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3191 let result = rule.check(&ctx).unwrap();
3192 assert_eq!(result.len(), 1, "Should flag heading");
3193 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3194 assert!(
3195 fix_text.contains("JavaScript,"),
3196 "Fix should preserve trailing punctuation, got: {fix_text:?}"
3197 );
3198 }
3199
3200 fn create_title_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
3207 let config = MD063Config {
3208 enabled: true,
3209 style: HeadingCapStyle::TitleCase,
3210 ..Default::default()
3211 };
3212 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3213 rule.proper_names = names;
3214 rule
3215 }
3216
3217 #[test]
3218 fn test_title_case_preserves_proper_name_with_lowercase_article() {
3219 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
3223 let content = "# listening to the rolling stones today\n";
3224 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3225 let result = rule.check(&ctx).unwrap();
3226 assert_eq!(result.len(), 1, "Should flag the heading");
3227 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3228 assert!(
3229 fix_text.contains("The Rolling Stones"),
3230 "Fix should preserve proper name 'The Rolling Stones', got: {fix_text:?}"
3231 );
3232 }
3233
3234 #[test]
3235 fn test_title_case_proper_name_no_oscillation() {
3236 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
3238 let content = "# listening to the rolling stones today\n";
3239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3240 let result = rule.check(&ctx).unwrap();
3241 assert_eq!(result.len(), 1);
3242 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
3243
3244 let fixed_line = format!("{fixed_heading}\n");
3245 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
3246 let result2 = rule.check(&ctx2).unwrap();
3247 assert!(
3248 result2.is_empty(),
3249 "After one title-case fix, heading must already satisfy both rules. \
3250 Second pass warnings: {result2:?}"
3251 );
3252 }
3253
3254 #[test]
3255 fn test_title_case_unicode_casefold_expansion_before_proper_name() {
3256 let rule = create_title_case_rule_with_proper_names(vec!["Österreich".to_string()]);
3257 let content = "# İ österreich guide\n";
3258 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3259 let result = rule.check(&ctx).unwrap();
3260 assert_eq!(result.len(), 1, "Should flag the heading");
3261 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3262 assert!(
3263 fix_text.contains("Österreich"),
3264 "Fix should preserve canonical proper-name casing, got: {fix_text:?}"
3265 );
3266 }
3267
3268 #[test]
3274 fn test_from_config_loads_md044_names_into_md063() {
3275 use crate::config::{Config, RuleConfig};
3276 use crate::rule::Rule;
3277 use std::collections::BTreeMap;
3278
3279 let mut config = Config::default();
3280
3281 let mut md063_values = BTreeMap::new();
3283 md063_values.insert("style".to_string(), toml::Value::String("sentence_case".to_string()));
3284 md063_values.insert("enabled".to_string(), toml::Value::Boolean(true));
3285 config.rules.insert(
3286 "MD063".to_string(),
3287 RuleConfig {
3288 values: md063_values,
3289 severity: None,
3290 },
3291 );
3292
3293 let mut md044_values = BTreeMap::new();
3295 md044_values.insert(
3296 "names".to_string(),
3297 toml::Value::Array(vec![toml::Value::String("Good Application".to_string())]),
3298 );
3299 config.rules.insert(
3300 "MD044".to_string(),
3301 RuleConfig {
3302 values: md044_values,
3303 severity: None,
3304 },
3305 );
3306
3307 let rule = MD063HeadingCapitalization::from_config(&config);
3309
3310 let content = "# using good application features\n";
3312 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3313 let result = rule.check(&ctx).unwrap();
3314 assert_eq!(result.len(), 1, "Should flag the heading");
3315 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3316 assert!(
3317 fix_text.contains("Good Application"),
3318 "from_config should wire MD044 names into MD063; fix should preserve \
3319 'Good Application', got: {fix_text:?}"
3320 );
3321 }
3322
3323 #[test]
3324 fn test_title_case_short_word_not_confused_with_substring() {
3325 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3329
3330 let content = "# in the insert\n";
3333 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3334 let result = rule.check(&ctx).unwrap();
3335 assert_eq!(result.len(), 1, "Should flag the heading");
3336 let fix = result[0].fix.as_ref().expect("Fix should be present");
3337 assert!(
3339 fix.replacement.contains("In the Insert"),
3340 "Expected 'In the Insert', got: {:?}",
3341 fix.replacement
3342 );
3343 }
3344
3345 #[test]
3346 fn test_title_case_or_not_confused_with_orchestra() {
3347 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3348
3349 let content = "# or the orchestra\n";
3352 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3353 let result = rule.check(&ctx).unwrap();
3354 assert_eq!(result.len(), 1, "Should flag the heading");
3355 let fix = result[0].fix.as_ref().expect("Fix should be present");
3356 assert!(
3358 fix.replacement.contains("Or the Orchestra"),
3359 "Expected 'Or the Orchestra', got: {:?}",
3360 fix.replacement
3361 );
3362 }
3363
3364 #[test]
3365 fn test_all_caps_preserves_all_words() {
3366 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
3367
3368 let content = "# in the insert\n";
3369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3370 let result = rule.check(&ctx).unwrap();
3371 assert_eq!(result.len(), 1, "Should flag the heading");
3372 let fix = result[0].fix.as_ref().expect("Fix should be present");
3373 assert!(
3374 fix.replacement.contains("IN THE INSERT"),
3375 "All caps should uppercase all words, got: {:?}",
3376 fix.replacement
3377 );
3378 }
3379
3380 #[test]
3382 fn test_title_case_numbered_prefix_lowercase_word() {
3383 let rule = create_rule();
3385 let content = "## 1. To Be a Thing\n";
3386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3387 let result = rule.check(&ctx).unwrap();
3388 assert!(
3389 result.is_empty(),
3390 "Should not flag '## 1. To Be a Thing', got: {result:?}"
3391 );
3392
3393 let content_lower = "## 1. to be a thing\n";
3394 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3395 let result2 = rule.check(&ctx2).unwrap();
3396 assert!(!result2.is_empty(), "Should flag '## 1. to be a thing'");
3397 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3398 assert!(
3399 fix.replacement.contains("1. To Be a Thing"),
3400 "Fix should capitalize 'To', got: {:?}",
3401 fix.replacement
3402 );
3403 }
3404
3405 #[test]
3406 fn test_title_case_numbered_prefix_article() {
3407 let rule = create_rule();
3409 let content = "## 2. A Guide to the Galaxy\n";
3410 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3411 let result = rule.check(&ctx).unwrap();
3412 assert!(
3413 result.is_empty(),
3414 "Should not flag '## 2. A Guide to the Galaxy', got: {result:?}"
3415 );
3416
3417 let content_lower = "## 2. a guide to the galaxy\n";
3418 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3419 let result2 = rule.check(&ctx2).unwrap();
3420 assert!(!result2.is_empty(), "Should flag '## 2. a guide to the galaxy'");
3421 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3422 assert!(
3423 fix.replacement.contains("2. A Guide to the Galaxy"),
3424 "Fix should capitalize 'A', got: {:?}",
3425 fix.replacement
3426 );
3427 }
3428
3429 #[test]
3430 fn test_title_case_mid_sentence_period_word() {
3431 let rule = create_rule();
3433 let content = "## Step 1. Introduction to the Problem\n";
3434 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3435 let result = rule.check(&ctx).unwrap();
3436 assert!(
3437 result.is_empty(),
3438 "Should not flag '## Step 1. Introduction to the Problem', got: {result:?}"
3439 );
3440
3441 let content_lower = "## Step 1. introduction to the problem\n";
3442 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3443 let result2 = rule.check(&ctx2).unwrap();
3444 assert!(
3445 !result2.is_empty(),
3446 "Should flag '## Step 1. introduction to the problem'"
3447 );
3448 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3449 assert!(
3450 fix.replacement.contains("Step 1. Introduction to the Problem"),
3451 "Fix should capitalize 'Introduction', got: {:?}",
3452 fix.replacement
3453 );
3454 }
3455
3456 #[test]
3457 fn test_title_case_numbered_prefix_in_link_text() {
3458 let config = MD063Config {
3461 enabled: true,
3462 style: HeadingCapStyle::TitleCase,
3463 ..Default::default()
3464 };
3465 let rule = MD063HeadingCapitalization::from_config_struct(config);
3466
3467 let content = "## [1. To Be a Thing](https://example.com)\n";
3469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3470 let result = rule.check(&ctx).unwrap();
3471 assert!(
3472 result.is_empty(),
3473 "Should not flag '## [1. To Be a Thing](url)', got: {result:?}"
3474 );
3475
3476 let content_lower = "## [1. to be a thing](https://example.com)\n";
3478 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3479 let result2 = rule.check(&ctx2).unwrap();
3480 assert!(!result2.is_empty(), "Should flag '## [1. to be a thing](url)'");
3481 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3482 assert!(
3483 fix.replacement.contains("1. To Be a Thing"),
3484 "Fix should capitalize 'To' in link text, got: {:?}",
3485 fix.replacement
3486 );
3487 }
3488
3489 #[test]
3494 fn test_is_numeric_ordinal_recognises_canonical_forms() {
3495 for word in &[
3496 "1st", "2nd", "3rd", "4th", "5th", "11th", "21st", "22nd", "23rd", "100th", "1ST", "5Th", "21St", "21sT",
3497 ] {
3498 assert!(
3499 MD063HeadingCapitalization::is_numeric_ordinal(word),
3500 "expected `{word}` to be detected as a numeric ordinal"
3501 );
3502 }
3503 }
3504
3505 #[test]
3506 fn test_is_numeric_ordinal_rejects_non_ordinals() {
3507 for word in &[
3512 "first", "1stop", "ist", "5", "th", "abc", "4G", "4K", "30s", "100k", "5x", "1.5", "iPhone6S",
3513 ] {
3514 assert!(
3515 !MD063HeadingCapitalization::is_numeric_ordinal(word),
3516 "expected `{word}` NOT to be detected as a numeric ordinal"
3517 );
3518 }
3519 }
3520
3521 #[test]
3522 fn test_is_numeric_ordinal_strips_trailing_punctuation() {
3523 for word in &["5th.", "1st,", "21st!", "3rd:", "4th)", "5th's"] {
3524 assert!(
3525 MD063HeadingCapitalization::is_numeric_ordinal(word),
3526 "expected `{word}` to be detected as a numeric ordinal (with punctuation)"
3527 );
3528 }
3529 }
3530
3531 #[test]
3532 fn test_is_numeric_ordinal_ignores_wrapping_punctuation() {
3533 for word in &[
3538 "(2nd", "[2nd", "\"2nd", "'2nd", "*2nd", "(21st)", "\"3rd\"", "**5th**", "_1st_",
3539 ] {
3540 assert!(
3541 MD063HeadingCapitalization::is_numeric_ordinal(word),
3542 "expected `{word}` to be detected as a numeric ordinal"
3543 );
3544 }
3545
3546 for word in &["2-nd", "2 nd", "(", "\"\"", "()", "(nd", "(2"] {
3550 assert!(
3551 !MD063HeadingCapitalization::is_numeric_ordinal(word),
3552 "expected `{word}` NOT to be detected as a numeric ordinal"
3553 );
3554 }
3555 }
3556
3557 #[test]
3558 fn test_ordinal_wrapped_in_punctuation_survives_a_fix() {
3559 for (style, content) in [
3562 (HeadingCapStyle::SentenceCase, "# The second (2nd) attempt\n"),
3563 (HeadingCapStyle::SentenceCase, "# Ranked \"3rd\" overall\n"),
3564 (HeadingCapStyle::SentenceCase, "# Plain 2nd place\n"),
3565 (HeadingCapStyle::TitleCase, "# The Second (2nd) Attempt\n"),
3566 (HeadingCapStyle::TitleCase, "# Ranked \"3rd\" Overall\n"),
3567 ] {
3568 let rule = create_rule_with_style(style);
3569 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3570 let result = rule.check(&ctx).unwrap();
3571 assert!(
3572 result.is_empty(),
3573 "{style:?} should not flag {content:?}, got: {result:?}"
3574 );
3575 assert_eq!(
3576 rule.fix(&ctx).unwrap(),
3577 content,
3578 "{style:?} must leave {content:?} alone"
3579 );
3580 }
3581
3582 for (style, content, expected) in [
3586 (
3587 HeadingCapStyle::SentenceCase,
3588 "# The Second (2nd) Attempt\n",
3589 "# The second (2nd) attempt\n",
3590 ),
3591 (
3592 HeadingCapStyle::TitleCase,
3593 "# the second (2nd) attempt\n",
3594 "# The Second (2nd) Attempt\n",
3595 ),
3596 ] {
3597 let rule = create_rule_with_style(style);
3598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3599 assert!(
3600 !rule.check(&ctx).unwrap().is_empty(),
3601 "{style:?} should flag {content:?}"
3602 );
3603 let fixed = rule.fix(&ctx).unwrap();
3604 assert_eq!(fixed, expected, "{style:?} fix of {content:?}");
3605
3606 let refixed = rule
3607 .fix(&LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None))
3608 .unwrap();
3609 assert_eq!(refixed, expected, "{style:?} second pass over {fixed:?}");
3610 }
3611 }
3612
3613 #[test]
3614 fn test_wrapped_ordinal_corrupted_by_the_old_fix_is_repaired() {
3615 for (style, content, expected) in [
3619 (
3620 HeadingCapStyle::SentenceCase,
3621 "# The second (2Nd) attempt\n",
3622 "# The second (2nd) attempt\n",
3623 ),
3624 (
3625 HeadingCapStyle::TitleCase,
3626 "# The Second (2Nd) Attempt\n",
3627 "# The Second (2nd) Attempt\n",
3628 ),
3629 (
3630 HeadingCapStyle::SentenceCase,
3631 "# Ranked \"3Rd\" overall\n",
3632 "# Ranked \"3rd\" overall\n",
3633 ),
3634 ] {
3635 let rule = create_rule_with_style(style);
3636 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3637 assert!(
3638 !rule.check(&ctx).unwrap().is_empty(),
3639 "{style:?} should flag {content:?}"
3640 );
3641 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{style:?} fix of {content:?}");
3642 }
3643 }
3644
3645 #[test]
3646 fn test_title_case_ordinal_first_word_not_flagged() {
3647 let rule = create_rule();
3648 for content in &[
3649 "# 1st Place\n",
3650 "# 2nd Edition\n",
3651 "# 3rd Time\n",
3652 "# 5th Avenue\n",
3653 "# 21st Century Skills\n",
3654 "# 100th Customer\n",
3655 ] {
3656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3657 let result = rule.check(&ctx).unwrap();
3658 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3659 }
3660 }
3661
3662 #[test]
3663 fn test_title_case_ordinal_mid_heading_not_flagged() {
3664 let rule = create_rule();
3665 for content in &[
3666 "# May 3rd Notes\n",
3667 "# Top 100th Customer\n",
3668 "# Notes for the 5th of May\n",
3669 ] {
3670 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3671 let result = rule.check(&ctx).unwrap();
3672 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3673 }
3674 }
3675
3676 #[test]
3677 fn test_title_case_ordinal_corrupted_form_is_fixed() {
3678 let rule = create_rule();
3681 for (input, expected) in &[
3682 ("# 1St Place\n", "1st Place"),
3683 ("# 5Th Avenue\n", "5th Avenue"),
3684 ("# 21St Century Skills\n", "21st Century Skills"),
3685 ("# May 3Rd Notes\n", "May 3rd Notes"),
3686 ("# 22Nd Edition\n", "22nd Edition"),
3687 ] {
3688 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
3689 let result = rule.check(&ctx).unwrap();
3690 assert!(!result.is_empty(), "Should flag {input:?}");
3691 let fix = result[0].fix.as_ref().expect("should have a fix");
3692 assert!(
3693 fix.replacement.contains(expected),
3694 "Fix for {input:?} should contain {expected:?}, got: {:?}",
3695 fix.replacement
3696 );
3697 }
3698 }
3699
3700 #[test]
3701 fn test_title_case_ordinal_lowercase_other_words_capitalised() {
3702 let rule = create_rule();
3704 let content = "# 5th avenue\n";
3705 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3706 let result = rule.check(&ctx).unwrap();
3707 assert_eq!(result.len(), 1);
3708 let fix = result[0].fix.as_ref().expect("should have a fix");
3709 assert!(
3710 fix.replacement.contains("5th Avenue"),
3711 "Fix should produce '5th Avenue', got: {:?}",
3712 fix.replacement
3713 );
3714 }
3715
3716 #[test]
3717 fn test_title_case_ordinal_with_trailing_punctuation() {
3718 let rule = create_rule();
3719 let content = "# Released on the 5th.\n";
3720 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3721 let result = rule.check(&ctx).unwrap();
3722 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3723 }
3724
3725 #[test]
3726 fn test_title_case_ordinal_hyphenated() {
3727 let rule = create_rule();
3728 for content in &["# 21st-Century Skills\n", "# A 19th-Century Novel\n"] {
3729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3730 let result = rule.check(&ctx).unwrap();
3731 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3732 }
3733 }
3734
3735 #[test]
3736 fn test_sentence_case_ordinal_corrupted_form_is_fixed() {
3737 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3738 let content = "# 5Th avenue\n";
3739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3740 let result = rule.check(&ctx).unwrap();
3741 assert_eq!(result.len(), 1);
3742 let fix = result[0].fix.as_ref().expect("should have a fix");
3743 assert!(
3744 fix.replacement.contains("5th avenue"),
3745 "Fix should produce '5th avenue', got: {:?}",
3746 fix.replacement
3747 );
3748 }
3749
3750 #[test]
3751 fn test_title_case_digit_acronym_unchanged() {
3752 let rule = create_rule();
3755 for content in &["# 4G Networks\n", "# 4K Streaming\n"] {
3756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3757 let result = rule.check(&ctx).unwrap();
3758 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3759 }
3760 }
3761
3762 fn restart_rule(boundaries: &[&str]) -> MD063HeadingCapitalization {
3765 let config = MD063Config {
3766 enabled: true,
3767 style: HeadingCapStyle::SentenceCase,
3768 sentence_case_restart_after: boundaries.iter().copied().map(String::from).collect(),
3769 ..Default::default()
3770 };
3771 MD063HeadingCapitalization::from_config_struct(config)
3772 }
3773
3774 fn suggested(rule: &MD063HeadingCapitalization, content: &str) -> Option<String> {
3777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3778 let warnings = rule.check(&ctx).unwrap();
3779 let fixed = rule.fix(&ctx).unwrap();
3780 assert_eq!(
3781 warnings.is_empty(),
3782 fixed == content,
3783 "a warning and a rewrite must agree for {content:?}"
3784 );
3785 (!warnings.is_empty()).then(|| fixed.trim_start_matches('#').trim().to_string())
3786 }
3787
3788 #[test]
3789 fn test_restart_after_capitalizes_the_word_following_a_boundary() {
3790 let rule = restart_rule(&[":"]);
3791 assert_eq!(
3792 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3793 Some("Requirement 1: Struct to logger slice conversion")
3794 );
3795 }
3796
3797 #[test]
3798 fn test_restart_after_defaults_to_no_boundaries() {
3799 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3801 assert_eq!(
3802 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3803 Some("Requirement 1: struct to logger slice conversion")
3804 );
3805 }
3806
3807 #[test]
3808 fn test_restart_after_only_honors_configured_punctuation() {
3809 let rule = restart_rule(&[":"]);
3811 assert_eq!(
3812 suggested(&rule, "# Design - Data Model Overview\n").as_deref(),
3813 Some("Design - data model overview")
3814 );
3815 assert_eq!(
3816 suggested(&rule, "# Setup; Then Run\n").as_deref(),
3817 Some("Setup; then run")
3818 );
3819
3820 let rule = restart_rule(&[";", "\u{2014}"]);
3821 assert_eq!(
3822 suggested(&rule, "# Setup; Then Run\n").as_deref(),
3823 Some("Setup; Then run")
3824 );
3825 assert_eq!(
3826 suggested(&rule, "# Part One \u{2014} The Big Idea\n").as_deref(),
3827 Some("Part one \u{2014} The big idea")
3828 );
3829 }
3830
3831 #[test]
3832 fn test_restart_after_matches_only_at_the_end_of_a_word() {
3833 let rule = restart_rule(&["-", ":"]);
3836 assert_eq!(
3837 suggested(&rule, "# Ports: Well-Known Ports Explained\n").as_deref(),
3838 Some("Ports: Well-Known ports explained")
3839 );
3840 assert_eq!(
3841 suggested(&rule, "# See https://example.com/A/B For Details\n").as_deref(),
3842 Some("See https://example.com/A/B for details")
3843 );
3844 }
3845
3846 #[test]
3847 fn test_restart_after_a_trailing_boundary_is_a_no_op() {
3848 let rule = restart_rule(&[":"]);
3849 assert_eq!(suggested(&rule, "# Setup:\n"), None);
3850 }
3851
3852 #[test]
3853 fn test_restart_after_does_not_override_preserved_words() {
3854 let rule = restart_rule(&[":"]);
3857 assert_eq!(
3858 suggested(&rule, "# Devices: iPhone And Android\n").as_deref(),
3859 Some("Devices: iPhone and android")
3860 );
3861
3862 let config = MD063Config {
3863 enabled: true,
3864 style: HeadingCapStyle::SentenceCase,
3865 sentence_case_restart_after: vec![":".to_string()],
3866 ignore_words: vec!["kubectl".to_string()],
3867 preserve_cased_words: false,
3868 ..Default::default()
3869 };
3870 let rule = MD063HeadingCapitalization::from_config_struct(config);
3871 assert_eq!(
3872 suggested(&rule, "# Tools: kubectl And Helm\n").as_deref(),
3873 Some("Tools: kubectl and helm")
3874 );
3875 }
3876
3877 #[test]
3878 fn test_restart_after_keeps_md044_canonical_forms() {
3879 let config = MD063Config {
3880 enabled: true,
3881 style: HeadingCapStyle::SentenceCase,
3882 sentence_case_restart_after: vec![":".to_string()],
3883 ..Default::default()
3884 };
3885 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3886 rule.proper_names = vec!["GitHub".to_string()];
3887
3888 assert_eq!(
3890 suggested(&rule, "# Docs: github Actions Guide\n").as_deref(),
3891 Some("Docs: GitHub actions guide")
3892 );
3893 assert_eq!(suggested(&rule, "# Docs: GitHub actions guide\n"), None);
3894 }
3895
3896 #[test]
3897 fn test_restart_after_carries_across_segments() {
3898 let rule = restart_rule(&[":"]);
3901 assert_eq!(
3902 suggested(
3903 &rule,
3904 "# Overview: [Some Link Here](https://example.com) Trailing Words\n"
3905 )
3906 .as_deref(),
3907 Some("Overview: [Some link here](https://example.com) trailing words")
3908 );
3909 assert_eq!(
3910 suggested(&rule, "# Overview: `code` Then More Words\n").as_deref(),
3911 Some("Overview: `code` then more words")
3912 );
3913 }
3914
3915 #[test]
3916 fn test_restart_after_ends_a_sentence_at_the_end_of_link_text() {
3917 let rule = restart_rule(&[":"]);
3921 assert_eq!(
3922 suggested(&rule, "# Topic [See:](https://example.com) More Words\n").as_deref(),
3923 Some("Topic [see:](https://example.com) More words")
3924 );
3925
3926 assert_eq!(
3928 suggested(&rule, "# Topic [See](https://example.com) More Words\n").as_deref(),
3929 Some("Topic [see](https://example.com) more words")
3930 );
3931 }
3932
3933 #[test]
3934 fn test_restart_after_ignores_boundaries_inside_opaque_segments() {
3935 let rule = restart_rule(&[":"]);
3938 for content in [
3939 "# Topic `see:` More Words\n",
3940 "# Topic  More Words\n",
3941 "# Topic <span title=\"x:\">y</span> More Words\n",
3942 ] {
3943 let fixed = suggested(&rule, content).expect("heading should be rewritten");
3944 assert!(
3945 fixed.ends_with("more words"),
3946 "opaque segment restarted the sentence in {content:?}: {fixed}"
3947 );
3948 }
3949 }
3950
3951 #[test]
3952 fn test_restart_after_treats_a_leading_link_as_sentence_initial() {
3953 for rule in [restart_rule(&[]), restart_rule(&[":"])] {
3956 assert_eq!(
3957 suggested(&rule, "# [Some Link Here](https://example.com) Trailing Words\n").as_deref(),
3958 Some("[Some link here](https://example.com) trailing words")
3959 );
3960 }
3961 }
3962
3963 #[test]
3964 fn test_restart_after_fix_is_idempotent() {
3965 let rule = restart_rule(&[":", ";", "-", "\u{2014}"]);
3966 for content in [
3967 "# Requirement 1: Struct to Logger Slice Conversion\n",
3968 "# Ports: Well-Known Ports Explained\n",
3969 "# Devices: iPhone And Android\n",
3970 "# Overview: [Some Link Here](https://example.com) Trailing Words\n",
3971 "# Setup:\n",
3972 ] {
3973 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3974 let once = rule.fix(&ctx).unwrap();
3975 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
3976 assert_eq!(rule.fix(&ctx).unwrap(), once, "fix is not idempotent for {content:?}");
3977 }
3978 }
3979
3980 #[test]
3981 fn test_restart_after_ignores_empty_boundary_entries() {
3982 let rule = restart_rule(&[""]);
3984 assert_eq!(
3985 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3986 Some("Requirement 1: struct to logger slice conversion")
3987 );
3988 }
3989
3990 const STYLES: [HeadingCapStyle; 3] = [
3993 HeadingCapStyle::TitleCase,
3994 HeadingCapStyle::SentenceCase,
3995 HeadingCapStyle::AllCaps,
3996 ];
3997
3998 const GHERKIN_STRUCTURES: [(&str, &str, &str, &str); 6] = [
4001 (
4002 "# Feature: the system under test",
4003 "# Feature: The System Under Test",
4004 "# Feature: The system under test",
4005 "# Feature: THE SYSTEM UNDER TEST",
4006 ),
4007 (
4008 "## Background: a shared setup",
4009 "## Background: A Shared Setup",
4010 "## Background: A shared setup",
4011 "## Background: A SHARED SETUP",
4012 ),
4013 (
4014 "## Rule: money is never lost",
4015 "## Rule: Money Is Never Lost",
4016 "## Rule: Money is never lost",
4017 "## Rule: MONEY IS NEVER LOST",
4018 ),
4019 (
4020 "### Scenario: add two numbers",
4021 "### Scenario: Add Two Numbers",
4022 "### Scenario: Add two numbers",
4023 "### Scenario: ADD TWO NUMBERS",
4024 ),
4025 (
4026 "### Scenario Outline: add two numbers",
4027 "### Scenario Outline: Add Two Numbers",
4028 "### Scenario Outline: Add two numbers",
4029 "### Scenario Outline: ADD TWO NUMBERS",
4030 ),
4031 (
4032 "#### Examples: happy path",
4033 "#### Examples: Happy Path",
4034 "#### Examples: Happy path",
4035 "#### Examples: HAPPY PATH",
4036 ),
4037 ];
4038
4039 fn recased(style: HeadingCapStyle, heading: &str, flavor: crate::config::MarkdownFlavor) -> String {
4041 let rule = create_rule_with_style(style);
4042 let content = format!("{heading}\n");
4043 let ctx = LintContext::new(&content, flavor, None);
4044 let warnings = rule.check(&ctx).unwrap();
4045 let fixed = rule.fix(&ctx).unwrap();
4046 assert_eq!(
4047 warnings.is_empty(),
4048 fixed == content,
4049 "a warning and a rewrite must agree for {content:?} under {flavor:?}"
4050 );
4051 fixed.trim_end().to_string()
4052 }
4053
4054 #[test]
4055 fn test_mdg_keeps_the_keyword_of_every_structure() {
4056 for (heading, ..) in GHERKIN_STRUCTURES {
4059 let keyword = &heading[..=heading.find(':').unwrap()];
4060 for style in STYLES {
4061 let fixed = recased(style, heading, crate::config::MarkdownFlavor::MDG);
4062 assert!(
4063 fixed.starts_with(keyword),
4064 "{style:?} lost the keyword of {heading:?}: {fixed}"
4065 );
4066 }
4067 }
4068 }
4069
4070 #[test]
4071 fn test_mdg_recases_only_the_name_of_a_structure() {
4072 for (heading, title, sentence, caps) in GHERKIN_STRUCTURES {
4073 let mdg = crate::config::MarkdownFlavor::MDG;
4074 assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), title);
4075 assert_eq!(recased(HeadingCapStyle::SentenceCase, heading, mdg), sentence);
4076 assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), caps);
4077 }
4078 }
4079
4080 #[test]
4081 fn test_standard_flavor_recases_a_keyword_like_any_other_word() {
4082 let standard = crate::config::MarkdownFlavor::Standard;
4084 assert_eq!(
4085 recased(HeadingCapStyle::TitleCase, "# Feature: the system under test", standard),
4086 "# Feature: the System Under Test"
4087 );
4088 assert_eq!(
4089 recased(
4090 HeadingCapStyle::SentenceCase,
4091 "### Scenario Outline: add two numbers",
4092 standard
4093 ),
4094 "### Scenario outline: add two numbers"
4095 );
4096 assert_eq!(
4097 recased(HeadingCapStyle::AllCaps, "# Feature: the system under test", standard),
4098 "# FEATURE: THE SYSTEM UNDER TEST"
4099 );
4100 }
4101
4102 #[test]
4103 fn test_mdg_leaves_a_heading_without_a_colon_to_the_normal_rule() {
4104 for heading in ["## notes about the system", "## Notes", "# THE SYSTEM"] {
4105 for style in STYLES {
4106 assert_eq!(
4107 recased(style, heading, crate::config::MarkdownFlavor::MDG),
4108 recased(style, heading, crate::config::MarkdownFlavor::Standard),
4109 "{style:?} treated {heading:?} as a Gherkin structure"
4110 );
4111 }
4112 }
4113 }
4114
4115 #[test]
4116 fn test_mdg_splits_at_the_first_colon_only() {
4117 let mdg = crate::config::MarkdownFlavor::MDG;
4119 let heading = "## Scenario: ratio: two to one";
4120 assert_eq!(
4121 recased(HeadingCapStyle::TitleCase, heading, mdg),
4122 "## Scenario: Ratio: Two to One"
4123 );
4124 assert_eq!(
4125 recased(HeadingCapStyle::SentenceCase, heading, mdg),
4126 "## Scenario: Ratio: two to one"
4127 );
4128 assert_eq!(
4129 recased(HeadingCapStyle::AllCaps, heading, mdg),
4130 "## Scenario: RATIO: TWO TO ONE"
4131 );
4132 }
4133
4134 #[test]
4135 fn test_mdg_leaves_a_colon_behind_a_backtick_to_the_normal_rule() {
4136 for heading in [
4140 "# See `x: y` Notes",
4141 "# `a: b`",
4142 "# `code` Feature: a name",
4143 "# `x: y` Feature: a name",
4144 ] {
4145 for style in STYLES {
4146 assert_eq!(
4147 recased(style, heading, crate::config::MarkdownFlavor::MDG),
4148 recased(style, heading, crate::config::MarkdownFlavor::Standard),
4149 "{style:?} split {heading:?} at a colon inside a code span"
4150 );
4151 }
4152 }
4153 }
4154
4155 #[test]
4156 fn test_mdg_splits_at_a_keyword_colon_that_precedes_a_code_span() {
4157 let mdg = crate::config::MarkdownFlavor::MDG;
4159 let heading = "# Scenario: use `a: b` here";
4160 assert_eq!(
4161 recased(HeadingCapStyle::TitleCase, heading, mdg),
4162 "# Scenario: Use `a: b` Here"
4163 );
4164 assert_eq!(
4165 recased(HeadingCapStyle::SentenceCase, heading, mdg),
4166 "# Scenario: Use `a: b` here"
4167 );
4168 assert_eq!(
4169 recased(HeadingCapStyle::AllCaps, heading, mdg),
4170 "# Scenario: USE `a: b` HERE"
4171 );
4172 }
4173
4174 #[test]
4175 fn test_mdg_splits_at_a_keyword_colon_before_an_unbalanced_backtick() {
4176 let mdg = crate::config::MarkdownFlavor::MDG;
4179 let heading = "# Scenario: a ` b";
4180 assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), "# Scenario: A ` B");
4181 assert_eq!(
4182 recased(HeadingCapStyle::SentenceCase, heading, mdg),
4183 "# Scenario: A ` b"
4184 );
4185 assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), "# Scenario: A ` B");
4186 }
4187
4188 #[test]
4189 fn test_mdg_keeps_a_keyword_with_nothing_left_to_recase() {
4190 for style in STYLES {
4191 assert_eq!(
4192 recased(style, "# Feature:", crate::config::MarkdownFlavor::MDG),
4193 "# Feature:"
4194 );
4195 }
4196 }
4197
4198 #[test]
4199 fn test_mdg_keeps_a_custom_id_after_the_name() {
4200 assert_eq!(
4201 recased(
4202 HeadingCapStyle::TitleCase,
4203 "# Feature: the system {#overview}",
4204 crate::config::MarkdownFlavor::MDG
4205 ),
4206 "# Feature: The System {#overview}"
4207 );
4208 }
4209
4210 #[test]
4211 fn test_mdg_fix_is_idempotent() {
4212 for (heading, ..) in GHERKIN_STRUCTURES {
4213 for style in STYLES {
4214 let rule = create_rule_with_style(style);
4215 let content = format!("{heading}\n");
4216 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
4217 let once = rule.fix(&ctx).unwrap();
4218 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::MDG, None);
4219 assert_eq!(
4220 rule.fix(&ctx).unwrap(),
4221 once,
4222 "fix is not idempotent for {heading:?} ({style:?})"
4223 );
4224 }
4225 }
4226 }
4227}