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 if !heading.is_valid {
1261 continue;
1262 }
1263
1264 let original_text = &heading.raw_text;
1266 let fixed_text = self.apply_capitalization(original_text, ctx.flavor);
1267
1268 if original_text != &fixed_text {
1269 let line = line_info.content(ctx.content);
1270 let style_name = match self.config.style {
1271 HeadingCapStyle::TitleCase => "title case",
1272 HeadingCapStyle::SentenceCase => "sentence case",
1273 HeadingCapStyle::AllCaps => "ALL CAPS",
1274 };
1275
1276 if heading.text_lines > 1 {
1280 let first_idx = line_num + 1 - heading.text_lines;
1281 let first_line = ctx.lines[first_idx].content(ctx.content);
1282 let fix = self
1283 .fix_setext_heading_span(ctx, first_idx, line_num, heading, ctx.flavor)
1284 .map(|rewritten| {
1285 let range = ctx.line_content_byte_range(first_idx + 1).start
1286 ..ctx.line_content_byte_range(line_num + 1).end;
1287 Fix::new(range, rewritten.join("\n"))
1288 });
1289 warnings.push(LintWarning {
1290 rule_name: Some(self.name().to_string()),
1291 line: first_idx + 1,
1292 column: byte_to_char_count(first_line, heading.content_column),
1293 end_line: line_num + 1,
1294 end_column: line.trim_end().chars().count() + 1,
1295 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1296 severity: Severity::Warning,
1297 fix,
1298 });
1299 continue;
1300 }
1301
1302 warnings.push(LintWarning {
1303 rule_name: Some(self.name().to_string()),
1304 line: line_num + 1,
1305 column: byte_to_char_count(line, heading.content_column),
1306 end_line: line_num + 1,
1307 end_column: byte_to_char_count(line, heading.content_column) + original_text.chars().count(),
1308 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1309 severity: Severity::Warning,
1310 fix: Some(Fix::new(
1311 ctx.line_content_byte_range(line_num + 1),
1312 match heading.style {
1313 crate::lint_context::HeadingStyle::ATX => {
1314 self.fix_atx_heading(line, heading, ctx.flavor)
1315 }
1316 _ => self.fix_setext_heading(line, heading, ctx.flavor),
1317 },
1318 )),
1319 });
1320 }
1321 }
1322 }
1323
1324 Ok(warnings)
1325 }
1326
1327 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1328 let content = ctx.content;
1329
1330 if content.is_empty() {
1331 return Ok(content.to_string());
1332 }
1333
1334 let lines = ctx.raw_lines();
1335 let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1336
1337 for (line_num, line_info) in ctx.lines.iter().enumerate() {
1338 if ctx.is_rule_disabled(self.name(), line_num + 1) {
1340 continue;
1341 }
1342
1343 if let Some(heading) = &line_info.heading {
1344 if heading.level < self.config.min_level || heading.level > self.config.max_level {
1346 continue;
1347 }
1348
1349 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
1351 continue;
1352 }
1353
1354 if !heading.is_valid {
1356 continue;
1357 }
1358
1359 let original_text = &heading.raw_text;
1360 let fixed_text = self.apply_capitalization(original_text, ctx.flavor);
1361
1362 if original_text != &fixed_text {
1363 let line = line_info.content(ctx.content);
1364 if heading.text_lines > 1 {
1367 let first_idx = line_num + 1 - heading.text_lines;
1368 if (first_idx..line_num).any(|idx| ctx.is_rule_disabled(self.name(), idx + 1)) {
1371 continue;
1372 }
1373 if let Some(rewritten) =
1374 self.fix_setext_heading_span(ctx, first_idx, line_num, heading, ctx.flavor)
1375 {
1376 fixed_lines[first_idx..=line_num].clone_from_slice(&rewritten);
1377 }
1378 continue;
1379 }
1380 fixed_lines[line_num] = match heading.style {
1381 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading, ctx.flavor),
1382 _ => self.fix_setext_heading(line, heading, ctx.flavor),
1383 };
1384 }
1385 }
1386 }
1387
1388 let mut result = String::with_capacity(content.len());
1390 for (i, line) in fixed_lines.iter().enumerate() {
1391 result.push_str(line);
1392 if i < fixed_lines.len() - 1 || content.ends_with('\n') {
1393 result.push('\n');
1394 }
1395 }
1396
1397 Ok(result)
1398 }
1399
1400 fn as_any(&self) -> &dyn std::any::Any {
1401 self
1402 }
1403
1404 crate::impl_rule_config_sections!(MD063Config);
1405
1406 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1407 where
1408 Self: Sized,
1409 {
1410 let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
1411 let md044_config =
1412 crate::rule_config_serde::load_rule_config::<crate::rules::md044_proper_names::MD044Config>(config);
1413 let mut rule = Self::from_config_struct(rule_config);
1414 rule.proper_names = md044_config.names;
1415 Box::new(rule)
1416 }
1417}
1418
1419#[cfg(test)]
1420mod tests {
1421 use super::*;
1422 use crate::lint_context::LintContext;
1423
1424 fn create_rule() -> MD063HeadingCapitalization {
1425 let config = MD063Config {
1426 enabled: true,
1427 ..Default::default()
1428 };
1429 MD063HeadingCapitalization::from_config_struct(config)
1430 }
1431
1432 fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
1433 let config = MD063Config {
1434 enabled: true,
1435 style,
1436 ..Default::default()
1437 };
1438 MD063HeadingCapitalization::from_config_struct(config)
1439 }
1440
1441 #[test]
1443 fn test_an_escaped_tag_does_not_hide_the_element_written_inside_it() {
1444 let text = r#"\<span title='<a id="x"></a>'>foo"#;
1447 assert_eq!(MD063HeadingCapitalization::html_regions(text, &[]), vec![(14, 28)]);
1448 }
1449
1450 #[test]
1451 fn test_title_case_basic() {
1452 let rule = create_rule();
1453 let content = "# hello world\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("Hello World"));
1458 }
1459
1460 #[test]
1461 fn test_title_case_lowercase_words() {
1462 let rule = create_rule();
1463 let content = "# the quick brown fox\n";
1464 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1465 let result = rule.check(&ctx).unwrap();
1466 assert_eq!(result.len(), 1);
1467 assert!(result[0].message.contains("The Quick Brown Fox"));
1469 }
1470
1471 #[test]
1472 fn test_title_case_already_correct() {
1473 let rule = create_rule();
1474 let content = "# The Quick Brown Fox\n";
1475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1476 let result = rule.check(&ctx).unwrap();
1477 assert!(result.is_empty(), "Already correct heading should not be flagged");
1478 }
1479
1480 #[test]
1481 fn test_title_case_hyphenated() {
1482 let rule = create_rule();
1483 let content = "# self-documenting code\n";
1484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1485 let result = rule.check(&ctx).unwrap();
1486 assert_eq!(result.len(), 1);
1487 assert!(result[0].message.contains("Self-Documenting Code"));
1488 }
1489
1490 #[test]
1491 fn test_title_case_preserves_url_with_nested_parens() {
1492 let rule = create_rule();
1493 let content = "# guide for [the api](https://example.com/docs/v(2)beta)\n";
1495 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1496 let fixed = rule.fix(&ctx).unwrap();
1497 assert!(
1500 fixed.contains("https://example.com/docs/v(2)beta"),
1501 "URL with nested parens was corrupted: {fixed:?}"
1502 );
1503 }
1504
1505 #[test]
1506 fn test_title_case_does_not_recase_image_alt() {
1507 let rule = create_rule();
1508 let content = "# overview \n";
1509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1510 let fixed = rule.fix(&ctx).unwrap();
1511 assert!(
1513 fixed.contains(""),
1514 "image alt text was modified: {fixed:?}"
1515 );
1516 assert!(
1517 fixed.contains("# Overview"),
1518 "surrounding prose should still be title-cased: {fixed:?}"
1519 );
1520 }
1521
1522 #[test]
1524 fn test_sentence_case_basic() {
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_eq!(result.len(), 1);
1530 assert!(result[0].message.contains("The quick brown fox"));
1531 }
1532
1533 #[test]
1534 fn test_sentence_case_already_correct() {
1535 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1536 let content = "# The quick brown fox\n";
1537 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1538 let result = rule.check(&ctx).unwrap();
1539 assert!(result.is_empty());
1540 }
1541
1542 #[test]
1543 fn test_sentence_case_preserves_first_person_pronoun() {
1544 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1546 let content = "# How do I debug playbooks?\n";
1547 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1548
1549 assert!(rule.check(&ctx).unwrap().is_empty());
1550 assert_eq!(rule.fix(&ctx).unwrap(), content);
1551 }
1552
1553 #[test]
1554 fn test_sentence_case_preserves_first_person_contractions() {
1555 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1556
1557 for content in [
1558 "# What I'd change\n",
1559 "# Where I'll look\n",
1560 "# Why I'm here\n",
1561 "# What I've learned\n",
1562 "# What I’d change\n",
1563 "# Where I’ll look\n",
1564 "# Why I’m here\n",
1565 "# What I’ve learned\n",
1566 ] {
1567 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1568 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1569 assert_eq!(rule.fix(&ctx).unwrap(), content);
1570 }
1571 }
1572
1573 #[test]
1574 fn test_sentence_case_pronoun_handles_markup_punctuation_and_suffix_case() {
1575 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1576 let cases = [
1577 ("# What (**I**) would change\n", "# What (**I**) would change\n"),
1578 ("# Why **I**'M changing it\n", "# Why **I**'m changing it\n"),
1579 ("# What I’LL change\n", "# What I’ll change\n"),
1580 ("# What I—really want\n", "# What I—really want\n"),
1581 ("# What I’d—reluctantly change\n", "# What I’d—reluctantly change\n"),
1582 ("# What I—yes—I—would do\n", "# What I—yes—I—would do\n"),
1583 ("# What [I'll change](plan.md)\n", "# What [I'll change](plan.md)\n"),
1584 ("# [What I—really want](plan.md)\n", "# [What I—really want](plan.md)\n"),
1585 ];
1586
1587 for (content, expected) in cases {
1588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1589 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
1590 }
1591 }
1592
1593 #[test]
1594 fn test_sentence_case_pronoun_is_independent_of_cased_word_preservation() {
1595 let config = MD063Config {
1596 enabled: true,
1597 style: HeadingCapStyle::SentenceCase,
1598 preserve_cased_words: false,
1599 ..Default::default()
1600 };
1601 let rule = MD063HeadingCapitalization::from_config_struct(config);
1602 let content = "# How do I debug what I’LL change?\n";
1603 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1604
1605 assert_eq!(rule.fix(&ctx).unwrap(), "# How do I debug what I’ll change?\n");
1606 }
1607
1608 #[test]
1609 fn test_sentence_case_explicit_ignore_wins_over_pronoun_normalization() {
1610 let config = MD063Config {
1611 enabled: true,
1612 style: HeadingCapStyle::SentenceCase,
1613 ignore_words: vec!["I'LL".to_string(), "I’LL".to_string()],
1614 ..Default::default()
1615 };
1616 let rule = MD063HeadingCapitalization::from_config_struct(config);
1617 let content = "# Why I'LL stay and why I’LL leave\n";
1618 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1619
1620 assert!(rule.check(&ctx).unwrap().is_empty());
1621 assert_eq!(rule.fix(&ctx).unwrap(), content);
1622 }
1623
1624 #[test]
1625 fn test_sentence_case_pronoun_does_not_preserve_other_single_letters_or_compounds() {
1626 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1627 let content = "# Compare i, A, I/O, and A.I. values\n";
1628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1629
1630 assert_eq!(rule.fix(&ctx).unwrap(), "# Compare i, a, i/o, and a.i. values\n");
1631 }
1632
1633 #[test]
1635 fn test_all_caps_basic() {
1636 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1637 let content = "# hello world\n";
1638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1639 let result = rule.check(&ctx).unwrap();
1640 assert_eq!(result.len(), 1);
1641 assert!(result[0].message.contains("HELLO WORLD"));
1642 }
1643
1644 #[test]
1646 fn test_preserve_ignore_words() {
1647 let config = MD063Config {
1648 enabled: true,
1649 ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
1650 ..Default::default()
1651 };
1652 let rule = MD063HeadingCapitalization::from_config_struct(config);
1653
1654 let content = "# using iPhone on macOS\n";
1655 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1656 let result = rule.check(&ctx).unwrap();
1657 assert_eq!(result.len(), 1);
1658 assert!(result[0].message.contains("iPhone"));
1660 assert!(result[0].message.contains("macOS"));
1661 }
1662
1663 #[test]
1664 fn test_preserve_cased_words() {
1665 let rule = create_rule();
1666 let content = "# using GitHub actions\n";
1667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1668 let result = rule.check(&ctx).unwrap();
1669 assert_eq!(result.len(), 1);
1670 assert!(result[0].message.contains("GitHub"));
1672 }
1673
1674 #[test]
1676 fn test_inline_code_preserved() {
1677 let rule = create_rule();
1678 let content = "# using `const` in javascript\n";
1679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1680 let result = rule.check(&ctx).unwrap();
1681 assert_eq!(result.len(), 1);
1682 assert!(result[0].message.contains("`const`"));
1684 assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
1685 }
1686
1687 #[test]
1689 fn test_level_filter() {
1690 let config = MD063Config {
1691 enabled: true,
1692 min_level: 2,
1693 max_level: 4,
1694 ..Default::default()
1695 };
1696 let rule = MD063HeadingCapitalization::from_config_struct(config);
1697
1698 let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
1699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1700 let result = rule.check(&ctx).unwrap();
1701
1702 assert_eq!(result.len(), 2);
1704 assert_eq!(result[0].line, 2); assert_eq!(result[1].line, 3); }
1707
1708 #[test]
1710 fn test_fix_atx_heading() {
1711 let rule = create_rule();
1712 let content = "# hello world\n";
1713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1714 let fixed = rule.fix(&ctx).unwrap();
1715 assert_eq!(fixed, "# Hello World\n");
1716 }
1717
1718 #[test]
1719 fn test_fix_multiple_headings() {
1720 let rule = create_rule();
1721 let content = "# first heading\n\n## second heading\n";
1722 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1723 let fixed = rule.fix(&ctx).unwrap();
1724 assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
1725 }
1726
1727 #[test]
1729 fn test_setext_heading() {
1730 let rule = create_rule();
1731 let content = "hello world\n============\n";
1732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1733 let result = rule.check(&ctx).unwrap();
1734 assert_eq!(result.len(), 1);
1735 assert!(result[0].message.contains("Hello World"));
1736 }
1737
1738 #[test]
1739 fn test_multi_line_setext_heading_is_capitalized_in_place() {
1740 let rule = create_rule();
1744 let content = "hello world\nand more words\n==============\n";
1745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1746 let fixed = rule.fix(&ctx).unwrap();
1747 assert_eq!(fixed, "Hello World\nand More Words\n==============\n");
1748
1749 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1750 assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent");
1751 }
1752
1753 #[test]
1754 fn test_multi_line_setext_heading_keeps_a_hard_break_backslash() {
1755 let rule = create_rule();
1758 let content = "foo bar\\\nbaz qux\n=======\n";
1759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1760 let fixed = rule.fix(&ctx).unwrap();
1761 assert_eq!(fixed, "Foo Bar\\\nBaz Qux\n=======\n");
1762
1763 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1764 assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent");
1765 }
1766
1767 #[test]
1768 fn test_multi_line_setext_heading_keeps_a_backslash_that_is_no_hard_break() {
1769 let rule = create_rule();
1773 for (content, expected) in [
1774 ("foo `a\\\nb` tail\n===\n", "Foo `a\\\nb` Tail\n===\n"),
1775 ("foo bar\nbaz\\\n===\n", "Foo Bar\nBaz\\\n===\n"),
1776 ] {
1777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1778 let fixed = rule.fix(&ctx).unwrap();
1779 assert_eq!(fixed, expected, "{content:?}");
1780
1781 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1782 assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent: {content:?}");
1783 }
1784 }
1785
1786 #[test]
1787 fn test_multi_line_setext_heading_warning_covers_the_whole_span() {
1788 let rule = create_rule();
1791 let content = "hello world\nand more\n=====\n";
1792 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1793 let result = rule.check(&ctx).unwrap();
1794 assert_eq!(result.len(), 1, "got: {result:?}");
1795 assert_eq!(result[0].line, 1);
1796 assert_eq!(result[0].column, 1);
1797 assert_eq!(result[0].end_line, 2);
1798 assert_eq!(result[0].end_column, 9);
1799 }
1800
1801 #[test]
1802 fn test_multi_line_setext_heading_with_a_multi_byte_word_is_idempotent() {
1803 let rule = create_rule();
1807 let content = "`A`\n| à | |\n| --- | --- |\n---";
1808 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1809 let fixed = rule.fix(&ctx).unwrap();
1810 assert_eq!(fixed, "`A`\n| À | |\n| --- | --- |\n---");
1811
1812 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1813 assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent");
1814 }
1815
1816 #[test]
1817 fn test_multi_line_setext_heading_whose_words_cannot_be_mapped_back_is_reported_without_a_fix() {
1818 let rule = create_rule();
1823 let content = "see [ the guide ](guide.md) first\nand then more\n=====\n";
1824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1825 let result = rule.check(&ctx).unwrap();
1826 assert_eq!(result.len(), 1, "got: {result:?}");
1827 assert_eq!(result[0].line, 1);
1828 assert_eq!(result[0].end_line, 2);
1829 assert!(
1830 result[0].fix.is_none(),
1831 "no rewrite is offered, got: {:?}",
1832 result[0].fix
1833 );
1834 assert_eq!(rule.fix(&ctx).unwrap(), content, "the heading is left as written");
1835
1836 let content = "see [the guide](guide.md) first\nand then more\n=====\n";
1838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1839 let result = rule.check(&ctx).unwrap();
1840 assert_eq!(result.len(), 1, "got: {result:?}");
1841 assert!(result[0].fix.is_some(), "the control heading is fixable");
1842 assert_eq!(
1843 rule.fix(&ctx).unwrap(),
1844 "See [The Guide](guide.md) First\nand Then More\n=====\n"
1845 );
1846 }
1847
1848 #[test]
1849 fn test_fix_honours_a_suppression_on_any_line_of_a_multi_line_setext_heading() {
1850 let rule = create_rule();
1854 let content = "<!-- rumdl-disable-next-line MD063 -->\nhello\nworld\n===\n";
1855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1856 assert_eq!(
1857 rule.fix(&ctx).unwrap(),
1858 content,
1859 "the suppressed heading is left as written"
1860 );
1861
1862 let content = "hello\nworld\n===\n";
1864 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1865 assert_eq!(rule.fix(&ctx).unwrap(), "Hello\nWorld\n===\n");
1866 }
1867
1868 #[test]
1870 fn test_custom_id_preserved() {
1871 let rule = create_rule();
1872 let content = "# getting started {#intro}\n";
1873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1874 let result = rule.check(&ctx).unwrap();
1875 assert_eq!(result.len(), 1);
1876 assert!(result[0].message.contains("{#intro}"));
1878 }
1879
1880 #[test]
1882 fn test_skip_obsidian_tags_not_headings() {
1883 let rule = create_rule();
1884
1885 let content = "# H1\n\n#tag\n";
1887 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1888 let result = rule.check(&ctx).unwrap();
1889 assert!(
1890 result.is_empty() || result.iter().all(|w| w.line != 3),
1891 "Obsidian tag #tag should not be treated as a heading: {result:?}"
1892 );
1893 }
1894
1895 #[test]
1896 fn test_skip_invalid_atx_headings_no_space() {
1897 let rule = create_rule();
1898
1899 let content = "#notaheading\n";
1901 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1902 let result = rule.check(&ctx).unwrap();
1903 assert!(
1904 result.is_empty(),
1905 "Invalid ATX heading without space should not be flagged: {result:?}"
1906 );
1907 }
1908
1909 #[test]
1910 fn test_fix_skips_obsidian_tags() {
1911 let rule = create_rule();
1912
1913 let content = "# hello world\n\n#tag\n";
1914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1915 let fixed = rule.fix(&ctx).unwrap();
1916 assert!(fixed.contains("#tag"), "Fix should not modify Obsidian tag #tag");
1918 assert!(fixed.contains("# Hello World"), "Fix should still fix real headings");
1919 }
1920
1921 #[test]
1922 fn test_preserve_all_caps_acronyms() {
1923 let rule = create_rule();
1924 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1925
1926 let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
1928 assert_eq!(fixed, "# Using API in Production\n");
1929
1930 let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
1932 assert_eq!(fixed, "# API and GPU Integration\n");
1933
1934 let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
1936 assert_eq!(fixed, "# IO Performance Guide\n");
1937
1938 let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
1940 assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
1941 }
1942
1943 #[test]
1944 fn test_preserve_acronyms_in_hyphenated_words() {
1945 let rule = create_rule();
1946 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1947
1948 let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
1950 assert_eq!(fixed, "# API-Driven Architecture\n");
1951
1952 let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
1954 assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
1955 }
1956
1957 #[test]
1958 fn test_single_letters_not_treated_as_acronyms() {
1959 let rule = create_rule();
1960 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1961
1962 let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
1964 assert_eq!(fixed, "# I Am a Heading\n");
1965 }
1966
1967 #[test]
1968 fn test_lowercase_terms_need_ignore_words() {
1969 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1970
1971 let rule = create_rule();
1973 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1974 assert_eq!(fixed, "# Using Npm Packages\n");
1975
1976 let config = MD063Config {
1978 enabled: true,
1979 ignore_words: vec!["npm".to_string()],
1980 ..Default::default()
1981 };
1982 let rule = MD063HeadingCapitalization::from_config_struct(config);
1983 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1984 assert_eq!(fixed, "# Using npm Packages\n");
1985 }
1986
1987 #[test]
1988 fn test_acronyms_with_mixed_case_preserved() {
1989 let rule = create_rule();
1990 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1991
1992 let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
1994 assert_eq!(fixed, "# Using API with GitHub\n");
1995 }
1996
1997 #[test]
1998 fn test_real_world_acronyms() {
1999 let rule = create_rule();
2000 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2001
2002 let content = "# FFI bindings for CPU optimization\n";
2004 let fixed = rule.fix(&ctx(content)).unwrap();
2005 assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
2006
2007 let content = "# DOM manipulation and SSR rendering\n";
2008 let fixed = rule.fix(&ctx(content)).unwrap();
2009 assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
2010
2011 let content = "# CVE security and RNN models\n";
2012 let fixed = rule.fix(&ctx(content)).unwrap();
2013 assert_eq!(fixed, "# CVE Security and RNN Models\n");
2014 }
2015
2016 #[test]
2017 fn test_is_all_caps_acronym() {
2018 let rule = create_rule();
2019
2020 assert!(rule.is_all_caps_acronym("API"));
2022 assert!(rule.is_all_caps_acronym("IO"));
2023 assert!(rule.is_all_caps_acronym("GPU"));
2024 assert!(rule.is_all_caps_acronym("HTTP2")); assert!(!rule.is_all_caps_acronym("A"));
2028 assert!(!rule.is_all_caps_acronym("I"));
2029
2030 assert!(!rule.is_all_caps_acronym("Api"));
2032 assert!(!rule.is_all_caps_acronym("npm"));
2033 assert!(!rule.is_all_caps_acronym("iPhone"));
2034 }
2035
2036 #[test]
2037 fn test_sentence_case_starts_after_a_leading_empty_anchor() {
2038 let config = MD063Config {
2040 enabled: true,
2041 style: HeadingCapStyle::SentenceCase,
2042 ..Default::default()
2043 };
2044 let rule = MD063HeadingCapitalization::from_config_struct(config);
2045
2046 let content = "# <a id=\"top\"></a>the beginning\n";
2047 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2048 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2049 assert_eq!(rule.fix(&ctx).unwrap(), "# <a id=\"top\"></a>The beginning\n");
2050
2051 let content = "# <kbd>ctrl</kbd> the key\n";
2053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2054 assert!(rule.check(&ctx).unwrap().is_empty());
2055
2056 for content in ["# <img src=\"x.png\"> the picture\n", "#  the picture\n"] {
2059 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2060 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
2061 }
2062 }
2063
2064 #[test]
2065 fn test_sentence_case_ignore_words_first_word() {
2066 let config = MD063Config {
2067 enabled: true,
2068 style: HeadingCapStyle::SentenceCase,
2069 ignore_words: vec!["nvim".to_string()],
2070 ..Default::default()
2071 };
2072 let rule = MD063HeadingCapitalization::from_config_struct(config);
2073
2074 let content = "# nvim config\n";
2076 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2077 let result = rule.check(&ctx).unwrap();
2078 assert!(
2079 result.is_empty(),
2080 "nvim in ignore-words should not be flagged. Got: {result:?}"
2081 );
2082
2083 let fixed = rule.fix(&ctx).unwrap();
2085 assert_eq!(fixed, "# nvim config\n");
2086 }
2087
2088 #[test]
2089 fn test_sentence_case_ignore_words_not_first() {
2090 let config = MD063Config {
2091 enabled: true,
2092 style: HeadingCapStyle::SentenceCase,
2093 ignore_words: vec!["nvim".to_string()],
2094 ..Default::default()
2095 };
2096 let rule = MD063HeadingCapitalization::from_config_struct(config);
2097
2098 let content = "# Using nvim editor\n";
2100 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2101 let result = rule.check(&ctx).unwrap();
2102 assert!(
2103 result.is_empty(),
2104 "nvim in ignore-words should be preserved. Got: {result:?}"
2105 );
2106 }
2107
2108 #[test]
2109 fn test_preserve_cased_words_ios() {
2110 let config = MD063Config {
2111 enabled: true,
2112 style: HeadingCapStyle::SentenceCase,
2113 preserve_cased_words: true,
2114 ..Default::default()
2115 };
2116 let rule = MD063HeadingCapitalization::from_config_struct(config);
2117
2118 let content = "## This is iOS\n";
2120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2121 let result = rule.check(&ctx).unwrap();
2122 assert!(
2123 result.is_empty(),
2124 "iOS should be preserved with preserve-cased-words. Got: {result:?}"
2125 );
2126
2127 let fixed = rule.fix(&ctx).unwrap();
2129 assert_eq!(fixed, "## This is iOS\n");
2130 }
2131
2132 #[test]
2133 fn test_preserve_cased_words_ios_title_case() {
2134 let config = MD063Config {
2135 enabled: true,
2136 style: HeadingCapStyle::TitleCase,
2137 preserve_cased_words: true,
2138 ..Default::default()
2139 };
2140 let rule = MD063HeadingCapitalization::from_config_struct(config);
2141
2142 let content = "# developing for iOS\n";
2144 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2145 let fixed = rule.fix(&ctx).unwrap();
2146 assert_eq!(fixed, "# Developing for iOS\n");
2147 }
2148
2149 #[test]
2150 fn test_has_internal_capitals_ios() {
2151 let rule = create_rule();
2152
2153 assert!(
2155 rule.has_internal_capitals("iOS"),
2156 "iOS has mixed case (lowercase i, uppercase OS)"
2157 );
2158
2159 assert!(rule.has_internal_capitals("iPhone"));
2161 assert!(rule.has_internal_capitals("macOS"));
2162 assert!(rule.has_internal_capitals("GitHub"));
2163 assert!(rule.has_internal_capitals("JavaScript"));
2164 assert!(rule.has_internal_capitals("eBay"));
2165
2166 assert!(!rule.has_internal_capitals("API"));
2168 assert!(!rule.has_internal_capitals("GPU"));
2169
2170 assert!(!rule.has_internal_capitals("npm"));
2172 assert!(!rule.has_internal_capitals("config"));
2173
2174 assert!(!rule.has_internal_capitals("The"));
2176 assert!(!rule.has_internal_capitals("Hello"));
2177 }
2178
2179 #[test]
2180 fn test_lowercase_words_before_trailing_code() {
2181 let config = MD063Config {
2182 enabled: true,
2183 style: HeadingCapStyle::TitleCase,
2184 lowercase_words: vec![
2185 "a".to_string(),
2186 "an".to_string(),
2187 "and".to_string(),
2188 "at".to_string(),
2189 "but".to_string(),
2190 "by".to_string(),
2191 "for".to_string(),
2192 "from".to_string(),
2193 "into".to_string(),
2194 "nor".to_string(),
2195 "on".to_string(),
2196 "onto".to_string(),
2197 "or".to_string(),
2198 "the".to_string(),
2199 "to".to_string(),
2200 "upon".to_string(),
2201 "via".to_string(),
2202 "vs".to_string(),
2203 "with".to_string(),
2204 "without".to_string(),
2205 ],
2206 preserve_cased_words: true,
2207 ..Default::default()
2208 };
2209 let rule = MD063HeadingCapitalization::from_config_struct(config);
2210
2211 let content = "## subtitle with a `app`\n";
2216 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2217 let result = rule.check(&ctx).unwrap();
2218
2219 assert!(!result.is_empty(), "Should flag incorrect capitalization");
2221 let fixed = rule.fix(&ctx).unwrap();
2222 assert!(
2224 fixed.contains("with a `app`"),
2225 "Expected 'with a `app`' but got: {fixed:?}"
2226 );
2227 assert!(
2228 !fixed.contains("with A `app`"),
2229 "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
2230 );
2231 assert!(
2233 fixed.contains("Subtitle with a `app`"),
2234 "Expected 'Subtitle with a `app`' but got: {fixed:?}"
2235 );
2236 }
2237
2238 #[test]
2239 fn test_lowercase_words_preserved_before_trailing_code_variant() {
2240 let config = MD063Config {
2241 enabled: true,
2242 style: HeadingCapStyle::TitleCase,
2243 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2244 ..Default::default()
2245 };
2246 let rule = MD063HeadingCapitalization::from_config_struct(config);
2247
2248 let content = "## Title with the `code`\n";
2250 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2251 let fixed = rule.fix(&ctx).unwrap();
2252 assert!(
2254 fixed.contains("with the `code`"),
2255 "Expected 'with the `code`' but got: {fixed:?}"
2256 );
2257 assert!(
2258 !fixed.contains("with The `code`"),
2259 "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
2260 );
2261 }
2262
2263 #[test]
2264 fn test_last_word_capitalized_when_no_trailing_code() {
2265 let config = MD063Config {
2268 enabled: true,
2269 style: HeadingCapStyle::TitleCase,
2270 lowercase_words: vec!["a".to_string(), "the".to_string()],
2271 ..Default::default()
2272 };
2273 let rule = MD063HeadingCapitalization::from_config_struct(config);
2274
2275 let content = "## title with a word\n";
2278 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2279 let fixed = rule.fix(&ctx).unwrap();
2280 assert!(
2282 fixed.contains("With a Word"),
2283 "Expected 'With a Word' but got: {fixed:?}"
2284 );
2285 }
2286
2287 #[test]
2288 fn test_multiple_lowercase_words_before_code() {
2289 let config = MD063Config {
2290 enabled: true,
2291 style: HeadingCapStyle::TitleCase,
2292 lowercase_words: vec![
2293 "a".to_string(),
2294 "the".to_string(),
2295 "with".to_string(),
2296 "for".to_string(),
2297 ],
2298 ..Default::default()
2299 };
2300 let rule = MD063HeadingCapitalization::from_config_struct(config);
2301
2302 let content = "## Guide for the `user`\n";
2304 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2305 let fixed = rule.fix(&ctx).unwrap();
2306 assert!(
2307 fixed.contains("for the `user`"),
2308 "Expected 'for the `user`' but got: {fixed:?}"
2309 );
2310 assert!(
2311 !fixed.contains("For The `user`"),
2312 "Should not capitalize lowercase words before code. Got: {fixed:?}"
2313 );
2314 }
2315
2316 #[test]
2317 fn test_code_in_middle_normal_rules_apply() {
2318 let config = MD063Config {
2319 enabled: true,
2320 style: HeadingCapStyle::TitleCase,
2321 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2322 ..Default::default()
2323 };
2324 let rule = MD063HeadingCapitalization::from_config_struct(config);
2325
2326 let content = "## Using `const` for the code\n";
2328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2329 let fixed = rule.fix(&ctx).unwrap();
2330 assert!(
2332 fixed.contains("for the Code"),
2333 "Expected 'for the Code' but got: {fixed:?}"
2334 );
2335 }
2336
2337 #[test]
2338 fn test_link_at_end_same_as_code() {
2339 let config = MD063Config {
2340 enabled: true,
2341 style: HeadingCapStyle::TitleCase,
2342 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2343 ..Default::default()
2344 };
2345 let rule = MD063HeadingCapitalization::from_config_struct(config);
2346
2347 let content = "## Guide for the [link](./page.md)\n";
2349 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2350 let fixed = rule.fix(&ctx).unwrap();
2351 assert!(
2353 fixed.contains("for the [Link]"),
2354 "Expected 'for the [Link]' but got: {fixed:?}"
2355 );
2356 assert!(
2357 !fixed.contains("for The [Link]"),
2358 "Should not capitalize 'the' before link. Got: {fixed:?}"
2359 );
2360 }
2361
2362 #[test]
2363 fn test_multiple_code_segments() {
2364 let config = MD063Config {
2365 enabled: true,
2366 style: HeadingCapStyle::TitleCase,
2367 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2368 ..Default::default()
2369 };
2370 let rule = MD063HeadingCapitalization::from_config_struct(config);
2371
2372 let content = "## Using `const` with a `variable`\n";
2374 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2375 let fixed = rule.fix(&ctx).unwrap();
2376 assert!(
2378 fixed.contains("with a `variable`"),
2379 "Expected 'with a `variable`' but got: {fixed:?}"
2380 );
2381 assert!(
2382 !fixed.contains("with A `variable`"),
2383 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2384 );
2385 }
2386
2387 #[test]
2388 fn test_code_and_link_combination() {
2389 let config = MD063Config {
2390 enabled: true,
2391 style: HeadingCapStyle::TitleCase,
2392 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2393 ..Default::default()
2394 };
2395 let rule = MD063HeadingCapitalization::from_config_struct(config);
2396
2397 let content = "## Guide for the `code` [link](./page.md)\n";
2399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2400 let fixed = rule.fix(&ctx).unwrap();
2401 assert!(
2403 fixed.contains("for the `code`"),
2404 "Expected 'for the `code`' but got: {fixed:?}"
2405 );
2406 }
2407
2408 #[test]
2409 fn test_text_after_code_capitalizes_last() {
2410 let config = MD063Config {
2411 enabled: true,
2412 style: HeadingCapStyle::TitleCase,
2413 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2414 ..Default::default()
2415 };
2416 let rule = MD063HeadingCapitalization::from_config_struct(config);
2417
2418 let content = "## Using `const` for the code\n";
2420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2421 let fixed = rule.fix(&ctx).unwrap();
2422 assert!(
2424 fixed.contains("for the Code"),
2425 "Expected 'for the Code' but got: {fixed:?}"
2426 );
2427 }
2428
2429 #[test]
2430 fn test_preserve_cased_words_with_trailing_code() {
2431 let config = MD063Config {
2432 enabled: true,
2433 style: HeadingCapStyle::TitleCase,
2434 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2435 preserve_cased_words: true,
2436 ..Default::default()
2437 };
2438 let rule = MD063HeadingCapitalization::from_config_struct(config);
2439
2440 let content = "## Guide for iOS `app`\n";
2442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2443 let fixed = rule.fix(&ctx).unwrap();
2444 assert!(
2446 fixed.contains("for iOS `app`"),
2447 "Expected 'for iOS `app`' but got: {fixed:?}"
2448 );
2449 assert!(
2450 !fixed.contains("For iOS `app`"),
2451 "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
2452 );
2453 }
2454
2455 #[test]
2456 fn test_ignore_words_with_trailing_code() {
2457 let config = MD063Config {
2458 enabled: true,
2459 style: HeadingCapStyle::TitleCase,
2460 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2461 ignore_words: vec!["npm".to_string()],
2462 ..Default::default()
2463 };
2464 let rule = MD063HeadingCapitalization::from_config_struct(config);
2465
2466 let content = "## Using npm with a `script`\n";
2468 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2469 let fixed = rule.fix(&ctx).unwrap();
2470 assert!(
2472 fixed.contains("npm with a `script`"),
2473 "Expected 'npm with a `script`' but got: {fixed:?}"
2474 );
2475 assert!(
2476 !fixed.contains("with A `script`"),
2477 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2478 );
2479 }
2480
2481 #[test]
2482 fn test_empty_text_segment_edge_case() {
2483 let config = MD063Config {
2484 enabled: true,
2485 style: HeadingCapStyle::TitleCase,
2486 lowercase_words: vec!["a".to_string(), "with".to_string()],
2487 ..Default::default()
2488 };
2489 let rule = MD063HeadingCapitalization::from_config_struct(config);
2490
2491 let content = "## `start` with a `end`\n";
2493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2494 let fixed = rule.fix(&ctx).unwrap();
2495 assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
2498 assert!(
2499 !fixed.contains("A `end`"),
2500 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2501 );
2502 }
2503
2504 #[test]
2505 fn test_sentence_case_with_trailing_code() {
2506 let config = MD063Config {
2507 enabled: true,
2508 style: HeadingCapStyle::SentenceCase,
2509 lowercase_words: vec!["a".to_string(), "the".to_string()],
2510 ..Default::default()
2511 };
2512 let rule = MD063HeadingCapitalization::from_config_struct(config);
2513
2514 let content = "## guide for the `user`\n";
2516 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2517 let fixed = rule.fix(&ctx).unwrap();
2518 assert!(
2520 fixed.contains("Guide for the `user`"),
2521 "Expected 'Guide for the `user`' but got: {fixed:?}"
2522 );
2523 }
2524
2525 #[test]
2526 fn test_hyphenated_word_before_code() {
2527 let config = MD063Config {
2528 enabled: true,
2529 style: HeadingCapStyle::TitleCase,
2530 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2531 ..Default::default()
2532 };
2533 let rule = MD063HeadingCapitalization::from_config_struct(config);
2534
2535 let content = "## Self-contained with a `feature`\n";
2537 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2538 let fixed = rule.fix(&ctx).unwrap();
2539 assert!(
2541 fixed.contains("with a `feature`"),
2542 "Expected 'with a `feature`' but got: {fixed:?}"
2543 );
2544 }
2545
2546 #[test]
2551 fn test_sentence_case_code_at_start_basic() {
2552 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2554 let content = "# `rumdl` is a linter\n";
2555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2556 let result = rule.check(&ctx).unwrap();
2557 assert!(
2559 result.is_empty(),
2560 "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
2561 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2562 );
2563 }
2564
2565 #[test]
2566 fn test_sentence_case_code_at_start_incorrect_capitalization() {
2567 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2569 let content = "# `rumdl` Is a Linter\n";
2570 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2571 let result = rule.check(&ctx).unwrap();
2572 assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
2574 assert!(
2575 result[0].message.contains("`rumdl` is a linter"),
2576 "Should suggest lowercase after code. Got: {:?}",
2577 result[0].message
2578 );
2579 }
2580
2581 #[test]
2582 fn test_sentence_case_code_at_start_fix() {
2583 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2584 let content = "# `rumdl` Is A Linter\n";
2585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2586 let fixed = rule.fix(&ctx).unwrap();
2587 assert!(
2588 fixed.contains("# `rumdl` is a linter"),
2589 "Should fix to lowercase after code. Got: {fixed:?}"
2590 );
2591 }
2592
2593 #[test]
2594 fn test_sentence_case_text_at_start_still_capitalizes() {
2595 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2597 let content = "# the quick brown fox\n";
2598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2599 let result = rule.check(&ctx).unwrap();
2600 assert_eq!(result.len(), 1);
2601 assert!(
2602 result[0].message.contains("The quick brown fox"),
2603 "Text-first heading should capitalize first word. Got: {:?}",
2604 result[0].message
2605 );
2606 }
2607
2608 #[test]
2609 fn test_sentence_case_link_at_start() {
2610 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2612 let content = "# [api](api.md) reference guide\n";
2613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2614 let result = rule.check(&ctx).unwrap();
2615 assert_eq!(result.len(), 1);
2616 assert!(result[0].message.contains("[Api](api.md) reference guide"));
2617 }
2618
2619 #[test]
2620 fn test_sentence_case_link_preserves_acronyms() {
2621 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2623 let content = "# [API](api.md) Reference Guide\n";
2624 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2625 let result = rule.check(&ctx).unwrap();
2626 assert_eq!(result.len(), 1);
2627 assert!(
2629 result[0].message.contains("[API](api.md) reference guide"),
2630 "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
2631 result[0].message
2632 );
2633 }
2634
2635 #[test]
2636 fn test_sentence_case_link_preserves_brand_names() {
2637 let config = MD063Config {
2639 enabled: true,
2640 style: HeadingCapStyle::SentenceCase,
2641 preserve_cased_words: true,
2642 ..Default::default()
2643 };
2644 let rule = MD063HeadingCapitalization::from_config_struct(config);
2645 let content = "# [iPhone](iphone.md) Features Guide\n";
2646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2647 let result = rule.check(&ctx).unwrap();
2648 assert_eq!(result.len(), 1);
2649 assert!(
2651 result[0].message.contains("[iPhone](iphone.md) features guide"),
2652 "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
2653 result[0].message
2654 );
2655 }
2656
2657 #[test]
2658 fn test_sentence_case_link_lowercases_regular_words() {
2659 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2661 let content = "# [Documentation](docs.md) Reference\n";
2662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2663 let result = rule.check(&ctx).unwrap();
2664 assert_eq!(result.len(), 1);
2665 assert!(
2666 result[0].message.contains("[Documentation](docs.md) reference"),
2667 "Should preserve the sentence-initial capital. Got: {:?}",
2668 result[0].message
2669 );
2670 }
2671
2672 #[test]
2673 fn test_sentence_case_opening_link_label_is_sentence_initial() {
2674 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2676 let content = "# [Foo bar](https://example.com)\n";
2677 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2678
2679 assert!(rule.check(&ctx).unwrap().is_empty());
2680 assert_eq!(rule.fix(&ctx).unwrap(), content);
2681 }
2682
2683 #[test]
2684 fn test_sentence_case_link_at_start_correct_already() {
2685 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2687 let content = "# [API](api.md) reference guide\n";
2688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2689 let result = rule.check(&ctx).unwrap();
2690 assert!(
2691 result.is_empty(),
2692 "Correctly cased heading with link should not be flagged. Got: {:?}",
2693 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2694 );
2695 }
2696
2697 #[test]
2698 fn test_sentence_case_link_github_preserved() {
2699 let config = MD063Config {
2701 enabled: true,
2702 style: HeadingCapStyle::SentenceCase,
2703 preserve_cased_words: true,
2704 ..Default::default()
2705 };
2706 let rule = MD063HeadingCapitalization::from_config_struct(config);
2707 let content = "# [GitHub](gh.md) Repository Setup\n";
2708 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2709 let result = rule.check(&ctx).unwrap();
2710 assert_eq!(result.len(), 1);
2711 assert!(
2712 result[0].message.contains("[GitHub](gh.md) repository setup"),
2713 "Should preserve 'GitHub'. Got: {:?}",
2714 result[0].message
2715 );
2716 }
2717
2718 #[test]
2719 fn test_sentence_case_multiple_code_spans() {
2720 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2721 let content = "# `foo` and `bar` are methods\n";
2722 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2723 let result = rule.check(&ctx).unwrap();
2724 assert!(
2726 result.is_empty(),
2727 "Should not capitalize words between/after code spans. Got: {:?}",
2728 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2729 );
2730 }
2731
2732 #[test]
2733 fn test_sentence_case_code_only_heading() {
2734 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2736 let content = "# `rumdl`\n";
2737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2738 let result = rule.check(&ctx).unwrap();
2739 assert!(
2740 result.is_empty(),
2741 "Code-only heading should be fine. Got: {:?}",
2742 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2743 );
2744 }
2745
2746 #[test]
2747 fn test_sentence_case_code_at_end() {
2748 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2750 let content = "# install the `rumdl` tool\n";
2751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2752 let result = rule.check(&ctx).unwrap();
2753 assert_eq!(result.len(), 1);
2755 assert!(
2756 result[0].message.contains("Install the `rumdl` tool"),
2757 "First word should still be capitalized when text comes first. Got: {:?}",
2758 result[0].message
2759 );
2760 }
2761
2762 #[test]
2763 fn test_sentence_case_code_in_middle() {
2764 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2766 let content = "# using the `rumdl` linter for markdown\n";
2767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2768 let result = rule.check(&ctx).unwrap();
2769 assert_eq!(result.len(), 1);
2771 assert!(
2772 result[0].message.contains("Using the `rumdl` linter for markdown"),
2773 "First word should be capitalized. Got: {:?}",
2774 result[0].message
2775 );
2776 }
2777
2778 #[test]
2779 fn test_sentence_case_preserved_word_after_code() {
2780 let config = MD063Config {
2782 enabled: true,
2783 style: HeadingCapStyle::SentenceCase,
2784 preserve_cased_words: true,
2785 ..Default::default()
2786 };
2787 let rule = MD063HeadingCapitalization::from_config_struct(config);
2788 let content = "# `swift` iPhone development\n";
2789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2790 let result = rule.check(&ctx).unwrap();
2791 assert!(
2793 result.is_empty(),
2794 "Preserved words after code should stay. Got: {:?}",
2795 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2796 );
2797 }
2798
2799 #[test]
2800 fn test_title_case_code_at_start_still_capitalizes() {
2801 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2803 let content = "# `api` quick start guide\n";
2804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2805 let result = rule.check(&ctx).unwrap();
2806 assert_eq!(result.len(), 1);
2808 assert!(
2809 result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
2810 "Title case should capitalize major words after code. Got: {:?}",
2811 result[0].message
2812 );
2813 }
2814
2815 #[test]
2818 fn test_sentence_case_html_tag_at_start() {
2819 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2821 let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
2822 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2823 let result = rule.check(&ctx).unwrap();
2824 assert_eq!(result.len(), 1);
2826 let fixed = rule.fix(&ctx).unwrap();
2827 assert_eq!(
2828 fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
2829 "Text after HTML at start should be lowercase"
2830 );
2831 }
2832
2833 #[test]
2834 fn test_sentence_case_html_tag_preserves_content() {
2835 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2837 let content = "# The <abbr>API</abbr> documentation guide\n";
2838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2839 let result = rule.check(&ctx).unwrap();
2840 assert!(
2842 result.is_empty(),
2843 "HTML tag content should be preserved. Got: {:?}",
2844 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2845 );
2846 }
2847
2848 #[test]
2849 fn test_sentence_case_html_tag_at_start_with_acronym() {
2850 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2852 let content = "# <abbr>API</abbr> Documentation Guide\n";
2853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2854 let result = rule.check(&ctx).unwrap();
2855 assert_eq!(result.len(), 1);
2856 let fixed = rule.fix(&ctx).unwrap();
2857 assert_eq!(
2858 fixed, "# <abbr>API</abbr> documentation guide\n",
2859 "Text after HTML at start should be lowercase, HTML content preserved"
2860 );
2861 }
2862
2863 #[test]
2864 fn test_sentence_case_html_tag_in_middle() {
2865 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2867 let content = "# using the <code>config</code> File\n";
2868 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2869 let result = rule.check(&ctx).unwrap();
2870 assert_eq!(result.len(), 1);
2871 let fixed = rule.fix(&ctx).unwrap();
2872 assert_eq!(
2873 fixed, "# Using the <code>config</code> file\n",
2874 "First word capitalized, HTML preserved, rest lowercase"
2875 );
2876 }
2877
2878 #[test]
2879 fn test_html_tag_strong_emphasis() {
2880 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2882 let content = "# The <strong>Bold</strong> Way\n";
2883 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2884 let result = rule.check(&ctx).unwrap();
2885 assert_eq!(result.len(), 1);
2886 let fixed = rule.fix(&ctx).unwrap();
2887 assert_eq!(
2888 fixed, "# The <strong>Bold</strong> way\n",
2889 "<strong> tag content should be preserved"
2890 );
2891 }
2892
2893 #[test]
2894 fn test_html_tag_with_attributes() {
2895 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2897 let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
2898 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2899 let result = rule.check(&ctx).unwrap();
2900 assert_eq!(result.len(), 1);
2901 let fixed = rule.fix(&ctx).unwrap();
2902 assert_eq!(
2903 fixed, "# <span class=\"highlight\">Important</span> notice here\n",
2904 "HTML tag with attributes should be preserved"
2905 );
2906 }
2907
2908 #[test]
2909 fn test_multiple_html_tags() {
2910 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2912 let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
2913 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2914 let result = rule.check(&ctx).unwrap();
2915 assert_eq!(result.len(), 1);
2916 let fixed = rule.fix(&ctx).unwrap();
2917 assert_eq!(
2918 fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
2919 "Multiple HTML tags should all be preserved"
2920 );
2921 }
2922
2923 #[test]
2924 fn test_html_and_code_mixed() {
2925 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2927 let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
2928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2929 let result = rule.check(&ctx).unwrap();
2930 assert_eq!(result.len(), 1);
2931 let fixed = rule.fix(&ctx).unwrap();
2932 assert_eq!(
2933 fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
2934 "HTML and code should both be preserved"
2935 );
2936 }
2937
2938 #[test]
2939 fn test_self_closing_html_tag() {
2940 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2942 let content = "# Line one<br/>Line Two Here\n";
2943 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2944 let result = rule.check(&ctx).unwrap();
2945 assert_eq!(result.len(), 1);
2946 let fixed = rule.fix(&ctx).unwrap();
2947 assert_eq!(
2948 fixed, "# Line one<br/>line two here\n",
2949 "Self-closing HTML tags should be preserved"
2950 );
2951 }
2952
2953 #[test]
2954 fn test_title_case_with_html_tags() {
2955 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2957 let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
2958 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2959 let result = rule.check(&ctx).unwrap();
2960 assert_eq!(result.len(), 1);
2961 let fixed = rule.fix(&ctx).unwrap();
2962 assert!(
2964 fixed.contains("<kbd>ctrl</kbd>"),
2965 "HTML tag content should be preserved in title case. Got: {fixed}"
2966 );
2967 assert!(
2968 fixed.starts_with("# The ") || fixed.starts_with("# the "),
2969 "Title case should work with HTML. Got: {fixed}"
2970 );
2971 }
2972
2973 #[test]
2976 fn test_sentence_case_preserves_caret_notation() {
2977 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2979 let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
2980 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2981 let result = rule.check(&ctx).unwrap();
2982 assert!(
2984 result.is_empty(),
2985 "Caret notation should be preserved. Got: {:?}",
2986 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2987 );
2988 }
2989
2990 #[test]
2991 fn test_sentence_case_caret_notation_various() {
2992 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2994
2995 let content = "## Press ^C to cancel\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 "^C should be preserved. Got: {:?}",
3002 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3003 );
3004
3005 let content = "## Use ^Z for background\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 "^Z should be preserved. Got: {:?}",
3012 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3013 );
3014
3015 let content = "## Press ^[ for escape\n";
3017 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3018 let result = rule.check(&ctx).unwrap();
3019 assert!(
3020 result.is_empty(),
3021 "^[ should be preserved. Got: {:?}",
3022 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3023 );
3024 }
3025
3026 #[test]
3027 fn test_caret_notation_detection() {
3028 let rule = create_rule();
3029
3030 assert!(rule.is_caret_notation("^A"));
3032 assert!(rule.is_caret_notation("^Z"));
3033 assert!(rule.is_caret_notation("^C"));
3034 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")); }
3046
3047 fn create_sentence_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
3054 let config = MD063Config {
3055 enabled: true,
3056 style: HeadingCapStyle::SentenceCase,
3057 ..Default::default()
3058 };
3059 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3060 rule.proper_names = names;
3061 rule
3062 }
3063
3064 #[test]
3065 fn test_sentence_case_preserves_single_word_proper_name() {
3066 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
3067 let content = "# installing javascript\n";
3069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3070 let result = rule.check(&ctx).unwrap();
3071 assert_eq!(result.len(), 1, "Should flag the heading");
3072 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3073 assert!(
3074 fix_text.contains("JavaScript"),
3075 "Fix should preserve proper name 'JavaScript', got: {fix_text:?}"
3076 );
3077 assert!(
3078 !fix_text.contains("javascript"),
3079 "Fix should not have lowercase 'javascript', got: {fix_text:?}"
3080 );
3081 }
3082
3083 #[test]
3084 fn test_sentence_case_preserves_multi_word_proper_name() {
3085 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3086 let content = "# using good application features\n";
3088 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3089 let result = rule.check(&ctx).unwrap();
3090 assert_eq!(result.len(), 1, "Should flag the heading");
3091 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3092 assert!(
3093 fix_text.contains("Good Application"),
3094 "Fix should preserve 'Good Application' as a phrase, got: {fix_text:?}"
3095 );
3096 }
3097
3098 #[test]
3099 fn test_sentence_case_proper_name_at_start_of_heading() {
3100 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3101 let content = "# good application overview\n";
3103 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3104 let result = rule.check(&ctx).unwrap();
3105 assert_eq!(result.len(), 1, "Should flag the heading");
3106 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3107 assert!(
3108 fix_text.contains("Good Application"),
3109 "Fix should produce 'Good Application' at start of heading, got: {fix_text:?}"
3110 );
3111 assert!(
3112 fix_text.contains("overview"),
3113 "Non-proper-name word 'overview' should be lowercase, got: {fix_text:?}"
3114 );
3115 }
3116
3117 #[test]
3118 fn test_sentence_case_with_proper_names_no_oscillation() {
3119 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3122
3123 let content = "# installing good application on your system\n";
3125 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3126 let result = rule.check(&ctx).unwrap();
3127 assert_eq!(result.len(), 1);
3128 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
3129
3130 assert!(
3132 fixed_heading.contains("Good Application"),
3133 "After fix, proper name must be preserved: {fixed_heading:?}"
3134 );
3135
3136 let fixed_line = format!("{fixed_heading}\n");
3138 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
3139 let result2 = rule.check(&ctx2).unwrap();
3140 assert!(
3141 result2.is_empty(),
3142 "After one fix, heading must already satisfy both MD063 and MD044 - no oscillation. \
3143 Second pass warnings: {result2:?}"
3144 );
3145 }
3146
3147 #[test]
3148 fn test_sentence_case_proper_names_already_correct() {
3149 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3150 let content = "# Installing Good Application\n";
3152 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3153 let result = rule.check(&ctx).unwrap();
3154 assert!(
3155 result.is_empty(),
3156 "Correct sentence-case heading with proper name should not be flagged, got: {result:?}"
3157 );
3158 }
3159
3160 #[test]
3161 fn test_sentence_case_multiple_proper_names_in_heading() {
3162 let rule = create_sentence_case_rule_with_proper_names(vec!["TypeScript".to_string(), "React".to_string()]);
3163 let content = "# using typescript with react\n";
3164 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3165 let result = rule.check(&ctx).unwrap();
3166 assert_eq!(result.len(), 1);
3167 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3168 assert!(
3169 fix_text.contains("TypeScript"),
3170 "Fix should preserve 'TypeScript', got: {fix_text:?}"
3171 );
3172 assert!(
3173 fix_text.contains("React"),
3174 "Fix should preserve 'React', got: {fix_text:?}"
3175 );
3176 }
3177
3178 #[test]
3179 fn test_sentence_case_unicode_casefold_expansion_before_proper_name() {
3180 let rule = create_sentence_case_rule_with_proper_names(vec!["Österreich".to_string()]);
3183 let content = "# İ österreich guide\n";
3184 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3185
3186 let result = rule.check(&ctx).unwrap();
3188 assert_eq!(result.len(), 1, "Should flag heading for canonical proper-name casing");
3189 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3190 assert!(
3191 fix_text.contains("Österreich"),
3192 "Fix should preserve canonical 'Österreich', got: {fix_text:?}"
3193 );
3194 }
3195
3196 #[test]
3197 fn test_sentence_case_preserves_trailing_punctuation_on_proper_name() {
3198 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
3199 let content = "# using javascript, today\n";
3200 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3201 let result = rule.check(&ctx).unwrap();
3202 assert_eq!(result.len(), 1, "Should flag heading");
3203 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3204 assert!(
3205 fix_text.contains("JavaScript,"),
3206 "Fix should preserve trailing punctuation, got: {fix_text:?}"
3207 );
3208 }
3209
3210 fn create_title_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
3217 let config = MD063Config {
3218 enabled: true,
3219 style: HeadingCapStyle::TitleCase,
3220 ..Default::default()
3221 };
3222 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3223 rule.proper_names = names;
3224 rule
3225 }
3226
3227 #[test]
3228 fn test_title_case_preserves_proper_name_with_lowercase_article() {
3229 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
3233 let content = "# listening to the rolling stones today\n";
3234 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3235 let result = rule.check(&ctx).unwrap();
3236 assert_eq!(result.len(), 1, "Should flag the heading");
3237 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3238 assert!(
3239 fix_text.contains("The Rolling Stones"),
3240 "Fix should preserve proper name 'The Rolling Stones', got: {fix_text:?}"
3241 );
3242 }
3243
3244 #[test]
3245 fn test_title_case_proper_name_no_oscillation() {
3246 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
3248 let content = "# listening to the rolling stones today\n";
3249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3250 let result = rule.check(&ctx).unwrap();
3251 assert_eq!(result.len(), 1);
3252 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
3253
3254 let fixed_line = format!("{fixed_heading}\n");
3255 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
3256 let result2 = rule.check(&ctx2).unwrap();
3257 assert!(
3258 result2.is_empty(),
3259 "After one title-case fix, heading must already satisfy both rules. \
3260 Second pass warnings: {result2:?}"
3261 );
3262 }
3263
3264 #[test]
3265 fn test_title_case_unicode_casefold_expansion_before_proper_name() {
3266 let rule = create_title_case_rule_with_proper_names(vec!["Österreich".to_string()]);
3267 let content = "# İ österreich guide\n";
3268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3269 let result = rule.check(&ctx).unwrap();
3270 assert_eq!(result.len(), 1, "Should flag the heading");
3271 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3272 assert!(
3273 fix_text.contains("Österreich"),
3274 "Fix should preserve canonical proper-name casing, got: {fix_text:?}"
3275 );
3276 }
3277
3278 #[test]
3284 fn test_from_config_loads_md044_names_into_md063() {
3285 use crate::config::{Config, RuleConfig};
3286 use crate::rule::Rule;
3287 use std::collections::BTreeMap;
3288
3289 let mut config = Config::default();
3290
3291 let mut md063_values = BTreeMap::new();
3293 md063_values.insert("style".to_string(), toml::Value::String("sentence_case".to_string()));
3294 md063_values.insert("enabled".to_string(), toml::Value::Boolean(true));
3295 config.rules.insert(
3296 "MD063".to_string(),
3297 RuleConfig {
3298 values: md063_values,
3299 severity: None,
3300 },
3301 );
3302
3303 let mut md044_values = BTreeMap::new();
3305 md044_values.insert(
3306 "names".to_string(),
3307 toml::Value::Array(vec![toml::Value::String("Good Application".to_string())]),
3308 );
3309 config.rules.insert(
3310 "MD044".to_string(),
3311 RuleConfig {
3312 values: md044_values,
3313 severity: None,
3314 },
3315 );
3316
3317 let rule = MD063HeadingCapitalization::from_config(&config);
3319
3320 let content = "# using good application features\n";
3322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3323 let result = rule.check(&ctx).unwrap();
3324 assert_eq!(result.len(), 1, "Should flag the heading");
3325 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3326 assert!(
3327 fix_text.contains("Good Application"),
3328 "from_config should wire MD044 names into MD063; fix should preserve \
3329 'Good Application', got: {fix_text:?}"
3330 );
3331 }
3332
3333 #[test]
3334 fn test_title_case_short_word_not_confused_with_substring() {
3335 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3339
3340 let content = "# in the insert\n";
3343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3344 let result = rule.check(&ctx).unwrap();
3345 assert_eq!(result.len(), 1, "Should flag the heading");
3346 let fix = result[0].fix.as_ref().expect("Fix should be present");
3347 assert!(
3349 fix.replacement.contains("In the Insert"),
3350 "Expected 'In the Insert', got: {:?}",
3351 fix.replacement
3352 );
3353 }
3354
3355 #[test]
3356 fn test_title_case_or_not_confused_with_orchestra() {
3357 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3358
3359 let content = "# or the orchestra\n";
3362 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3363 let result = rule.check(&ctx).unwrap();
3364 assert_eq!(result.len(), 1, "Should flag the heading");
3365 let fix = result[0].fix.as_ref().expect("Fix should be present");
3366 assert!(
3368 fix.replacement.contains("Or the Orchestra"),
3369 "Expected 'Or the Orchestra', got: {:?}",
3370 fix.replacement
3371 );
3372 }
3373
3374 #[test]
3375 fn test_all_caps_preserves_all_words() {
3376 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
3377
3378 let content = "# in the insert\n";
3379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3380 let result = rule.check(&ctx).unwrap();
3381 assert_eq!(result.len(), 1, "Should flag the heading");
3382 let fix = result[0].fix.as_ref().expect("Fix should be present");
3383 assert!(
3384 fix.replacement.contains("IN THE INSERT"),
3385 "All caps should uppercase all words, got: {:?}",
3386 fix.replacement
3387 );
3388 }
3389
3390 #[test]
3392 fn test_title_case_numbered_prefix_lowercase_word() {
3393 let rule = create_rule();
3395 let content = "## 1. To Be a Thing\n";
3396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3397 let result = rule.check(&ctx).unwrap();
3398 assert!(
3399 result.is_empty(),
3400 "Should not flag '## 1. To Be a Thing', got: {result:?}"
3401 );
3402
3403 let content_lower = "## 1. to be a thing\n";
3404 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3405 let result2 = rule.check(&ctx2).unwrap();
3406 assert!(!result2.is_empty(), "Should flag '## 1. to be a thing'");
3407 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3408 assert!(
3409 fix.replacement.contains("1. To Be a Thing"),
3410 "Fix should capitalize 'To', got: {:?}",
3411 fix.replacement
3412 );
3413 }
3414
3415 #[test]
3416 fn test_title_case_numbered_prefix_article() {
3417 let rule = create_rule();
3419 let content = "## 2. A Guide to the Galaxy\n";
3420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3421 let result = rule.check(&ctx).unwrap();
3422 assert!(
3423 result.is_empty(),
3424 "Should not flag '## 2. A Guide to the Galaxy', got: {result:?}"
3425 );
3426
3427 let content_lower = "## 2. a guide to the galaxy\n";
3428 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3429 let result2 = rule.check(&ctx2).unwrap();
3430 assert!(!result2.is_empty(), "Should flag '## 2. a guide to the galaxy'");
3431 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3432 assert!(
3433 fix.replacement.contains("2. A Guide to the Galaxy"),
3434 "Fix should capitalize 'A', got: {:?}",
3435 fix.replacement
3436 );
3437 }
3438
3439 #[test]
3440 fn test_title_case_mid_sentence_period_word() {
3441 let rule = create_rule();
3443 let content = "## Step 1. Introduction to the Problem\n";
3444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3445 let result = rule.check(&ctx).unwrap();
3446 assert!(
3447 result.is_empty(),
3448 "Should not flag '## Step 1. Introduction to the Problem', got: {result:?}"
3449 );
3450
3451 let content_lower = "## Step 1. introduction to the problem\n";
3452 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3453 let result2 = rule.check(&ctx2).unwrap();
3454 assert!(
3455 !result2.is_empty(),
3456 "Should flag '## Step 1. introduction to the problem'"
3457 );
3458 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3459 assert!(
3460 fix.replacement.contains("Step 1. Introduction to the Problem"),
3461 "Fix should capitalize 'Introduction', got: {:?}",
3462 fix.replacement
3463 );
3464 }
3465
3466 #[test]
3467 fn test_title_case_numbered_prefix_in_link_text() {
3468 let config = MD063Config {
3471 enabled: true,
3472 style: HeadingCapStyle::TitleCase,
3473 ..Default::default()
3474 };
3475 let rule = MD063HeadingCapitalization::from_config_struct(config);
3476
3477 let content = "## [1. To Be a Thing](https://example.com)\n";
3479 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3480 let result = rule.check(&ctx).unwrap();
3481 assert!(
3482 result.is_empty(),
3483 "Should not flag '## [1. To Be a Thing](url)', got: {result:?}"
3484 );
3485
3486 let content_lower = "## [1. to be a thing](https://example.com)\n";
3488 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3489 let result2 = rule.check(&ctx2).unwrap();
3490 assert!(!result2.is_empty(), "Should flag '## [1. to be a thing](url)'");
3491 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3492 assert!(
3493 fix.replacement.contains("1. To Be a Thing"),
3494 "Fix should capitalize 'To' in link text, got: {:?}",
3495 fix.replacement
3496 );
3497 }
3498
3499 #[test]
3504 fn test_is_numeric_ordinal_recognises_canonical_forms() {
3505 for word in &[
3506 "1st", "2nd", "3rd", "4th", "5th", "11th", "21st", "22nd", "23rd", "100th", "1ST", "5Th", "21St", "21sT",
3507 ] {
3508 assert!(
3509 MD063HeadingCapitalization::is_numeric_ordinal(word),
3510 "expected `{word}` to be detected as a numeric ordinal"
3511 );
3512 }
3513 }
3514
3515 #[test]
3516 fn test_is_numeric_ordinal_rejects_non_ordinals() {
3517 for word in &[
3522 "first", "1stop", "ist", "5", "th", "abc", "4G", "4K", "30s", "100k", "5x", "1.5", "iPhone6S",
3523 ] {
3524 assert!(
3525 !MD063HeadingCapitalization::is_numeric_ordinal(word),
3526 "expected `{word}` NOT to be detected as a numeric ordinal"
3527 );
3528 }
3529 }
3530
3531 #[test]
3532 fn test_is_numeric_ordinal_strips_trailing_punctuation() {
3533 for word in &["5th.", "1st,", "21st!", "3rd:", "4th)", "5th's"] {
3534 assert!(
3535 MD063HeadingCapitalization::is_numeric_ordinal(word),
3536 "expected `{word}` to be detected as a numeric ordinal (with punctuation)"
3537 );
3538 }
3539 }
3540
3541 #[test]
3542 fn test_is_numeric_ordinal_ignores_wrapping_punctuation() {
3543 for word in &[
3548 "(2nd", "[2nd", "\"2nd", "'2nd", "*2nd", "(21st)", "\"3rd\"", "**5th**", "_1st_",
3549 ] {
3550 assert!(
3551 MD063HeadingCapitalization::is_numeric_ordinal(word),
3552 "expected `{word}` to be detected as a numeric ordinal"
3553 );
3554 }
3555
3556 for word in &["2-nd", "2 nd", "(", "\"\"", "()", "(nd", "(2"] {
3560 assert!(
3561 !MD063HeadingCapitalization::is_numeric_ordinal(word),
3562 "expected `{word}` NOT to be detected as a numeric ordinal"
3563 );
3564 }
3565 }
3566
3567 #[test]
3568 fn test_ordinal_wrapped_in_punctuation_survives_a_fix() {
3569 for (style, content) in [
3572 (HeadingCapStyle::SentenceCase, "# The second (2nd) attempt\n"),
3573 (HeadingCapStyle::SentenceCase, "# Ranked \"3rd\" overall\n"),
3574 (HeadingCapStyle::SentenceCase, "# Plain 2nd place\n"),
3575 (HeadingCapStyle::TitleCase, "# The Second (2nd) Attempt\n"),
3576 (HeadingCapStyle::TitleCase, "# Ranked \"3rd\" Overall\n"),
3577 ] {
3578 let rule = create_rule_with_style(style);
3579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3580 let result = rule.check(&ctx).unwrap();
3581 assert!(
3582 result.is_empty(),
3583 "{style:?} should not flag {content:?}, got: {result:?}"
3584 );
3585 assert_eq!(
3586 rule.fix(&ctx).unwrap(),
3587 content,
3588 "{style:?} must leave {content:?} alone"
3589 );
3590 }
3591
3592 for (style, content, expected) in [
3596 (
3597 HeadingCapStyle::SentenceCase,
3598 "# The Second (2nd) Attempt\n",
3599 "# The second (2nd) attempt\n",
3600 ),
3601 (
3602 HeadingCapStyle::TitleCase,
3603 "# the second (2nd) attempt\n",
3604 "# The Second (2nd) Attempt\n",
3605 ),
3606 ] {
3607 let rule = create_rule_with_style(style);
3608 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3609 assert!(
3610 !rule.check(&ctx).unwrap().is_empty(),
3611 "{style:?} should flag {content:?}"
3612 );
3613 let fixed = rule.fix(&ctx).unwrap();
3614 assert_eq!(fixed, expected, "{style:?} fix of {content:?}");
3615
3616 let refixed = rule
3617 .fix(&LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None))
3618 .unwrap();
3619 assert_eq!(refixed, expected, "{style:?} second pass over {fixed:?}");
3620 }
3621 }
3622
3623 #[test]
3624 fn test_wrapped_ordinal_corrupted_by_the_old_fix_is_repaired() {
3625 for (style, content, expected) in [
3629 (
3630 HeadingCapStyle::SentenceCase,
3631 "# The second (2Nd) attempt\n",
3632 "# The second (2nd) attempt\n",
3633 ),
3634 (
3635 HeadingCapStyle::TitleCase,
3636 "# The Second (2Nd) Attempt\n",
3637 "# The Second (2nd) Attempt\n",
3638 ),
3639 (
3640 HeadingCapStyle::SentenceCase,
3641 "# Ranked \"3Rd\" overall\n",
3642 "# Ranked \"3rd\" overall\n",
3643 ),
3644 ] {
3645 let rule = create_rule_with_style(style);
3646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3647 assert!(
3648 !rule.check(&ctx).unwrap().is_empty(),
3649 "{style:?} should flag {content:?}"
3650 );
3651 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{style:?} fix of {content:?}");
3652 }
3653 }
3654
3655 #[test]
3656 fn test_title_case_ordinal_first_word_not_flagged() {
3657 let rule = create_rule();
3658 for content in &[
3659 "# 1st Place\n",
3660 "# 2nd Edition\n",
3661 "# 3rd Time\n",
3662 "# 5th Avenue\n",
3663 "# 21st Century Skills\n",
3664 "# 100th Customer\n",
3665 ] {
3666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3667 let result = rule.check(&ctx).unwrap();
3668 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3669 }
3670 }
3671
3672 #[test]
3673 fn test_title_case_ordinal_mid_heading_not_flagged() {
3674 let rule = create_rule();
3675 for content in &[
3676 "# May 3rd Notes\n",
3677 "# Top 100th Customer\n",
3678 "# Notes for the 5th of May\n",
3679 ] {
3680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3681 let result = rule.check(&ctx).unwrap();
3682 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3683 }
3684 }
3685
3686 #[test]
3687 fn test_title_case_ordinal_corrupted_form_is_fixed() {
3688 let rule = create_rule();
3691 for (input, expected) in &[
3692 ("# 1St Place\n", "1st Place"),
3693 ("# 5Th Avenue\n", "5th Avenue"),
3694 ("# 21St Century Skills\n", "21st Century Skills"),
3695 ("# May 3Rd Notes\n", "May 3rd Notes"),
3696 ("# 22Nd Edition\n", "22nd Edition"),
3697 ] {
3698 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
3699 let result = rule.check(&ctx).unwrap();
3700 assert!(!result.is_empty(), "Should flag {input:?}");
3701 let fix = result[0].fix.as_ref().expect("should have a fix");
3702 assert!(
3703 fix.replacement.contains(expected),
3704 "Fix for {input:?} should contain {expected:?}, got: {:?}",
3705 fix.replacement
3706 );
3707 }
3708 }
3709
3710 #[test]
3711 fn test_title_case_ordinal_lowercase_other_words_capitalised() {
3712 let rule = create_rule();
3714 let content = "# 5th avenue\n";
3715 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3716 let result = rule.check(&ctx).unwrap();
3717 assert_eq!(result.len(), 1);
3718 let fix = result[0].fix.as_ref().expect("should have a fix");
3719 assert!(
3720 fix.replacement.contains("5th Avenue"),
3721 "Fix should produce '5th Avenue', got: {:?}",
3722 fix.replacement
3723 );
3724 }
3725
3726 #[test]
3727 fn test_title_case_ordinal_with_trailing_punctuation() {
3728 let rule = create_rule();
3729 let content = "# Released on the 5th.\n";
3730 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3731 let result = rule.check(&ctx).unwrap();
3732 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3733 }
3734
3735 #[test]
3736 fn test_title_case_ordinal_hyphenated() {
3737 let rule = create_rule();
3738 for content in &["# 21st-Century Skills\n", "# A 19th-Century Novel\n"] {
3739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3740 let result = rule.check(&ctx).unwrap();
3741 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3742 }
3743 }
3744
3745 #[test]
3746 fn test_sentence_case_ordinal_corrupted_form_is_fixed() {
3747 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3748 let content = "# 5Th avenue\n";
3749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3750 let result = rule.check(&ctx).unwrap();
3751 assert_eq!(result.len(), 1);
3752 let fix = result[0].fix.as_ref().expect("should have a fix");
3753 assert!(
3754 fix.replacement.contains("5th avenue"),
3755 "Fix should produce '5th avenue', got: {:?}",
3756 fix.replacement
3757 );
3758 }
3759
3760 #[test]
3761 fn test_title_case_digit_acronym_unchanged() {
3762 let rule = create_rule();
3765 for content in &["# 4G Networks\n", "# 4K Streaming\n"] {
3766 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3767 let result = rule.check(&ctx).unwrap();
3768 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3769 }
3770 }
3771
3772 fn restart_rule(boundaries: &[&str]) -> MD063HeadingCapitalization {
3775 let config = MD063Config {
3776 enabled: true,
3777 style: HeadingCapStyle::SentenceCase,
3778 sentence_case_restart_after: boundaries.iter().copied().map(String::from).collect(),
3779 ..Default::default()
3780 };
3781 MD063HeadingCapitalization::from_config_struct(config)
3782 }
3783
3784 fn suggested(rule: &MD063HeadingCapitalization, content: &str) -> Option<String> {
3787 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3788 let warnings = rule.check(&ctx).unwrap();
3789 let fixed = rule.fix(&ctx).unwrap();
3790 assert_eq!(
3791 warnings.is_empty(),
3792 fixed == content,
3793 "a warning and a rewrite must agree for {content:?}"
3794 );
3795 (!warnings.is_empty()).then(|| fixed.trim_start_matches('#').trim().to_string())
3796 }
3797
3798 #[test]
3799 fn test_restart_after_capitalizes_the_word_following_a_boundary() {
3800 let rule = restart_rule(&[":"]);
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_defaults_to_no_boundaries() {
3809 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3811 assert_eq!(
3812 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3813 Some("Requirement 1: struct to logger slice conversion")
3814 );
3815 }
3816
3817 #[test]
3818 fn test_restart_after_only_honors_configured_punctuation() {
3819 let rule = restart_rule(&[":"]);
3821 assert_eq!(
3822 suggested(&rule, "# Design - Data Model Overview\n").as_deref(),
3823 Some("Design - data model overview")
3824 );
3825 assert_eq!(
3826 suggested(&rule, "# Setup; Then Run\n").as_deref(),
3827 Some("Setup; then run")
3828 );
3829
3830 let rule = restart_rule(&[";", "\u{2014}"]);
3831 assert_eq!(
3832 suggested(&rule, "# Setup; Then Run\n").as_deref(),
3833 Some("Setup; Then run")
3834 );
3835 assert_eq!(
3836 suggested(&rule, "# Part One \u{2014} The Big Idea\n").as_deref(),
3837 Some("Part one \u{2014} The big idea")
3838 );
3839 }
3840
3841 #[test]
3842 fn test_restart_after_matches_only_at_the_end_of_a_word() {
3843 let rule = restart_rule(&["-", ":"]);
3846 assert_eq!(
3847 suggested(&rule, "# Ports: Well-Known Ports Explained\n").as_deref(),
3848 Some("Ports: Well-Known ports explained")
3849 );
3850 assert_eq!(
3851 suggested(&rule, "# See https://example.com/A/B For Details\n").as_deref(),
3852 Some("See https://example.com/A/B for details")
3853 );
3854 }
3855
3856 #[test]
3857 fn test_restart_after_a_trailing_boundary_is_a_no_op() {
3858 let rule = restart_rule(&[":"]);
3859 assert_eq!(suggested(&rule, "# Setup:\n"), None);
3860 }
3861
3862 #[test]
3863 fn test_restart_after_does_not_override_preserved_words() {
3864 let rule = restart_rule(&[":"]);
3867 assert_eq!(
3868 suggested(&rule, "# Devices: iPhone And Android\n").as_deref(),
3869 Some("Devices: iPhone and android")
3870 );
3871
3872 let config = MD063Config {
3873 enabled: true,
3874 style: HeadingCapStyle::SentenceCase,
3875 sentence_case_restart_after: vec![":".to_string()],
3876 ignore_words: vec!["kubectl".to_string()],
3877 preserve_cased_words: false,
3878 ..Default::default()
3879 };
3880 let rule = MD063HeadingCapitalization::from_config_struct(config);
3881 assert_eq!(
3882 suggested(&rule, "# Tools: kubectl And Helm\n").as_deref(),
3883 Some("Tools: kubectl and helm")
3884 );
3885 }
3886
3887 #[test]
3888 fn test_restart_after_keeps_md044_canonical_forms() {
3889 let config = MD063Config {
3890 enabled: true,
3891 style: HeadingCapStyle::SentenceCase,
3892 sentence_case_restart_after: vec![":".to_string()],
3893 ..Default::default()
3894 };
3895 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3896 rule.proper_names = vec!["GitHub".to_string()];
3897
3898 assert_eq!(
3900 suggested(&rule, "# Docs: github Actions Guide\n").as_deref(),
3901 Some("Docs: GitHub actions guide")
3902 );
3903 assert_eq!(suggested(&rule, "# Docs: GitHub actions guide\n"), None);
3904 }
3905
3906 #[test]
3907 fn test_restart_after_carries_across_segments() {
3908 let rule = restart_rule(&[":"]);
3911 assert_eq!(
3912 suggested(
3913 &rule,
3914 "# Overview: [Some Link Here](https://example.com) Trailing Words\n"
3915 )
3916 .as_deref(),
3917 Some("Overview: [Some link here](https://example.com) trailing words")
3918 );
3919 assert_eq!(
3920 suggested(&rule, "# Overview: `code` Then More Words\n").as_deref(),
3921 Some("Overview: `code` then more words")
3922 );
3923 }
3924
3925 #[test]
3926 fn test_restart_after_ends_a_sentence_at_the_end_of_link_text() {
3927 let rule = restart_rule(&[":"]);
3931 assert_eq!(
3932 suggested(&rule, "# Topic [See:](https://example.com) More Words\n").as_deref(),
3933 Some("Topic [see:](https://example.com) More words")
3934 );
3935
3936 assert_eq!(
3938 suggested(&rule, "# Topic [See](https://example.com) More Words\n").as_deref(),
3939 Some("Topic [see](https://example.com) more words")
3940 );
3941 }
3942
3943 #[test]
3944 fn test_restart_after_ignores_boundaries_inside_opaque_segments() {
3945 let rule = restart_rule(&[":"]);
3948 for content in [
3949 "# Topic `see:` More Words\n",
3950 "# Topic  More Words\n",
3951 "# Topic <span title=\"x:\">y</span> More Words\n",
3952 ] {
3953 let fixed = suggested(&rule, content).expect("heading should be rewritten");
3954 assert!(
3955 fixed.ends_with("more words"),
3956 "opaque segment restarted the sentence in {content:?}: {fixed}"
3957 );
3958 }
3959 }
3960
3961 #[test]
3962 fn test_restart_after_treats_a_leading_link_as_sentence_initial() {
3963 for rule in [restart_rule(&[]), restart_rule(&[":"])] {
3966 assert_eq!(
3967 suggested(&rule, "# [Some Link Here](https://example.com) Trailing Words\n").as_deref(),
3968 Some("[Some link here](https://example.com) trailing words")
3969 );
3970 }
3971 }
3972
3973 #[test]
3974 fn test_restart_after_fix_is_idempotent() {
3975 let rule = restart_rule(&[":", ";", "-", "\u{2014}"]);
3976 for content in [
3977 "# Requirement 1: Struct to Logger Slice Conversion\n",
3978 "# Ports: Well-Known Ports Explained\n",
3979 "# Devices: iPhone And Android\n",
3980 "# Overview: [Some Link Here](https://example.com) Trailing Words\n",
3981 "# Setup:\n",
3982 ] {
3983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3984 let once = rule.fix(&ctx).unwrap();
3985 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
3986 assert_eq!(rule.fix(&ctx).unwrap(), once, "fix is not idempotent for {content:?}");
3987 }
3988 }
3989
3990 #[test]
3991 fn test_restart_after_ignores_empty_boundary_entries() {
3992 let rule = restart_rule(&[""]);
3994 assert_eq!(
3995 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3996 Some("Requirement 1: struct to logger slice conversion")
3997 );
3998 }
3999
4000 const STYLES: [HeadingCapStyle; 3] = [
4003 HeadingCapStyle::TitleCase,
4004 HeadingCapStyle::SentenceCase,
4005 HeadingCapStyle::AllCaps,
4006 ];
4007
4008 const GHERKIN_STRUCTURES: [(&str, &str, &str, &str); 6] = [
4011 (
4012 "# Feature: the system under test",
4013 "# Feature: The System Under Test",
4014 "# Feature: The system under test",
4015 "# Feature: THE SYSTEM UNDER TEST",
4016 ),
4017 (
4018 "## Background: a shared setup",
4019 "## Background: A Shared Setup",
4020 "## Background: A shared setup",
4021 "## Background: A SHARED SETUP",
4022 ),
4023 (
4024 "## Rule: money is never lost",
4025 "## Rule: Money Is Never Lost",
4026 "## Rule: Money is never lost",
4027 "## Rule: MONEY IS NEVER LOST",
4028 ),
4029 (
4030 "### Scenario: add two numbers",
4031 "### Scenario: Add Two Numbers",
4032 "### Scenario: Add two numbers",
4033 "### Scenario: ADD TWO NUMBERS",
4034 ),
4035 (
4036 "### Scenario Outline: add two numbers",
4037 "### Scenario Outline: Add Two Numbers",
4038 "### Scenario Outline: Add two numbers",
4039 "### Scenario Outline: ADD TWO NUMBERS",
4040 ),
4041 (
4042 "#### Examples: happy path",
4043 "#### Examples: Happy Path",
4044 "#### Examples: Happy path",
4045 "#### Examples: HAPPY PATH",
4046 ),
4047 ];
4048
4049 fn recased(style: HeadingCapStyle, heading: &str, flavor: crate::config::MarkdownFlavor) -> String {
4051 let rule = create_rule_with_style(style);
4052 let content = format!("{heading}\n");
4053 let ctx = LintContext::new(&content, flavor, None);
4054 let warnings = rule.check(&ctx).unwrap();
4055 let fixed = rule.fix(&ctx).unwrap();
4056 assert_eq!(
4057 warnings.is_empty(),
4058 fixed == content,
4059 "a warning and a rewrite must agree for {content:?} under {flavor:?}"
4060 );
4061 fixed.trim_end().to_string()
4062 }
4063
4064 #[test]
4065 fn test_mdg_keeps_the_keyword_of_every_structure() {
4066 for (heading, ..) in GHERKIN_STRUCTURES {
4069 let keyword = &heading[..=heading.find(':').unwrap()];
4070 for style in STYLES {
4071 let fixed = recased(style, heading, crate::config::MarkdownFlavor::MDG);
4072 assert!(
4073 fixed.starts_with(keyword),
4074 "{style:?} lost the keyword of {heading:?}: {fixed}"
4075 );
4076 }
4077 }
4078 }
4079
4080 #[test]
4081 fn test_mdg_recases_only_the_name_of_a_structure() {
4082 for (heading, title, sentence, caps) in GHERKIN_STRUCTURES {
4083 let mdg = crate::config::MarkdownFlavor::MDG;
4084 assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), title);
4085 assert_eq!(recased(HeadingCapStyle::SentenceCase, heading, mdg), sentence);
4086 assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), caps);
4087 }
4088 }
4089
4090 #[test]
4091 fn test_standard_flavor_recases_a_keyword_like_any_other_word() {
4092 let standard = crate::config::MarkdownFlavor::Standard;
4094 assert_eq!(
4095 recased(HeadingCapStyle::TitleCase, "# Feature: the system under test", standard),
4096 "# Feature: the System Under Test"
4097 );
4098 assert_eq!(
4099 recased(
4100 HeadingCapStyle::SentenceCase,
4101 "### Scenario Outline: add two numbers",
4102 standard
4103 ),
4104 "### Scenario outline: add two numbers"
4105 );
4106 assert_eq!(
4107 recased(HeadingCapStyle::AllCaps, "# Feature: the system under test", standard),
4108 "# FEATURE: THE SYSTEM UNDER TEST"
4109 );
4110 }
4111
4112 #[test]
4113 fn test_mdg_leaves_a_heading_without_a_colon_to_the_normal_rule() {
4114 for heading in ["## notes about the system", "## Notes", "# THE SYSTEM"] {
4115 for style in STYLES {
4116 assert_eq!(
4117 recased(style, heading, crate::config::MarkdownFlavor::MDG),
4118 recased(style, heading, crate::config::MarkdownFlavor::Standard),
4119 "{style:?} treated {heading:?} as a Gherkin structure"
4120 );
4121 }
4122 }
4123 }
4124
4125 #[test]
4126 fn test_mdg_splits_at_the_first_colon_only() {
4127 let mdg = crate::config::MarkdownFlavor::MDG;
4129 let heading = "## Scenario: ratio: two to one";
4130 assert_eq!(
4131 recased(HeadingCapStyle::TitleCase, heading, mdg),
4132 "## Scenario: Ratio: Two to One"
4133 );
4134 assert_eq!(
4135 recased(HeadingCapStyle::SentenceCase, heading, mdg),
4136 "## Scenario: Ratio: two to one"
4137 );
4138 assert_eq!(
4139 recased(HeadingCapStyle::AllCaps, heading, mdg),
4140 "## Scenario: RATIO: TWO TO ONE"
4141 );
4142 }
4143
4144 #[test]
4145 fn test_mdg_leaves_a_colon_behind_a_backtick_to_the_normal_rule() {
4146 for heading in [
4150 "# See `x: y` Notes",
4151 "# `a: b`",
4152 "# `code` Feature: a name",
4153 "# `x: y` Feature: a name",
4154 ] {
4155 for style in STYLES {
4156 assert_eq!(
4157 recased(style, heading, crate::config::MarkdownFlavor::MDG),
4158 recased(style, heading, crate::config::MarkdownFlavor::Standard),
4159 "{style:?} split {heading:?} at a colon inside a code span"
4160 );
4161 }
4162 }
4163 }
4164
4165 #[test]
4166 fn test_mdg_splits_at_a_keyword_colon_that_precedes_a_code_span() {
4167 let mdg = crate::config::MarkdownFlavor::MDG;
4169 let heading = "# Scenario: use `a: b` here";
4170 assert_eq!(
4171 recased(HeadingCapStyle::TitleCase, heading, mdg),
4172 "# Scenario: Use `a: b` Here"
4173 );
4174 assert_eq!(
4175 recased(HeadingCapStyle::SentenceCase, heading, mdg),
4176 "# Scenario: Use `a: b` here"
4177 );
4178 assert_eq!(
4179 recased(HeadingCapStyle::AllCaps, heading, mdg),
4180 "# Scenario: USE `a: b` HERE"
4181 );
4182 }
4183
4184 #[test]
4185 fn test_mdg_splits_at_a_keyword_colon_before_an_unbalanced_backtick() {
4186 let mdg = crate::config::MarkdownFlavor::MDG;
4189 let heading = "# Scenario: a ` b";
4190 assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), "# Scenario: A ` B");
4191 assert_eq!(
4192 recased(HeadingCapStyle::SentenceCase, heading, mdg),
4193 "# Scenario: A ` b"
4194 );
4195 assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), "# Scenario: A ` B");
4196 }
4197
4198 #[test]
4199 fn test_mdg_keeps_a_keyword_with_nothing_left_to_recase() {
4200 for style in STYLES {
4201 assert_eq!(
4202 recased(style, "# Feature:", crate::config::MarkdownFlavor::MDG),
4203 "# Feature:"
4204 );
4205 }
4206 }
4207
4208 #[test]
4209 fn test_mdg_keeps_a_custom_id_after_the_name() {
4210 assert_eq!(
4211 recased(
4212 HeadingCapStyle::TitleCase,
4213 "# Feature: the system {#overview}",
4214 crate::config::MarkdownFlavor::MDG
4215 ),
4216 "# Feature: The System {#overview}"
4217 );
4218 }
4219
4220 #[test]
4221 fn test_mdg_fix_is_idempotent() {
4222 for (heading, ..) in GHERKIN_STRUCTURES {
4223 for style in STYLES {
4224 let rule = create_rule_with_style(style);
4225 let content = format!("{heading}\n");
4226 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
4227 let once = rule.fix(&ctx).unwrap();
4228 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::MDG, None);
4229 assert_eq!(
4230 rule.fix(&ctx).unwrap(),
4231 once,
4232 "fix is not idempotent for {heading:?} ({style:?})"
4233 );
4234 }
4235 }
4236 }
4237}