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_starts_sentence = segments
858 .iter()
859 .find(|s| !s.renders_nothing())
860 .is_some_and(|s| matches!(s, HeadingSegment::Text(_) | HeadingSegment::Link { .. }));
861
862 let last_segment_is_text = segments
865 .iter()
866 .rev()
867 .find(|s| !s.renders_nothing())
868 .is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
869
870 let mut result_parts: Vec<String> = Vec::new();
872
873 let mut at_sentence_start = first_segment_starts_sentence;
876
877 for (i, segment) in segments.iter().enumerate() {
878 at_sentence_start = match segment {
883 HeadingSegment::Text(t) => {
884 let is_first_text = text_segments.first() == Some(&i);
885 let is_last_text = text_segments.last() == Some(&i) && last_segment_is_text;
889
890 let capitalized = match self.config.style {
891 HeadingCapStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
892 HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(t, at_sentence_start),
893 HeadingCapStyle::AllCaps => self.apply_all_caps(t),
894 };
895 let ends_sentence = self.ends_sentence(capitalized.trim_end());
896 result_parts.push(capitalized);
897 ends_sentence
898 }
899 HeadingSegment::Code(c) => {
900 result_parts.push(c.clone());
901 false
902 }
903 HeadingSegment::Link {
904 full,
905 text_start,
906 text_end,
907 } => {
908 let link_text = &full[*text_start..*text_end];
910 let capitalized_text = match self.config.style {
911 HeadingCapStyle::TitleCase => self.apply_title_case(link_text),
912 HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(link_text, at_sentence_start),
915 HeadingCapStyle::AllCaps => self.apply_all_caps(link_text),
916 };
917 let ends_sentence = self.ends_sentence(capitalized_text.trim_end());
920
921 let mut new_link = String::new();
922 new_link.push_str(&full[..*text_start]);
923 new_link.push_str(&capitalized_text);
924 new_link.push_str(&full[*text_end..]);
925 result_parts.push(new_link);
926 ends_sentence
927 }
928 HeadingSegment::Html(h) => {
929 result_parts.push(h.clone());
933 segment.renders_nothing() && at_sentence_start
934 }
935 HeadingSegment::Image(img) => {
936 result_parts.push(img.clone());
938 false
939 }
940 };
941 }
942
943 let mut result = String::with_capacity(text.len());
944 result.push_str(keyword);
945 result.push_str(&result_parts.join(""));
946
947 if let Some(id) = custom_id {
949 result.push_str(id);
950 }
951
952 result
953 }
954
955 fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
957 let canonical_forms = self.proper_name_canonical_forms(text);
958 let words: Vec<&str> = text.split_whitespace().collect();
959 let total_words = words.len();
960
961 if total_words == 0 {
962 return text.to_string();
963 }
964
965 let mut word_positions: Vec<usize> = Vec::with_capacity(words.len());
968 let mut pos = 0;
969 for word in &words {
970 if let Some(rel) = text[pos..].find(word) {
971 word_positions.push(pos + rel);
972 pos = pos + rel + word.len();
973 } else {
974 word_positions.push(usize::MAX);
975 }
976 }
977
978 let result_words: Vec<String> = words
979 .iter()
980 .enumerate()
981 .map(|(i, word)| {
982 let after_period = i > 0 && words[i - 1].ends_with('.');
983 let is_first = (is_first_segment && i == 0) || after_period;
984 let is_last = is_last_segment && i == total_words - 1;
985
986 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
988 return Self::apply_canonical_form_to_word(word, canonical);
989 }
990
991 if word.contains('-') {
993 return self.handle_hyphenated_word(word, is_first, is_last);
994 }
995
996 self.title_case_word(word, is_first, is_last)
997 })
998 .collect();
999
1000 let mut result = String::new();
1002 let mut word_iter = result_words.iter();
1003 let mut in_word = false;
1004
1005 for c in text.chars() {
1006 if c.is_whitespace() {
1007 if in_word {
1008 in_word = false;
1009 }
1010 result.push(c);
1011 } else if !in_word {
1012 if let Some(word) = word_iter.next() {
1013 result.push_str(word);
1014 }
1015 in_word = true;
1016 }
1017 }
1018
1019 result
1020 }
1021
1022 fn fix_atx_heading(
1024 &self,
1025 _line: &str,
1026 heading: &crate::lint_context::HeadingInfo,
1027 flavor: crate::config::MarkdownFlavor,
1028 ) -> String {
1029 let indent = " ".repeat(heading.marker_column);
1031 let hashes = "#".repeat(heading.level as usize);
1032
1033 let fixed_text = self.apply_capitalization(&heading.raw_text, flavor);
1035
1036 let closing = &heading.closing_sequence;
1038 if heading.has_closing_sequence {
1039 format!("{indent}{hashes} {fixed_text} {closing}")
1040 } else {
1041 format!("{indent}{hashes} {fixed_text}")
1042 }
1043 }
1044
1045 fn fix_setext_heading(
1047 &self,
1048 line: &str,
1049 heading: &crate::lint_context::HeadingInfo,
1050 flavor: crate::config::MarkdownFlavor,
1051 ) -> String {
1052 let fixed_text = self.apply_capitalization(&heading.raw_text, flavor);
1054
1055 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
1057
1058 format!("{leading_ws}{fixed_text}")
1059 }
1060}
1061
1062impl Rule for MD063HeadingCapitalization {
1063 fn name(&self) -> &'static str {
1064 "MD063"
1065 }
1066
1067 fn description(&self) -> &'static str {
1068 "Heading capitalization"
1069 }
1070
1071 fn category(&self) -> RuleCategory {
1072 RuleCategory::Heading
1073 }
1074
1075 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1076 !ctx.likely_has_headings() || !ctx.lines.iter().any(|line| line.heading.is_some())
1077 }
1078
1079 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1080 let content = ctx.content;
1081
1082 if content.is_empty() {
1083 return Ok(Vec::new());
1084 }
1085
1086 let mut warnings = Vec::new();
1087
1088 for (line_num, line_info) in ctx.lines.iter().enumerate() {
1089 if let Some(heading) = &line_info.heading {
1090 if heading.level < self.config.min_level || heading.level > self.config.max_level {
1092 continue;
1093 }
1094
1095 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
1097 continue;
1098 }
1099
1100 if !heading.is_valid {
1102 continue;
1103 }
1104
1105 let original_text = &heading.raw_text;
1107 let fixed_text = self.apply_capitalization(original_text, ctx.flavor);
1108
1109 if original_text != &fixed_text {
1110 let line = line_info.content(ctx.content);
1111 let style_name = match self.config.style {
1112 HeadingCapStyle::TitleCase => "title case",
1113 HeadingCapStyle::SentenceCase => "sentence case",
1114 HeadingCapStyle::AllCaps => "ALL CAPS",
1115 };
1116
1117 warnings.push(LintWarning {
1118 rule_name: Some(self.name().to_string()),
1119 line: line_num + 1,
1120 column: byte_to_char_count(line, heading.content_column),
1121 end_line: line_num + 1,
1122 end_column: byte_to_char_count(line, heading.content_column) + original_text.chars().count(),
1123 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1124 severity: Severity::Warning,
1125 fix: Some(Fix::new(
1126 ctx.line_content_byte_range(line_num + 1),
1127 match heading.style {
1128 crate::lint_context::HeadingStyle::ATX => {
1129 self.fix_atx_heading(line, heading, ctx.flavor)
1130 }
1131 _ => self.fix_setext_heading(line, heading, ctx.flavor),
1132 },
1133 )),
1134 });
1135 }
1136 }
1137 }
1138
1139 Ok(warnings)
1140 }
1141
1142 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1143 let content = ctx.content;
1144
1145 if content.is_empty() {
1146 return Ok(content.to_string());
1147 }
1148
1149 let lines = ctx.raw_lines();
1150 let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1151
1152 for (line_num, line_info) in ctx.lines.iter().enumerate() {
1153 if ctx.is_rule_disabled(self.name(), line_num + 1) {
1155 continue;
1156 }
1157
1158 if let Some(heading) = &line_info.heading {
1159 if heading.level < self.config.min_level || heading.level > self.config.max_level {
1161 continue;
1162 }
1163
1164 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
1166 continue;
1167 }
1168
1169 if !heading.is_valid {
1171 continue;
1172 }
1173
1174 let original_text = &heading.raw_text;
1175 let fixed_text = self.apply_capitalization(original_text, ctx.flavor);
1176
1177 if original_text != &fixed_text {
1178 let line = line_info.content(ctx.content);
1179 fixed_lines[line_num] = match heading.style {
1180 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading, ctx.flavor),
1181 _ => self.fix_setext_heading(line, heading, ctx.flavor),
1182 };
1183 }
1184 }
1185 }
1186
1187 let mut result = String::with_capacity(content.len());
1189 for (i, line) in fixed_lines.iter().enumerate() {
1190 result.push_str(line);
1191 if i < fixed_lines.len() - 1 || content.ends_with('\n') {
1192 result.push('\n');
1193 }
1194 }
1195
1196 Ok(result)
1197 }
1198
1199 fn as_any(&self) -> &dyn std::any::Any {
1200 self
1201 }
1202
1203 crate::impl_rule_config_sections!(MD063Config);
1204
1205 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1206 where
1207 Self: Sized,
1208 {
1209 let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
1210 let md044_config =
1211 crate::rule_config_serde::load_rule_config::<crate::rules::md044_proper_names::MD044Config>(config);
1212 let mut rule = Self::from_config_struct(rule_config);
1213 rule.proper_names = md044_config.names;
1214 Box::new(rule)
1215 }
1216}
1217
1218#[cfg(test)]
1219mod tests {
1220 use super::*;
1221 use crate::lint_context::LintContext;
1222
1223 fn create_rule() -> MD063HeadingCapitalization {
1224 let config = MD063Config {
1225 enabled: true,
1226 ..Default::default()
1227 };
1228 MD063HeadingCapitalization::from_config_struct(config)
1229 }
1230
1231 fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
1232 let config = MD063Config {
1233 enabled: true,
1234 style,
1235 ..Default::default()
1236 };
1237 MD063HeadingCapitalization::from_config_struct(config)
1238 }
1239
1240 #[test]
1242 fn test_an_escaped_tag_does_not_hide_the_element_written_inside_it() {
1243 let text = r#"\<span title='<a id="x"></a>'>foo"#;
1246 assert_eq!(MD063HeadingCapitalization::html_regions(text, &[]), vec![(14, 28)]);
1247 }
1248
1249 #[test]
1250 fn test_title_case_basic() {
1251 let rule = create_rule();
1252 let content = "# hello world\n";
1253 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1254 let result = rule.check(&ctx).unwrap();
1255 assert_eq!(result.len(), 1);
1256 assert!(result[0].message.contains("Hello World"));
1257 }
1258
1259 #[test]
1260 fn test_title_case_lowercase_words() {
1261 let rule = create_rule();
1262 let content = "# the quick brown fox\n";
1263 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1264 let result = rule.check(&ctx).unwrap();
1265 assert_eq!(result.len(), 1);
1266 assert!(result[0].message.contains("The Quick Brown Fox"));
1268 }
1269
1270 #[test]
1271 fn test_title_case_already_correct() {
1272 let rule = create_rule();
1273 let content = "# The Quick Brown Fox\n";
1274 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1275 let result = rule.check(&ctx).unwrap();
1276 assert!(result.is_empty(), "Already correct heading should not be flagged");
1277 }
1278
1279 #[test]
1280 fn test_title_case_hyphenated() {
1281 let rule = create_rule();
1282 let content = "# self-documenting code\n";
1283 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1284 let result = rule.check(&ctx).unwrap();
1285 assert_eq!(result.len(), 1);
1286 assert!(result[0].message.contains("Self-Documenting Code"));
1287 }
1288
1289 #[test]
1290 fn test_title_case_preserves_url_with_nested_parens() {
1291 let rule = create_rule();
1292 let content = "# guide for [the api](https://example.com/docs/v(2)beta)\n";
1294 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1295 let fixed = rule.fix(&ctx).unwrap();
1296 assert!(
1299 fixed.contains("https://example.com/docs/v(2)beta"),
1300 "URL with nested parens was corrupted: {fixed:?}"
1301 );
1302 }
1303
1304 #[test]
1305 fn test_title_case_does_not_recase_image_alt() {
1306 let rule = create_rule();
1307 let content = "# overview \n";
1308 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1309 let fixed = rule.fix(&ctx).unwrap();
1310 assert!(
1312 fixed.contains(""),
1313 "image alt text was modified: {fixed:?}"
1314 );
1315 assert!(
1316 fixed.contains("# Overview"),
1317 "surrounding prose should still be title-cased: {fixed:?}"
1318 );
1319 }
1320
1321 #[test]
1323 fn test_sentence_case_basic() {
1324 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1325 let content = "# The Quick Brown Fox\n";
1326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1327 let result = rule.check(&ctx).unwrap();
1328 assert_eq!(result.len(), 1);
1329 assert!(result[0].message.contains("The quick brown fox"));
1330 }
1331
1332 #[test]
1333 fn test_sentence_case_already_correct() {
1334 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1335 let content = "# The quick brown fox\n";
1336 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1337 let result = rule.check(&ctx).unwrap();
1338 assert!(result.is_empty());
1339 }
1340
1341 #[test]
1343 fn test_all_caps_basic() {
1344 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1345 let content = "# hello world\n";
1346 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1347 let result = rule.check(&ctx).unwrap();
1348 assert_eq!(result.len(), 1);
1349 assert!(result[0].message.contains("HELLO WORLD"));
1350 }
1351
1352 #[test]
1354 fn test_preserve_ignore_words() {
1355 let config = MD063Config {
1356 enabled: true,
1357 ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
1358 ..Default::default()
1359 };
1360 let rule = MD063HeadingCapitalization::from_config_struct(config);
1361
1362 let content = "# using iPhone on macOS\n";
1363 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1364 let result = rule.check(&ctx).unwrap();
1365 assert_eq!(result.len(), 1);
1366 assert!(result[0].message.contains("iPhone"));
1368 assert!(result[0].message.contains("macOS"));
1369 }
1370
1371 #[test]
1372 fn test_preserve_cased_words() {
1373 let rule = create_rule();
1374 let content = "# using GitHub actions\n";
1375 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1376 let result = rule.check(&ctx).unwrap();
1377 assert_eq!(result.len(), 1);
1378 assert!(result[0].message.contains("GitHub"));
1380 }
1381
1382 #[test]
1384 fn test_inline_code_preserved() {
1385 let rule = create_rule();
1386 let content = "# using `const` in javascript\n";
1387 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1388 let result = rule.check(&ctx).unwrap();
1389 assert_eq!(result.len(), 1);
1390 assert!(result[0].message.contains("`const`"));
1392 assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
1393 }
1394
1395 #[test]
1397 fn test_level_filter() {
1398 let config = MD063Config {
1399 enabled: true,
1400 min_level: 2,
1401 max_level: 4,
1402 ..Default::default()
1403 };
1404 let rule = MD063HeadingCapitalization::from_config_struct(config);
1405
1406 let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
1407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1408 let result = rule.check(&ctx).unwrap();
1409
1410 assert_eq!(result.len(), 2);
1412 assert_eq!(result[0].line, 2); assert_eq!(result[1].line, 3); }
1415
1416 #[test]
1418 fn test_fix_atx_heading() {
1419 let rule = create_rule();
1420 let content = "# hello world\n";
1421 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1422 let fixed = rule.fix(&ctx).unwrap();
1423 assert_eq!(fixed, "# Hello World\n");
1424 }
1425
1426 #[test]
1427 fn test_fix_multiple_headings() {
1428 let rule = create_rule();
1429 let content = "# first heading\n\n## second heading\n";
1430 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1431 let fixed = rule.fix(&ctx).unwrap();
1432 assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
1433 }
1434
1435 #[test]
1437 fn test_setext_heading() {
1438 let rule = create_rule();
1439 let content = "hello world\n============\n";
1440 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1441 let result = rule.check(&ctx).unwrap();
1442 assert_eq!(result.len(), 1);
1443 assert!(result[0].message.contains("Hello World"));
1444 }
1445
1446 #[test]
1448 fn test_custom_id_preserved() {
1449 let rule = create_rule();
1450 let content = "# getting started {#intro}\n";
1451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1452 let result = rule.check(&ctx).unwrap();
1453 assert_eq!(result.len(), 1);
1454 assert!(result[0].message.contains("{#intro}"));
1456 }
1457
1458 #[test]
1460 fn test_skip_obsidian_tags_not_headings() {
1461 let rule = create_rule();
1462
1463 let content = "# H1\n\n#tag\n";
1465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1466 let result = rule.check(&ctx).unwrap();
1467 assert!(
1468 result.is_empty() || result.iter().all(|w| w.line != 3),
1469 "Obsidian tag #tag should not be treated as a heading: {result:?}"
1470 );
1471 }
1472
1473 #[test]
1474 fn test_skip_invalid_atx_headings_no_space() {
1475 let rule = create_rule();
1476
1477 let content = "#notaheading\n";
1479 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1480 let result = rule.check(&ctx).unwrap();
1481 assert!(
1482 result.is_empty(),
1483 "Invalid ATX heading without space should not be flagged: {result:?}"
1484 );
1485 }
1486
1487 #[test]
1488 fn test_fix_skips_obsidian_tags() {
1489 let rule = create_rule();
1490
1491 let content = "# hello world\n\n#tag\n";
1492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1493 let fixed = rule.fix(&ctx).unwrap();
1494 assert!(fixed.contains("#tag"), "Fix should not modify Obsidian tag #tag");
1496 assert!(fixed.contains("# Hello World"), "Fix should still fix real headings");
1497 }
1498
1499 #[test]
1500 fn test_preserve_all_caps_acronyms() {
1501 let rule = create_rule();
1502 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1503
1504 let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
1506 assert_eq!(fixed, "# Using API in Production\n");
1507
1508 let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
1510 assert_eq!(fixed, "# API and GPU Integration\n");
1511
1512 let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
1514 assert_eq!(fixed, "# IO Performance Guide\n");
1515
1516 let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
1518 assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
1519 }
1520
1521 #[test]
1522 fn test_preserve_acronyms_in_hyphenated_words() {
1523 let rule = create_rule();
1524 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1525
1526 let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
1528 assert_eq!(fixed, "# API-Driven Architecture\n");
1529
1530 let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
1532 assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
1533 }
1534
1535 #[test]
1536 fn test_single_letters_not_treated_as_acronyms() {
1537 let rule = create_rule();
1538 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1539
1540 let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
1542 assert_eq!(fixed, "# I Am a Heading\n");
1543 }
1544
1545 #[test]
1546 fn test_lowercase_terms_need_ignore_words() {
1547 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1548
1549 let rule = create_rule();
1551 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1552 assert_eq!(fixed, "# Using Npm Packages\n");
1553
1554 let config = MD063Config {
1556 enabled: true,
1557 ignore_words: vec!["npm".to_string()],
1558 ..Default::default()
1559 };
1560 let rule = MD063HeadingCapitalization::from_config_struct(config);
1561 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1562 assert_eq!(fixed, "# Using npm Packages\n");
1563 }
1564
1565 #[test]
1566 fn test_acronyms_with_mixed_case_preserved() {
1567 let rule = create_rule();
1568 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1569
1570 let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
1572 assert_eq!(fixed, "# Using API with GitHub\n");
1573 }
1574
1575 #[test]
1576 fn test_real_world_acronyms() {
1577 let rule = create_rule();
1578 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1579
1580 let content = "# FFI bindings for CPU optimization\n";
1582 let fixed = rule.fix(&ctx(content)).unwrap();
1583 assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
1584
1585 let content = "# DOM manipulation and SSR rendering\n";
1586 let fixed = rule.fix(&ctx(content)).unwrap();
1587 assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
1588
1589 let content = "# CVE security and RNN models\n";
1590 let fixed = rule.fix(&ctx(content)).unwrap();
1591 assert_eq!(fixed, "# CVE Security and RNN Models\n");
1592 }
1593
1594 #[test]
1595 fn test_is_all_caps_acronym() {
1596 let rule = create_rule();
1597
1598 assert!(rule.is_all_caps_acronym("API"));
1600 assert!(rule.is_all_caps_acronym("IO"));
1601 assert!(rule.is_all_caps_acronym("GPU"));
1602 assert!(rule.is_all_caps_acronym("HTTP2")); assert!(!rule.is_all_caps_acronym("A"));
1606 assert!(!rule.is_all_caps_acronym("I"));
1607
1608 assert!(!rule.is_all_caps_acronym("Api"));
1610 assert!(!rule.is_all_caps_acronym("npm"));
1611 assert!(!rule.is_all_caps_acronym("iPhone"));
1612 }
1613
1614 #[test]
1615 fn test_sentence_case_starts_after_a_leading_empty_anchor() {
1616 let config = MD063Config {
1618 enabled: true,
1619 style: HeadingCapStyle::SentenceCase,
1620 ..Default::default()
1621 };
1622 let rule = MD063HeadingCapitalization::from_config_struct(config);
1623
1624 let content = "# <a id=\"top\"></a>the beginning\n";
1625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1626 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1627 assert_eq!(rule.fix(&ctx).unwrap(), "# <a id=\"top\"></a>The beginning\n");
1628
1629 let content = "# <kbd>ctrl</kbd> the key\n";
1631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1632 assert!(rule.check(&ctx).unwrap().is_empty());
1633
1634 for content in ["# <img src=\"x.png\"> the picture\n", "#  the picture\n"] {
1637 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1638 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1639 }
1640 }
1641
1642 #[test]
1643 fn test_sentence_case_ignore_words_first_word() {
1644 let config = MD063Config {
1645 enabled: true,
1646 style: HeadingCapStyle::SentenceCase,
1647 ignore_words: vec!["nvim".to_string()],
1648 ..Default::default()
1649 };
1650 let rule = MD063HeadingCapitalization::from_config_struct(config);
1651
1652 let content = "# nvim config\n";
1654 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1655 let result = rule.check(&ctx).unwrap();
1656 assert!(
1657 result.is_empty(),
1658 "nvim in ignore-words should not be flagged. Got: {result:?}"
1659 );
1660
1661 let fixed = rule.fix(&ctx).unwrap();
1663 assert_eq!(fixed, "# nvim config\n");
1664 }
1665
1666 #[test]
1667 fn test_sentence_case_ignore_words_not_first() {
1668 let config = MD063Config {
1669 enabled: true,
1670 style: HeadingCapStyle::SentenceCase,
1671 ignore_words: vec!["nvim".to_string()],
1672 ..Default::default()
1673 };
1674 let rule = MD063HeadingCapitalization::from_config_struct(config);
1675
1676 let content = "# Using nvim editor\n";
1678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1679 let result = rule.check(&ctx).unwrap();
1680 assert!(
1681 result.is_empty(),
1682 "nvim in ignore-words should be preserved. Got: {result:?}"
1683 );
1684 }
1685
1686 #[test]
1687 fn test_preserve_cased_words_ios() {
1688 let config = MD063Config {
1689 enabled: true,
1690 style: HeadingCapStyle::SentenceCase,
1691 preserve_cased_words: true,
1692 ..Default::default()
1693 };
1694 let rule = MD063HeadingCapitalization::from_config_struct(config);
1695
1696 let content = "## This is iOS\n";
1698 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1699 let result = rule.check(&ctx).unwrap();
1700 assert!(
1701 result.is_empty(),
1702 "iOS should be preserved with preserve-cased-words. Got: {result:?}"
1703 );
1704
1705 let fixed = rule.fix(&ctx).unwrap();
1707 assert_eq!(fixed, "## This is iOS\n");
1708 }
1709
1710 #[test]
1711 fn test_preserve_cased_words_ios_title_case() {
1712 let config = MD063Config {
1713 enabled: true,
1714 style: HeadingCapStyle::TitleCase,
1715 preserve_cased_words: true,
1716 ..Default::default()
1717 };
1718 let rule = MD063HeadingCapitalization::from_config_struct(config);
1719
1720 let content = "# developing for iOS\n";
1722 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1723 let fixed = rule.fix(&ctx).unwrap();
1724 assert_eq!(fixed, "# Developing for iOS\n");
1725 }
1726
1727 #[test]
1728 fn test_has_internal_capitals_ios() {
1729 let rule = create_rule();
1730
1731 assert!(
1733 rule.has_internal_capitals("iOS"),
1734 "iOS has mixed case (lowercase i, uppercase OS)"
1735 );
1736
1737 assert!(rule.has_internal_capitals("iPhone"));
1739 assert!(rule.has_internal_capitals("macOS"));
1740 assert!(rule.has_internal_capitals("GitHub"));
1741 assert!(rule.has_internal_capitals("JavaScript"));
1742 assert!(rule.has_internal_capitals("eBay"));
1743
1744 assert!(!rule.has_internal_capitals("API"));
1746 assert!(!rule.has_internal_capitals("GPU"));
1747
1748 assert!(!rule.has_internal_capitals("npm"));
1750 assert!(!rule.has_internal_capitals("config"));
1751
1752 assert!(!rule.has_internal_capitals("The"));
1754 assert!(!rule.has_internal_capitals("Hello"));
1755 }
1756
1757 #[test]
1758 fn test_lowercase_words_before_trailing_code() {
1759 let config = MD063Config {
1760 enabled: true,
1761 style: HeadingCapStyle::TitleCase,
1762 lowercase_words: vec![
1763 "a".to_string(),
1764 "an".to_string(),
1765 "and".to_string(),
1766 "at".to_string(),
1767 "but".to_string(),
1768 "by".to_string(),
1769 "for".to_string(),
1770 "from".to_string(),
1771 "into".to_string(),
1772 "nor".to_string(),
1773 "on".to_string(),
1774 "onto".to_string(),
1775 "or".to_string(),
1776 "the".to_string(),
1777 "to".to_string(),
1778 "upon".to_string(),
1779 "via".to_string(),
1780 "vs".to_string(),
1781 "with".to_string(),
1782 "without".to_string(),
1783 ],
1784 preserve_cased_words: true,
1785 ..Default::default()
1786 };
1787 let rule = MD063HeadingCapitalization::from_config_struct(config);
1788
1789 let content = "## subtitle with a `app`\n";
1794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1795 let result = rule.check(&ctx).unwrap();
1796
1797 assert!(!result.is_empty(), "Should flag incorrect capitalization");
1799 let fixed = rule.fix(&ctx).unwrap();
1800 assert!(
1802 fixed.contains("with a `app`"),
1803 "Expected 'with a `app`' but got: {fixed:?}"
1804 );
1805 assert!(
1806 !fixed.contains("with A `app`"),
1807 "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
1808 );
1809 assert!(
1811 fixed.contains("Subtitle with a `app`"),
1812 "Expected 'Subtitle with a `app`' but got: {fixed:?}"
1813 );
1814 }
1815
1816 #[test]
1817 fn test_lowercase_words_preserved_before_trailing_code_variant() {
1818 let config = MD063Config {
1819 enabled: true,
1820 style: HeadingCapStyle::TitleCase,
1821 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1822 ..Default::default()
1823 };
1824 let rule = MD063HeadingCapitalization::from_config_struct(config);
1825
1826 let content = "## Title with the `code`\n";
1828 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1829 let fixed = rule.fix(&ctx).unwrap();
1830 assert!(
1832 fixed.contains("with the `code`"),
1833 "Expected 'with the `code`' but got: {fixed:?}"
1834 );
1835 assert!(
1836 !fixed.contains("with The `code`"),
1837 "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
1838 );
1839 }
1840
1841 #[test]
1842 fn test_last_word_capitalized_when_no_trailing_code() {
1843 let config = MD063Config {
1846 enabled: true,
1847 style: HeadingCapStyle::TitleCase,
1848 lowercase_words: vec!["a".to_string(), "the".to_string()],
1849 ..Default::default()
1850 };
1851 let rule = MD063HeadingCapitalization::from_config_struct(config);
1852
1853 let content = "## title with a word\n";
1856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1857 let fixed = rule.fix(&ctx).unwrap();
1858 assert!(
1860 fixed.contains("With a Word"),
1861 "Expected 'With a Word' but got: {fixed:?}"
1862 );
1863 }
1864
1865 #[test]
1866 fn test_multiple_lowercase_words_before_code() {
1867 let config = MD063Config {
1868 enabled: true,
1869 style: HeadingCapStyle::TitleCase,
1870 lowercase_words: vec![
1871 "a".to_string(),
1872 "the".to_string(),
1873 "with".to_string(),
1874 "for".to_string(),
1875 ],
1876 ..Default::default()
1877 };
1878 let rule = MD063HeadingCapitalization::from_config_struct(config);
1879
1880 let content = "## Guide for the `user`\n";
1882 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1883 let fixed = rule.fix(&ctx).unwrap();
1884 assert!(
1885 fixed.contains("for the `user`"),
1886 "Expected 'for the `user`' but got: {fixed:?}"
1887 );
1888 assert!(
1889 !fixed.contains("For The `user`"),
1890 "Should not capitalize lowercase words before code. Got: {fixed:?}"
1891 );
1892 }
1893
1894 #[test]
1895 fn test_code_in_middle_normal_rules_apply() {
1896 let config = MD063Config {
1897 enabled: true,
1898 style: HeadingCapStyle::TitleCase,
1899 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1900 ..Default::default()
1901 };
1902 let rule = MD063HeadingCapitalization::from_config_struct(config);
1903
1904 let content = "## Using `const` for the code\n";
1906 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1907 let fixed = rule.fix(&ctx).unwrap();
1908 assert!(
1910 fixed.contains("for the Code"),
1911 "Expected 'for the Code' but got: {fixed:?}"
1912 );
1913 }
1914
1915 #[test]
1916 fn test_link_at_end_same_as_code() {
1917 let config = MD063Config {
1918 enabled: true,
1919 style: HeadingCapStyle::TitleCase,
1920 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1921 ..Default::default()
1922 };
1923 let rule = MD063HeadingCapitalization::from_config_struct(config);
1924
1925 let content = "## Guide for the [link](./page.md)\n";
1927 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1928 let fixed = rule.fix(&ctx).unwrap();
1929 assert!(
1931 fixed.contains("for the [Link]"),
1932 "Expected 'for the [Link]' but got: {fixed:?}"
1933 );
1934 assert!(
1935 !fixed.contains("for The [Link]"),
1936 "Should not capitalize 'the' before link. Got: {fixed:?}"
1937 );
1938 }
1939
1940 #[test]
1941 fn test_multiple_code_segments() {
1942 let config = MD063Config {
1943 enabled: true,
1944 style: HeadingCapStyle::TitleCase,
1945 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1946 ..Default::default()
1947 };
1948 let rule = MD063HeadingCapitalization::from_config_struct(config);
1949
1950 let content = "## Using `const` with a `variable`\n";
1952 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1953 let fixed = rule.fix(&ctx).unwrap();
1954 assert!(
1956 fixed.contains("with a `variable`"),
1957 "Expected 'with a `variable`' but got: {fixed:?}"
1958 );
1959 assert!(
1960 !fixed.contains("with A `variable`"),
1961 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1962 );
1963 }
1964
1965 #[test]
1966 fn test_code_and_link_combination() {
1967 let config = MD063Config {
1968 enabled: true,
1969 style: HeadingCapStyle::TitleCase,
1970 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1971 ..Default::default()
1972 };
1973 let rule = MD063HeadingCapitalization::from_config_struct(config);
1974
1975 let content = "## Guide for the `code` [link](./page.md)\n";
1977 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1978 let fixed = rule.fix(&ctx).unwrap();
1979 assert!(
1981 fixed.contains("for the `code`"),
1982 "Expected 'for the `code`' but got: {fixed:?}"
1983 );
1984 }
1985
1986 #[test]
1987 fn test_text_after_code_capitalizes_last() {
1988 let config = MD063Config {
1989 enabled: true,
1990 style: HeadingCapStyle::TitleCase,
1991 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1992 ..Default::default()
1993 };
1994 let rule = MD063HeadingCapitalization::from_config_struct(config);
1995
1996 let content = "## Using `const` for the code\n";
1998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1999 let fixed = rule.fix(&ctx).unwrap();
2000 assert!(
2002 fixed.contains("for the Code"),
2003 "Expected 'for the Code' but got: {fixed:?}"
2004 );
2005 }
2006
2007 #[test]
2008 fn test_preserve_cased_words_with_trailing_code() {
2009 let config = MD063Config {
2010 enabled: true,
2011 style: HeadingCapStyle::TitleCase,
2012 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2013 preserve_cased_words: true,
2014 ..Default::default()
2015 };
2016 let rule = MD063HeadingCapitalization::from_config_struct(config);
2017
2018 let content = "## Guide for iOS `app`\n";
2020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2021 let fixed = rule.fix(&ctx).unwrap();
2022 assert!(
2024 fixed.contains("for iOS `app`"),
2025 "Expected 'for iOS `app`' but got: {fixed:?}"
2026 );
2027 assert!(
2028 !fixed.contains("For iOS `app`"),
2029 "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
2030 );
2031 }
2032
2033 #[test]
2034 fn test_ignore_words_with_trailing_code() {
2035 let config = MD063Config {
2036 enabled: true,
2037 style: HeadingCapStyle::TitleCase,
2038 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2039 ignore_words: vec!["npm".to_string()],
2040 ..Default::default()
2041 };
2042 let rule = MD063HeadingCapitalization::from_config_struct(config);
2043
2044 let content = "## Using npm with a `script`\n";
2046 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2047 let fixed = rule.fix(&ctx).unwrap();
2048 assert!(
2050 fixed.contains("npm with a `script`"),
2051 "Expected 'npm with a `script`' but got: {fixed:?}"
2052 );
2053 assert!(
2054 !fixed.contains("with A `script`"),
2055 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2056 );
2057 }
2058
2059 #[test]
2060 fn test_empty_text_segment_edge_case() {
2061 let config = MD063Config {
2062 enabled: true,
2063 style: HeadingCapStyle::TitleCase,
2064 lowercase_words: vec!["a".to_string(), "with".to_string()],
2065 ..Default::default()
2066 };
2067 let rule = MD063HeadingCapitalization::from_config_struct(config);
2068
2069 let content = "## `start` with a `end`\n";
2071 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2072 let fixed = rule.fix(&ctx).unwrap();
2073 assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
2076 assert!(
2077 !fixed.contains("A `end`"),
2078 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2079 );
2080 }
2081
2082 #[test]
2083 fn test_sentence_case_with_trailing_code() {
2084 let config = MD063Config {
2085 enabled: true,
2086 style: HeadingCapStyle::SentenceCase,
2087 lowercase_words: vec!["a".to_string(), "the".to_string()],
2088 ..Default::default()
2089 };
2090 let rule = MD063HeadingCapitalization::from_config_struct(config);
2091
2092 let content = "## guide for the `user`\n";
2094 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2095 let fixed = rule.fix(&ctx).unwrap();
2096 assert!(
2098 fixed.contains("Guide for the `user`"),
2099 "Expected 'Guide for the `user`' but got: {fixed:?}"
2100 );
2101 }
2102
2103 #[test]
2104 fn test_hyphenated_word_before_code() {
2105 let config = MD063Config {
2106 enabled: true,
2107 style: HeadingCapStyle::TitleCase,
2108 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2109 ..Default::default()
2110 };
2111 let rule = MD063HeadingCapitalization::from_config_struct(config);
2112
2113 let content = "## Self-contained with a `feature`\n";
2115 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2116 let fixed = rule.fix(&ctx).unwrap();
2117 assert!(
2119 fixed.contains("with a `feature`"),
2120 "Expected 'with a `feature`' but got: {fixed:?}"
2121 );
2122 }
2123
2124 #[test]
2129 fn test_sentence_case_code_at_start_basic() {
2130 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2132 let content = "# `rumdl` is a linter\n";
2133 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2134 let result = rule.check(&ctx).unwrap();
2135 assert!(
2137 result.is_empty(),
2138 "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
2139 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2140 );
2141 }
2142
2143 #[test]
2144 fn test_sentence_case_code_at_start_incorrect_capitalization() {
2145 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2147 let content = "# `rumdl` Is a Linter\n";
2148 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2149 let result = rule.check(&ctx).unwrap();
2150 assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
2152 assert!(
2153 result[0].message.contains("`rumdl` is a linter"),
2154 "Should suggest lowercase after code. Got: {:?}",
2155 result[0].message
2156 );
2157 }
2158
2159 #[test]
2160 fn test_sentence_case_code_at_start_fix() {
2161 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2162 let content = "# `rumdl` Is A Linter\n";
2163 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2164 let fixed = rule.fix(&ctx).unwrap();
2165 assert!(
2166 fixed.contains("# `rumdl` is a linter"),
2167 "Should fix to lowercase after code. Got: {fixed:?}"
2168 );
2169 }
2170
2171 #[test]
2172 fn test_sentence_case_text_at_start_still_capitalizes() {
2173 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2175 let content = "# the quick brown fox\n";
2176 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2177 let result = rule.check(&ctx).unwrap();
2178 assert_eq!(result.len(), 1);
2179 assert!(
2180 result[0].message.contains("The quick brown fox"),
2181 "Text-first heading should capitalize first word. Got: {:?}",
2182 result[0].message
2183 );
2184 }
2185
2186 #[test]
2187 fn test_sentence_case_link_at_start() {
2188 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2190 let content = "# [api](api.md) reference guide\n";
2191 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2192 let result = rule.check(&ctx).unwrap();
2193 assert_eq!(result.len(), 1);
2194 assert!(result[0].message.contains("[Api](api.md) reference guide"));
2195 }
2196
2197 #[test]
2198 fn test_sentence_case_link_preserves_acronyms() {
2199 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2201 let content = "# [API](api.md) Reference Guide\n";
2202 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2203 let result = rule.check(&ctx).unwrap();
2204 assert_eq!(result.len(), 1);
2205 assert!(
2207 result[0].message.contains("[API](api.md) reference guide"),
2208 "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
2209 result[0].message
2210 );
2211 }
2212
2213 #[test]
2214 fn test_sentence_case_link_preserves_brand_names() {
2215 let config = MD063Config {
2217 enabled: true,
2218 style: HeadingCapStyle::SentenceCase,
2219 preserve_cased_words: true,
2220 ..Default::default()
2221 };
2222 let rule = MD063HeadingCapitalization::from_config_struct(config);
2223 let content = "# [iPhone](iphone.md) Features Guide\n";
2224 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2225 let result = rule.check(&ctx).unwrap();
2226 assert_eq!(result.len(), 1);
2227 assert!(
2229 result[0].message.contains("[iPhone](iphone.md) features guide"),
2230 "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
2231 result[0].message
2232 );
2233 }
2234
2235 #[test]
2236 fn test_sentence_case_link_lowercases_regular_words() {
2237 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2239 let content = "# [Documentation](docs.md) Reference\n";
2240 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2241 let result = rule.check(&ctx).unwrap();
2242 assert_eq!(result.len(), 1);
2243 assert!(
2244 result[0].message.contains("[Documentation](docs.md) reference"),
2245 "Should preserve the sentence-initial capital. Got: {:?}",
2246 result[0].message
2247 );
2248 }
2249
2250 #[test]
2251 fn test_sentence_case_opening_link_label_is_sentence_initial() {
2252 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2254 let content = "# [Foo bar](https://example.com)\n";
2255 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2256
2257 assert!(rule.check(&ctx).unwrap().is_empty());
2258 assert_eq!(rule.fix(&ctx).unwrap(), content);
2259 }
2260
2261 #[test]
2262 fn test_sentence_case_link_at_start_correct_already() {
2263 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2265 let content = "# [API](api.md) reference guide\n";
2266 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2267 let result = rule.check(&ctx).unwrap();
2268 assert!(
2269 result.is_empty(),
2270 "Correctly cased heading with link should not be flagged. Got: {:?}",
2271 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2272 );
2273 }
2274
2275 #[test]
2276 fn test_sentence_case_link_github_preserved() {
2277 let config = MD063Config {
2279 enabled: true,
2280 style: HeadingCapStyle::SentenceCase,
2281 preserve_cased_words: true,
2282 ..Default::default()
2283 };
2284 let rule = MD063HeadingCapitalization::from_config_struct(config);
2285 let content = "# [GitHub](gh.md) Repository Setup\n";
2286 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2287 let result = rule.check(&ctx).unwrap();
2288 assert_eq!(result.len(), 1);
2289 assert!(
2290 result[0].message.contains("[GitHub](gh.md) repository setup"),
2291 "Should preserve 'GitHub'. Got: {:?}",
2292 result[0].message
2293 );
2294 }
2295
2296 #[test]
2297 fn test_sentence_case_multiple_code_spans() {
2298 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2299 let content = "# `foo` and `bar` are methods\n";
2300 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2301 let result = rule.check(&ctx).unwrap();
2302 assert!(
2304 result.is_empty(),
2305 "Should not capitalize words between/after code spans. Got: {:?}",
2306 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2307 );
2308 }
2309
2310 #[test]
2311 fn test_sentence_case_code_only_heading() {
2312 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2314 let content = "# `rumdl`\n";
2315 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2316 let result = rule.check(&ctx).unwrap();
2317 assert!(
2318 result.is_empty(),
2319 "Code-only heading should be fine. Got: {:?}",
2320 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2321 );
2322 }
2323
2324 #[test]
2325 fn test_sentence_case_code_at_end() {
2326 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2328 let content = "# install the `rumdl` tool\n";
2329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2330 let result = rule.check(&ctx).unwrap();
2331 assert_eq!(result.len(), 1);
2333 assert!(
2334 result[0].message.contains("Install the `rumdl` tool"),
2335 "First word should still be capitalized when text comes first. Got: {:?}",
2336 result[0].message
2337 );
2338 }
2339
2340 #[test]
2341 fn test_sentence_case_code_in_middle() {
2342 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2344 let content = "# using the `rumdl` linter for markdown\n";
2345 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2346 let result = rule.check(&ctx).unwrap();
2347 assert_eq!(result.len(), 1);
2349 assert!(
2350 result[0].message.contains("Using the `rumdl` linter for markdown"),
2351 "First word should be capitalized. Got: {:?}",
2352 result[0].message
2353 );
2354 }
2355
2356 #[test]
2357 fn test_sentence_case_preserved_word_after_code() {
2358 let config = MD063Config {
2360 enabled: true,
2361 style: HeadingCapStyle::SentenceCase,
2362 preserve_cased_words: true,
2363 ..Default::default()
2364 };
2365 let rule = MD063HeadingCapitalization::from_config_struct(config);
2366 let content = "# `swift` iPhone development\n";
2367 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2368 let result = rule.check(&ctx).unwrap();
2369 assert!(
2371 result.is_empty(),
2372 "Preserved words after code should stay. Got: {:?}",
2373 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2374 );
2375 }
2376
2377 #[test]
2378 fn test_title_case_code_at_start_still_capitalizes() {
2379 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2381 let content = "# `api` quick start guide\n";
2382 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2383 let result = rule.check(&ctx).unwrap();
2384 assert_eq!(result.len(), 1);
2386 assert!(
2387 result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
2388 "Title case should capitalize major words after code. Got: {:?}",
2389 result[0].message
2390 );
2391 }
2392
2393 #[test]
2396 fn test_sentence_case_html_tag_at_start() {
2397 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2399 let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
2400 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2401 let result = rule.check(&ctx).unwrap();
2402 assert_eq!(result.len(), 1);
2404 let fixed = rule.fix(&ctx).unwrap();
2405 assert_eq!(
2406 fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
2407 "Text after HTML at start should be lowercase"
2408 );
2409 }
2410
2411 #[test]
2412 fn test_sentence_case_html_tag_preserves_content() {
2413 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2415 let content = "# The <abbr>API</abbr> documentation guide\n";
2416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2417 let result = rule.check(&ctx).unwrap();
2418 assert!(
2420 result.is_empty(),
2421 "HTML tag content should be preserved. Got: {:?}",
2422 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2423 );
2424 }
2425
2426 #[test]
2427 fn test_sentence_case_html_tag_at_start_with_acronym() {
2428 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2430 let content = "# <abbr>API</abbr> Documentation Guide\n";
2431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2432 let result = rule.check(&ctx).unwrap();
2433 assert_eq!(result.len(), 1);
2434 let fixed = rule.fix(&ctx).unwrap();
2435 assert_eq!(
2436 fixed, "# <abbr>API</abbr> documentation guide\n",
2437 "Text after HTML at start should be lowercase, HTML content preserved"
2438 );
2439 }
2440
2441 #[test]
2442 fn test_sentence_case_html_tag_in_middle() {
2443 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2445 let content = "# using the <code>config</code> File\n";
2446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2447 let result = rule.check(&ctx).unwrap();
2448 assert_eq!(result.len(), 1);
2449 let fixed = rule.fix(&ctx).unwrap();
2450 assert_eq!(
2451 fixed, "# Using the <code>config</code> file\n",
2452 "First word capitalized, HTML preserved, rest lowercase"
2453 );
2454 }
2455
2456 #[test]
2457 fn test_html_tag_strong_emphasis() {
2458 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2460 let content = "# The <strong>Bold</strong> Way\n";
2461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2462 let result = rule.check(&ctx).unwrap();
2463 assert_eq!(result.len(), 1);
2464 let fixed = rule.fix(&ctx).unwrap();
2465 assert_eq!(
2466 fixed, "# The <strong>Bold</strong> way\n",
2467 "<strong> tag content should be preserved"
2468 );
2469 }
2470
2471 #[test]
2472 fn test_html_tag_with_attributes() {
2473 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2475 let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
2476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2477 let result = rule.check(&ctx).unwrap();
2478 assert_eq!(result.len(), 1);
2479 let fixed = rule.fix(&ctx).unwrap();
2480 assert_eq!(
2481 fixed, "# <span class=\"highlight\">Important</span> notice here\n",
2482 "HTML tag with attributes should be preserved"
2483 );
2484 }
2485
2486 #[test]
2487 fn test_multiple_html_tags() {
2488 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2490 let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
2491 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2492 let result = rule.check(&ctx).unwrap();
2493 assert_eq!(result.len(), 1);
2494 let fixed = rule.fix(&ctx).unwrap();
2495 assert_eq!(
2496 fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
2497 "Multiple HTML tags should all be preserved"
2498 );
2499 }
2500
2501 #[test]
2502 fn test_html_and_code_mixed() {
2503 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2505 let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
2506 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2507 let result = rule.check(&ctx).unwrap();
2508 assert_eq!(result.len(), 1);
2509 let fixed = rule.fix(&ctx).unwrap();
2510 assert_eq!(
2511 fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
2512 "HTML and code should both be preserved"
2513 );
2514 }
2515
2516 #[test]
2517 fn test_self_closing_html_tag() {
2518 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2520 let content = "# Line one<br/>Line Two Here\n";
2521 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2522 let result = rule.check(&ctx).unwrap();
2523 assert_eq!(result.len(), 1);
2524 let fixed = rule.fix(&ctx).unwrap();
2525 assert_eq!(
2526 fixed, "# Line one<br/>line two here\n",
2527 "Self-closing HTML tags should be preserved"
2528 );
2529 }
2530
2531 #[test]
2532 fn test_title_case_with_html_tags() {
2533 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2535 let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
2536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2537 let result = rule.check(&ctx).unwrap();
2538 assert_eq!(result.len(), 1);
2539 let fixed = rule.fix(&ctx).unwrap();
2540 assert!(
2542 fixed.contains("<kbd>ctrl</kbd>"),
2543 "HTML tag content should be preserved in title case. Got: {fixed}"
2544 );
2545 assert!(
2546 fixed.starts_with("# The ") || fixed.starts_with("# the "),
2547 "Title case should work with HTML. Got: {fixed}"
2548 );
2549 }
2550
2551 #[test]
2554 fn test_sentence_case_preserves_caret_notation() {
2555 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2557 let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
2558 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2559 let result = rule.check(&ctx).unwrap();
2560 assert!(
2562 result.is_empty(),
2563 "Caret notation should be preserved. Got: {:?}",
2564 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2565 );
2566 }
2567
2568 #[test]
2569 fn test_sentence_case_caret_notation_various() {
2570 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2572
2573 let content = "## Press ^C to cancel\n";
2575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2576 let result = rule.check(&ctx).unwrap();
2577 assert!(
2578 result.is_empty(),
2579 "^C should be preserved. Got: {:?}",
2580 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2581 );
2582
2583 let content = "## Use ^Z for background\n";
2585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2586 let result = rule.check(&ctx).unwrap();
2587 assert!(
2588 result.is_empty(),
2589 "^Z should be preserved. Got: {:?}",
2590 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2591 );
2592
2593 let content = "## Press ^[ for escape\n";
2595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2596 let result = rule.check(&ctx).unwrap();
2597 assert!(
2598 result.is_empty(),
2599 "^[ should be preserved. Got: {:?}",
2600 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2601 );
2602 }
2603
2604 #[test]
2605 fn test_caret_notation_detection() {
2606 let rule = create_rule();
2607
2608 assert!(rule.is_caret_notation("^A"));
2610 assert!(rule.is_caret_notation("^Z"));
2611 assert!(rule.is_caret_notation("^C"));
2612 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")); }
2624
2625 fn create_sentence_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2632 let config = MD063Config {
2633 enabled: true,
2634 style: HeadingCapStyle::SentenceCase,
2635 ..Default::default()
2636 };
2637 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2638 rule.proper_names = names;
2639 rule
2640 }
2641
2642 #[test]
2643 fn test_sentence_case_preserves_single_word_proper_name() {
2644 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2645 let content = "# installing javascript\n";
2647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2648 let result = rule.check(&ctx).unwrap();
2649 assert_eq!(result.len(), 1, "Should flag the heading");
2650 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2651 assert!(
2652 fix_text.contains("JavaScript"),
2653 "Fix should preserve proper name 'JavaScript', got: {fix_text:?}"
2654 );
2655 assert!(
2656 !fix_text.contains("javascript"),
2657 "Fix should not have lowercase 'javascript', got: {fix_text:?}"
2658 );
2659 }
2660
2661 #[test]
2662 fn test_sentence_case_preserves_multi_word_proper_name() {
2663 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2664 let content = "# using good application features\n";
2666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2667 let result = rule.check(&ctx).unwrap();
2668 assert_eq!(result.len(), 1, "Should flag the heading");
2669 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2670 assert!(
2671 fix_text.contains("Good Application"),
2672 "Fix should preserve 'Good Application' as a phrase, got: {fix_text:?}"
2673 );
2674 }
2675
2676 #[test]
2677 fn test_sentence_case_proper_name_at_start_of_heading() {
2678 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2679 let content = "# good application overview\n";
2681 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2682 let result = rule.check(&ctx).unwrap();
2683 assert_eq!(result.len(), 1, "Should flag the heading");
2684 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2685 assert!(
2686 fix_text.contains("Good Application"),
2687 "Fix should produce 'Good Application' at start of heading, got: {fix_text:?}"
2688 );
2689 assert!(
2690 fix_text.contains("overview"),
2691 "Non-proper-name word 'overview' should be lowercase, got: {fix_text:?}"
2692 );
2693 }
2694
2695 #[test]
2696 fn test_sentence_case_with_proper_names_no_oscillation() {
2697 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2700
2701 let content = "# installing good application on your system\n";
2703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2704 let result = rule.check(&ctx).unwrap();
2705 assert_eq!(result.len(), 1);
2706 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
2707
2708 assert!(
2710 fixed_heading.contains("Good Application"),
2711 "After fix, proper name must be preserved: {fixed_heading:?}"
2712 );
2713
2714 let fixed_line = format!("{fixed_heading}\n");
2716 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
2717 let result2 = rule.check(&ctx2).unwrap();
2718 assert!(
2719 result2.is_empty(),
2720 "After one fix, heading must already satisfy both MD063 and MD044 - no oscillation. \
2721 Second pass warnings: {result2:?}"
2722 );
2723 }
2724
2725 #[test]
2726 fn test_sentence_case_proper_names_already_correct() {
2727 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2728 let content = "# Installing Good Application\n";
2730 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2731 let result = rule.check(&ctx).unwrap();
2732 assert!(
2733 result.is_empty(),
2734 "Correct sentence-case heading with proper name should not be flagged, got: {result:?}"
2735 );
2736 }
2737
2738 #[test]
2739 fn test_sentence_case_multiple_proper_names_in_heading() {
2740 let rule = create_sentence_case_rule_with_proper_names(vec!["TypeScript".to_string(), "React".to_string()]);
2741 let content = "# using typescript with react\n";
2742 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2743 let result = rule.check(&ctx).unwrap();
2744 assert_eq!(result.len(), 1);
2745 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2746 assert!(
2747 fix_text.contains("TypeScript"),
2748 "Fix should preserve 'TypeScript', got: {fix_text:?}"
2749 );
2750 assert!(
2751 fix_text.contains("React"),
2752 "Fix should preserve 'React', got: {fix_text:?}"
2753 );
2754 }
2755
2756 #[test]
2757 fn test_sentence_case_unicode_casefold_expansion_before_proper_name() {
2758 let rule = create_sentence_case_rule_with_proper_names(vec!["Österreich".to_string()]);
2761 let content = "# İ österreich guide\n";
2762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2763
2764 let result = rule.check(&ctx).unwrap();
2766 assert_eq!(result.len(), 1, "Should flag heading for canonical proper-name casing");
2767 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2768 assert!(
2769 fix_text.contains("Österreich"),
2770 "Fix should preserve canonical 'Österreich', got: {fix_text:?}"
2771 );
2772 }
2773
2774 #[test]
2775 fn test_sentence_case_preserves_trailing_punctuation_on_proper_name() {
2776 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2777 let content = "# using javascript, today\n";
2778 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2779 let result = rule.check(&ctx).unwrap();
2780 assert_eq!(result.len(), 1, "Should flag heading");
2781 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2782 assert!(
2783 fix_text.contains("JavaScript,"),
2784 "Fix should preserve trailing punctuation, got: {fix_text:?}"
2785 );
2786 }
2787
2788 fn create_title_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2795 let config = MD063Config {
2796 enabled: true,
2797 style: HeadingCapStyle::TitleCase,
2798 ..Default::default()
2799 };
2800 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2801 rule.proper_names = names;
2802 rule
2803 }
2804
2805 #[test]
2806 fn test_title_case_preserves_proper_name_with_lowercase_article() {
2807 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
2811 let content = "# listening to the rolling stones today\n";
2812 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2813 let result = rule.check(&ctx).unwrap();
2814 assert_eq!(result.len(), 1, "Should flag the heading");
2815 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2816 assert!(
2817 fix_text.contains("The Rolling Stones"),
2818 "Fix should preserve proper name 'The Rolling Stones', got: {fix_text:?}"
2819 );
2820 }
2821
2822 #[test]
2823 fn test_title_case_proper_name_no_oscillation() {
2824 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
2826 let content = "# listening to the rolling stones today\n";
2827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2828 let result = rule.check(&ctx).unwrap();
2829 assert_eq!(result.len(), 1);
2830 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
2831
2832 let fixed_line = format!("{fixed_heading}\n");
2833 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
2834 let result2 = rule.check(&ctx2).unwrap();
2835 assert!(
2836 result2.is_empty(),
2837 "After one title-case fix, heading must already satisfy both rules. \
2838 Second pass warnings: {result2:?}"
2839 );
2840 }
2841
2842 #[test]
2843 fn test_title_case_unicode_casefold_expansion_before_proper_name() {
2844 let rule = create_title_case_rule_with_proper_names(vec!["Österreich".to_string()]);
2845 let content = "# İ österreich guide\n";
2846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2847 let result = rule.check(&ctx).unwrap();
2848 assert_eq!(result.len(), 1, "Should flag the heading");
2849 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2850 assert!(
2851 fix_text.contains("Österreich"),
2852 "Fix should preserve canonical proper-name casing, got: {fix_text:?}"
2853 );
2854 }
2855
2856 #[test]
2862 fn test_from_config_loads_md044_names_into_md063() {
2863 use crate::config::{Config, RuleConfig};
2864 use crate::rule::Rule;
2865 use std::collections::BTreeMap;
2866
2867 let mut config = Config::default();
2868
2869 let mut md063_values = BTreeMap::new();
2871 md063_values.insert("style".to_string(), toml::Value::String("sentence_case".to_string()));
2872 md063_values.insert("enabled".to_string(), toml::Value::Boolean(true));
2873 config.rules.insert(
2874 "MD063".to_string(),
2875 RuleConfig {
2876 values: md063_values,
2877 severity: None,
2878 },
2879 );
2880
2881 let mut md044_values = BTreeMap::new();
2883 md044_values.insert(
2884 "names".to_string(),
2885 toml::Value::Array(vec![toml::Value::String("Good Application".to_string())]),
2886 );
2887 config.rules.insert(
2888 "MD044".to_string(),
2889 RuleConfig {
2890 values: md044_values,
2891 severity: None,
2892 },
2893 );
2894
2895 let rule = MD063HeadingCapitalization::from_config(&config);
2897
2898 let content = "# using good application features\n";
2900 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2901 let result = rule.check(&ctx).unwrap();
2902 assert_eq!(result.len(), 1, "Should flag the heading");
2903 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2904 assert!(
2905 fix_text.contains("Good Application"),
2906 "from_config should wire MD044 names into MD063; fix should preserve \
2907 'Good Application', got: {fix_text:?}"
2908 );
2909 }
2910
2911 #[test]
2912 fn test_title_case_short_word_not_confused_with_substring() {
2913 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2917
2918 let content = "# in the insert\n";
2921 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2922 let result = rule.check(&ctx).unwrap();
2923 assert_eq!(result.len(), 1, "Should flag the heading");
2924 let fix = result[0].fix.as_ref().expect("Fix should be present");
2925 assert!(
2927 fix.replacement.contains("In the Insert"),
2928 "Expected 'In the Insert', got: {:?}",
2929 fix.replacement
2930 );
2931 }
2932
2933 #[test]
2934 fn test_title_case_or_not_confused_with_orchestra() {
2935 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2936
2937 let content = "# or the orchestra\n";
2940 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2941 let result = rule.check(&ctx).unwrap();
2942 assert_eq!(result.len(), 1, "Should flag the heading");
2943 let fix = result[0].fix.as_ref().expect("Fix should be present");
2944 assert!(
2946 fix.replacement.contains("Or the Orchestra"),
2947 "Expected 'Or the Orchestra', got: {:?}",
2948 fix.replacement
2949 );
2950 }
2951
2952 #[test]
2953 fn test_all_caps_preserves_all_words() {
2954 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
2955
2956 let content = "# in the insert\n";
2957 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2958 let result = rule.check(&ctx).unwrap();
2959 assert_eq!(result.len(), 1, "Should flag the heading");
2960 let fix = result[0].fix.as_ref().expect("Fix should be present");
2961 assert!(
2962 fix.replacement.contains("IN THE INSERT"),
2963 "All caps should uppercase all words, got: {:?}",
2964 fix.replacement
2965 );
2966 }
2967
2968 #[test]
2970 fn test_title_case_numbered_prefix_lowercase_word() {
2971 let rule = create_rule();
2973 let content = "## 1. To Be a Thing\n";
2974 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2975 let result = rule.check(&ctx).unwrap();
2976 assert!(
2977 result.is_empty(),
2978 "Should not flag '## 1. To Be a Thing', got: {result:?}"
2979 );
2980
2981 let content_lower = "## 1. to be a thing\n";
2982 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2983 let result2 = rule.check(&ctx2).unwrap();
2984 assert!(!result2.is_empty(), "Should flag '## 1. to be a thing'");
2985 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2986 assert!(
2987 fix.replacement.contains("1. To Be a Thing"),
2988 "Fix should capitalize 'To', got: {:?}",
2989 fix.replacement
2990 );
2991 }
2992
2993 #[test]
2994 fn test_title_case_numbered_prefix_article() {
2995 let rule = create_rule();
2997 let content = "## 2. A Guide to the Galaxy\n";
2998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2999 let result = rule.check(&ctx).unwrap();
3000 assert!(
3001 result.is_empty(),
3002 "Should not flag '## 2. A Guide to the Galaxy', got: {result:?}"
3003 );
3004
3005 let content_lower = "## 2. a guide to the galaxy\n";
3006 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3007 let result2 = rule.check(&ctx2).unwrap();
3008 assert!(!result2.is_empty(), "Should flag '## 2. a guide to the galaxy'");
3009 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3010 assert!(
3011 fix.replacement.contains("2. A Guide to the Galaxy"),
3012 "Fix should capitalize 'A', got: {:?}",
3013 fix.replacement
3014 );
3015 }
3016
3017 #[test]
3018 fn test_title_case_mid_sentence_period_word() {
3019 let rule = create_rule();
3021 let content = "## Step 1. Introduction to the Problem\n";
3022 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3023 let result = rule.check(&ctx).unwrap();
3024 assert!(
3025 result.is_empty(),
3026 "Should not flag '## Step 1. Introduction to the Problem', got: {result:?}"
3027 );
3028
3029 let content_lower = "## Step 1. introduction to the problem\n";
3030 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3031 let result2 = rule.check(&ctx2).unwrap();
3032 assert!(
3033 !result2.is_empty(),
3034 "Should flag '## Step 1. introduction to the problem'"
3035 );
3036 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3037 assert!(
3038 fix.replacement.contains("Step 1. Introduction to the Problem"),
3039 "Fix should capitalize 'Introduction', got: {:?}",
3040 fix.replacement
3041 );
3042 }
3043
3044 #[test]
3045 fn test_title_case_numbered_prefix_in_link_text() {
3046 let config = MD063Config {
3049 enabled: true,
3050 style: HeadingCapStyle::TitleCase,
3051 ..Default::default()
3052 };
3053 let rule = MD063HeadingCapitalization::from_config_struct(config);
3054
3055 let content = "## [1. To Be a Thing](https://example.com)\n";
3057 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3058 let result = rule.check(&ctx).unwrap();
3059 assert!(
3060 result.is_empty(),
3061 "Should not flag '## [1. To Be a Thing](url)', got: {result:?}"
3062 );
3063
3064 let content_lower = "## [1. to be a thing](https://example.com)\n";
3066 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3067 let result2 = rule.check(&ctx2).unwrap();
3068 assert!(!result2.is_empty(), "Should flag '## [1. to be a thing](url)'");
3069 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3070 assert!(
3071 fix.replacement.contains("1. To Be a Thing"),
3072 "Fix should capitalize 'To' in link text, got: {:?}",
3073 fix.replacement
3074 );
3075 }
3076
3077 #[test]
3082 fn test_is_numeric_ordinal_recognises_canonical_forms() {
3083 for word in &[
3084 "1st", "2nd", "3rd", "4th", "5th", "11th", "21st", "22nd", "23rd", "100th", "1ST", "5Th", "21St", "21sT",
3085 ] {
3086 assert!(
3087 MD063HeadingCapitalization::is_numeric_ordinal(word),
3088 "expected `{word}` to be detected as a numeric ordinal"
3089 );
3090 }
3091 }
3092
3093 #[test]
3094 fn test_is_numeric_ordinal_rejects_non_ordinals() {
3095 for word in &[
3100 "first", "1stop", "ist", "5", "th", "abc", "4G", "4K", "30s", "100k", "5x", "1.5", "iPhone6S",
3101 ] {
3102 assert!(
3103 !MD063HeadingCapitalization::is_numeric_ordinal(word),
3104 "expected `{word}` NOT to be detected as a numeric ordinal"
3105 );
3106 }
3107 }
3108
3109 #[test]
3110 fn test_is_numeric_ordinal_strips_trailing_punctuation() {
3111 for word in &["5th.", "1st,", "21st!", "3rd:", "4th)", "5th's"] {
3112 assert!(
3113 MD063HeadingCapitalization::is_numeric_ordinal(word),
3114 "expected `{word}` to be detected as a numeric ordinal (with punctuation)"
3115 );
3116 }
3117 }
3118
3119 #[test]
3120 fn test_title_case_ordinal_first_word_not_flagged() {
3121 let rule = create_rule();
3122 for content in &[
3123 "# 1st Place\n",
3124 "# 2nd Edition\n",
3125 "# 3rd Time\n",
3126 "# 5th Avenue\n",
3127 "# 21st Century Skills\n",
3128 "# 100th Customer\n",
3129 ] {
3130 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3131 let result = rule.check(&ctx).unwrap();
3132 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3133 }
3134 }
3135
3136 #[test]
3137 fn test_title_case_ordinal_mid_heading_not_flagged() {
3138 let rule = create_rule();
3139 for content in &[
3140 "# May 3rd Notes\n",
3141 "# Top 100th Customer\n",
3142 "# Notes for the 5th of May\n",
3143 ] {
3144 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3145 let result = rule.check(&ctx).unwrap();
3146 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3147 }
3148 }
3149
3150 #[test]
3151 fn test_title_case_ordinal_corrupted_form_is_fixed() {
3152 let rule = create_rule();
3155 for (input, expected) in &[
3156 ("# 1St Place\n", "1st Place"),
3157 ("# 5Th Avenue\n", "5th Avenue"),
3158 ("# 21St Century Skills\n", "21st Century Skills"),
3159 ("# May 3Rd Notes\n", "May 3rd Notes"),
3160 ("# 22Nd Edition\n", "22nd Edition"),
3161 ] {
3162 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
3163 let result = rule.check(&ctx).unwrap();
3164 assert!(!result.is_empty(), "Should flag {input:?}");
3165 let fix = result[0].fix.as_ref().expect("should have a fix");
3166 assert!(
3167 fix.replacement.contains(expected),
3168 "Fix for {input:?} should contain {expected:?}, got: {:?}",
3169 fix.replacement
3170 );
3171 }
3172 }
3173
3174 #[test]
3175 fn test_title_case_ordinal_lowercase_other_words_capitalised() {
3176 let rule = create_rule();
3178 let content = "# 5th avenue\n";
3179 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3180 let result = rule.check(&ctx).unwrap();
3181 assert_eq!(result.len(), 1);
3182 let fix = result[0].fix.as_ref().expect("should have a fix");
3183 assert!(
3184 fix.replacement.contains("5th Avenue"),
3185 "Fix should produce '5th Avenue', got: {:?}",
3186 fix.replacement
3187 );
3188 }
3189
3190 #[test]
3191 fn test_title_case_ordinal_with_trailing_punctuation() {
3192 let rule = create_rule();
3193 let content = "# Released on the 5th.\n";
3194 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3195 let result = rule.check(&ctx).unwrap();
3196 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3197 }
3198
3199 #[test]
3200 fn test_title_case_ordinal_hyphenated() {
3201 let rule = create_rule();
3202 for content in &["# 21st-Century Skills\n", "# A 19th-Century Novel\n"] {
3203 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3204 let result = rule.check(&ctx).unwrap();
3205 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3206 }
3207 }
3208
3209 #[test]
3210 fn test_sentence_case_ordinal_corrupted_form_is_fixed() {
3211 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3212 let content = "# 5Th avenue\n";
3213 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3214 let result = rule.check(&ctx).unwrap();
3215 assert_eq!(result.len(), 1);
3216 let fix = result[0].fix.as_ref().expect("should have a fix");
3217 assert!(
3218 fix.replacement.contains("5th avenue"),
3219 "Fix should produce '5th avenue', got: {:?}",
3220 fix.replacement
3221 );
3222 }
3223
3224 #[test]
3225 fn test_title_case_digit_acronym_unchanged() {
3226 let rule = create_rule();
3229 for content in &["# 4G Networks\n", "# 4K Streaming\n"] {
3230 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3231 let result = rule.check(&ctx).unwrap();
3232 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3233 }
3234 }
3235
3236 fn restart_rule(boundaries: &[&str]) -> MD063HeadingCapitalization {
3239 let config = MD063Config {
3240 enabled: true,
3241 style: HeadingCapStyle::SentenceCase,
3242 sentence_case_restart_after: boundaries.iter().copied().map(String::from).collect(),
3243 ..Default::default()
3244 };
3245 MD063HeadingCapitalization::from_config_struct(config)
3246 }
3247
3248 fn suggested(rule: &MD063HeadingCapitalization, content: &str) -> Option<String> {
3251 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3252 let warnings = rule.check(&ctx).unwrap();
3253 let fixed = rule.fix(&ctx).unwrap();
3254 assert_eq!(
3255 warnings.is_empty(),
3256 fixed == content,
3257 "a warning and a rewrite must agree for {content:?}"
3258 );
3259 (!warnings.is_empty()).then(|| fixed.trim_start_matches('#').trim().to_string())
3260 }
3261
3262 #[test]
3263 fn test_restart_after_capitalizes_the_word_following_a_boundary() {
3264 let rule = restart_rule(&[":"]);
3265 assert_eq!(
3266 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3267 Some("Requirement 1: Struct to logger slice conversion")
3268 );
3269 }
3270
3271 #[test]
3272 fn test_restart_after_defaults_to_no_boundaries() {
3273 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3275 assert_eq!(
3276 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3277 Some("Requirement 1: struct to logger slice conversion")
3278 );
3279 }
3280
3281 #[test]
3282 fn test_restart_after_only_honors_configured_punctuation() {
3283 let rule = restart_rule(&[":"]);
3285 assert_eq!(
3286 suggested(&rule, "# Design - Data Model Overview\n").as_deref(),
3287 Some("Design - data model overview")
3288 );
3289 assert_eq!(
3290 suggested(&rule, "# Setup; Then Run\n").as_deref(),
3291 Some("Setup; then run")
3292 );
3293
3294 let rule = restart_rule(&[";", "\u{2014}"]);
3295 assert_eq!(
3296 suggested(&rule, "# Setup; Then Run\n").as_deref(),
3297 Some("Setup; Then run")
3298 );
3299 assert_eq!(
3300 suggested(&rule, "# Part One \u{2014} The Big Idea\n").as_deref(),
3301 Some("Part one \u{2014} The big idea")
3302 );
3303 }
3304
3305 #[test]
3306 fn test_restart_after_matches_only_at_the_end_of_a_word() {
3307 let rule = restart_rule(&["-", ":"]);
3310 assert_eq!(
3311 suggested(&rule, "# Ports: Well-Known Ports Explained\n").as_deref(),
3312 Some("Ports: Well-Known ports explained")
3313 );
3314 assert_eq!(
3315 suggested(&rule, "# See https://example.com/A/B For Details\n").as_deref(),
3316 Some("See https://example.com/A/B for details")
3317 );
3318 }
3319
3320 #[test]
3321 fn test_restart_after_a_trailing_boundary_is_a_no_op() {
3322 let rule = restart_rule(&[":"]);
3323 assert_eq!(suggested(&rule, "# Setup:\n"), None);
3324 }
3325
3326 #[test]
3327 fn test_restart_after_does_not_override_preserved_words() {
3328 let rule = restart_rule(&[":"]);
3331 assert_eq!(
3332 suggested(&rule, "# Devices: iPhone And Android\n").as_deref(),
3333 Some("Devices: iPhone and android")
3334 );
3335
3336 let config = MD063Config {
3337 enabled: true,
3338 style: HeadingCapStyle::SentenceCase,
3339 sentence_case_restart_after: vec![":".to_string()],
3340 ignore_words: vec!["kubectl".to_string()],
3341 preserve_cased_words: false,
3342 ..Default::default()
3343 };
3344 let rule = MD063HeadingCapitalization::from_config_struct(config);
3345 assert_eq!(
3346 suggested(&rule, "# Tools: kubectl And Helm\n").as_deref(),
3347 Some("Tools: kubectl and helm")
3348 );
3349 }
3350
3351 #[test]
3352 fn test_restart_after_keeps_md044_canonical_forms() {
3353 let config = MD063Config {
3354 enabled: true,
3355 style: HeadingCapStyle::SentenceCase,
3356 sentence_case_restart_after: vec![":".to_string()],
3357 ..Default::default()
3358 };
3359 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3360 rule.proper_names = vec!["GitHub".to_string()];
3361
3362 assert_eq!(
3364 suggested(&rule, "# Docs: github Actions Guide\n").as_deref(),
3365 Some("Docs: GitHub actions guide")
3366 );
3367 assert_eq!(suggested(&rule, "# Docs: GitHub actions guide\n"), None);
3368 }
3369
3370 #[test]
3371 fn test_restart_after_carries_across_segments() {
3372 let rule = restart_rule(&[":"]);
3375 assert_eq!(
3376 suggested(
3377 &rule,
3378 "# Overview: [Some Link Here](https://example.com) Trailing Words\n"
3379 )
3380 .as_deref(),
3381 Some("Overview: [Some link here](https://example.com) trailing words")
3382 );
3383 assert_eq!(
3384 suggested(&rule, "# Overview: `code` Then More Words\n").as_deref(),
3385 Some("Overview: `code` then more words")
3386 );
3387 }
3388
3389 #[test]
3390 fn test_restart_after_ends_a_sentence_at_the_end_of_link_text() {
3391 let rule = restart_rule(&[":"]);
3395 assert_eq!(
3396 suggested(&rule, "# Topic [See:](https://example.com) More Words\n").as_deref(),
3397 Some("Topic [see:](https://example.com) More words")
3398 );
3399
3400 assert_eq!(
3402 suggested(&rule, "# Topic [See](https://example.com) More Words\n").as_deref(),
3403 Some("Topic [see](https://example.com) more words")
3404 );
3405 }
3406
3407 #[test]
3408 fn test_restart_after_ignores_boundaries_inside_opaque_segments() {
3409 let rule = restart_rule(&[":"]);
3412 for content in [
3413 "# Topic `see:` More Words\n",
3414 "# Topic  More Words\n",
3415 "# Topic <span title=\"x:\">y</span> More Words\n",
3416 ] {
3417 let fixed = suggested(&rule, content).expect("heading should be rewritten");
3418 assert!(
3419 fixed.ends_with("more words"),
3420 "opaque segment restarted the sentence in {content:?}: {fixed}"
3421 );
3422 }
3423 }
3424
3425 #[test]
3426 fn test_restart_after_treats_a_leading_link_as_sentence_initial() {
3427 for rule in [restart_rule(&[]), restart_rule(&[":"])] {
3430 assert_eq!(
3431 suggested(&rule, "# [Some Link Here](https://example.com) Trailing Words\n").as_deref(),
3432 Some("[Some link here](https://example.com) trailing words")
3433 );
3434 }
3435 }
3436
3437 #[test]
3438 fn test_restart_after_fix_is_idempotent() {
3439 let rule = restart_rule(&[":", ";", "-", "\u{2014}"]);
3440 for content in [
3441 "# Requirement 1: Struct to Logger Slice Conversion\n",
3442 "# Ports: Well-Known Ports Explained\n",
3443 "# Devices: iPhone And Android\n",
3444 "# Overview: [Some Link Here](https://example.com) Trailing Words\n",
3445 "# Setup:\n",
3446 ] {
3447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3448 let once = rule.fix(&ctx).unwrap();
3449 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
3450 assert_eq!(rule.fix(&ctx).unwrap(), once, "fix is not idempotent for {content:?}");
3451 }
3452 }
3453
3454 #[test]
3455 fn test_restart_after_ignores_empty_boundary_entries() {
3456 let rule = restart_rule(&[""]);
3458 assert_eq!(
3459 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3460 Some("Requirement 1: struct to logger slice conversion")
3461 );
3462 }
3463
3464 const STYLES: [HeadingCapStyle; 3] = [
3467 HeadingCapStyle::TitleCase,
3468 HeadingCapStyle::SentenceCase,
3469 HeadingCapStyle::AllCaps,
3470 ];
3471
3472 const GHERKIN_STRUCTURES: [(&str, &str, &str, &str); 6] = [
3475 (
3476 "# Feature: the system under test",
3477 "# Feature: The System Under Test",
3478 "# Feature: The system under test",
3479 "# Feature: THE SYSTEM UNDER TEST",
3480 ),
3481 (
3482 "## Background: a shared setup",
3483 "## Background: A Shared Setup",
3484 "## Background: A shared setup",
3485 "## Background: A SHARED SETUP",
3486 ),
3487 (
3488 "## Rule: money is never lost",
3489 "## Rule: Money Is Never Lost",
3490 "## Rule: Money is never lost",
3491 "## Rule: MONEY IS NEVER LOST",
3492 ),
3493 (
3494 "### Scenario: add two numbers",
3495 "### Scenario: Add Two Numbers",
3496 "### Scenario: Add two numbers",
3497 "### Scenario: ADD TWO NUMBERS",
3498 ),
3499 (
3500 "### Scenario Outline: add two numbers",
3501 "### Scenario Outline: Add Two Numbers",
3502 "### Scenario Outline: Add two numbers",
3503 "### Scenario Outline: ADD TWO NUMBERS",
3504 ),
3505 (
3506 "#### Examples: happy path",
3507 "#### Examples: Happy Path",
3508 "#### Examples: Happy path",
3509 "#### Examples: HAPPY PATH",
3510 ),
3511 ];
3512
3513 fn recased(style: HeadingCapStyle, heading: &str, flavor: crate::config::MarkdownFlavor) -> String {
3515 let rule = create_rule_with_style(style);
3516 let content = format!("{heading}\n");
3517 let ctx = LintContext::new(&content, flavor, None);
3518 let warnings = rule.check(&ctx).unwrap();
3519 let fixed = rule.fix(&ctx).unwrap();
3520 assert_eq!(
3521 warnings.is_empty(),
3522 fixed == content,
3523 "a warning and a rewrite must agree for {content:?} under {flavor:?}"
3524 );
3525 fixed.trim_end().to_string()
3526 }
3527
3528 #[test]
3529 fn test_mdg_keeps_the_keyword_of_every_structure() {
3530 for (heading, ..) in GHERKIN_STRUCTURES {
3533 let keyword = &heading[..=heading.find(':').unwrap()];
3534 for style in STYLES {
3535 let fixed = recased(style, heading, crate::config::MarkdownFlavor::MDG);
3536 assert!(
3537 fixed.starts_with(keyword),
3538 "{style:?} lost the keyword of {heading:?}: {fixed}"
3539 );
3540 }
3541 }
3542 }
3543
3544 #[test]
3545 fn test_mdg_recases_only_the_name_of_a_structure() {
3546 for (heading, title, sentence, caps) in GHERKIN_STRUCTURES {
3547 let mdg = crate::config::MarkdownFlavor::MDG;
3548 assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), title);
3549 assert_eq!(recased(HeadingCapStyle::SentenceCase, heading, mdg), sentence);
3550 assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), caps);
3551 }
3552 }
3553
3554 #[test]
3555 fn test_standard_flavor_recases_a_keyword_like_any_other_word() {
3556 let standard = crate::config::MarkdownFlavor::Standard;
3558 assert_eq!(
3559 recased(HeadingCapStyle::TitleCase, "# Feature: the system under test", standard),
3560 "# Feature: the System Under Test"
3561 );
3562 assert_eq!(
3563 recased(
3564 HeadingCapStyle::SentenceCase,
3565 "### Scenario Outline: add two numbers",
3566 standard
3567 ),
3568 "### Scenario outline: add two numbers"
3569 );
3570 assert_eq!(
3571 recased(HeadingCapStyle::AllCaps, "# Feature: the system under test", standard),
3572 "# FEATURE: THE SYSTEM UNDER TEST"
3573 );
3574 }
3575
3576 #[test]
3577 fn test_mdg_leaves_a_heading_without_a_colon_to_the_normal_rule() {
3578 for heading in ["## notes about the system", "## Notes", "# THE SYSTEM"] {
3579 for style in STYLES {
3580 assert_eq!(
3581 recased(style, heading, crate::config::MarkdownFlavor::MDG),
3582 recased(style, heading, crate::config::MarkdownFlavor::Standard),
3583 "{style:?} treated {heading:?} as a Gherkin structure"
3584 );
3585 }
3586 }
3587 }
3588
3589 #[test]
3590 fn test_mdg_splits_at_the_first_colon_only() {
3591 let mdg = crate::config::MarkdownFlavor::MDG;
3593 let heading = "## Scenario: ratio: two to one";
3594 assert_eq!(
3595 recased(HeadingCapStyle::TitleCase, heading, mdg),
3596 "## Scenario: Ratio: Two to One"
3597 );
3598 assert_eq!(
3599 recased(HeadingCapStyle::SentenceCase, heading, mdg),
3600 "## Scenario: Ratio: two to one"
3601 );
3602 assert_eq!(
3603 recased(HeadingCapStyle::AllCaps, heading, mdg),
3604 "## Scenario: RATIO: TWO TO ONE"
3605 );
3606 }
3607
3608 #[test]
3609 fn test_mdg_leaves_a_colon_behind_a_backtick_to_the_normal_rule() {
3610 for heading in [
3614 "# See `x: y` Notes",
3615 "# `a: b`",
3616 "# `code` Feature: a name",
3617 "# `x: y` Feature: a name",
3618 ] {
3619 for style in STYLES {
3620 assert_eq!(
3621 recased(style, heading, crate::config::MarkdownFlavor::MDG),
3622 recased(style, heading, crate::config::MarkdownFlavor::Standard),
3623 "{style:?} split {heading:?} at a colon inside a code span"
3624 );
3625 }
3626 }
3627 }
3628
3629 #[test]
3630 fn test_mdg_splits_at_a_keyword_colon_that_precedes_a_code_span() {
3631 let mdg = crate::config::MarkdownFlavor::MDG;
3633 let heading = "# Scenario: use `a: b` here";
3634 assert_eq!(
3635 recased(HeadingCapStyle::TitleCase, heading, mdg),
3636 "# Scenario: Use `a: b` Here"
3637 );
3638 assert_eq!(
3639 recased(HeadingCapStyle::SentenceCase, heading, mdg),
3640 "# Scenario: Use `a: b` here"
3641 );
3642 assert_eq!(
3643 recased(HeadingCapStyle::AllCaps, heading, mdg),
3644 "# Scenario: USE `a: b` HERE"
3645 );
3646 }
3647
3648 #[test]
3649 fn test_mdg_splits_at_a_keyword_colon_before_an_unbalanced_backtick() {
3650 let mdg = crate::config::MarkdownFlavor::MDG;
3653 let heading = "# Scenario: a ` b";
3654 assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), "# Scenario: A ` B");
3655 assert_eq!(
3656 recased(HeadingCapStyle::SentenceCase, heading, mdg),
3657 "# Scenario: A ` b"
3658 );
3659 assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), "# Scenario: A ` B");
3660 }
3661
3662 #[test]
3663 fn test_mdg_keeps_a_keyword_with_nothing_left_to_recase() {
3664 for style in STYLES {
3665 assert_eq!(
3666 recased(style, "# Feature:", crate::config::MarkdownFlavor::MDG),
3667 "# Feature:"
3668 );
3669 }
3670 }
3671
3672 #[test]
3673 fn test_mdg_keeps_a_custom_id_after_the_name() {
3674 assert_eq!(
3675 recased(
3676 HeadingCapStyle::TitleCase,
3677 "# Feature: the system {#overview}",
3678 crate::config::MarkdownFlavor::MDG
3679 ),
3680 "# Feature: The System {#overview}"
3681 );
3682 }
3683
3684 #[test]
3685 fn test_mdg_fix_is_idempotent() {
3686 for (heading, ..) in GHERKIN_STRUCTURES {
3687 for style in STYLES {
3688 let rule = create_rule_with_style(style);
3689 let content = format!("{heading}\n");
3690 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
3691 let once = rule.fix(&ctx).unwrap();
3692 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::MDG, None);
3693 assert_eq!(
3694 rule.fix(&ctx).unwrap(),
3695 once,
3696 "fix is not idempotent for {heading:?} ({style:?})"
3697 );
3698 }
3699 }
3700 }
3701}