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.config.ignore_words.iter().any(|w| w == 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_numeric_ordinal(word: &str) -> bool {
342 let bytes = word.as_bytes();
343
344 let alpha_start = match bytes.iter().position(|&b| !b.is_ascii_digit()) {
346 Some(pos) if pos > 0 => pos,
347 _ => return false,
348 };
349
350 let alpha_end = bytes[alpha_start..]
352 .iter()
353 .position(|b| !b.is_ascii_alphabetic())
354 .map_or(bytes.len(), |p| alpha_start + p);
355
356 let suffix = &word[alpha_start..alpha_end];
357 matches!(suffix.to_ascii_lowercase().as_str(), "st" | "nd" | "rd" | "th")
358 }
359
360 fn is_caret_notation(&self, word: &str) -> bool {
362 let chars: Vec<char> = word.chars().collect();
363 if chars.len() >= 2 && chars[0] == '^' {
365 let second = chars[1];
366 if second.is_ascii_uppercase() || "@[\\]^_".contains(second) {
368 return true;
369 }
370 }
371 false
372 }
373
374 fn is_lowercase_word(&self, word: &str) -> bool {
376 self.lowercase_set.contains(&word.to_lowercase())
377 }
378
379 fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
381 if word.is_empty() {
382 return word.to_string();
383 }
384
385 if self.should_preserve_word(word) {
387 return word.to_string();
388 }
389
390 if is_first || is_last {
392 return self.capitalize_first(word);
393 }
394
395 if self.is_lowercase_word(word) {
397 return Self::lowercase_preserving_composition(word);
398 }
399
400 self.capitalize_first(word)
402 }
403
404 fn apply_canonical_form_to_word(word: &str, canonical: &str) -> String {
407 let canonical_lower = canonical.to_lowercase();
408 if canonical_lower.is_empty() {
409 return canonical.to_string();
410 }
411
412 if let Some(end_pos) = Self::match_case_insensitive_at(word, 0, &canonical_lower) {
413 let mut out = String::with_capacity(canonical.len() + word.len().saturating_sub(end_pos));
414 out.push_str(canonical);
415 out.push_str(&word[end_pos..]);
416 out
417 } else {
418 canonical.to_string()
419 }
420 }
421
422 fn capitalize_first(&self, word: &str) -> String {
424 if word.is_empty() {
425 return String::new();
426 }
427
428 let first_alpha_pos = word.find(|c: char| c.is_alphabetic());
430 let Some(pos) = first_alpha_pos else {
431 return word.to_string();
432 };
433
434 let prefix = &word[..pos];
435 let suffix = &word[pos..];
436
437 if Self::is_numeric_ordinal(word) {
440 let suffix_lower = Self::lowercase_preserving_composition(suffix);
441 return format!("{prefix}{suffix_lower}");
442 }
443
444 let mut chars = suffix.chars();
445 let first = chars.next().unwrap();
446 let first_upper = Self::uppercase_preserving_composition(&first.to_string());
449 let rest: String = chars.collect();
450 let rest_lower = Self::lowercase_preserving_composition(&rest);
451 format!("{prefix}{first_upper}{rest_lower}")
452 }
453
454 fn lowercase_preserving_composition(s: &str) -> String {
457 let mut result = String::with_capacity(s.len());
458 for c in s.chars() {
459 let lower: String = c.to_lowercase().collect();
460 if lower.chars().count() == 1 {
461 result.push_str(&lower);
462 } else {
463 result.push(c);
465 }
466 }
467 result
468 }
469
470 fn uppercase_preserving_composition(s: &str) -> String {
475 let mut result = String::with_capacity(s.len());
476 for c in s.chars() {
477 let upper: String = c.to_uppercase().collect();
478 if upper.chars().count() == 1 {
479 result.push_str(&upper);
480 } else {
481 result.push(c);
483 }
484 }
485 result
486 }
487
488 fn apply_title_case(&self, text: &str) -> String {
492 let canonical_forms = self.proper_name_canonical_forms(text);
493
494 let original_words: Vec<&str> = text.split_whitespace().collect();
495 let total_words = original_words.len();
496
497 let mut word_positions: Vec<usize> = Vec::with_capacity(original_words.len());
500 let mut pos = 0;
501 for word in &original_words {
502 if let Some(rel) = text[pos..].find(word) {
503 word_positions.push(pos + rel);
504 pos = pos + rel + word.len();
505 } else {
506 word_positions.push(usize::MAX);
507 }
508 }
509
510 let result_words: Vec<String> = original_words
511 .iter()
512 .enumerate()
513 .map(|(i, word)| {
514 let after_period = i > 0 && original_words[i - 1].ends_with('.');
515 let is_first = i == 0 || after_period;
516 let is_last = i == total_words - 1;
517
518 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
520 return Self::apply_canonical_form_to_word(word, canonical);
521 }
522
523 if self.should_preserve_word(word) {
525 return (*word).to_string();
526 }
527
528 if word.contains('-') {
530 return self.handle_hyphenated_word(word, is_first, is_last);
531 }
532
533 self.title_case_word(word, is_first, is_last)
534 })
535 .collect();
536
537 result_words.join(" ")
538 }
539
540 fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
542 let parts: Vec<&str> = word.split('-').collect();
543 let total_parts = parts.len();
544
545 let result_parts: Vec<String> = parts
546 .iter()
547 .enumerate()
548 .map(|(i, part)| {
549 let part_is_first = is_first && i == 0;
551 let part_is_last = is_last && i == total_parts - 1;
552 self.title_case_word(part, part_is_first, part_is_last)
553 })
554 .collect();
555
556 result_parts.join("-")
557 }
558
559 fn ends_sentence(&self, word: &str) -> bool {
565 self.config
566 .sentence_case_restart_after
567 .iter()
568 .any(|boundary| !boundary.is_empty() && word.ends_with(boundary.as_str()))
569 }
570
571 fn apply_sentence_case_from(&self, text: &str, starts_sentence: bool) -> String {
575 if text.is_empty() {
576 return text.to_string();
577 }
578
579 let canonical_forms = self.proper_name_canonical_forms(text);
580 let mut result = String::new();
581 let mut current_pos = 0;
582 let mut at_sentence_start = starts_sentence;
583
584 for word in text.split_whitespace() {
586 if let Some(pos) = text[current_pos..].find(word) {
587 let abs_pos = current_pos + pos;
588
589 result.push_str(&text[current_pos..abs_pos]);
591
592 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
595 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
596 } else if at_sentence_start {
597 if self.should_preserve_word(word) {
599 result.push_str(word);
601 } else {
602 let mut chars = word.chars();
604 if let Some(first) = chars.next() {
605 result.push_str(&Self::uppercase_preserving_composition(&first.to_string()));
606 let rest: String = chars.collect();
607 result.push_str(&Self::lowercase_preserving_composition(&rest));
608 }
609 }
610 } else {
611 if self.should_preserve_word(word) {
613 result.push_str(word);
614 } else {
615 result.push_str(&Self::lowercase_preserving_composition(word));
616 }
617 }
618
619 at_sentence_start = self.ends_sentence(word);
620 current_pos = abs_pos + word.len();
621 }
622 }
623
624 if current_pos < text.len() {
626 result.push_str(&text[current_pos..]);
627 }
628
629 result
630 }
631
632 fn apply_all_caps(&self, text: &str) -> String {
634 if text.is_empty() {
635 return text.to_string();
636 }
637
638 let canonical_forms = self.proper_name_canonical_forms(text);
639 let mut result = String::new();
640 let mut current_pos = 0;
641
642 for word in text.split_whitespace() {
644 if let Some(pos) = text[current_pos..].find(word) {
645 let abs_pos = current_pos + pos;
646
647 result.push_str(&text[current_pos..abs_pos]);
649
650 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
653 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
654 } else if self.should_preserve_word(word) {
655 result.push_str(word);
656 } else {
657 result.push_str(&Self::uppercase_preserving_composition(word));
658 }
659
660 current_pos = abs_pos + word.len();
661 }
662 }
663
664 if current_pos < text.len() {
666 result.push_str(&text[current_pos..]);
667 }
668
669 result
670 }
671
672 fn parse_segments(&self, text: &str) -> Vec<HeadingSegment> {
674 let mut segments = Vec::new();
675 let mut last_end = 0;
676
677 let mut special_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
679
680 for mat in INLINE_CODE_REGEX.find_iter(text) {
682 special_regions.push((mat.start(), mat.end(), HeadingSegment::Code(mat.as_str().to_string())));
683 }
684
685 for caps in LINK_REGEX.captures_iter(text) {
687 let full_match = caps.get(0).unwrap();
688
689 if full_match.start() >= 1 && text.as_bytes()[full_match.start() - 1] == b'!' {
693 let region_start = full_match.start() - 1;
694 special_regions.push((
695 region_start,
696 full_match.end(),
697 HeadingSegment::Image(text[region_start..full_match.end()].to_string()),
698 ));
699 continue;
700 }
701
702 let text_match = caps.get(1).or_else(|| caps.get(2));
703
704 if let Some(text_m) = text_match {
705 special_regions.push((
706 full_match.start(),
707 full_match.end(),
708 HeadingSegment::Link {
709 full: full_match.as_str().to_string(),
710 text_start: text_m.start() - full_match.start(),
711 text_end: text_m.end() - full_match.start(),
712 },
713 ));
714 }
715 }
716
717 let code_ranges: Vec<(usize, usize)> = special_regions
720 .iter()
721 .filter(|(_, _, segment)| matches!(segment, HeadingSegment::Code(_)))
722 .map(|(start, end, _)| (*start, *end))
723 .collect();
724 for (start, end) in Self::html_regions(text, &code_ranges) {
725 special_regions.push((start, end, HeadingSegment::Html(text[start..end].to_string())));
726 }
727
728 special_regions.sort_by_key(|(start, _, _)| *start);
730
731 let mut filtered_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
734 for region in special_regions {
735 let overlaps = filtered_regions.iter().any(|(s, e, _)| region.0 < *e && region.1 > *s);
736 if !overlaps {
737 filtered_regions.push(region);
738 }
739 }
740
741 for (start, end, segment) in filtered_regions {
743 if start > last_end {
745 let text_segment = &text[last_end..start];
746 if !text_segment.is_empty() {
747 segments.push(HeadingSegment::Text(text_segment.to_string()));
748 }
749 }
750 segments.push(segment);
751 last_end = end;
752 }
753
754 if last_end < text.len() {
756 let remaining = &text[last_end..];
757 if !remaining.is_empty() {
758 segments.push(HeadingSegment::Text(remaining.to_string()));
759 }
760 }
761
762 if segments.is_empty() && !text.is_empty() {
764 segments.push(HeadingSegment::Text(text.to_string()));
765 }
766
767 segments
768 }
769
770 fn html_regions(text: &str, code_ranges: &[(usize, usize)]) -> Vec<(usize, usize)> {
784 let mut regions: Vec<(usize, usize)> = Vec::new();
785 let mut open_elements: Vec<(String, usize)> = Vec::new();
786
787 let mut pos = 0;
788 while let Some(token) = HTML_TOKEN_REGEX.captures_at(text, pos) {
789 let whole = token.get(0).unwrap();
790 if code_ranges
791 .iter()
792 .any(|&(start, end)| start <= whole.start() && whole.start() < end)
793 || is_backslash_escaped(text, whole.start())
794 {
795 pos = whole.start() + 1;
797 continue;
798 }
799 pos = whole.end();
800
801 if let Some(closing) = token.get(1) {
802 let name = closing.as_str().to_ascii_lowercase();
803 if let Some(depth) = open_elements.iter().rposition(|(open_name, _)| *open_name == name) {
804 let element_start = open_elements[depth].1;
805 open_elements.truncate(depth);
806 regions.retain(|&(start, _)| start < element_start);
807 regions.push((element_start, whole.end()));
808 continue;
809 }
810 } else if let Some(opening) = token.get(2) {
811 let name = opening.as_str().to_ascii_lowercase();
812 if !whole.as_str().ends_with("/>") && !is_void_element(&name) {
813 open_elements.push((name, whole.start()));
814 }
815 }
816
817 regions.push((whole.start(), whole.end()));
818 }
819
820 regions
821 }
822
823 fn apply_capitalization(&self, text: &str, flavor: crate::config::MarkdownFlavor) -> String {
825 let (main_text, custom_id) = if let Some(mat) = CUSTOM_ID_REGEX.find(text) {
827 (&text[..mat.start()], Some(mat.as_str()))
828 } else {
829 (text, None)
830 };
831
832 let (keyword, main_text) = if flavor == crate::config::MarkdownFlavor::MDG {
839 mdg::keyword_split(main_text).unwrap_or(("", main_text))
840 } else {
841 ("", main_text)
842 };
843
844 let segments = self.parse_segments(main_text);
846
847 let text_segments: Vec<usize> = segments
849 .iter()
850 .enumerate()
851 .filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
852 .collect();
853
854 let first_segment_is_text = segments
859 .iter()
860 .find(|s| !s.renders_nothing())
861 .is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
862
863 let last_segment_is_text = segments
866 .iter()
867 .rev()
868 .find(|s| !s.renders_nothing())
869 .is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
870
871 let mut result_parts: Vec<String> = Vec::new();
873
874 let mut at_sentence_start = first_segment_is_text;
878
879 for (i, segment) in segments.iter().enumerate() {
880 at_sentence_start = match segment {
885 HeadingSegment::Text(t) => {
886 let is_first_text = text_segments.first() == Some(&i);
887 let is_last_text = text_segments.last() == Some(&i) && last_segment_is_text;
891
892 let capitalized = match self.config.style {
893 HeadingCapStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
894 HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(t, at_sentence_start),
895 HeadingCapStyle::AllCaps => self.apply_all_caps(t),
896 };
897 let ends_sentence = self.ends_sentence(capitalized.trim_end());
898 result_parts.push(capitalized);
899 ends_sentence
900 }
901 HeadingSegment::Code(c) => {
902 result_parts.push(c.clone());
903 false
904 }
905 HeadingSegment::Link {
906 full,
907 text_start,
908 text_end,
909 } => {
910 let link_text = &full[*text_start..*text_end];
912 let capitalized_text = match self.config.style {
913 HeadingCapStyle::TitleCase => self.apply_title_case(link_text),
914 HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(link_text, at_sentence_start),
917 HeadingCapStyle::AllCaps => self.apply_all_caps(link_text),
918 };
919 let ends_sentence = self.ends_sentence(capitalized_text.trim_end());
922
923 let mut new_link = String::new();
924 new_link.push_str(&full[..*text_start]);
925 new_link.push_str(&capitalized_text);
926 new_link.push_str(&full[*text_end..]);
927 result_parts.push(new_link);
928 ends_sentence
929 }
930 HeadingSegment::Html(h) => {
931 result_parts.push(h.clone());
935 segment.renders_nothing() && at_sentence_start
936 }
937 HeadingSegment::Image(img) => {
938 result_parts.push(img.clone());
940 false
941 }
942 };
943 }
944
945 let mut result = String::with_capacity(text.len());
946 result.push_str(keyword);
947 result.push_str(&result_parts.join(""));
948
949 if let Some(id) = custom_id {
951 result.push_str(id);
952 }
953
954 result
955 }
956
957 fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
959 let canonical_forms = self.proper_name_canonical_forms(text);
960 let words: Vec<&str> = text.split_whitespace().collect();
961 let total_words = words.len();
962
963 if total_words == 0 {
964 return text.to_string();
965 }
966
967 let mut word_positions: Vec<usize> = Vec::with_capacity(words.len());
970 let mut pos = 0;
971 for word in &words {
972 if let Some(rel) = text[pos..].find(word) {
973 word_positions.push(pos + rel);
974 pos = pos + rel + word.len();
975 } else {
976 word_positions.push(usize::MAX);
977 }
978 }
979
980 let result_words: Vec<String> = words
981 .iter()
982 .enumerate()
983 .map(|(i, word)| {
984 let after_period = i > 0 && words[i - 1].ends_with('.');
985 let is_first = (is_first_segment && i == 0) || after_period;
986 let is_last = is_last_segment && i == total_words - 1;
987
988 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
990 return Self::apply_canonical_form_to_word(word, canonical);
991 }
992
993 if word.contains('-') {
995 return self.handle_hyphenated_word(word, is_first, is_last);
996 }
997
998 self.title_case_word(word, is_first, is_last)
999 })
1000 .collect();
1001
1002 let mut result = String::new();
1004 let mut word_iter = result_words.iter();
1005 let mut in_word = false;
1006
1007 for c in text.chars() {
1008 if c.is_whitespace() {
1009 if in_word {
1010 in_word = false;
1011 }
1012 result.push(c);
1013 } else if !in_word {
1014 if let Some(word) = word_iter.next() {
1015 result.push_str(word);
1016 }
1017 in_word = true;
1018 }
1019 }
1020
1021 result
1022 }
1023
1024 fn fix_atx_heading(
1026 &self,
1027 _line: &str,
1028 heading: &crate::lint_context::HeadingInfo,
1029 flavor: crate::config::MarkdownFlavor,
1030 ) -> String {
1031 let indent = " ".repeat(heading.marker_column);
1033 let hashes = "#".repeat(heading.level as usize);
1034
1035 let fixed_text = self.apply_capitalization(&heading.raw_text, flavor);
1037
1038 let closing = &heading.closing_sequence;
1040 if heading.has_closing_sequence {
1041 format!("{indent}{hashes} {fixed_text} {closing}")
1042 } else {
1043 format!("{indent}{hashes} {fixed_text}")
1044 }
1045 }
1046
1047 fn fix_setext_heading(
1049 &self,
1050 line: &str,
1051 heading: &crate::lint_context::HeadingInfo,
1052 flavor: crate::config::MarkdownFlavor,
1053 ) -> String {
1054 let fixed_text = self.apply_capitalization(&heading.raw_text, flavor);
1056
1057 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
1059
1060 format!("{leading_ws}{fixed_text}")
1061 }
1062}
1063
1064impl Rule for MD063HeadingCapitalization {
1065 fn name(&self) -> &'static str {
1066 "MD063"
1067 }
1068
1069 fn description(&self) -> &'static str {
1070 "Heading capitalization"
1071 }
1072
1073 fn category(&self) -> RuleCategory {
1074 RuleCategory::Heading
1075 }
1076
1077 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1078 !ctx.likely_has_headings() || !ctx.lines.iter().any(|line| line.heading.is_some())
1079 }
1080
1081 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1082 let content = ctx.content;
1083
1084 if content.is_empty() {
1085 return Ok(Vec::new());
1086 }
1087
1088 let mut warnings = Vec::new();
1089
1090 for (line_num, line_info) in ctx.lines.iter().enumerate() {
1091 if let Some(heading) = &line_info.heading {
1092 if heading.level < self.config.min_level || heading.level > self.config.max_level {
1094 continue;
1095 }
1096
1097 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
1099 continue;
1100 }
1101
1102 if !heading.is_valid {
1104 continue;
1105 }
1106
1107 let original_text = &heading.raw_text;
1109 let fixed_text = self.apply_capitalization(original_text, ctx.flavor);
1110
1111 if original_text != &fixed_text {
1112 let line = line_info.content(ctx.content);
1113 let style_name = match self.config.style {
1114 HeadingCapStyle::TitleCase => "title case",
1115 HeadingCapStyle::SentenceCase => "sentence case",
1116 HeadingCapStyle::AllCaps => "ALL CAPS",
1117 };
1118
1119 warnings.push(LintWarning {
1120 rule_name: Some(self.name().to_string()),
1121 line: line_num + 1,
1122 column: byte_to_char_count(line, heading.content_column),
1123 end_line: line_num + 1,
1124 end_column: byte_to_char_count(line, heading.content_column) + original_text.chars().count(),
1125 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1126 severity: Severity::Warning,
1127 fix: Some(Fix::new(
1128 ctx.line_content_byte_range(line_num + 1),
1129 match heading.style {
1130 crate::lint_context::HeadingStyle::ATX => {
1131 self.fix_atx_heading(line, heading, ctx.flavor)
1132 }
1133 _ => self.fix_setext_heading(line, heading, ctx.flavor),
1134 },
1135 )),
1136 });
1137 }
1138 }
1139 }
1140
1141 Ok(warnings)
1142 }
1143
1144 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1145 let content = ctx.content;
1146
1147 if content.is_empty() {
1148 return Ok(content.to_string());
1149 }
1150
1151 let lines = ctx.raw_lines();
1152 let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1153
1154 for (line_num, line_info) in ctx.lines.iter().enumerate() {
1155 if ctx.is_rule_disabled(self.name(), line_num + 1) {
1157 continue;
1158 }
1159
1160 if let Some(heading) = &line_info.heading {
1161 if heading.level < self.config.min_level || heading.level > self.config.max_level {
1163 continue;
1164 }
1165
1166 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
1168 continue;
1169 }
1170
1171 if !heading.is_valid {
1173 continue;
1174 }
1175
1176 let original_text = &heading.raw_text;
1177 let fixed_text = self.apply_capitalization(original_text, ctx.flavor);
1178
1179 if original_text != &fixed_text {
1180 let line = line_info.content(ctx.content);
1181 fixed_lines[line_num] = match heading.style {
1182 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading, ctx.flavor),
1183 _ => self.fix_setext_heading(line, heading, ctx.flavor),
1184 };
1185 }
1186 }
1187 }
1188
1189 let mut result = String::with_capacity(content.len());
1191 for (i, line) in fixed_lines.iter().enumerate() {
1192 result.push_str(line);
1193 if i < fixed_lines.len() - 1 || content.ends_with('\n') {
1194 result.push('\n');
1195 }
1196 }
1197
1198 Ok(result)
1199 }
1200
1201 fn as_any(&self) -> &dyn std::any::Any {
1202 self
1203 }
1204
1205 crate::impl_rule_config_sections!(MD063Config);
1206
1207 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1208 where
1209 Self: Sized,
1210 {
1211 let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
1212 let md044_config =
1213 crate::rule_config_serde::load_rule_config::<crate::rules::md044_proper_names::MD044Config>(config);
1214 let mut rule = Self::from_config_struct(rule_config);
1215 rule.proper_names = md044_config.names;
1216 Box::new(rule)
1217 }
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222 use super::*;
1223 use crate::lint_context::LintContext;
1224
1225 fn create_rule() -> MD063HeadingCapitalization {
1226 let config = MD063Config {
1227 enabled: true,
1228 ..Default::default()
1229 };
1230 MD063HeadingCapitalization::from_config_struct(config)
1231 }
1232
1233 fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
1234 let config = MD063Config {
1235 enabled: true,
1236 style,
1237 ..Default::default()
1238 };
1239 MD063HeadingCapitalization::from_config_struct(config)
1240 }
1241
1242 #[test]
1244 fn test_an_escaped_tag_does_not_hide_the_element_written_inside_it() {
1245 let text = r#"\<span title='<a id="x"></a>'>foo"#;
1248 assert_eq!(MD063HeadingCapitalization::html_regions(text, &[]), vec![(14, 28)]);
1249 }
1250
1251 #[test]
1252 fn test_title_case_basic() {
1253 let rule = create_rule();
1254 let content = "# hello world\n";
1255 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1256 let result = rule.check(&ctx).unwrap();
1257 assert_eq!(result.len(), 1);
1258 assert!(result[0].message.contains("Hello World"));
1259 }
1260
1261 #[test]
1262 fn test_title_case_lowercase_words() {
1263 let rule = create_rule();
1264 let content = "# the quick brown fox\n";
1265 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1266 let result = rule.check(&ctx).unwrap();
1267 assert_eq!(result.len(), 1);
1268 assert!(result[0].message.contains("The Quick Brown Fox"));
1270 }
1271
1272 #[test]
1273 fn test_title_case_already_correct() {
1274 let rule = create_rule();
1275 let content = "# The Quick Brown Fox\n";
1276 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1277 let result = rule.check(&ctx).unwrap();
1278 assert!(result.is_empty(), "Already correct heading should not be flagged");
1279 }
1280
1281 #[test]
1282 fn test_title_case_hyphenated() {
1283 let rule = create_rule();
1284 let content = "# self-documenting code\n";
1285 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1286 let result = rule.check(&ctx).unwrap();
1287 assert_eq!(result.len(), 1);
1288 assert!(result[0].message.contains("Self-Documenting Code"));
1289 }
1290
1291 #[test]
1292 fn test_title_case_preserves_url_with_nested_parens() {
1293 let rule = create_rule();
1294 let content = "# guide for [the api](https://example.com/docs/v(2)beta)\n";
1296 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1297 let fixed = rule.fix(&ctx).unwrap();
1298 assert!(
1301 fixed.contains("https://example.com/docs/v(2)beta"),
1302 "URL with nested parens was corrupted: {fixed:?}"
1303 );
1304 }
1305
1306 #[test]
1307 fn test_title_case_does_not_recase_image_alt() {
1308 let rule = create_rule();
1309 let content = "# overview \n";
1310 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1311 let fixed = rule.fix(&ctx).unwrap();
1312 assert!(
1314 fixed.contains(""),
1315 "image alt text was modified: {fixed:?}"
1316 );
1317 assert!(
1318 fixed.contains("# Overview"),
1319 "surrounding prose should still be title-cased: {fixed:?}"
1320 );
1321 }
1322
1323 #[test]
1325 fn test_sentence_case_basic() {
1326 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1327 let content = "# The Quick Brown Fox\n";
1328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1329 let result = rule.check(&ctx).unwrap();
1330 assert_eq!(result.len(), 1);
1331 assert!(result[0].message.contains("The quick brown fox"));
1332 }
1333
1334 #[test]
1335 fn test_sentence_case_already_correct() {
1336 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1337 let content = "# The quick brown fox\n";
1338 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1339 let result = rule.check(&ctx).unwrap();
1340 assert!(result.is_empty());
1341 }
1342
1343 #[test]
1345 fn test_all_caps_basic() {
1346 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1347 let content = "# hello world\n";
1348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1349 let result = rule.check(&ctx).unwrap();
1350 assert_eq!(result.len(), 1);
1351 assert!(result[0].message.contains("HELLO WORLD"));
1352 }
1353
1354 #[test]
1356 fn test_preserve_ignore_words() {
1357 let config = MD063Config {
1358 enabled: true,
1359 ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
1360 ..Default::default()
1361 };
1362 let rule = MD063HeadingCapitalization::from_config_struct(config);
1363
1364 let content = "# using iPhone on macOS\n";
1365 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1366 let result = rule.check(&ctx).unwrap();
1367 assert_eq!(result.len(), 1);
1368 assert!(result[0].message.contains("iPhone"));
1370 assert!(result[0].message.contains("macOS"));
1371 }
1372
1373 #[test]
1374 fn test_preserve_cased_words() {
1375 let rule = create_rule();
1376 let content = "# using GitHub actions\n";
1377 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1378 let result = rule.check(&ctx).unwrap();
1379 assert_eq!(result.len(), 1);
1380 assert!(result[0].message.contains("GitHub"));
1382 }
1383
1384 #[test]
1386 fn test_inline_code_preserved() {
1387 let rule = create_rule();
1388 let content = "# using `const` in javascript\n";
1389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1390 let result = rule.check(&ctx).unwrap();
1391 assert_eq!(result.len(), 1);
1392 assert!(result[0].message.contains("`const`"));
1394 assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
1395 }
1396
1397 #[test]
1399 fn test_level_filter() {
1400 let config = MD063Config {
1401 enabled: true,
1402 min_level: 2,
1403 max_level: 4,
1404 ..Default::default()
1405 };
1406 let rule = MD063HeadingCapitalization::from_config_struct(config);
1407
1408 let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
1409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1410 let result = rule.check(&ctx).unwrap();
1411
1412 assert_eq!(result.len(), 2);
1414 assert_eq!(result[0].line, 2); assert_eq!(result[1].line, 3); }
1417
1418 #[test]
1420 fn test_fix_atx_heading() {
1421 let rule = create_rule();
1422 let content = "# hello world\n";
1423 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1424 let fixed = rule.fix(&ctx).unwrap();
1425 assert_eq!(fixed, "# Hello World\n");
1426 }
1427
1428 #[test]
1429 fn test_fix_multiple_headings() {
1430 let rule = create_rule();
1431 let content = "# first heading\n\n## second heading\n";
1432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1433 let fixed = rule.fix(&ctx).unwrap();
1434 assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
1435 }
1436
1437 #[test]
1439 fn test_setext_heading() {
1440 let rule = create_rule();
1441 let content = "hello world\n============\n";
1442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1443 let result = rule.check(&ctx).unwrap();
1444 assert_eq!(result.len(), 1);
1445 assert!(result[0].message.contains("Hello World"));
1446 }
1447
1448 #[test]
1450 fn test_custom_id_preserved() {
1451 let rule = create_rule();
1452 let content = "# getting started {#intro}\n";
1453 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1454 let result = rule.check(&ctx).unwrap();
1455 assert_eq!(result.len(), 1);
1456 assert!(result[0].message.contains("{#intro}"));
1458 }
1459
1460 #[test]
1462 fn test_skip_obsidian_tags_not_headings() {
1463 let rule = create_rule();
1464
1465 let content = "# H1\n\n#tag\n";
1467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1468 let result = rule.check(&ctx).unwrap();
1469 assert!(
1470 result.is_empty() || result.iter().all(|w| w.line != 3),
1471 "Obsidian tag #tag should not be treated as a heading: {result:?}"
1472 );
1473 }
1474
1475 #[test]
1476 fn test_skip_invalid_atx_headings_no_space() {
1477 let rule = create_rule();
1478
1479 let content = "#notaheading\n";
1481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1482 let result = rule.check(&ctx).unwrap();
1483 assert!(
1484 result.is_empty(),
1485 "Invalid ATX heading without space should not be flagged: {result:?}"
1486 );
1487 }
1488
1489 #[test]
1490 fn test_fix_skips_obsidian_tags() {
1491 let rule = create_rule();
1492
1493 let content = "# hello world\n\n#tag\n";
1494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1495 let fixed = rule.fix(&ctx).unwrap();
1496 assert!(fixed.contains("#tag"), "Fix should not modify Obsidian tag #tag");
1498 assert!(fixed.contains("# Hello World"), "Fix should still fix real headings");
1499 }
1500
1501 #[test]
1502 fn test_preserve_all_caps_acronyms() {
1503 let rule = create_rule();
1504 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1505
1506 let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
1508 assert_eq!(fixed, "# Using API in Production\n");
1509
1510 let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
1512 assert_eq!(fixed, "# API and GPU Integration\n");
1513
1514 let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
1516 assert_eq!(fixed, "# IO Performance Guide\n");
1517
1518 let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
1520 assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
1521 }
1522
1523 #[test]
1524 fn test_preserve_acronyms_in_hyphenated_words() {
1525 let rule = create_rule();
1526 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1527
1528 let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
1530 assert_eq!(fixed, "# API-Driven Architecture\n");
1531
1532 let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
1534 assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
1535 }
1536
1537 #[test]
1538 fn test_single_letters_not_treated_as_acronyms() {
1539 let rule = create_rule();
1540 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1541
1542 let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
1544 assert_eq!(fixed, "# I Am a Heading\n");
1545 }
1546
1547 #[test]
1548 fn test_lowercase_terms_need_ignore_words() {
1549 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1550
1551 let rule = create_rule();
1553 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1554 assert_eq!(fixed, "# Using Npm Packages\n");
1555
1556 let config = MD063Config {
1558 enabled: true,
1559 ignore_words: vec!["npm".to_string()],
1560 ..Default::default()
1561 };
1562 let rule = MD063HeadingCapitalization::from_config_struct(config);
1563 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1564 assert_eq!(fixed, "# Using npm Packages\n");
1565 }
1566
1567 #[test]
1568 fn test_acronyms_with_mixed_case_preserved() {
1569 let rule = create_rule();
1570 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1571
1572 let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
1574 assert_eq!(fixed, "# Using API with GitHub\n");
1575 }
1576
1577 #[test]
1578 fn test_real_world_acronyms() {
1579 let rule = create_rule();
1580 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1581
1582 let content = "# FFI bindings for CPU optimization\n";
1584 let fixed = rule.fix(&ctx(content)).unwrap();
1585 assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
1586
1587 let content = "# DOM manipulation and SSR rendering\n";
1588 let fixed = rule.fix(&ctx(content)).unwrap();
1589 assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
1590
1591 let content = "# CVE security and RNN models\n";
1592 let fixed = rule.fix(&ctx(content)).unwrap();
1593 assert_eq!(fixed, "# CVE Security and RNN Models\n");
1594 }
1595
1596 #[test]
1597 fn test_is_all_caps_acronym() {
1598 let rule = create_rule();
1599
1600 assert!(rule.is_all_caps_acronym("API"));
1602 assert!(rule.is_all_caps_acronym("IO"));
1603 assert!(rule.is_all_caps_acronym("GPU"));
1604 assert!(rule.is_all_caps_acronym("HTTP2")); assert!(!rule.is_all_caps_acronym("A"));
1608 assert!(!rule.is_all_caps_acronym("I"));
1609
1610 assert!(!rule.is_all_caps_acronym("Api"));
1612 assert!(!rule.is_all_caps_acronym("npm"));
1613 assert!(!rule.is_all_caps_acronym("iPhone"));
1614 }
1615
1616 #[test]
1617 fn test_sentence_case_starts_after_a_leading_empty_anchor() {
1618 let config = MD063Config {
1620 enabled: true,
1621 style: HeadingCapStyle::SentenceCase,
1622 ..Default::default()
1623 };
1624 let rule = MD063HeadingCapitalization::from_config_struct(config);
1625
1626 let content = "# <a id=\"top\"></a>the beginning\n";
1627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1628 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1629 assert_eq!(rule.fix(&ctx).unwrap(), "# <a id=\"top\"></a>The beginning\n");
1630
1631 let content = "# <kbd>ctrl</kbd> the key\n";
1633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1634 assert!(rule.check(&ctx).unwrap().is_empty());
1635
1636 for content in ["# <img src=\"x.png\"> the picture\n", "#  the picture\n"] {
1639 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1640 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1641 }
1642 }
1643
1644 #[test]
1645 fn test_sentence_case_ignore_words_first_word() {
1646 let config = MD063Config {
1647 enabled: true,
1648 style: HeadingCapStyle::SentenceCase,
1649 ignore_words: vec!["nvim".to_string()],
1650 ..Default::default()
1651 };
1652 let rule = MD063HeadingCapitalization::from_config_struct(config);
1653
1654 let content = "# nvim config\n";
1656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1657 let result = rule.check(&ctx).unwrap();
1658 assert!(
1659 result.is_empty(),
1660 "nvim in ignore-words should not be flagged. Got: {result:?}"
1661 );
1662
1663 let fixed = rule.fix(&ctx).unwrap();
1665 assert_eq!(fixed, "# nvim config\n");
1666 }
1667
1668 #[test]
1669 fn test_sentence_case_ignore_words_not_first() {
1670 let config = MD063Config {
1671 enabled: true,
1672 style: HeadingCapStyle::SentenceCase,
1673 ignore_words: vec!["nvim".to_string()],
1674 ..Default::default()
1675 };
1676 let rule = MD063HeadingCapitalization::from_config_struct(config);
1677
1678 let content = "# Using nvim editor\n";
1680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1681 let result = rule.check(&ctx).unwrap();
1682 assert!(
1683 result.is_empty(),
1684 "nvim in ignore-words should be preserved. Got: {result:?}"
1685 );
1686 }
1687
1688 #[test]
1689 fn test_preserve_cased_words_ios() {
1690 let config = MD063Config {
1691 enabled: true,
1692 style: HeadingCapStyle::SentenceCase,
1693 preserve_cased_words: true,
1694 ..Default::default()
1695 };
1696 let rule = MD063HeadingCapitalization::from_config_struct(config);
1697
1698 let content = "## This is iOS\n";
1700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1701 let result = rule.check(&ctx).unwrap();
1702 assert!(
1703 result.is_empty(),
1704 "iOS should be preserved with preserve-cased-words. Got: {result:?}"
1705 );
1706
1707 let fixed = rule.fix(&ctx).unwrap();
1709 assert_eq!(fixed, "## This is iOS\n");
1710 }
1711
1712 #[test]
1713 fn test_preserve_cased_words_ios_title_case() {
1714 let config = MD063Config {
1715 enabled: true,
1716 style: HeadingCapStyle::TitleCase,
1717 preserve_cased_words: true,
1718 ..Default::default()
1719 };
1720 let rule = MD063HeadingCapitalization::from_config_struct(config);
1721
1722 let content = "# developing for iOS\n";
1724 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1725 let fixed = rule.fix(&ctx).unwrap();
1726 assert_eq!(fixed, "# Developing for iOS\n");
1727 }
1728
1729 #[test]
1730 fn test_has_internal_capitals_ios() {
1731 let rule = create_rule();
1732
1733 assert!(
1735 rule.has_internal_capitals("iOS"),
1736 "iOS has mixed case (lowercase i, uppercase OS)"
1737 );
1738
1739 assert!(rule.has_internal_capitals("iPhone"));
1741 assert!(rule.has_internal_capitals("macOS"));
1742 assert!(rule.has_internal_capitals("GitHub"));
1743 assert!(rule.has_internal_capitals("JavaScript"));
1744 assert!(rule.has_internal_capitals("eBay"));
1745
1746 assert!(!rule.has_internal_capitals("API"));
1748 assert!(!rule.has_internal_capitals("GPU"));
1749
1750 assert!(!rule.has_internal_capitals("npm"));
1752 assert!(!rule.has_internal_capitals("config"));
1753
1754 assert!(!rule.has_internal_capitals("The"));
1756 assert!(!rule.has_internal_capitals("Hello"));
1757 }
1758
1759 #[test]
1760 fn test_lowercase_words_before_trailing_code() {
1761 let config = MD063Config {
1762 enabled: true,
1763 style: HeadingCapStyle::TitleCase,
1764 lowercase_words: vec![
1765 "a".to_string(),
1766 "an".to_string(),
1767 "and".to_string(),
1768 "at".to_string(),
1769 "but".to_string(),
1770 "by".to_string(),
1771 "for".to_string(),
1772 "from".to_string(),
1773 "into".to_string(),
1774 "nor".to_string(),
1775 "on".to_string(),
1776 "onto".to_string(),
1777 "or".to_string(),
1778 "the".to_string(),
1779 "to".to_string(),
1780 "upon".to_string(),
1781 "via".to_string(),
1782 "vs".to_string(),
1783 "with".to_string(),
1784 "without".to_string(),
1785 ],
1786 preserve_cased_words: true,
1787 ..Default::default()
1788 };
1789 let rule = MD063HeadingCapitalization::from_config_struct(config);
1790
1791 let content = "## subtitle with a `app`\n";
1796 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1797 let result = rule.check(&ctx).unwrap();
1798
1799 assert!(!result.is_empty(), "Should flag incorrect capitalization");
1801 let fixed = rule.fix(&ctx).unwrap();
1802 assert!(
1804 fixed.contains("with a `app`"),
1805 "Expected 'with a `app`' but got: {fixed:?}"
1806 );
1807 assert!(
1808 !fixed.contains("with A `app`"),
1809 "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
1810 );
1811 assert!(
1813 fixed.contains("Subtitle with a `app`"),
1814 "Expected 'Subtitle with a `app`' but got: {fixed:?}"
1815 );
1816 }
1817
1818 #[test]
1819 fn test_lowercase_words_preserved_before_trailing_code_variant() {
1820 let config = MD063Config {
1821 enabled: true,
1822 style: HeadingCapStyle::TitleCase,
1823 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1824 ..Default::default()
1825 };
1826 let rule = MD063HeadingCapitalization::from_config_struct(config);
1827
1828 let content = "## Title with the `code`\n";
1830 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1831 let fixed = rule.fix(&ctx).unwrap();
1832 assert!(
1834 fixed.contains("with the `code`"),
1835 "Expected 'with the `code`' but got: {fixed:?}"
1836 );
1837 assert!(
1838 !fixed.contains("with The `code`"),
1839 "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
1840 );
1841 }
1842
1843 #[test]
1844 fn test_last_word_capitalized_when_no_trailing_code() {
1845 let config = MD063Config {
1848 enabled: true,
1849 style: HeadingCapStyle::TitleCase,
1850 lowercase_words: vec!["a".to_string(), "the".to_string()],
1851 ..Default::default()
1852 };
1853 let rule = MD063HeadingCapitalization::from_config_struct(config);
1854
1855 let content = "## title with a word\n";
1858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1859 let fixed = rule.fix(&ctx).unwrap();
1860 assert!(
1862 fixed.contains("With a Word"),
1863 "Expected 'With a Word' but got: {fixed:?}"
1864 );
1865 }
1866
1867 #[test]
1868 fn test_multiple_lowercase_words_before_code() {
1869 let config = MD063Config {
1870 enabled: true,
1871 style: HeadingCapStyle::TitleCase,
1872 lowercase_words: vec![
1873 "a".to_string(),
1874 "the".to_string(),
1875 "with".to_string(),
1876 "for".to_string(),
1877 ],
1878 ..Default::default()
1879 };
1880 let rule = MD063HeadingCapitalization::from_config_struct(config);
1881
1882 let content = "## Guide for the `user`\n";
1884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1885 let fixed = rule.fix(&ctx).unwrap();
1886 assert!(
1887 fixed.contains("for the `user`"),
1888 "Expected 'for the `user`' but got: {fixed:?}"
1889 );
1890 assert!(
1891 !fixed.contains("For The `user`"),
1892 "Should not capitalize lowercase words before code. Got: {fixed:?}"
1893 );
1894 }
1895
1896 #[test]
1897 fn test_code_in_middle_normal_rules_apply() {
1898 let config = MD063Config {
1899 enabled: true,
1900 style: HeadingCapStyle::TitleCase,
1901 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1902 ..Default::default()
1903 };
1904 let rule = MD063HeadingCapitalization::from_config_struct(config);
1905
1906 let content = "## Using `const` for the code\n";
1908 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1909 let fixed = rule.fix(&ctx).unwrap();
1910 assert!(
1912 fixed.contains("for the Code"),
1913 "Expected 'for the Code' but got: {fixed:?}"
1914 );
1915 }
1916
1917 #[test]
1918 fn test_link_at_end_same_as_code() {
1919 let config = MD063Config {
1920 enabled: true,
1921 style: HeadingCapStyle::TitleCase,
1922 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1923 ..Default::default()
1924 };
1925 let rule = MD063HeadingCapitalization::from_config_struct(config);
1926
1927 let content = "## Guide for the [link](./page.md)\n";
1929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1930 let fixed = rule.fix(&ctx).unwrap();
1931 assert!(
1933 fixed.contains("for the [Link]"),
1934 "Expected 'for the [Link]' but got: {fixed:?}"
1935 );
1936 assert!(
1937 !fixed.contains("for The [Link]"),
1938 "Should not capitalize 'the' before link. Got: {fixed:?}"
1939 );
1940 }
1941
1942 #[test]
1943 fn test_multiple_code_segments() {
1944 let config = MD063Config {
1945 enabled: true,
1946 style: HeadingCapStyle::TitleCase,
1947 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1948 ..Default::default()
1949 };
1950 let rule = MD063HeadingCapitalization::from_config_struct(config);
1951
1952 let content = "## Using `const` with a `variable`\n";
1954 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1955 let fixed = rule.fix(&ctx).unwrap();
1956 assert!(
1958 fixed.contains("with a `variable`"),
1959 "Expected 'with a `variable`' but got: {fixed:?}"
1960 );
1961 assert!(
1962 !fixed.contains("with A `variable`"),
1963 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1964 );
1965 }
1966
1967 #[test]
1968 fn test_code_and_link_combination() {
1969 let config = MD063Config {
1970 enabled: true,
1971 style: HeadingCapStyle::TitleCase,
1972 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1973 ..Default::default()
1974 };
1975 let rule = MD063HeadingCapitalization::from_config_struct(config);
1976
1977 let content = "## Guide for the `code` [link](./page.md)\n";
1979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1980 let fixed = rule.fix(&ctx).unwrap();
1981 assert!(
1983 fixed.contains("for the `code`"),
1984 "Expected 'for the `code`' but got: {fixed:?}"
1985 );
1986 }
1987
1988 #[test]
1989 fn test_text_after_code_capitalizes_last() {
1990 let config = MD063Config {
1991 enabled: true,
1992 style: HeadingCapStyle::TitleCase,
1993 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1994 ..Default::default()
1995 };
1996 let rule = MD063HeadingCapitalization::from_config_struct(config);
1997
1998 let content = "## Using `const` for the code\n";
2000 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2001 let fixed = rule.fix(&ctx).unwrap();
2002 assert!(
2004 fixed.contains("for the Code"),
2005 "Expected 'for the Code' but got: {fixed:?}"
2006 );
2007 }
2008
2009 #[test]
2010 fn test_preserve_cased_words_with_trailing_code() {
2011 let config = MD063Config {
2012 enabled: true,
2013 style: HeadingCapStyle::TitleCase,
2014 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2015 preserve_cased_words: true,
2016 ..Default::default()
2017 };
2018 let rule = MD063HeadingCapitalization::from_config_struct(config);
2019
2020 let content = "## Guide for iOS `app`\n";
2022 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2023 let fixed = rule.fix(&ctx).unwrap();
2024 assert!(
2026 fixed.contains("for iOS `app`"),
2027 "Expected 'for iOS `app`' but got: {fixed:?}"
2028 );
2029 assert!(
2030 !fixed.contains("For iOS `app`"),
2031 "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
2032 );
2033 }
2034
2035 #[test]
2036 fn test_ignore_words_with_trailing_code() {
2037 let config = MD063Config {
2038 enabled: true,
2039 style: HeadingCapStyle::TitleCase,
2040 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2041 ignore_words: vec!["npm".to_string()],
2042 ..Default::default()
2043 };
2044 let rule = MD063HeadingCapitalization::from_config_struct(config);
2045
2046 let content = "## Using npm with a `script`\n";
2048 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2049 let fixed = rule.fix(&ctx).unwrap();
2050 assert!(
2052 fixed.contains("npm with a `script`"),
2053 "Expected 'npm with a `script`' but got: {fixed:?}"
2054 );
2055 assert!(
2056 !fixed.contains("with A `script`"),
2057 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2058 );
2059 }
2060
2061 #[test]
2062 fn test_empty_text_segment_edge_case() {
2063 let config = MD063Config {
2064 enabled: true,
2065 style: HeadingCapStyle::TitleCase,
2066 lowercase_words: vec!["a".to_string(), "with".to_string()],
2067 ..Default::default()
2068 };
2069 let rule = MD063HeadingCapitalization::from_config_struct(config);
2070
2071 let content = "## `start` with a `end`\n";
2073 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2074 let fixed = rule.fix(&ctx).unwrap();
2075 assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
2078 assert!(
2079 !fixed.contains("A `end`"),
2080 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2081 );
2082 }
2083
2084 #[test]
2085 fn test_sentence_case_with_trailing_code() {
2086 let config = MD063Config {
2087 enabled: true,
2088 style: HeadingCapStyle::SentenceCase,
2089 lowercase_words: vec!["a".to_string(), "the".to_string()],
2090 ..Default::default()
2091 };
2092 let rule = MD063HeadingCapitalization::from_config_struct(config);
2093
2094 let content = "## guide for the `user`\n";
2096 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2097 let fixed = rule.fix(&ctx).unwrap();
2098 assert!(
2100 fixed.contains("Guide for the `user`"),
2101 "Expected 'Guide for the `user`' but got: {fixed:?}"
2102 );
2103 }
2104
2105 #[test]
2106 fn test_hyphenated_word_before_code() {
2107 let config = MD063Config {
2108 enabled: true,
2109 style: HeadingCapStyle::TitleCase,
2110 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2111 ..Default::default()
2112 };
2113 let rule = MD063HeadingCapitalization::from_config_struct(config);
2114
2115 let content = "## Self-contained with a `feature`\n";
2117 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2118 let fixed = rule.fix(&ctx).unwrap();
2119 assert!(
2121 fixed.contains("with a `feature`"),
2122 "Expected 'with a `feature`' but got: {fixed:?}"
2123 );
2124 }
2125
2126 #[test]
2131 fn test_sentence_case_code_at_start_basic() {
2132 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2134 let content = "# `rumdl` is a linter\n";
2135 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2136 let result = rule.check(&ctx).unwrap();
2137 assert!(
2139 result.is_empty(),
2140 "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
2141 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2142 );
2143 }
2144
2145 #[test]
2146 fn test_sentence_case_code_at_start_incorrect_capitalization() {
2147 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2149 let content = "# `rumdl` Is a Linter\n";
2150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2151 let result = rule.check(&ctx).unwrap();
2152 assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
2154 assert!(
2155 result[0].message.contains("`rumdl` is a linter"),
2156 "Should suggest lowercase after code. Got: {:?}",
2157 result[0].message
2158 );
2159 }
2160
2161 #[test]
2162 fn test_sentence_case_code_at_start_fix() {
2163 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2164 let content = "# `rumdl` Is A Linter\n";
2165 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2166 let fixed = rule.fix(&ctx).unwrap();
2167 assert!(
2168 fixed.contains("# `rumdl` is a linter"),
2169 "Should fix to lowercase after code. Got: {fixed:?}"
2170 );
2171 }
2172
2173 #[test]
2174 fn test_sentence_case_text_at_start_still_capitalizes() {
2175 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2177 let content = "# the quick brown fox\n";
2178 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2179 let result = rule.check(&ctx).unwrap();
2180 assert_eq!(result.len(), 1);
2181 assert!(
2182 result[0].message.contains("The quick brown fox"),
2183 "Text-first heading should capitalize first word. Got: {:?}",
2184 result[0].message
2185 );
2186 }
2187
2188 #[test]
2189 fn test_sentence_case_link_at_start() {
2190 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2192 let content = "# [api](api.md) reference guide\n";
2194 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2195 let result = rule.check(&ctx).unwrap();
2196 assert!(
2198 result.is_empty(),
2199 "Heading with link at start should not capitalize 'reference'. Got: {:?}",
2200 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2201 );
2202 }
2203
2204 #[test]
2205 fn test_sentence_case_link_preserves_acronyms() {
2206 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2208 let content = "# [API](api.md) Reference Guide\n";
2209 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2210 let result = rule.check(&ctx).unwrap();
2211 assert_eq!(result.len(), 1);
2212 assert!(
2214 result[0].message.contains("[API](api.md) reference guide"),
2215 "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
2216 result[0].message
2217 );
2218 }
2219
2220 #[test]
2221 fn test_sentence_case_link_preserves_brand_names() {
2222 let config = MD063Config {
2224 enabled: true,
2225 style: HeadingCapStyle::SentenceCase,
2226 preserve_cased_words: true,
2227 ..Default::default()
2228 };
2229 let rule = MD063HeadingCapitalization::from_config_struct(config);
2230 let content = "# [iPhone](iphone.md) Features Guide\n";
2231 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2232 let result = rule.check(&ctx).unwrap();
2233 assert_eq!(result.len(), 1);
2234 assert!(
2236 result[0].message.contains("[iPhone](iphone.md) features guide"),
2237 "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
2238 result[0].message
2239 );
2240 }
2241
2242 #[test]
2243 fn test_sentence_case_link_lowercases_regular_words() {
2244 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2246 let content = "# [Documentation](docs.md) Reference\n";
2247 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2248 let result = rule.check(&ctx).unwrap();
2249 assert_eq!(result.len(), 1);
2250 assert!(
2252 result[0].message.contains("[documentation](docs.md) reference"),
2253 "Should lowercase regular link text. Got: {:?}",
2254 result[0].message
2255 );
2256 }
2257
2258 #[test]
2259 fn test_sentence_case_link_at_start_correct_already() {
2260 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2262 let content = "# [API](api.md) reference guide\n";
2263 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2264 let result = rule.check(&ctx).unwrap();
2265 assert!(
2266 result.is_empty(),
2267 "Correctly cased heading with link should not be flagged. Got: {:?}",
2268 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2269 );
2270 }
2271
2272 #[test]
2273 fn test_sentence_case_link_github_preserved() {
2274 let config = MD063Config {
2276 enabled: true,
2277 style: HeadingCapStyle::SentenceCase,
2278 preserve_cased_words: true,
2279 ..Default::default()
2280 };
2281 let rule = MD063HeadingCapitalization::from_config_struct(config);
2282 let content = "# [GitHub](gh.md) Repository Setup\n";
2283 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2284 let result = rule.check(&ctx).unwrap();
2285 assert_eq!(result.len(), 1);
2286 assert!(
2287 result[0].message.contains("[GitHub](gh.md) repository setup"),
2288 "Should preserve 'GitHub'. Got: {:?}",
2289 result[0].message
2290 );
2291 }
2292
2293 #[test]
2294 fn test_sentence_case_multiple_code_spans() {
2295 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2296 let content = "# `foo` and `bar` are methods\n";
2297 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2298 let result = rule.check(&ctx).unwrap();
2299 assert!(
2301 result.is_empty(),
2302 "Should not capitalize words between/after code spans. Got: {:?}",
2303 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2304 );
2305 }
2306
2307 #[test]
2308 fn test_sentence_case_code_only_heading() {
2309 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2311 let content = "# `rumdl`\n";
2312 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2313 let result = rule.check(&ctx).unwrap();
2314 assert!(
2315 result.is_empty(),
2316 "Code-only heading should be fine. Got: {:?}",
2317 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2318 );
2319 }
2320
2321 #[test]
2322 fn test_sentence_case_code_at_end() {
2323 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2325 let content = "# install the `rumdl` tool\n";
2326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2327 let result = rule.check(&ctx).unwrap();
2328 assert_eq!(result.len(), 1);
2330 assert!(
2331 result[0].message.contains("Install the `rumdl` tool"),
2332 "First word should still be capitalized when text comes first. Got: {:?}",
2333 result[0].message
2334 );
2335 }
2336
2337 #[test]
2338 fn test_sentence_case_code_in_middle() {
2339 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2341 let content = "# using the `rumdl` linter for markdown\n";
2342 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2343 let result = rule.check(&ctx).unwrap();
2344 assert_eq!(result.len(), 1);
2346 assert!(
2347 result[0].message.contains("Using the `rumdl` linter for markdown"),
2348 "First word should be capitalized. Got: {:?}",
2349 result[0].message
2350 );
2351 }
2352
2353 #[test]
2354 fn test_sentence_case_preserved_word_after_code() {
2355 let config = MD063Config {
2357 enabled: true,
2358 style: HeadingCapStyle::SentenceCase,
2359 preserve_cased_words: true,
2360 ..Default::default()
2361 };
2362 let rule = MD063HeadingCapitalization::from_config_struct(config);
2363 let content = "# `swift` iPhone development\n";
2364 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2365 let result = rule.check(&ctx).unwrap();
2366 assert!(
2368 result.is_empty(),
2369 "Preserved words after code should stay. Got: {:?}",
2370 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2371 );
2372 }
2373
2374 #[test]
2375 fn test_title_case_code_at_start_still_capitalizes() {
2376 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2378 let content = "# `api` quick start guide\n";
2379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2380 let result = rule.check(&ctx).unwrap();
2381 assert_eq!(result.len(), 1);
2383 assert!(
2384 result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
2385 "Title case should capitalize major words after code. Got: {:?}",
2386 result[0].message
2387 );
2388 }
2389
2390 #[test]
2393 fn test_sentence_case_html_tag_at_start() {
2394 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2396 let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
2397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2398 let result = rule.check(&ctx).unwrap();
2399 assert_eq!(result.len(), 1);
2401 let fixed = rule.fix(&ctx).unwrap();
2402 assert_eq!(
2403 fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
2404 "Text after HTML at start should be lowercase"
2405 );
2406 }
2407
2408 #[test]
2409 fn test_sentence_case_html_tag_preserves_content() {
2410 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2412 let content = "# The <abbr>API</abbr> documentation guide\n";
2413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2414 let result = rule.check(&ctx).unwrap();
2415 assert!(
2417 result.is_empty(),
2418 "HTML tag content should be preserved. Got: {:?}",
2419 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2420 );
2421 }
2422
2423 #[test]
2424 fn test_sentence_case_html_tag_at_start_with_acronym() {
2425 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2427 let content = "# <abbr>API</abbr> Documentation Guide\n";
2428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2429 let result = rule.check(&ctx).unwrap();
2430 assert_eq!(result.len(), 1);
2431 let fixed = rule.fix(&ctx).unwrap();
2432 assert_eq!(
2433 fixed, "# <abbr>API</abbr> documentation guide\n",
2434 "Text after HTML at start should be lowercase, HTML content preserved"
2435 );
2436 }
2437
2438 #[test]
2439 fn test_sentence_case_html_tag_in_middle() {
2440 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2442 let content = "# using the <code>config</code> File\n";
2443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2444 let result = rule.check(&ctx).unwrap();
2445 assert_eq!(result.len(), 1);
2446 let fixed = rule.fix(&ctx).unwrap();
2447 assert_eq!(
2448 fixed, "# Using the <code>config</code> file\n",
2449 "First word capitalized, HTML preserved, rest lowercase"
2450 );
2451 }
2452
2453 #[test]
2454 fn test_html_tag_strong_emphasis() {
2455 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2457 let content = "# The <strong>Bold</strong> Way\n";
2458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2459 let result = rule.check(&ctx).unwrap();
2460 assert_eq!(result.len(), 1);
2461 let fixed = rule.fix(&ctx).unwrap();
2462 assert_eq!(
2463 fixed, "# The <strong>Bold</strong> way\n",
2464 "<strong> tag content should be preserved"
2465 );
2466 }
2467
2468 #[test]
2469 fn test_html_tag_with_attributes() {
2470 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2472 let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
2473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2474 let result = rule.check(&ctx).unwrap();
2475 assert_eq!(result.len(), 1);
2476 let fixed = rule.fix(&ctx).unwrap();
2477 assert_eq!(
2478 fixed, "# <span class=\"highlight\">Important</span> notice here\n",
2479 "HTML tag with attributes should be preserved"
2480 );
2481 }
2482
2483 #[test]
2484 fn test_multiple_html_tags() {
2485 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2487 let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
2488 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2489 let result = rule.check(&ctx).unwrap();
2490 assert_eq!(result.len(), 1);
2491 let fixed = rule.fix(&ctx).unwrap();
2492 assert_eq!(
2493 fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
2494 "Multiple HTML tags should all be preserved"
2495 );
2496 }
2497
2498 #[test]
2499 fn test_html_and_code_mixed() {
2500 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2502 let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
2503 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2504 let result = rule.check(&ctx).unwrap();
2505 assert_eq!(result.len(), 1);
2506 let fixed = rule.fix(&ctx).unwrap();
2507 assert_eq!(
2508 fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
2509 "HTML and code should both be preserved"
2510 );
2511 }
2512
2513 #[test]
2514 fn test_self_closing_html_tag() {
2515 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2517 let content = "# Line one<br/>Line Two Here\n";
2518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2519 let result = rule.check(&ctx).unwrap();
2520 assert_eq!(result.len(), 1);
2521 let fixed = rule.fix(&ctx).unwrap();
2522 assert_eq!(
2523 fixed, "# Line one<br/>line two here\n",
2524 "Self-closing HTML tags should be preserved"
2525 );
2526 }
2527
2528 #[test]
2529 fn test_title_case_with_html_tags() {
2530 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2532 let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
2533 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2534 let result = rule.check(&ctx).unwrap();
2535 assert_eq!(result.len(), 1);
2536 let fixed = rule.fix(&ctx).unwrap();
2537 assert!(
2539 fixed.contains("<kbd>ctrl</kbd>"),
2540 "HTML tag content should be preserved in title case. Got: {fixed}"
2541 );
2542 assert!(
2543 fixed.starts_with("# The ") || fixed.starts_with("# the "),
2544 "Title case should work with HTML. Got: {fixed}"
2545 );
2546 }
2547
2548 #[test]
2551 fn test_sentence_case_preserves_caret_notation() {
2552 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2554 let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\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 "Caret notation should be preserved. Got: {:?}",
2561 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2562 );
2563 }
2564
2565 #[test]
2566 fn test_sentence_case_caret_notation_various() {
2567 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2569
2570 let content = "## Press ^C to cancel\n";
2572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2573 let result = rule.check(&ctx).unwrap();
2574 assert!(
2575 result.is_empty(),
2576 "^C should be preserved. Got: {:?}",
2577 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2578 );
2579
2580 let content = "## Use ^Z for background\n";
2582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2583 let result = rule.check(&ctx).unwrap();
2584 assert!(
2585 result.is_empty(),
2586 "^Z should be preserved. Got: {:?}",
2587 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2588 );
2589
2590 let content = "## Press ^[ for escape\n";
2592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2593 let result = rule.check(&ctx).unwrap();
2594 assert!(
2595 result.is_empty(),
2596 "^[ should be preserved. Got: {:?}",
2597 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2598 );
2599 }
2600
2601 #[test]
2602 fn test_caret_notation_detection() {
2603 let rule = create_rule();
2604
2605 assert!(rule.is_caret_notation("^A"));
2607 assert!(rule.is_caret_notation("^Z"));
2608 assert!(rule.is_caret_notation("^C"));
2609 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")); }
2621
2622 fn create_sentence_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2629 let config = MD063Config {
2630 enabled: true,
2631 style: HeadingCapStyle::SentenceCase,
2632 ..Default::default()
2633 };
2634 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2635 rule.proper_names = names;
2636 rule
2637 }
2638
2639 #[test]
2640 fn test_sentence_case_preserves_single_word_proper_name() {
2641 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2642 let content = "# installing javascript\n";
2644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2645 let result = rule.check(&ctx).unwrap();
2646 assert_eq!(result.len(), 1, "Should flag the heading");
2647 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2648 assert!(
2649 fix_text.contains("JavaScript"),
2650 "Fix should preserve proper name 'JavaScript', got: {fix_text:?}"
2651 );
2652 assert!(
2653 !fix_text.contains("javascript"),
2654 "Fix should not have lowercase 'javascript', got: {fix_text:?}"
2655 );
2656 }
2657
2658 #[test]
2659 fn test_sentence_case_preserves_multi_word_proper_name() {
2660 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2661 let content = "# using good application features\n";
2663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2664 let result = rule.check(&ctx).unwrap();
2665 assert_eq!(result.len(), 1, "Should flag the heading");
2666 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2667 assert!(
2668 fix_text.contains("Good Application"),
2669 "Fix should preserve 'Good Application' as a phrase, got: {fix_text:?}"
2670 );
2671 }
2672
2673 #[test]
2674 fn test_sentence_case_proper_name_at_start_of_heading() {
2675 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2676 let content = "# good application overview\n";
2678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2679 let result = rule.check(&ctx).unwrap();
2680 assert_eq!(result.len(), 1, "Should flag the heading");
2681 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2682 assert!(
2683 fix_text.contains("Good Application"),
2684 "Fix should produce 'Good Application' at start of heading, got: {fix_text:?}"
2685 );
2686 assert!(
2687 fix_text.contains("overview"),
2688 "Non-proper-name word 'overview' should be lowercase, got: {fix_text:?}"
2689 );
2690 }
2691
2692 #[test]
2693 fn test_sentence_case_with_proper_names_no_oscillation() {
2694 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2697
2698 let content = "# installing good application on your system\n";
2700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2701 let result = rule.check(&ctx).unwrap();
2702 assert_eq!(result.len(), 1);
2703 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
2704
2705 assert!(
2707 fixed_heading.contains("Good Application"),
2708 "After fix, proper name must be preserved: {fixed_heading:?}"
2709 );
2710
2711 let fixed_line = format!("{fixed_heading}\n");
2713 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
2714 let result2 = rule.check(&ctx2).unwrap();
2715 assert!(
2716 result2.is_empty(),
2717 "After one fix, heading must already satisfy both MD063 and MD044 - no oscillation. \
2718 Second pass warnings: {result2:?}"
2719 );
2720 }
2721
2722 #[test]
2723 fn test_sentence_case_proper_names_already_correct() {
2724 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2725 let content = "# Installing Good Application\n";
2727 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2728 let result = rule.check(&ctx).unwrap();
2729 assert!(
2730 result.is_empty(),
2731 "Correct sentence-case heading with proper name should not be flagged, got: {result:?}"
2732 );
2733 }
2734
2735 #[test]
2736 fn test_sentence_case_multiple_proper_names_in_heading() {
2737 let rule = create_sentence_case_rule_with_proper_names(vec!["TypeScript".to_string(), "React".to_string()]);
2738 let content = "# using typescript with react\n";
2739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2740 let result = rule.check(&ctx).unwrap();
2741 assert_eq!(result.len(), 1);
2742 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2743 assert!(
2744 fix_text.contains("TypeScript"),
2745 "Fix should preserve 'TypeScript', got: {fix_text:?}"
2746 );
2747 assert!(
2748 fix_text.contains("React"),
2749 "Fix should preserve 'React', got: {fix_text:?}"
2750 );
2751 }
2752
2753 #[test]
2754 fn test_sentence_case_unicode_casefold_expansion_before_proper_name() {
2755 let rule = create_sentence_case_rule_with_proper_names(vec!["Österreich".to_string()]);
2758 let content = "# İ österreich guide\n";
2759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2760
2761 let result = rule.check(&ctx).unwrap();
2763 assert_eq!(result.len(), 1, "Should flag heading for canonical proper-name casing");
2764 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2765 assert!(
2766 fix_text.contains("Österreich"),
2767 "Fix should preserve canonical 'Österreich', got: {fix_text:?}"
2768 );
2769 }
2770
2771 #[test]
2772 fn test_sentence_case_preserves_trailing_punctuation_on_proper_name() {
2773 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2774 let content = "# using javascript, today\n";
2775 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2776 let result = rule.check(&ctx).unwrap();
2777 assert_eq!(result.len(), 1, "Should flag heading");
2778 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2779 assert!(
2780 fix_text.contains("JavaScript,"),
2781 "Fix should preserve trailing punctuation, got: {fix_text:?}"
2782 );
2783 }
2784
2785 fn create_title_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2792 let config = MD063Config {
2793 enabled: true,
2794 style: HeadingCapStyle::TitleCase,
2795 ..Default::default()
2796 };
2797 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2798 rule.proper_names = names;
2799 rule
2800 }
2801
2802 #[test]
2803 fn test_title_case_preserves_proper_name_with_lowercase_article() {
2804 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
2808 let content = "# listening to the rolling stones today\n";
2809 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2810 let result = rule.check(&ctx).unwrap();
2811 assert_eq!(result.len(), 1, "Should flag the heading");
2812 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2813 assert!(
2814 fix_text.contains("The Rolling Stones"),
2815 "Fix should preserve proper name 'The Rolling Stones', got: {fix_text:?}"
2816 );
2817 }
2818
2819 #[test]
2820 fn test_title_case_proper_name_no_oscillation() {
2821 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
2823 let content = "# listening to the rolling stones today\n";
2824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2825 let result = rule.check(&ctx).unwrap();
2826 assert_eq!(result.len(), 1);
2827 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
2828
2829 let fixed_line = format!("{fixed_heading}\n");
2830 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
2831 let result2 = rule.check(&ctx2).unwrap();
2832 assert!(
2833 result2.is_empty(),
2834 "After one title-case fix, heading must already satisfy both rules. \
2835 Second pass warnings: {result2:?}"
2836 );
2837 }
2838
2839 #[test]
2840 fn test_title_case_unicode_casefold_expansion_before_proper_name() {
2841 let rule = create_title_case_rule_with_proper_names(vec!["Österreich".to_string()]);
2842 let content = "# İ österreich guide\n";
2843 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2844 let result = rule.check(&ctx).unwrap();
2845 assert_eq!(result.len(), 1, "Should flag the heading");
2846 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2847 assert!(
2848 fix_text.contains("Österreich"),
2849 "Fix should preserve canonical proper-name casing, got: {fix_text:?}"
2850 );
2851 }
2852
2853 #[test]
2859 fn test_from_config_loads_md044_names_into_md063() {
2860 use crate::config::{Config, RuleConfig};
2861 use crate::rule::Rule;
2862 use std::collections::BTreeMap;
2863
2864 let mut config = Config::default();
2865
2866 let mut md063_values = BTreeMap::new();
2868 md063_values.insert("style".to_string(), toml::Value::String("sentence_case".to_string()));
2869 md063_values.insert("enabled".to_string(), toml::Value::Boolean(true));
2870 config.rules.insert(
2871 "MD063".to_string(),
2872 RuleConfig {
2873 values: md063_values,
2874 severity: None,
2875 },
2876 );
2877
2878 let mut md044_values = BTreeMap::new();
2880 md044_values.insert(
2881 "names".to_string(),
2882 toml::Value::Array(vec![toml::Value::String("Good Application".to_string())]),
2883 );
2884 config.rules.insert(
2885 "MD044".to_string(),
2886 RuleConfig {
2887 values: md044_values,
2888 severity: None,
2889 },
2890 );
2891
2892 let rule = MD063HeadingCapitalization::from_config(&config);
2894
2895 let content = "# using good application features\n";
2897 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2898 let result = rule.check(&ctx).unwrap();
2899 assert_eq!(result.len(), 1, "Should flag the heading");
2900 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2901 assert!(
2902 fix_text.contains("Good Application"),
2903 "from_config should wire MD044 names into MD063; fix should preserve \
2904 'Good Application', got: {fix_text:?}"
2905 );
2906 }
2907
2908 #[test]
2909 fn test_title_case_short_word_not_confused_with_substring() {
2910 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2914
2915 let content = "# in the insert\n";
2918 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2919 let result = rule.check(&ctx).unwrap();
2920 assert_eq!(result.len(), 1, "Should flag the heading");
2921 let fix = result[0].fix.as_ref().expect("Fix should be present");
2922 assert!(
2924 fix.replacement.contains("In the Insert"),
2925 "Expected 'In the Insert', got: {:?}",
2926 fix.replacement
2927 );
2928 }
2929
2930 #[test]
2931 fn test_title_case_or_not_confused_with_orchestra() {
2932 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2933
2934 let content = "# or the orchestra\n";
2937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2938 let result = rule.check(&ctx).unwrap();
2939 assert_eq!(result.len(), 1, "Should flag the heading");
2940 let fix = result[0].fix.as_ref().expect("Fix should be present");
2941 assert!(
2943 fix.replacement.contains("Or the Orchestra"),
2944 "Expected 'Or the Orchestra', got: {:?}",
2945 fix.replacement
2946 );
2947 }
2948
2949 #[test]
2950 fn test_all_caps_preserves_all_words() {
2951 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
2952
2953 let content = "# in the insert\n";
2954 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2955 let result = rule.check(&ctx).unwrap();
2956 assert_eq!(result.len(), 1, "Should flag the heading");
2957 let fix = result[0].fix.as_ref().expect("Fix should be present");
2958 assert!(
2959 fix.replacement.contains("IN THE INSERT"),
2960 "All caps should uppercase all words, got: {:?}",
2961 fix.replacement
2962 );
2963 }
2964
2965 #[test]
2967 fn test_title_case_numbered_prefix_lowercase_word() {
2968 let rule = create_rule();
2970 let content = "## 1. To Be a Thing\n";
2971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2972 let result = rule.check(&ctx).unwrap();
2973 assert!(
2974 result.is_empty(),
2975 "Should not flag '## 1. To Be a Thing', got: {result:?}"
2976 );
2977
2978 let content_lower = "## 1. to be a thing\n";
2979 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2980 let result2 = rule.check(&ctx2).unwrap();
2981 assert!(!result2.is_empty(), "Should flag '## 1. to be a thing'");
2982 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2983 assert!(
2984 fix.replacement.contains("1. To Be a Thing"),
2985 "Fix should capitalize 'To', got: {:?}",
2986 fix.replacement
2987 );
2988 }
2989
2990 #[test]
2991 fn test_title_case_numbered_prefix_article() {
2992 let rule = create_rule();
2994 let content = "## 2. A Guide to the Galaxy\n";
2995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2996 let result = rule.check(&ctx).unwrap();
2997 assert!(
2998 result.is_empty(),
2999 "Should not flag '## 2. A Guide to the Galaxy', got: {result:?}"
3000 );
3001
3002 let content_lower = "## 2. a guide to the galaxy\n";
3003 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3004 let result2 = rule.check(&ctx2).unwrap();
3005 assert!(!result2.is_empty(), "Should flag '## 2. a guide to the galaxy'");
3006 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3007 assert!(
3008 fix.replacement.contains("2. A Guide to the Galaxy"),
3009 "Fix should capitalize 'A', got: {:?}",
3010 fix.replacement
3011 );
3012 }
3013
3014 #[test]
3015 fn test_title_case_mid_sentence_period_word() {
3016 let rule = create_rule();
3018 let content = "## Step 1. Introduction to the Problem\n";
3019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3020 let result = rule.check(&ctx).unwrap();
3021 assert!(
3022 result.is_empty(),
3023 "Should not flag '## Step 1. Introduction to the Problem', got: {result:?}"
3024 );
3025
3026 let content_lower = "## Step 1. introduction to the problem\n";
3027 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3028 let result2 = rule.check(&ctx2).unwrap();
3029 assert!(
3030 !result2.is_empty(),
3031 "Should flag '## Step 1. introduction to the problem'"
3032 );
3033 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3034 assert!(
3035 fix.replacement.contains("Step 1. Introduction to the Problem"),
3036 "Fix should capitalize 'Introduction', got: {:?}",
3037 fix.replacement
3038 );
3039 }
3040
3041 #[test]
3042 fn test_title_case_numbered_prefix_in_link_text() {
3043 let config = MD063Config {
3046 enabled: true,
3047 style: HeadingCapStyle::TitleCase,
3048 ..Default::default()
3049 };
3050 let rule = MD063HeadingCapitalization::from_config_struct(config);
3051
3052 let content = "## [1. To Be a Thing](https://example.com)\n";
3054 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3055 let result = rule.check(&ctx).unwrap();
3056 assert!(
3057 result.is_empty(),
3058 "Should not flag '## [1. To Be a Thing](url)', got: {result:?}"
3059 );
3060
3061 let content_lower = "## [1. to be a thing](https://example.com)\n";
3063 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3064 let result2 = rule.check(&ctx2).unwrap();
3065 assert!(!result2.is_empty(), "Should flag '## [1. to be a thing](url)'");
3066 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3067 assert!(
3068 fix.replacement.contains("1. To Be a Thing"),
3069 "Fix should capitalize 'To' in link text, got: {:?}",
3070 fix.replacement
3071 );
3072 }
3073
3074 #[test]
3079 fn test_is_numeric_ordinal_recognises_canonical_forms() {
3080 for word in &[
3081 "1st", "2nd", "3rd", "4th", "5th", "11th", "21st", "22nd", "23rd", "100th", "1ST", "5Th", "21St", "21sT",
3082 ] {
3083 assert!(
3084 MD063HeadingCapitalization::is_numeric_ordinal(word),
3085 "expected `{word}` to be detected as a numeric ordinal"
3086 );
3087 }
3088 }
3089
3090 #[test]
3091 fn test_is_numeric_ordinal_rejects_non_ordinals() {
3092 for word in &[
3097 "first", "1stop", "ist", "5", "th", "abc", "4G", "4K", "30s", "100k", "5x", "1.5", "iPhone6S",
3098 ] {
3099 assert!(
3100 !MD063HeadingCapitalization::is_numeric_ordinal(word),
3101 "expected `{word}` NOT to be detected as a numeric ordinal"
3102 );
3103 }
3104 }
3105
3106 #[test]
3107 fn test_is_numeric_ordinal_strips_trailing_punctuation() {
3108 for word in &["5th.", "1st,", "21st!", "3rd:", "4th)", "5th's"] {
3109 assert!(
3110 MD063HeadingCapitalization::is_numeric_ordinal(word),
3111 "expected `{word}` to be detected as a numeric ordinal (with punctuation)"
3112 );
3113 }
3114 }
3115
3116 #[test]
3117 fn test_title_case_ordinal_first_word_not_flagged() {
3118 let rule = create_rule();
3119 for content in &[
3120 "# 1st Place\n",
3121 "# 2nd Edition\n",
3122 "# 3rd Time\n",
3123 "# 5th Avenue\n",
3124 "# 21st Century Skills\n",
3125 "# 100th Customer\n",
3126 ] {
3127 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3128 let result = rule.check(&ctx).unwrap();
3129 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3130 }
3131 }
3132
3133 #[test]
3134 fn test_title_case_ordinal_mid_heading_not_flagged() {
3135 let rule = create_rule();
3136 for content in &[
3137 "# May 3rd Notes\n",
3138 "# Top 100th Customer\n",
3139 "# Notes for the 5th of May\n",
3140 ] {
3141 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3142 let result = rule.check(&ctx).unwrap();
3143 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3144 }
3145 }
3146
3147 #[test]
3148 fn test_title_case_ordinal_corrupted_form_is_fixed() {
3149 let rule = create_rule();
3152 for (input, expected) in &[
3153 ("# 1St Place\n", "1st Place"),
3154 ("# 5Th Avenue\n", "5th Avenue"),
3155 ("# 21St Century Skills\n", "21st Century Skills"),
3156 ("# May 3Rd Notes\n", "May 3rd Notes"),
3157 ("# 22Nd Edition\n", "22nd Edition"),
3158 ] {
3159 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
3160 let result = rule.check(&ctx).unwrap();
3161 assert!(!result.is_empty(), "Should flag {input:?}");
3162 let fix = result[0].fix.as_ref().expect("should have a fix");
3163 assert!(
3164 fix.replacement.contains(expected),
3165 "Fix for {input:?} should contain {expected:?}, got: {:?}",
3166 fix.replacement
3167 );
3168 }
3169 }
3170
3171 #[test]
3172 fn test_title_case_ordinal_lowercase_other_words_capitalised() {
3173 let rule = create_rule();
3175 let content = "# 5th avenue\n";
3176 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3177 let result = rule.check(&ctx).unwrap();
3178 assert_eq!(result.len(), 1);
3179 let fix = result[0].fix.as_ref().expect("should have a fix");
3180 assert!(
3181 fix.replacement.contains("5th Avenue"),
3182 "Fix should produce '5th Avenue', got: {:?}",
3183 fix.replacement
3184 );
3185 }
3186
3187 #[test]
3188 fn test_title_case_ordinal_with_trailing_punctuation() {
3189 let rule = create_rule();
3190 let content = "# Released on the 5th.\n";
3191 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3192 let result = rule.check(&ctx).unwrap();
3193 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3194 }
3195
3196 #[test]
3197 fn test_title_case_ordinal_hyphenated() {
3198 let rule = create_rule();
3199 for content in &["# 21st-Century Skills\n", "# A 19th-Century Novel\n"] {
3200 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3201 let result = rule.check(&ctx).unwrap();
3202 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3203 }
3204 }
3205
3206 #[test]
3207 fn test_sentence_case_ordinal_corrupted_form_is_fixed() {
3208 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3209 let content = "# 5Th avenue\n";
3210 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3211 let result = rule.check(&ctx).unwrap();
3212 assert_eq!(result.len(), 1);
3213 let fix = result[0].fix.as_ref().expect("should have a fix");
3214 assert!(
3215 fix.replacement.contains("5th avenue"),
3216 "Fix should produce '5th avenue', got: {:?}",
3217 fix.replacement
3218 );
3219 }
3220
3221 #[test]
3222 fn test_title_case_digit_acronym_unchanged() {
3223 let rule = create_rule();
3226 for content in &["# 4G Networks\n", "# 4K Streaming\n"] {
3227 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3228 let result = rule.check(&ctx).unwrap();
3229 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3230 }
3231 }
3232
3233 fn restart_rule(boundaries: &[&str]) -> MD063HeadingCapitalization {
3236 let config = MD063Config {
3237 enabled: true,
3238 style: HeadingCapStyle::SentenceCase,
3239 sentence_case_restart_after: boundaries.iter().copied().map(String::from).collect(),
3240 ..Default::default()
3241 };
3242 MD063HeadingCapitalization::from_config_struct(config)
3243 }
3244
3245 fn suggested(rule: &MD063HeadingCapitalization, content: &str) -> Option<String> {
3248 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3249 let warnings = rule.check(&ctx).unwrap();
3250 let fixed = rule.fix(&ctx).unwrap();
3251 assert_eq!(
3252 warnings.is_empty(),
3253 fixed == content,
3254 "a warning and a rewrite must agree for {content:?}"
3255 );
3256 (!warnings.is_empty()).then(|| fixed.trim_start_matches('#').trim().to_string())
3257 }
3258
3259 #[test]
3260 fn test_restart_after_capitalizes_the_word_following_a_boundary() {
3261 let rule = restart_rule(&[":"]);
3262 assert_eq!(
3263 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3264 Some("Requirement 1: Struct to logger slice conversion")
3265 );
3266 }
3267
3268 #[test]
3269 fn test_restart_after_defaults_to_no_boundaries() {
3270 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3272 assert_eq!(
3273 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3274 Some("Requirement 1: struct to logger slice conversion")
3275 );
3276 }
3277
3278 #[test]
3279 fn test_restart_after_only_honors_configured_punctuation() {
3280 let rule = restart_rule(&[":"]);
3282 assert_eq!(
3283 suggested(&rule, "# Design - Data Model Overview\n").as_deref(),
3284 Some("Design - data model overview")
3285 );
3286 assert_eq!(
3287 suggested(&rule, "# Setup; Then Run\n").as_deref(),
3288 Some("Setup; then run")
3289 );
3290
3291 let rule = restart_rule(&[";", "\u{2014}"]);
3292 assert_eq!(
3293 suggested(&rule, "# Setup; Then Run\n").as_deref(),
3294 Some("Setup; Then run")
3295 );
3296 assert_eq!(
3297 suggested(&rule, "# Part One \u{2014} The Big Idea\n").as_deref(),
3298 Some("Part one \u{2014} The big idea")
3299 );
3300 }
3301
3302 #[test]
3303 fn test_restart_after_matches_only_at_the_end_of_a_word() {
3304 let rule = restart_rule(&["-", ":"]);
3307 assert_eq!(
3308 suggested(&rule, "# Ports: Well-Known Ports Explained\n").as_deref(),
3309 Some("Ports: Well-Known ports explained")
3310 );
3311 assert_eq!(
3312 suggested(&rule, "# See https://example.com/A/B For Details\n").as_deref(),
3313 Some("See https://example.com/A/B for details")
3314 );
3315 }
3316
3317 #[test]
3318 fn test_restart_after_a_trailing_boundary_is_a_no_op() {
3319 let rule = restart_rule(&[":"]);
3320 assert_eq!(suggested(&rule, "# Setup:\n"), None);
3321 }
3322
3323 #[test]
3324 fn test_restart_after_does_not_override_preserved_words() {
3325 let rule = restart_rule(&[":"]);
3328 assert_eq!(
3329 suggested(&rule, "# Devices: iPhone And Android\n").as_deref(),
3330 Some("Devices: iPhone and android")
3331 );
3332
3333 let config = MD063Config {
3334 enabled: true,
3335 style: HeadingCapStyle::SentenceCase,
3336 sentence_case_restart_after: vec![":".to_string()],
3337 ignore_words: vec!["kubectl".to_string()],
3338 preserve_cased_words: false,
3339 ..Default::default()
3340 };
3341 let rule = MD063HeadingCapitalization::from_config_struct(config);
3342 assert_eq!(
3343 suggested(&rule, "# Tools: kubectl And Helm\n").as_deref(),
3344 Some("Tools: kubectl and helm")
3345 );
3346 }
3347
3348 #[test]
3349 fn test_restart_after_keeps_md044_canonical_forms() {
3350 let config = MD063Config {
3351 enabled: true,
3352 style: HeadingCapStyle::SentenceCase,
3353 sentence_case_restart_after: vec![":".to_string()],
3354 ..Default::default()
3355 };
3356 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3357 rule.proper_names = vec!["GitHub".to_string()];
3358
3359 assert_eq!(
3361 suggested(&rule, "# Docs: github Actions Guide\n").as_deref(),
3362 Some("Docs: GitHub actions guide")
3363 );
3364 assert_eq!(suggested(&rule, "# Docs: GitHub actions guide\n"), None);
3365 }
3366
3367 #[test]
3368 fn test_restart_after_carries_across_segments() {
3369 let rule = restart_rule(&[":"]);
3372 assert_eq!(
3373 suggested(
3374 &rule,
3375 "# Overview: [Some Link Here](https://example.com) Trailing Words\n"
3376 )
3377 .as_deref(),
3378 Some("Overview: [Some link here](https://example.com) trailing words")
3379 );
3380 assert_eq!(
3381 suggested(&rule, "# Overview: `code` Then More Words\n").as_deref(),
3382 Some("Overview: `code` then more words")
3383 );
3384 }
3385
3386 #[test]
3387 fn test_restart_after_ends_a_sentence_at_the_end_of_link_text() {
3388 let rule = restart_rule(&[":"]);
3392 assert_eq!(
3393 suggested(&rule, "# Topic [See:](https://example.com) More Words\n").as_deref(),
3394 Some("Topic [see:](https://example.com) More words")
3395 );
3396
3397 assert_eq!(
3399 suggested(&rule, "# Topic [See](https://example.com) More Words\n").as_deref(),
3400 Some("Topic [see](https://example.com) more words")
3401 );
3402 }
3403
3404 #[test]
3405 fn test_restart_after_ignores_boundaries_inside_opaque_segments() {
3406 let rule = restart_rule(&[":"]);
3409 for content in [
3410 "# Topic `see:` More Words\n",
3411 "# Topic  More Words\n",
3412 "# Topic <span title=\"x:\">y</span> More Words\n",
3413 ] {
3414 let fixed = suggested(&rule, content).expect("heading should be rewritten");
3415 assert!(
3416 fixed.ends_with("more words"),
3417 "opaque segment restarted the sentence in {content:?}: {fixed}"
3418 );
3419 }
3420 }
3421
3422 #[test]
3423 fn test_restart_after_leaves_a_leading_link_mid_sentence() {
3424 for rule in [restart_rule(&[]), restart_rule(&[":"])] {
3427 assert_eq!(
3428 suggested(&rule, "# [Some Link Here](https://example.com) Trailing Words\n").as_deref(),
3429 Some("[some link here](https://example.com) trailing words")
3430 );
3431 }
3432 }
3433
3434 #[test]
3435 fn test_restart_after_fix_is_idempotent() {
3436 let rule = restart_rule(&[":", ";", "-", "\u{2014}"]);
3437 for content in [
3438 "# Requirement 1: Struct to Logger Slice Conversion\n",
3439 "# Ports: Well-Known Ports Explained\n",
3440 "# Devices: iPhone And Android\n",
3441 "# Overview: [Some Link Here](https://example.com) Trailing Words\n",
3442 "# Setup:\n",
3443 ] {
3444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3445 let once = rule.fix(&ctx).unwrap();
3446 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
3447 assert_eq!(rule.fix(&ctx).unwrap(), once, "fix is not idempotent for {content:?}");
3448 }
3449 }
3450
3451 #[test]
3452 fn test_restart_after_ignores_empty_boundary_entries() {
3453 let rule = restart_rule(&[""]);
3455 assert_eq!(
3456 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3457 Some("Requirement 1: struct to logger slice conversion")
3458 );
3459 }
3460
3461 const STYLES: [HeadingCapStyle; 3] = [
3464 HeadingCapStyle::TitleCase,
3465 HeadingCapStyle::SentenceCase,
3466 HeadingCapStyle::AllCaps,
3467 ];
3468
3469 const GHERKIN_STRUCTURES: [(&str, &str, &str, &str); 6] = [
3472 (
3473 "# Feature: the system under test",
3474 "# Feature: The System Under Test",
3475 "# Feature: The system under test",
3476 "# Feature: THE SYSTEM UNDER TEST",
3477 ),
3478 (
3479 "## Background: a shared setup",
3480 "## Background: A Shared Setup",
3481 "## Background: A shared setup",
3482 "## Background: A SHARED SETUP",
3483 ),
3484 (
3485 "## Rule: money is never lost",
3486 "## Rule: Money Is Never Lost",
3487 "## Rule: Money is never lost",
3488 "## Rule: MONEY IS NEVER LOST",
3489 ),
3490 (
3491 "### Scenario: add two numbers",
3492 "### Scenario: Add Two Numbers",
3493 "### Scenario: Add two numbers",
3494 "### Scenario: ADD TWO NUMBERS",
3495 ),
3496 (
3497 "### Scenario Outline: add two numbers",
3498 "### Scenario Outline: Add Two Numbers",
3499 "### Scenario Outline: Add two numbers",
3500 "### Scenario Outline: ADD TWO NUMBERS",
3501 ),
3502 (
3503 "#### Examples: happy path",
3504 "#### Examples: Happy Path",
3505 "#### Examples: Happy path",
3506 "#### Examples: HAPPY PATH",
3507 ),
3508 ];
3509
3510 fn recased(style: HeadingCapStyle, heading: &str, flavor: crate::config::MarkdownFlavor) -> String {
3512 let rule = create_rule_with_style(style);
3513 let content = format!("{heading}\n");
3514 let ctx = LintContext::new(&content, flavor, None);
3515 let warnings = rule.check(&ctx).unwrap();
3516 let fixed = rule.fix(&ctx).unwrap();
3517 assert_eq!(
3518 warnings.is_empty(),
3519 fixed == content,
3520 "a warning and a rewrite must agree for {content:?} under {flavor:?}"
3521 );
3522 fixed.trim_end().to_string()
3523 }
3524
3525 #[test]
3526 fn test_mdg_keeps_the_keyword_of_every_structure() {
3527 for (heading, ..) in GHERKIN_STRUCTURES {
3530 let keyword = &heading[..=heading.find(':').unwrap()];
3531 for style in STYLES {
3532 let fixed = recased(style, heading, crate::config::MarkdownFlavor::MDG);
3533 assert!(
3534 fixed.starts_with(keyword),
3535 "{style:?} lost the keyword of {heading:?}: {fixed}"
3536 );
3537 }
3538 }
3539 }
3540
3541 #[test]
3542 fn test_mdg_recases_only_the_name_of_a_structure() {
3543 for (heading, title, sentence, caps) in GHERKIN_STRUCTURES {
3544 let mdg = crate::config::MarkdownFlavor::MDG;
3545 assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), title);
3546 assert_eq!(recased(HeadingCapStyle::SentenceCase, heading, mdg), sentence);
3547 assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), caps);
3548 }
3549 }
3550
3551 #[test]
3552 fn test_standard_flavor_recases_a_keyword_like_any_other_word() {
3553 let standard = crate::config::MarkdownFlavor::Standard;
3555 assert_eq!(
3556 recased(HeadingCapStyle::TitleCase, "# Feature: the system under test", standard),
3557 "# Feature: the System Under Test"
3558 );
3559 assert_eq!(
3560 recased(
3561 HeadingCapStyle::SentenceCase,
3562 "### Scenario Outline: add two numbers",
3563 standard
3564 ),
3565 "### Scenario outline: add two numbers"
3566 );
3567 assert_eq!(
3568 recased(HeadingCapStyle::AllCaps, "# Feature: the system under test", standard),
3569 "# FEATURE: THE SYSTEM UNDER TEST"
3570 );
3571 }
3572
3573 #[test]
3574 fn test_mdg_leaves_a_heading_without_a_colon_to_the_normal_rule() {
3575 for heading in ["## notes about the system", "## Notes", "# THE SYSTEM"] {
3576 for style in STYLES {
3577 assert_eq!(
3578 recased(style, heading, crate::config::MarkdownFlavor::MDG),
3579 recased(style, heading, crate::config::MarkdownFlavor::Standard),
3580 "{style:?} treated {heading:?} as a Gherkin structure"
3581 );
3582 }
3583 }
3584 }
3585
3586 #[test]
3587 fn test_mdg_splits_at_the_first_colon_only() {
3588 let mdg = crate::config::MarkdownFlavor::MDG;
3590 let heading = "## Scenario: ratio: two to one";
3591 assert_eq!(
3592 recased(HeadingCapStyle::TitleCase, heading, mdg),
3593 "## Scenario: Ratio: Two to One"
3594 );
3595 assert_eq!(
3596 recased(HeadingCapStyle::SentenceCase, heading, mdg),
3597 "## Scenario: Ratio: two to one"
3598 );
3599 assert_eq!(
3600 recased(HeadingCapStyle::AllCaps, heading, mdg),
3601 "## Scenario: RATIO: TWO TO ONE"
3602 );
3603 }
3604
3605 #[test]
3606 fn test_mdg_leaves_a_colon_behind_a_backtick_to_the_normal_rule() {
3607 for heading in [
3611 "# See `x: y` Notes",
3612 "# `a: b`",
3613 "# `code` Feature: a name",
3614 "# `x: y` Feature: a name",
3615 ] {
3616 for style in STYLES {
3617 assert_eq!(
3618 recased(style, heading, crate::config::MarkdownFlavor::MDG),
3619 recased(style, heading, crate::config::MarkdownFlavor::Standard),
3620 "{style:?} split {heading:?} at a colon inside a code span"
3621 );
3622 }
3623 }
3624 }
3625
3626 #[test]
3627 fn test_mdg_splits_at_a_keyword_colon_that_precedes_a_code_span() {
3628 let mdg = crate::config::MarkdownFlavor::MDG;
3630 let heading = "# Scenario: use `a: b` here";
3631 assert_eq!(
3632 recased(HeadingCapStyle::TitleCase, heading, mdg),
3633 "# Scenario: Use `a: b` Here"
3634 );
3635 assert_eq!(
3636 recased(HeadingCapStyle::SentenceCase, heading, mdg),
3637 "# Scenario: Use `a: b` here"
3638 );
3639 assert_eq!(
3640 recased(HeadingCapStyle::AllCaps, heading, mdg),
3641 "# Scenario: USE `a: b` HERE"
3642 );
3643 }
3644
3645 #[test]
3646 fn test_mdg_splits_at_a_keyword_colon_before_an_unbalanced_backtick() {
3647 let mdg = crate::config::MarkdownFlavor::MDG;
3650 let heading = "# Scenario: a ` b";
3651 assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), "# Scenario: A ` B");
3652 assert_eq!(
3653 recased(HeadingCapStyle::SentenceCase, heading, mdg),
3654 "# Scenario: A ` b"
3655 );
3656 assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), "# Scenario: A ` B");
3657 }
3658
3659 #[test]
3660 fn test_mdg_keeps_a_keyword_with_nothing_left_to_recase() {
3661 for style in STYLES {
3662 assert_eq!(
3663 recased(style, "# Feature:", crate::config::MarkdownFlavor::MDG),
3664 "# Feature:"
3665 );
3666 }
3667 }
3668
3669 #[test]
3670 fn test_mdg_keeps_a_custom_id_after_the_name() {
3671 assert_eq!(
3672 recased(
3673 HeadingCapStyle::TitleCase,
3674 "# Feature: the system {#overview}",
3675 crate::config::MarkdownFlavor::MDG
3676 ),
3677 "# Feature: The System {#overview}"
3678 );
3679 }
3680
3681 #[test]
3682 fn test_mdg_fix_is_idempotent() {
3683 for (heading, ..) in GHERKIN_STRUCTURES {
3684 for style in STYLES {
3685 let rule = create_rule_with_style(style);
3686 let content = format!("{heading}\n");
3687 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
3688 let once = rule.fix(&ctx).unwrap();
3689 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::MDG, None);
3690 assert_eq!(
3691 rule.fix(&ctx).unwrap(),
3692 once,
3693 "fix is not idempotent for {heading:?} ({style:?})"
3694 );
3695 }
3696 }
3697 }
3698}