1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
15use crate::utils::header_id_utils::{HTML_TAG_ATTRIBUTES_PATTERN, HTML_TAG_NAME_PATTERN, is_backslash_escaped};
16use crate::utils::html_elements::is_void_element;
17use crate::utils::mdg;
18use crate::utils::range_utils::byte_to_char_count;
19use regex::Regex;
20use std::collections::HashSet;
21use std::sync::LazyLock;
22
23mod md063_config;
24pub(super) use md063_config::{HeadingCapStyle, MD063Config};
25
26static INLINE_CODE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`+[^`]+`+").unwrap());
28
29static LINK_REGEX: LazyLock<Regex> =
34 LazyLock::new(|| Regex::new(r"\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)|\[([^\]]*)\]\[[^\]]*\]").unwrap());
35
36static HTML_TOKEN_REGEX: LazyLock<Regex> = LazyLock::new(|| {
42 let pattern = format!(
43 r"<!-->|<!--->|<!--.*?-->|</({HTML_TAG_NAME_PATTERN})\s*>|<({HTML_TAG_NAME_PATTERN}){HTML_TAG_ATTRIBUTES_PATTERN}\s*/?>"
44 );
45 Regex::new(&pattern).unwrap()
46});
47
48const SELF_RENDERING_ELEMENTS: &[&str] = &[
53 "img", "video", "audio", "iframe", "embed", "object", "canvas", "svg", "math", "input", "select", "textarea",
54 "button", "meter", "progress",
55];
56
57static CUSTOM_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*\{#[^}]+\}\s*$").unwrap());
59
60#[derive(Debug, Clone)]
62enum HeadingSegment {
63 Text(String),
65 Code(String),
67 Link {
69 full: String,
70 text_start: usize,
71 text_end: usize,
72 },
73 Html(String),
75 Image(String),
78}
79
80impl HeadingSegment {
81 fn renders_nothing(&self) -> bool {
86 match self {
87 HeadingSegment::Html(html) => {
88 let paints_something = HTML_TOKEN_REGEX.captures_iter(html).any(|token| {
89 token.get(2).is_some_and(|name| {
90 let name = name.as_str().to_ascii_lowercase();
91 SELF_RENDERING_ELEMENTS.contains(&name.as_str())
92 })
93 });
94 !paints_something && HTML_TOKEN_REGEX.replace_all(html, "").trim().is_empty()
95 }
96 _ => false,
97 }
98 }
99}
100
101#[derive(Clone)]
103pub struct MD063HeadingCapitalization {
104 config: MD063Config,
105 lowercase_set: HashSet<String>,
106 proper_names: Vec<String>,
109}
110
111impl Default for MD063HeadingCapitalization {
112 fn default() -> Self {
113 Self::new()
114 }
115}
116
117impl MD063HeadingCapitalization {
118 pub fn new() -> Self {
119 let config = MD063Config::default();
120 let lowercase_set = config.lowercase_words.iter().cloned().collect();
121 Self {
122 config,
123 lowercase_set,
124 proper_names: Vec::new(),
125 }
126 }
127
128 pub fn from_config_struct(config: MD063Config) -> Self {
129 let lowercase_set = config.lowercase_words.iter().cloned().collect();
130 Self {
131 config,
132 lowercase_set,
133 proper_names: Vec::new(),
134 }
135 }
136
137 fn match_case_insensitive_at(text: &str, start: usize, pattern_lower: &str) -> Option<usize> {
144 if start > text.len() || !text.is_char_boundary(start) || pattern_lower.is_empty() {
145 return None;
146 }
147
148 let mut matched_bytes = 0;
149
150 for (offset, ch) in text[start..].char_indices() {
151 if matched_bytes >= pattern_lower.len() {
152 break;
153 }
154
155 let lowered: String = ch.to_lowercase().collect();
156 if !pattern_lower[matched_bytes..].starts_with(&lowered) {
157 return None;
158 }
159
160 matched_bytes += lowered.len();
161
162 if matched_bytes == pattern_lower.len() {
163 return Some(start + offset + ch.len_utf8());
164 }
165 }
166
167 None
168 }
169
170 fn find_case_insensitive_match(text: &str, pattern_lower: &str, search_start: usize) -> Option<(usize, usize)> {
173 if pattern_lower.is_empty() || search_start >= text.len() || !text.is_char_boundary(search_start) {
174 return None;
175 }
176
177 for (offset, _) in text[search_start..].char_indices() {
178 let start = search_start + offset;
179 if let Some(end) = Self::match_case_insensitive_at(text, start, pattern_lower) {
180 return Some((start, end));
181 }
182 }
183
184 None
185 }
186
187 fn proper_name_canonical_forms(&self, text: &str) -> std::collections::HashMap<usize, &str> {
193 let mut map = std::collections::HashMap::new();
194
195 for name in &self.proper_names {
196 if name.is_empty() {
197 continue;
198 }
199 let name_lower = name.to_lowercase();
200 let canonical_words: Vec<&str> = name.split_whitespace().collect();
201 if canonical_words.is_empty() {
202 continue;
203 }
204 let mut search_start = 0;
205
206 while search_start < text.len() {
207 let Some((abs_pos, end_pos)) = Self::find_case_insensitive_match(text, &name_lower, search_start)
208 else {
209 break;
210 };
211
212 let before_ok = abs_pos == 0 || !text[..abs_pos].chars().last().is_some_and(char::is_alphanumeric);
214 let after_ok =
215 end_pos >= text.len() || !text[end_pos..].chars().next().is_some_and(char::is_alphanumeric);
216
217 if before_ok && after_ok {
218 let text_slice = &text[abs_pos..end_pos];
222 let mut word_idx = 0;
223 let mut slice_offset = 0;
224
225 for text_word in text_slice.split_whitespace() {
226 if let Some(w_rel) = text_slice[slice_offset..].find(text_word) {
227 let word_abs = abs_pos + slice_offset + w_rel;
228 if let Some(&canonical_word) = canonical_words.get(word_idx) {
229 map.insert(word_abs, canonical_word);
230 }
231 slice_offset += w_rel + text_word.len();
232 word_idx += 1;
233 }
234 }
235 }
236
237 search_start = abs_pos + text[abs_pos..].chars().next().map_or(1, char::len_utf8);
240 }
241 }
242
243 map
244 }
245
246 fn has_internal_capitals(&self, word: &str) -> bool {
248 let chars: Vec<char> = word.chars().collect();
249 if chars.len() < 2 {
250 return false;
251 }
252
253 let first = chars[0];
254 let rest = &chars[1..];
255 let has_upper_in_rest = rest.iter().any(|c| c.is_uppercase());
256 let has_lower_in_rest = rest.iter().any(|c| c.is_lowercase());
257
258 if has_upper_in_rest && has_lower_in_rest {
260 return true;
261 }
262
263 if first.is_lowercase() && has_upper_in_rest {
265 return true;
266 }
267
268 false
269 }
270
271 fn is_all_caps_acronym(&self, word: &str) -> bool {
275 if word.len() < 2 {
277 return false;
278 }
279
280 let mut consecutive_upper = 0;
281 let mut max_consecutive = 0;
282
283 for c in word.chars() {
284 if c.is_uppercase() {
285 consecutive_upper += 1;
286 max_consecutive = max_consecutive.max(consecutive_upper);
287 } else if c.is_lowercase() {
288 return false;
290 } else {
291 consecutive_upper = 0;
293 }
294 }
295
296 max_consecutive >= 2
298 }
299
300 fn should_preserve_word(&self, word: &str) -> bool {
302 if self.is_ignored_word(word) {
304 return true;
305 }
306
307 let is_ordinal = Self::is_numeric_ordinal(word);
313
314 if !is_ordinal {
315 if self.config.preserve_cased_words && self.has_internal_capitals(word) {
317 return true;
318 }
319
320 if self.config.preserve_cased_words && self.is_all_caps_acronym(word) {
322 return true;
323 }
324 }
325
326 if self.is_caret_notation(word) {
328 return true;
329 }
330
331 false
332 }
333
334 fn is_ignored_word(&self, word: &str) -> bool {
335 self.config.ignore_words.iter().any(|ignored| ignored == word)
336 }
337
338 fn is_numeric_ordinal(word: &str) -> bool {
346 let core = word.trim_matches(|c: char| !c.is_alphanumeric());
351 let bytes = core.as_bytes();
352
353 let alpha_start = match bytes.iter().position(|&b| !b.is_ascii_digit()) {
355 Some(pos) if pos > 0 => pos,
356 _ => return false,
357 };
358
359 let alpha_end = bytes[alpha_start..]
361 .iter()
362 .position(|b| !b.is_ascii_alphabetic())
363 .map_or(bytes.len(), |p| alpha_start + p);
364
365 let suffix = &core[alpha_start..alpha_end];
366 matches!(suffix.to_ascii_lowercase().as_str(), "st" | "nd" | "rd" | "th")
367 }
368
369 fn is_caret_notation(&self, word: &str) -> bool {
371 let chars: Vec<char> = word.chars().collect();
372 if chars.len() >= 2 && chars[0] == '^' {
374 let second = chars[1];
375 if second.is_ascii_uppercase() || "@[\\]^_".contains(second) {
377 return true;
378 }
379 }
380 false
381 }
382
383 fn is_lowercase_word(&self, word: &str) -> bool {
385 self.lowercase_set.contains(&word.to_lowercase())
386 }
387
388 fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
390 if word.is_empty() {
391 return word.to_string();
392 }
393
394 if self.should_preserve_word(word) {
396 return word.to_string();
397 }
398
399 if is_first || is_last {
401 return self.capitalize_first(word);
402 }
403
404 if self.is_lowercase_word(word) {
406 return Self::lowercase_preserving_composition(word);
407 }
408
409 self.capitalize_first(word)
411 }
412
413 fn apply_canonical_form_to_word(word: &str, canonical: &str) -> String {
416 let canonical_lower = canonical.to_lowercase();
417 if canonical_lower.is_empty() {
418 return canonical.to_string();
419 }
420
421 if let Some(end_pos) = Self::match_case_insensitive_at(word, 0, &canonical_lower) {
422 let mut out = String::with_capacity(canonical.len() + word.len().saturating_sub(end_pos));
423 out.push_str(canonical);
424 out.push_str(&word[end_pos..]);
425 out
426 } else {
427 canonical.to_string()
428 }
429 }
430
431 fn capitalize_first(&self, word: &str) -> String {
433 if word.is_empty() {
434 return String::new();
435 }
436
437 let first_alpha_pos = word.find(|c: char| c.is_alphabetic());
439 let Some(pos) = first_alpha_pos else {
440 return word.to_string();
441 };
442
443 let prefix = &word[..pos];
444 let suffix = &word[pos..];
445
446 if Self::is_numeric_ordinal(word) {
449 let suffix_lower = Self::lowercase_preserving_composition(suffix);
450 return format!("{prefix}{suffix_lower}");
451 }
452
453 let mut chars = suffix.chars();
454 let first = chars.next().unwrap();
455 let first_upper = Self::uppercase_preserving_composition(&first.to_string());
458 let rest: String = chars.collect();
459 let rest_lower = Self::lowercase_preserving_composition(&rest);
460 format!("{prefix}{first_upper}{rest_lower}")
461 }
462
463 fn lowercase_preserving_composition(s: &str) -> String {
466 let mut result = String::with_capacity(s.len());
467 for c in s.chars() {
468 let lower: String = c.to_lowercase().collect();
469 if lower.chars().count() == 1 {
470 result.push_str(&lower);
471 } else {
472 result.push(c);
474 }
475 }
476 result
477 }
478
479 fn sentence_case_first_person_pronouns(word: &str) -> Option<String> {
489 fn is_emphasis_marker(c: char) -> bool {
490 matches!(c, '*' | '_' | '~')
491 }
492
493 fn is_word_connector(c: char) -> bool {
497 matches!(c, '/' | '\\' | '-' | '.' | '+' | '&')
498 }
499
500 fn is_word_boundary(c: char) -> bool {
501 !c.is_alphanumeric() && !is_word_connector(c)
502 }
503
504 fn is_pronoun_at(word: &str, pos: usize) -> bool {
505 let left = word[..pos].trim_end_matches(is_emphasis_marker);
506 if !left.chars().next_back().is_none_or(is_word_boundary) {
507 return false;
508 }
509
510 let after_i = word[pos + 1..].trim_start_matches(is_emphasis_marker);
511 let Some(apostrophe) = after_i.chars().next() else {
512 return true;
513 };
514 if !matches!(apostrophe, '\'' | '’') {
515 return is_word_boundary(apostrophe);
516 }
517
518 let after_apostrophe = after_i[apostrophe.len_utf8()..].trim_start_matches(is_emphasis_marker);
519 let suffix_end = after_apostrophe
520 .find(|c: char| !c.is_ascii_alphabetic())
521 .unwrap_or(after_apostrophe.len());
522 let suffix = &after_apostrophe[..suffix_end];
523 if suffix.is_empty() {
524 return after_apostrophe.chars().next().is_none_or(is_word_boundary);
526 }
527 if !matches!(suffix.to_ascii_lowercase().as_str(), "d" | "ll" | "m" | "ve") {
528 return false;
529 }
530
531 let after_suffix = after_apostrophe[suffix_end..].trim_start_matches(is_emphasis_marker);
532 after_suffix.chars().next().is_none_or(is_word_boundary)
533 }
534
535 let pronoun_positions: Vec<usize> = word
536 .char_indices()
537 .filter_map(|(pos, c)| (c == 'I' && is_pronoun_at(word, pos)).then_some(pos))
538 .collect();
539 if pronoun_positions.is_empty() {
540 return None;
541 }
542
543 let mut result = String::with_capacity(word.len());
544 let mut copied_through = 0;
545 for pos in pronoun_positions {
546 result.push_str(&Self::lowercase_preserving_composition(&word[copied_through..pos]));
547 result.push('I');
548 copied_through = pos + 1;
549 }
550 result.push_str(&Self::lowercase_preserving_composition(&word[copied_through..]));
551 Some(result)
552 }
553
554 fn uppercase_preserving_composition(s: &str) -> String {
559 let mut result = String::with_capacity(s.len());
560 for c in s.chars() {
561 let upper: String = c.to_uppercase().collect();
562 if upper.chars().count() == 1 {
563 result.push_str(&upper);
564 } else {
565 result.push(c);
567 }
568 }
569 result
570 }
571
572 fn apply_title_case(&self, text: &str) -> String {
576 let canonical_forms = self.proper_name_canonical_forms(text);
577
578 let original_words: Vec<&str> = text.split_whitespace().collect();
579 let total_words = original_words.len();
580
581 let mut word_positions: Vec<usize> = Vec::with_capacity(original_words.len());
584 let mut pos = 0;
585 for word in &original_words {
586 if let Some(rel) = text[pos..].find(word) {
587 word_positions.push(pos + rel);
588 pos = pos + rel + word.len();
589 } else {
590 word_positions.push(usize::MAX);
591 }
592 }
593
594 let result_words: Vec<String> = original_words
595 .iter()
596 .enumerate()
597 .map(|(i, word)| {
598 let after_period = i > 0 && original_words[i - 1].ends_with('.');
599 let is_first = i == 0 || after_period;
600 let is_last = i == total_words - 1;
601
602 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
604 return Self::apply_canonical_form_to_word(word, canonical);
605 }
606
607 if self.should_preserve_word(word) {
609 return (*word).to_string();
610 }
611
612 if word.contains('-') {
614 return self.handle_hyphenated_word(word, is_first, is_last);
615 }
616
617 self.title_case_word(word, is_first, is_last)
618 })
619 .collect();
620
621 result_words.join(" ")
622 }
623
624 fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
626 let parts: Vec<&str> = word.split('-').collect();
627 let total_parts = parts.len();
628
629 let result_parts: Vec<String> = parts
630 .iter()
631 .enumerate()
632 .map(|(i, part)| {
633 let part_is_first = is_first && i == 0;
635 let part_is_last = is_last && i == total_parts - 1;
636 self.title_case_word(part, part_is_first, part_is_last)
637 })
638 .collect();
639
640 result_parts.join("-")
641 }
642
643 fn ends_sentence(&self, word: &str) -> bool {
649 self.config
650 .sentence_case_restart_after
651 .iter()
652 .any(|boundary| !boundary.is_empty() && word.ends_with(boundary.as_str()))
653 }
654
655 fn apply_sentence_case_from(&self, text: &str, starts_sentence: bool) -> String {
659 if text.is_empty() {
660 return text.to_string();
661 }
662
663 let canonical_forms = self.proper_name_canonical_forms(text);
664 let mut result = String::new();
665 let mut current_pos = 0;
666 let mut at_sentence_start = starts_sentence;
667
668 for word in text.split_whitespace() {
670 if let Some(pos) = text[current_pos..].find(word) {
671 let abs_pos = current_pos + pos;
672
673 result.push_str(&text[current_pos..abs_pos]);
675
676 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
679 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
680 } else if self.is_ignored_word(word) {
681 result.push_str(word);
683 } else if let Some(pronoun) = Self::sentence_case_first_person_pronouns(word) {
684 result.push_str(&pronoun);
687 } else if at_sentence_start {
688 if self.should_preserve_word(word) {
690 result.push_str(word);
692 } else {
693 let mut chars = word.chars();
695 if let Some(first) = chars.next() {
696 result.push_str(&Self::uppercase_preserving_composition(&first.to_string()));
697 let rest: String = chars.collect();
698 result.push_str(&Self::lowercase_preserving_composition(&rest));
699 }
700 }
701 } else {
702 if self.should_preserve_word(word) {
704 result.push_str(word);
705 } else {
706 result.push_str(&Self::lowercase_preserving_composition(word));
707 }
708 }
709
710 at_sentence_start = self.ends_sentence(word);
711 current_pos = abs_pos + word.len();
712 }
713 }
714
715 if current_pos < text.len() {
717 result.push_str(&text[current_pos..]);
718 }
719
720 result
721 }
722
723 fn apply_all_caps(&self, text: &str) -> String {
725 if text.is_empty() {
726 return text.to_string();
727 }
728
729 let canonical_forms = self.proper_name_canonical_forms(text);
730 let mut result = String::new();
731 let mut current_pos = 0;
732
733 for word in text.split_whitespace() {
735 if let Some(pos) = text[current_pos..].find(word) {
736 let abs_pos = current_pos + pos;
737
738 result.push_str(&text[current_pos..abs_pos]);
740
741 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
744 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
745 } else if self.should_preserve_word(word) {
746 result.push_str(word);
747 } else {
748 result.push_str(&Self::uppercase_preserving_composition(word));
749 }
750
751 current_pos = abs_pos + word.len();
752 }
753 }
754
755 if current_pos < text.len() {
757 result.push_str(&text[current_pos..]);
758 }
759
760 result
761 }
762
763 fn parse_segments(&self, text: &str) -> Vec<HeadingSegment> {
765 let mut segments = Vec::new();
766 let mut last_end = 0;
767
768 let mut special_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
770
771 for mat in INLINE_CODE_REGEX.find_iter(text) {
773 special_regions.push((mat.start(), mat.end(), HeadingSegment::Code(mat.as_str().to_string())));
774 }
775
776 for caps in LINK_REGEX.captures_iter(text) {
778 let full_match = caps.get(0).unwrap();
779
780 if full_match.start() >= 1 && text.as_bytes()[full_match.start() - 1] == b'!' {
784 let region_start = full_match.start() - 1;
785 special_regions.push((
786 region_start,
787 full_match.end(),
788 HeadingSegment::Image(text[region_start..full_match.end()].to_string()),
789 ));
790 continue;
791 }
792
793 let text_match = caps.get(1).or_else(|| caps.get(2));
794
795 if let Some(text_m) = text_match {
796 special_regions.push((
797 full_match.start(),
798 full_match.end(),
799 HeadingSegment::Link {
800 full: full_match.as_str().to_string(),
801 text_start: text_m.start() - full_match.start(),
802 text_end: text_m.end() - full_match.start(),
803 },
804 ));
805 }
806 }
807
808 let code_ranges: Vec<(usize, usize)> = special_regions
811 .iter()
812 .filter(|(_, _, segment)| matches!(segment, HeadingSegment::Code(_)))
813 .map(|(start, end, _)| (*start, *end))
814 .collect();
815 for (start, end) in Self::html_regions(text, &code_ranges) {
816 special_regions.push((start, end, HeadingSegment::Html(text[start..end].to_string())));
817 }
818
819 special_regions.sort_by_key(|(start, _, _)| *start);
821
822 let mut filtered_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
825 for region in special_regions {
826 let overlaps = filtered_regions.iter().any(|(s, e, _)| region.0 < *e && region.1 > *s);
827 if !overlaps {
828 filtered_regions.push(region);
829 }
830 }
831
832 for (start, end, segment) in filtered_regions {
834 if start > last_end {
836 let text_segment = &text[last_end..start];
837 if !text_segment.is_empty() {
838 segments.push(HeadingSegment::Text(text_segment.to_string()));
839 }
840 }
841 segments.push(segment);
842 last_end = end;
843 }
844
845 if last_end < text.len() {
847 let remaining = &text[last_end..];
848 if !remaining.is_empty() {
849 segments.push(HeadingSegment::Text(remaining.to_string()));
850 }
851 }
852
853 if segments.is_empty() && !text.is_empty() {
855 segments.push(HeadingSegment::Text(text.to_string()));
856 }
857
858 segments
859 }
860
861 fn html_regions(text: &str, code_ranges: &[(usize, usize)]) -> Vec<(usize, usize)> {
875 let mut regions: Vec<(usize, usize)> = Vec::new();
876 let mut open_elements: Vec<(String, usize)> = Vec::new();
877
878 let mut pos = 0;
879 while let Some(token) = HTML_TOKEN_REGEX.captures_at(text, pos) {
880 let whole = token.get(0).unwrap();
881 if code_ranges
882 .iter()
883 .any(|&(start, end)| start <= whole.start() && whole.start() < end)
884 || is_backslash_escaped(text, whole.start())
885 {
886 pos = whole.start() + 1;
888 continue;
889 }
890 pos = whole.end();
891
892 if let Some(closing) = token.get(1) {
893 let name = closing.as_str().to_ascii_lowercase();
894 if let Some(depth) = open_elements.iter().rposition(|(open_name, _)| *open_name == name) {
895 let element_start = open_elements[depth].1;
896 open_elements.truncate(depth);
897 regions.retain(|&(start, _)| start < element_start);
898 regions.push((element_start, whole.end()));
899 continue;
900 }
901 } else if let Some(opening) = token.get(2) {
902 let name = opening.as_str().to_ascii_lowercase();
903 if !whole.as_str().ends_with("/>") && !is_void_element(&name) {
904 open_elements.push((name, whole.start()));
905 }
906 }
907
908 regions.push((whole.start(), whole.end()));
909 }
910
911 regions
912 }
913
914 fn apply_capitalization(&self, text: &str, flavor: crate::config::MarkdownFlavor) -> String {
916 let (main_text, custom_id) = if let Some(mat) = CUSTOM_ID_REGEX.find(text) {
918 (&text[..mat.start()], Some(mat.as_str()))
919 } else {
920 (text, None)
921 };
922
923 let (keyword, main_text) = if flavor == crate::config::MarkdownFlavor::MDG {
930 mdg::keyword_split(main_text).unwrap_or(("", main_text))
931 } else {
932 ("", main_text)
933 };
934
935 let segments = self.parse_segments(main_text);
937
938 let text_segments: Vec<usize> = segments
940 .iter()
941 .enumerate()
942 .filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
943 .collect();
944
945 let first_segment_starts_sentence = segments
949 .iter()
950 .find(|s| !s.renders_nothing())
951 .is_some_and(|s| matches!(s, HeadingSegment::Text(_) | HeadingSegment::Link { .. }));
952
953 let last_segment_is_text = segments
956 .iter()
957 .rev()
958 .find(|s| !s.renders_nothing())
959 .is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
960
961 let mut result_parts: Vec<String> = Vec::new();
963
964 let mut at_sentence_start = first_segment_starts_sentence;
967
968 for (i, segment) in segments.iter().enumerate() {
969 at_sentence_start = match segment {
974 HeadingSegment::Text(t) => {
975 let is_first_text = text_segments.first() == Some(&i);
976 let is_last_text = text_segments.last() == Some(&i) && last_segment_is_text;
980
981 let capitalized = match self.config.style {
982 HeadingCapStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
983 HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(t, at_sentence_start),
984 HeadingCapStyle::AllCaps => self.apply_all_caps(t),
985 };
986 let ends_sentence = self.ends_sentence(capitalized.trim_end());
987 result_parts.push(capitalized);
988 ends_sentence
989 }
990 HeadingSegment::Code(c) => {
991 result_parts.push(c.clone());
992 false
993 }
994 HeadingSegment::Link {
995 full,
996 text_start,
997 text_end,
998 } => {
999 let link_text = &full[*text_start..*text_end];
1001 let capitalized_text = match self.config.style {
1002 HeadingCapStyle::TitleCase => self.apply_title_case(link_text),
1003 HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(link_text, at_sentence_start),
1006 HeadingCapStyle::AllCaps => self.apply_all_caps(link_text),
1007 };
1008 let ends_sentence = self.ends_sentence(capitalized_text.trim_end());
1011
1012 let mut new_link = String::new();
1013 new_link.push_str(&full[..*text_start]);
1014 new_link.push_str(&capitalized_text);
1015 new_link.push_str(&full[*text_end..]);
1016 result_parts.push(new_link);
1017 ends_sentence
1018 }
1019 HeadingSegment::Html(h) => {
1020 result_parts.push(h.clone());
1024 segment.renders_nothing() && at_sentence_start
1025 }
1026 HeadingSegment::Image(img) => {
1027 result_parts.push(img.clone());
1029 false
1030 }
1031 };
1032 }
1033
1034 let mut result = String::with_capacity(text.len());
1035 result.push_str(keyword);
1036 result.push_str(&result_parts.join(""));
1037
1038 if let Some(id) = custom_id {
1040 result.push_str(id);
1041 }
1042
1043 result
1044 }
1045
1046 fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
1048 let canonical_forms = self.proper_name_canonical_forms(text);
1049 let words: Vec<&str> = text.split_whitespace().collect();
1050 let total_words = words.len();
1051
1052 if total_words == 0 {
1053 return text.to_string();
1054 }
1055
1056 let mut word_positions: Vec<usize> = Vec::with_capacity(words.len());
1059 let mut pos = 0;
1060 for word in &words {
1061 if let Some(rel) = text[pos..].find(word) {
1062 word_positions.push(pos + rel);
1063 pos = pos + rel + word.len();
1064 } else {
1065 word_positions.push(usize::MAX);
1066 }
1067 }
1068
1069 let result_words: Vec<String> = words
1070 .iter()
1071 .enumerate()
1072 .map(|(i, word)| {
1073 let after_period = i > 0 && words[i - 1].ends_with('.');
1074 let is_first = (is_first_segment && i == 0) || after_period;
1075 let is_last = is_last_segment && i == total_words - 1;
1076
1077 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
1079 return Self::apply_canonical_form_to_word(word, canonical);
1080 }
1081
1082 if word.contains('-') {
1084 return self.handle_hyphenated_word(word, is_first, is_last);
1085 }
1086
1087 self.title_case_word(word, is_first, is_last)
1088 })
1089 .collect();
1090
1091 let mut result = String::new();
1093 let mut word_iter = result_words.iter();
1094 let mut in_word = false;
1095
1096 for c in text.chars() {
1097 if c.is_whitespace() {
1098 if in_word {
1099 in_word = false;
1100 }
1101 result.push(c);
1102 } else if !in_word {
1103 if let Some(word) = word_iter.next() {
1104 result.push_str(word);
1105 }
1106 in_word = true;
1107 }
1108 }
1109
1110 result
1111 }
1112
1113 fn fix_atx_heading(
1115 &self,
1116 _line: &str,
1117 heading: &crate::lint_context::HeadingInfo,
1118 flavor: crate::config::MarkdownFlavor,
1119 ) -> String {
1120 let indent = " ".repeat(heading.marker_column);
1122 let hashes = "#".repeat(heading.level as usize);
1123
1124 let fixed_text = self.apply_capitalization(&heading.raw_text, flavor);
1126
1127 let closing = &heading.closing_sequence;
1129 if heading.has_closing_sequence {
1130 format!("{indent}{hashes} {fixed_text} {closing}")
1131 } else {
1132 format!("{indent}{hashes} {fixed_text}")
1133 }
1134 }
1135
1136 fn fix_setext_heading(
1138 &self,
1139 line: &str,
1140 heading: &crate::lint_context::HeadingInfo,
1141 flavor: crate::config::MarkdownFlavor,
1142 ) -> String {
1143 let fixed_text = self.apply_capitalization(&heading.raw_text, flavor);
1145
1146 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
1148
1149 format!("{leading_ws}{fixed_text}")
1150 }
1151}
1152
1153impl Rule for MD063HeadingCapitalization {
1154 fn name(&self) -> &'static str {
1155 "MD063"
1156 }
1157
1158 fn description(&self) -> &'static str {
1159 "Heading capitalization"
1160 }
1161
1162 fn category(&self) -> RuleCategory {
1163 RuleCategory::Heading
1164 }
1165
1166 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1167 !ctx.likely_has_headings() || !ctx.lines.iter().any(|line| line.heading.is_some())
1168 }
1169
1170 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1171 let content = ctx.content;
1172
1173 if content.is_empty() {
1174 return Ok(Vec::new());
1175 }
1176
1177 let mut warnings = Vec::new();
1178
1179 for (line_num, line_info) in ctx.lines.iter().enumerate() {
1180 if let Some(heading) = &line_info.heading {
1181 if heading.level < self.config.min_level || heading.level > self.config.max_level {
1183 continue;
1184 }
1185
1186 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
1188 continue;
1189 }
1190
1191 if !heading.is_valid {
1193 continue;
1194 }
1195
1196 let original_text = &heading.raw_text;
1198 let fixed_text = self.apply_capitalization(original_text, ctx.flavor);
1199
1200 if original_text != &fixed_text {
1201 let line = line_info.content(ctx.content);
1202 let style_name = match self.config.style {
1203 HeadingCapStyle::TitleCase => "title case",
1204 HeadingCapStyle::SentenceCase => "sentence case",
1205 HeadingCapStyle::AllCaps => "ALL CAPS",
1206 };
1207
1208 warnings.push(LintWarning {
1209 rule_name: Some(self.name().to_string()),
1210 line: line_num + 1,
1211 column: byte_to_char_count(line, heading.content_column),
1212 end_line: line_num + 1,
1213 end_column: byte_to_char_count(line, heading.content_column) + original_text.chars().count(),
1214 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1215 severity: Severity::Warning,
1216 fix: Some(Fix::new(
1217 ctx.line_content_byte_range(line_num + 1),
1218 match heading.style {
1219 crate::lint_context::HeadingStyle::ATX => {
1220 self.fix_atx_heading(line, heading, ctx.flavor)
1221 }
1222 _ => self.fix_setext_heading(line, heading, ctx.flavor),
1223 },
1224 )),
1225 });
1226 }
1227 }
1228 }
1229
1230 Ok(warnings)
1231 }
1232
1233 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1234 let content = ctx.content;
1235
1236 if content.is_empty() {
1237 return Ok(content.to_string());
1238 }
1239
1240 let lines = ctx.raw_lines();
1241 let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1242
1243 for (line_num, line_info) in ctx.lines.iter().enumerate() {
1244 if ctx.is_rule_disabled(self.name(), line_num + 1) {
1246 continue;
1247 }
1248
1249 if let Some(heading) = &line_info.heading {
1250 if heading.level < self.config.min_level || heading.level > self.config.max_level {
1252 continue;
1253 }
1254
1255 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
1257 continue;
1258 }
1259
1260 if !heading.is_valid {
1262 continue;
1263 }
1264
1265 let original_text = &heading.raw_text;
1266 let fixed_text = self.apply_capitalization(original_text, ctx.flavor);
1267
1268 if original_text != &fixed_text {
1269 let line = line_info.content(ctx.content);
1270 fixed_lines[line_num] = match heading.style {
1271 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading, ctx.flavor),
1272 _ => self.fix_setext_heading(line, heading, ctx.flavor),
1273 };
1274 }
1275 }
1276 }
1277
1278 let mut result = String::with_capacity(content.len());
1280 for (i, line) in fixed_lines.iter().enumerate() {
1281 result.push_str(line);
1282 if i < fixed_lines.len() - 1 || content.ends_with('\n') {
1283 result.push('\n');
1284 }
1285 }
1286
1287 Ok(result)
1288 }
1289
1290 fn as_any(&self) -> &dyn std::any::Any {
1291 self
1292 }
1293
1294 crate::impl_rule_config_sections!(MD063Config);
1295
1296 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1297 where
1298 Self: Sized,
1299 {
1300 let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
1301 let md044_config =
1302 crate::rule_config_serde::load_rule_config::<crate::rules::md044_proper_names::MD044Config>(config);
1303 let mut rule = Self::from_config_struct(rule_config);
1304 rule.proper_names = md044_config.names;
1305 Box::new(rule)
1306 }
1307}
1308
1309#[cfg(test)]
1310mod tests {
1311 use super::*;
1312 use crate::lint_context::LintContext;
1313
1314 fn create_rule() -> MD063HeadingCapitalization {
1315 let config = MD063Config {
1316 enabled: true,
1317 ..Default::default()
1318 };
1319 MD063HeadingCapitalization::from_config_struct(config)
1320 }
1321
1322 fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
1323 let config = MD063Config {
1324 enabled: true,
1325 style,
1326 ..Default::default()
1327 };
1328 MD063HeadingCapitalization::from_config_struct(config)
1329 }
1330
1331 #[test]
1333 fn test_an_escaped_tag_does_not_hide_the_element_written_inside_it() {
1334 let text = r#"\<span title='<a id="x"></a>'>foo"#;
1337 assert_eq!(MD063HeadingCapitalization::html_regions(text, &[]), vec![(14, 28)]);
1338 }
1339
1340 #[test]
1341 fn test_title_case_basic() {
1342 let rule = create_rule();
1343 let content = "# hello world\n";
1344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1345 let result = rule.check(&ctx).unwrap();
1346 assert_eq!(result.len(), 1);
1347 assert!(result[0].message.contains("Hello World"));
1348 }
1349
1350 #[test]
1351 fn test_title_case_lowercase_words() {
1352 let rule = create_rule();
1353 let content = "# the quick brown fox\n";
1354 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1355 let result = rule.check(&ctx).unwrap();
1356 assert_eq!(result.len(), 1);
1357 assert!(result[0].message.contains("The Quick Brown Fox"));
1359 }
1360
1361 #[test]
1362 fn test_title_case_already_correct() {
1363 let rule = create_rule();
1364 let content = "# The Quick Brown Fox\n";
1365 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1366 let result = rule.check(&ctx).unwrap();
1367 assert!(result.is_empty(), "Already correct heading should not be flagged");
1368 }
1369
1370 #[test]
1371 fn test_title_case_hyphenated() {
1372 let rule = create_rule();
1373 let content = "# self-documenting code\n";
1374 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1375 let result = rule.check(&ctx).unwrap();
1376 assert_eq!(result.len(), 1);
1377 assert!(result[0].message.contains("Self-Documenting Code"));
1378 }
1379
1380 #[test]
1381 fn test_title_case_preserves_url_with_nested_parens() {
1382 let rule = create_rule();
1383 let content = "# guide for [the api](https://example.com/docs/v(2)beta)\n";
1385 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1386 let fixed = rule.fix(&ctx).unwrap();
1387 assert!(
1390 fixed.contains("https://example.com/docs/v(2)beta"),
1391 "URL with nested parens was corrupted: {fixed:?}"
1392 );
1393 }
1394
1395 #[test]
1396 fn test_title_case_does_not_recase_image_alt() {
1397 let rule = create_rule();
1398 let content = "# overview \n";
1399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1400 let fixed = rule.fix(&ctx).unwrap();
1401 assert!(
1403 fixed.contains(""),
1404 "image alt text was modified: {fixed:?}"
1405 );
1406 assert!(
1407 fixed.contains("# Overview"),
1408 "surrounding prose should still be title-cased: {fixed:?}"
1409 );
1410 }
1411
1412 #[test]
1414 fn test_sentence_case_basic() {
1415 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1416 let content = "# The Quick Brown Fox\n";
1417 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1418 let result = rule.check(&ctx).unwrap();
1419 assert_eq!(result.len(), 1);
1420 assert!(result[0].message.contains("The quick brown fox"));
1421 }
1422
1423 #[test]
1424 fn test_sentence_case_already_correct() {
1425 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1426 let content = "# The quick brown fox\n";
1427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1428 let result = rule.check(&ctx).unwrap();
1429 assert!(result.is_empty());
1430 }
1431
1432 #[test]
1433 fn test_sentence_case_preserves_first_person_pronoun() {
1434 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1436 let content = "# How do I debug playbooks?\n";
1437 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1438
1439 assert!(rule.check(&ctx).unwrap().is_empty());
1440 assert_eq!(rule.fix(&ctx).unwrap(), content);
1441 }
1442
1443 #[test]
1444 fn test_sentence_case_preserves_first_person_contractions() {
1445 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1446
1447 for content in [
1448 "# What I'd change\n",
1449 "# Where I'll look\n",
1450 "# Why I'm here\n",
1451 "# What I've learned\n",
1452 "# What I’d change\n",
1453 "# Where I’ll look\n",
1454 "# Why I’m here\n",
1455 "# What I’ve learned\n",
1456 ] {
1457 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1458 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1459 assert_eq!(rule.fix(&ctx).unwrap(), content);
1460 }
1461 }
1462
1463 #[test]
1464 fn test_sentence_case_pronoun_handles_markup_punctuation_and_suffix_case() {
1465 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1466 let cases = [
1467 ("# What (**I**) would change\n", "# What (**I**) would change\n"),
1468 ("# Why **I**'M changing it\n", "# Why **I**'m changing it\n"),
1469 ("# What I’LL change\n", "# What I’ll change\n"),
1470 ("# What I—really want\n", "# What I—really want\n"),
1471 ("# What I’d—reluctantly change\n", "# What I’d—reluctantly change\n"),
1472 ("# What I—yes—I—would do\n", "# What I—yes—I—would do\n"),
1473 ("# What [I'll change](plan.md)\n", "# What [I'll change](plan.md)\n"),
1474 ("# [What I—really want](plan.md)\n", "# [What I—really want](plan.md)\n"),
1475 ];
1476
1477 for (content, expected) in cases {
1478 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1479 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
1480 }
1481 }
1482
1483 #[test]
1484 fn test_sentence_case_pronoun_is_independent_of_cased_word_preservation() {
1485 let config = MD063Config {
1486 enabled: true,
1487 style: HeadingCapStyle::SentenceCase,
1488 preserve_cased_words: false,
1489 ..Default::default()
1490 };
1491 let rule = MD063HeadingCapitalization::from_config_struct(config);
1492 let content = "# How do I debug what I’LL change?\n";
1493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1494
1495 assert_eq!(rule.fix(&ctx).unwrap(), "# How do I debug what I’ll change?\n");
1496 }
1497
1498 #[test]
1499 fn test_sentence_case_explicit_ignore_wins_over_pronoun_normalization() {
1500 let config = MD063Config {
1501 enabled: true,
1502 style: HeadingCapStyle::SentenceCase,
1503 ignore_words: vec!["I'LL".to_string(), "I’LL".to_string()],
1504 ..Default::default()
1505 };
1506 let rule = MD063HeadingCapitalization::from_config_struct(config);
1507 let content = "# Why I'LL stay and why I’LL leave\n";
1508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1509
1510 assert!(rule.check(&ctx).unwrap().is_empty());
1511 assert_eq!(rule.fix(&ctx).unwrap(), content);
1512 }
1513
1514 #[test]
1515 fn test_sentence_case_pronoun_does_not_preserve_other_single_letters_or_compounds() {
1516 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1517 let content = "# Compare i, A, I/O, and A.I. values\n";
1518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1519
1520 assert_eq!(rule.fix(&ctx).unwrap(), "# Compare i, a, i/o, and a.i. values\n");
1521 }
1522
1523 #[test]
1525 fn test_all_caps_basic() {
1526 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1527 let content = "# hello world\n";
1528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1529 let result = rule.check(&ctx).unwrap();
1530 assert_eq!(result.len(), 1);
1531 assert!(result[0].message.contains("HELLO WORLD"));
1532 }
1533
1534 #[test]
1536 fn test_preserve_ignore_words() {
1537 let config = MD063Config {
1538 enabled: true,
1539 ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
1540 ..Default::default()
1541 };
1542 let rule = MD063HeadingCapitalization::from_config_struct(config);
1543
1544 let content = "# using iPhone on macOS\n";
1545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1546 let result = rule.check(&ctx).unwrap();
1547 assert_eq!(result.len(), 1);
1548 assert!(result[0].message.contains("iPhone"));
1550 assert!(result[0].message.contains("macOS"));
1551 }
1552
1553 #[test]
1554 fn test_preserve_cased_words() {
1555 let rule = create_rule();
1556 let content = "# using GitHub actions\n";
1557 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1558 let result = rule.check(&ctx).unwrap();
1559 assert_eq!(result.len(), 1);
1560 assert!(result[0].message.contains("GitHub"));
1562 }
1563
1564 #[test]
1566 fn test_inline_code_preserved() {
1567 let rule = create_rule();
1568 let content = "# using `const` in javascript\n";
1569 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1570 let result = rule.check(&ctx).unwrap();
1571 assert_eq!(result.len(), 1);
1572 assert!(result[0].message.contains("`const`"));
1574 assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
1575 }
1576
1577 #[test]
1579 fn test_level_filter() {
1580 let config = MD063Config {
1581 enabled: true,
1582 min_level: 2,
1583 max_level: 4,
1584 ..Default::default()
1585 };
1586 let rule = MD063HeadingCapitalization::from_config_struct(config);
1587
1588 let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
1589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1590 let result = rule.check(&ctx).unwrap();
1591
1592 assert_eq!(result.len(), 2);
1594 assert_eq!(result[0].line, 2); assert_eq!(result[1].line, 3); }
1597
1598 #[test]
1600 fn test_fix_atx_heading() {
1601 let rule = create_rule();
1602 let content = "# hello world\n";
1603 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1604 let fixed = rule.fix(&ctx).unwrap();
1605 assert_eq!(fixed, "# Hello World\n");
1606 }
1607
1608 #[test]
1609 fn test_fix_multiple_headings() {
1610 let rule = create_rule();
1611 let content = "# first heading\n\n## second heading\n";
1612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1613 let fixed = rule.fix(&ctx).unwrap();
1614 assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
1615 }
1616
1617 #[test]
1619 fn test_setext_heading() {
1620 let rule = create_rule();
1621 let content = "hello world\n============\n";
1622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1623 let result = rule.check(&ctx).unwrap();
1624 assert_eq!(result.len(), 1);
1625 assert!(result[0].message.contains("Hello World"));
1626 }
1627
1628 #[test]
1630 fn test_custom_id_preserved() {
1631 let rule = create_rule();
1632 let content = "# getting started {#intro}\n";
1633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1634 let result = rule.check(&ctx).unwrap();
1635 assert_eq!(result.len(), 1);
1636 assert!(result[0].message.contains("{#intro}"));
1638 }
1639
1640 #[test]
1642 fn test_skip_obsidian_tags_not_headings() {
1643 let rule = create_rule();
1644
1645 let content = "# H1\n\n#tag\n";
1647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1648 let result = rule.check(&ctx).unwrap();
1649 assert!(
1650 result.is_empty() || result.iter().all(|w| w.line != 3),
1651 "Obsidian tag #tag should not be treated as a heading: {result:?}"
1652 );
1653 }
1654
1655 #[test]
1656 fn test_skip_invalid_atx_headings_no_space() {
1657 let rule = create_rule();
1658
1659 let content = "#notaheading\n";
1661 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1662 let result = rule.check(&ctx).unwrap();
1663 assert!(
1664 result.is_empty(),
1665 "Invalid ATX heading without space should not be flagged: {result:?}"
1666 );
1667 }
1668
1669 #[test]
1670 fn test_fix_skips_obsidian_tags() {
1671 let rule = create_rule();
1672
1673 let content = "# hello world\n\n#tag\n";
1674 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1675 let fixed = rule.fix(&ctx).unwrap();
1676 assert!(fixed.contains("#tag"), "Fix should not modify Obsidian tag #tag");
1678 assert!(fixed.contains("# Hello World"), "Fix should still fix real headings");
1679 }
1680
1681 #[test]
1682 fn test_preserve_all_caps_acronyms() {
1683 let rule = create_rule();
1684 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1685
1686 let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
1688 assert_eq!(fixed, "# Using API in Production\n");
1689
1690 let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
1692 assert_eq!(fixed, "# API and GPU Integration\n");
1693
1694 let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
1696 assert_eq!(fixed, "# IO Performance Guide\n");
1697
1698 let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
1700 assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
1701 }
1702
1703 #[test]
1704 fn test_preserve_acronyms_in_hyphenated_words() {
1705 let rule = create_rule();
1706 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1707
1708 let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
1710 assert_eq!(fixed, "# API-Driven Architecture\n");
1711
1712 let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
1714 assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
1715 }
1716
1717 #[test]
1718 fn test_single_letters_not_treated_as_acronyms() {
1719 let rule = create_rule();
1720 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1721
1722 let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
1724 assert_eq!(fixed, "# I Am a Heading\n");
1725 }
1726
1727 #[test]
1728 fn test_lowercase_terms_need_ignore_words() {
1729 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1730
1731 let rule = create_rule();
1733 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1734 assert_eq!(fixed, "# Using Npm Packages\n");
1735
1736 let config = MD063Config {
1738 enabled: true,
1739 ignore_words: vec!["npm".to_string()],
1740 ..Default::default()
1741 };
1742 let rule = MD063HeadingCapitalization::from_config_struct(config);
1743 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1744 assert_eq!(fixed, "# Using npm Packages\n");
1745 }
1746
1747 #[test]
1748 fn test_acronyms_with_mixed_case_preserved() {
1749 let rule = create_rule();
1750 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1751
1752 let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
1754 assert_eq!(fixed, "# Using API with GitHub\n");
1755 }
1756
1757 #[test]
1758 fn test_real_world_acronyms() {
1759 let rule = create_rule();
1760 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1761
1762 let content = "# FFI bindings for CPU optimization\n";
1764 let fixed = rule.fix(&ctx(content)).unwrap();
1765 assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
1766
1767 let content = "# DOM manipulation and SSR rendering\n";
1768 let fixed = rule.fix(&ctx(content)).unwrap();
1769 assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
1770
1771 let content = "# CVE security and RNN models\n";
1772 let fixed = rule.fix(&ctx(content)).unwrap();
1773 assert_eq!(fixed, "# CVE Security and RNN Models\n");
1774 }
1775
1776 #[test]
1777 fn test_is_all_caps_acronym() {
1778 let rule = create_rule();
1779
1780 assert!(rule.is_all_caps_acronym("API"));
1782 assert!(rule.is_all_caps_acronym("IO"));
1783 assert!(rule.is_all_caps_acronym("GPU"));
1784 assert!(rule.is_all_caps_acronym("HTTP2")); assert!(!rule.is_all_caps_acronym("A"));
1788 assert!(!rule.is_all_caps_acronym("I"));
1789
1790 assert!(!rule.is_all_caps_acronym("Api"));
1792 assert!(!rule.is_all_caps_acronym("npm"));
1793 assert!(!rule.is_all_caps_acronym("iPhone"));
1794 }
1795
1796 #[test]
1797 fn test_sentence_case_starts_after_a_leading_empty_anchor() {
1798 let config = MD063Config {
1800 enabled: true,
1801 style: HeadingCapStyle::SentenceCase,
1802 ..Default::default()
1803 };
1804 let rule = MD063HeadingCapitalization::from_config_struct(config);
1805
1806 let content = "# <a id=\"top\"></a>the beginning\n";
1807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1808 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1809 assert_eq!(rule.fix(&ctx).unwrap(), "# <a id=\"top\"></a>The beginning\n");
1810
1811 let content = "# <kbd>ctrl</kbd> the key\n";
1813 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1814 assert!(rule.check(&ctx).unwrap().is_empty());
1815
1816 for content in ["# <img src=\"x.png\"> the picture\n", "#  the picture\n"] {
1819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1820 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1821 }
1822 }
1823
1824 #[test]
1825 fn test_sentence_case_ignore_words_first_word() {
1826 let config = MD063Config {
1827 enabled: true,
1828 style: HeadingCapStyle::SentenceCase,
1829 ignore_words: vec!["nvim".to_string()],
1830 ..Default::default()
1831 };
1832 let rule = MD063HeadingCapitalization::from_config_struct(config);
1833
1834 let content = "# nvim config\n";
1836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1837 let result = rule.check(&ctx).unwrap();
1838 assert!(
1839 result.is_empty(),
1840 "nvim in ignore-words should not be flagged. Got: {result:?}"
1841 );
1842
1843 let fixed = rule.fix(&ctx).unwrap();
1845 assert_eq!(fixed, "# nvim config\n");
1846 }
1847
1848 #[test]
1849 fn test_sentence_case_ignore_words_not_first() {
1850 let config = MD063Config {
1851 enabled: true,
1852 style: HeadingCapStyle::SentenceCase,
1853 ignore_words: vec!["nvim".to_string()],
1854 ..Default::default()
1855 };
1856 let rule = MD063HeadingCapitalization::from_config_struct(config);
1857
1858 let content = "# Using nvim editor\n";
1860 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1861 let result = rule.check(&ctx).unwrap();
1862 assert!(
1863 result.is_empty(),
1864 "nvim in ignore-words should be preserved. Got: {result:?}"
1865 );
1866 }
1867
1868 #[test]
1869 fn test_preserve_cased_words_ios() {
1870 let config = MD063Config {
1871 enabled: true,
1872 style: HeadingCapStyle::SentenceCase,
1873 preserve_cased_words: true,
1874 ..Default::default()
1875 };
1876 let rule = MD063HeadingCapitalization::from_config_struct(config);
1877
1878 let content = "## This is iOS\n";
1880 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1881 let result = rule.check(&ctx).unwrap();
1882 assert!(
1883 result.is_empty(),
1884 "iOS should be preserved with preserve-cased-words. Got: {result:?}"
1885 );
1886
1887 let fixed = rule.fix(&ctx).unwrap();
1889 assert_eq!(fixed, "## This is iOS\n");
1890 }
1891
1892 #[test]
1893 fn test_preserve_cased_words_ios_title_case() {
1894 let config = MD063Config {
1895 enabled: true,
1896 style: HeadingCapStyle::TitleCase,
1897 preserve_cased_words: true,
1898 ..Default::default()
1899 };
1900 let rule = MD063HeadingCapitalization::from_config_struct(config);
1901
1902 let content = "# developing for iOS\n";
1904 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1905 let fixed = rule.fix(&ctx).unwrap();
1906 assert_eq!(fixed, "# Developing for iOS\n");
1907 }
1908
1909 #[test]
1910 fn test_has_internal_capitals_ios() {
1911 let rule = create_rule();
1912
1913 assert!(
1915 rule.has_internal_capitals("iOS"),
1916 "iOS has mixed case (lowercase i, uppercase OS)"
1917 );
1918
1919 assert!(rule.has_internal_capitals("iPhone"));
1921 assert!(rule.has_internal_capitals("macOS"));
1922 assert!(rule.has_internal_capitals("GitHub"));
1923 assert!(rule.has_internal_capitals("JavaScript"));
1924 assert!(rule.has_internal_capitals("eBay"));
1925
1926 assert!(!rule.has_internal_capitals("API"));
1928 assert!(!rule.has_internal_capitals("GPU"));
1929
1930 assert!(!rule.has_internal_capitals("npm"));
1932 assert!(!rule.has_internal_capitals("config"));
1933
1934 assert!(!rule.has_internal_capitals("The"));
1936 assert!(!rule.has_internal_capitals("Hello"));
1937 }
1938
1939 #[test]
1940 fn test_lowercase_words_before_trailing_code() {
1941 let config = MD063Config {
1942 enabled: true,
1943 style: HeadingCapStyle::TitleCase,
1944 lowercase_words: vec![
1945 "a".to_string(),
1946 "an".to_string(),
1947 "and".to_string(),
1948 "at".to_string(),
1949 "but".to_string(),
1950 "by".to_string(),
1951 "for".to_string(),
1952 "from".to_string(),
1953 "into".to_string(),
1954 "nor".to_string(),
1955 "on".to_string(),
1956 "onto".to_string(),
1957 "or".to_string(),
1958 "the".to_string(),
1959 "to".to_string(),
1960 "upon".to_string(),
1961 "via".to_string(),
1962 "vs".to_string(),
1963 "with".to_string(),
1964 "without".to_string(),
1965 ],
1966 preserve_cased_words: true,
1967 ..Default::default()
1968 };
1969 let rule = MD063HeadingCapitalization::from_config_struct(config);
1970
1971 let content = "## subtitle with a `app`\n";
1976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1977 let result = rule.check(&ctx).unwrap();
1978
1979 assert!(!result.is_empty(), "Should flag incorrect capitalization");
1981 let fixed = rule.fix(&ctx).unwrap();
1982 assert!(
1984 fixed.contains("with a `app`"),
1985 "Expected 'with a `app`' but got: {fixed:?}"
1986 );
1987 assert!(
1988 !fixed.contains("with A `app`"),
1989 "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
1990 );
1991 assert!(
1993 fixed.contains("Subtitle with a `app`"),
1994 "Expected 'Subtitle with a `app`' but got: {fixed:?}"
1995 );
1996 }
1997
1998 #[test]
1999 fn test_lowercase_words_preserved_before_trailing_code_variant() {
2000 let config = MD063Config {
2001 enabled: true,
2002 style: HeadingCapStyle::TitleCase,
2003 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2004 ..Default::default()
2005 };
2006 let rule = MD063HeadingCapitalization::from_config_struct(config);
2007
2008 let content = "## Title with the `code`\n";
2010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2011 let fixed = rule.fix(&ctx).unwrap();
2012 assert!(
2014 fixed.contains("with the `code`"),
2015 "Expected 'with the `code`' but got: {fixed:?}"
2016 );
2017 assert!(
2018 !fixed.contains("with The `code`"),
2019 "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
2020 );
2021 }
2022
2023 #[test]
2024 fn test_last_word_capitalized_when_no_trailing_code() {
2025 let config = MD063Config {
2028 enabled: true,
2029 style: HeadingCapStyle::TitleCase,
2030 lowercase_words: vec!["a".to_string(), "the".to_string()],
2031 ..Default::default()
2032 };
2033 let rule = MD063HeadingCapitalization::from_config_struct(config);
2034
2035 let content = "## title with a word\n";
2038 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2039 let fixed = rule.fix(&ctx).unwrap();
2040 assert!(
2042 fixed.contains("With a Word"),
2043 "Expected 'With a Word' but got: {fixed:?}"
2044 );
2045 }
2046
2047 #[test]
2048 fn test_multiple_lowercase_words_before_code() {
2049 let config = MD063Config {
2050 enabled: true,
2051 style: HeadingCapStyle::TitleCase,
2052 lowercase_words: vec![
2053 "a".to_string(),
2054 "the".to_string(),
2055 "with".to_string(),
2056 "for".to_string(),
2057 ],
2058 ..Default::default()
2059 };
2060 let rule = MD063HeadingCapitalization::from_config_struct(config);
2061
2062 let content = "## Guide for the `user`\n";
2064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2065 let fixed = rule.fix(&ctx).unwrap();
2066 assert!(
2067 fixed.contains("for the `user`"),
2068 "Expected 'for the `user`' but got: {fixed:?}"
2069 );
2070 assert!(
2071 !fixed.contains("For The `user`"),
2072 "Should not capitalize lowercase words before code. Got: {fixed:?}"
2073 );
2074 }
2075
2076 #[test]
2077 fn test_code_in_middle_normal_rules_apply() {
2078 let config = MD063Config {
2079 enabled: true,
2080 style: HeadingCapStyle::TitleCase,
2081 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2082 ..Default::default()
2083 };
2084 let rule = MD063HeadingCapitalization::from_config_struct(config);
2085
2086 let content = "## Using `const` for the code\n";
2088 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2089 let fixed = rule.fix(&ctx).unwrap();
2090 assert!(
2092 fixed.contains("for the Code"),
2093 "Expected 'for the Code' but got: {fixed:?}"
2094 );
2095 }
2096
2097 #[test]
2098 fn test_link_at_end_same_as_code() {
2099 let config = MD063Config {
2100 enabled: true,
2101 style: HeadingCapStyle::TitleCase,
2102 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2103 ..Default::default()
2104 };
2105 let rule = MD063HeadingCapitalization::from_config_struct(config);
2106
2107 let content = "## Guide for the [link](./page.md)\n";
2109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2110 let fixed = rule.fix(&ctx).unwrap();
2111 assert!(
2113 fixed.contains("for the [Link]"),
2114 "Expected 'for the [Link]' but got: {fixed:?}"
2115 );
2116 assert!(
2117 !fixed.contains("for The [Link]"),
2118 "Should not capitalize 'the' before link. Got: {fixed:?}"
2119 );
2120 }
2121
2122 #[test]
2123 fn test_multiple_code_segments() {
2124 let config = MD063Config {
2125 enabled: true,
2126 style: HeadingCapStyle::TitleCase,
2127 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2128 ..Default::default()
2129 };
2130 let rule = MD063HeadingCapitalization::from_config_struct(config);
2131
2132 let content = "## Using `const` with a `variable`\n";
2134 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2135 let fixed = rule.fix(&ctx).unwrap();
2136 assert!(
2138 fixed.contains("with a `variable`"),
2139 "Expected 'with a `variable`' but got: {fixed:?}"
2140 );
2141 assert!(
2142 !fixed.contains("with A `variable`"),
2143 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2144 );
2145 }
2146
2147 #[test]
2148 fn test_code_and_link_combination() {
2149 let config = MD063Config {
2150 enabled: true,
2151 style: HeadingCapStyle::TitleCase,
2152 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2153 ..Default::default()
2154 };
2155 let rule = MD063HeadingCapitalization::from_config_struct(config);
2156
2157 let content = "## Guide for the `code` [link](./page.md)\n";
2159 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2160 let fixed = rule.fix(&ctx).unwrap();
2161 assert!(
2163 fixed.contains("for the `code`"),
2164 "Expected 'for the `code`' but got: {fixed:?}"
2165 );
2166 }
2167
2168 #[test]
2169 fn test_text_after_code_capitalizes_last() {
2170 let config = MD063Config {
2171 enabled: true,
2172 style: HeadingCapStyle::TitleCase,
2173 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2174 ..Default::default()
2175 };
2176 let rule = MD063HeadingCapitalization::from_config_struct(config);
2177
2178 let content = "## Using `const` for the code\n";
2180 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2181 let fixed = rule.fix(&ctx).unwrap();
2182 assert!(
2184 fixed.contains("for the Code"),
2185 "Expected 'for the Code' but got: {fixed:?}"
2186 );
2187 }
2188
2189 #[test]
2190 fn test_preserve_cased_words_with_trailing_code() {
2191 let config = MD063Config {
2192 enabled: true,
2193 style: HeadingCapStyle::TitleCase,
2194 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2195 preserve_cased_words: true,
2196 ..Default::default()
2197 };
2198 let rule = MD063HeadingCapitalization::from_config_struct(config);
2199
2200 let content = "## Guide for iOS `app`\n";
2202 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2203 let fixed = rule.fix(&ctx).unwrap();
2204 assert!(
2206 fixed.contains("for iOS `app`"),
2207 "Expected 'for iOS `app`' but got: {fixed:?}"
2208 );
2209 assert!(
2210 !fixed.contains("For iOS `app`"),
2211 "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
2212 );
2213 }
2214
2215 #[test]
2216 fn test_ignore_words_with_trailing_code() {
2217 let config = MD063Config {
2218 enabled: true,
2219 style: HeadingCapStyle::TitleCase,
2220 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2221 ignore_words: vec!["npm".to_string()],
2222 ..Default::default()
2223 };
2224 let rule = MD063HeadingCapitalization::from_config_struct(config);
2225
2226 let content = "## Using npm with a `script`\n";
2228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2229 let fixed = rule.fix(&ctx).unwrap();
2230 assert!(
2232 fixed.contains("npm with a `script`"),
2233 "Expected 'npm with a `script`' but got: {fixed:?}"
2234 );
2235 assert!(
2236 !fixed.contains("with A `script`"),
2237 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2238 );
2239 }
2240
2241 #[test]
2242 fn test_empty_text_segment_edge_case() {
2243 let config = MD063Config {
2244 enabled: true,
2245 style: HeadingCapStyle::TitleCase,
2246 lowercase_words: vec!["a".to_string(), "with".to_string()],
2247 ..Default::default()
2248 };
2249 let rule = MD063HeadingCapitalization::from_config_struct(config);
2250
2251 let content = "## `start` with a `end`\n";
2253 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2254 let fixed = rule.fix(&ctx).unwrap();
2255 assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
2258 assert!(
2259 !fixed.contains("A `end`"),
2260 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2261 );
2262 }
2263
2264 #[test]
2265 fn test_sentence_case_with_trailing_code() {
2266 let config = MD063Config {
2267 enabled: true,
2268 style: HeadingCapStyle::SentenceCase,
2269 lowercase_words: vec!["a".to_string(), "the".to_string()],
2270 ..Default::default()
2271 };
2272 let rule = MD063HeadingCapitalization::from_config_struct(config);
2273
2274 let content = "## guide for the `user`\n";
2276 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2277 let fixed = rule.fix(&ctx).unwrap();
2278 assert!(
2280 fixed.contains("Guide for the `user`"),
2281 "Expected 'Guide for the `user`' but got: {fixed:?}"
2282 );
2283 }
2284
2285 #[test]
2286 fn test_hyphenated_word_before_code() {
2287 let config = MD063Config {
2288 enabled: true,
2289 style: HeadingCapStyle::TitleCase,
2290 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2291 ..Default::default()
2292 };
2293 let rule = MD063HeadingCapitalization::from_config_struct(config);
2294
2295 let content = "## Self-contained with a `feature`\n";
2297 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2298 let fixed = rule.fix(&ctx).unwrap();
2299 assert!(
2301 fixed.contains("with a `feature`"),
2302 "Expected 'with a `feature`' but got: {fixed:?}"
2303 );
2304 }
2305
2306 #[test]
2311 fn test_sentence_case_code_at_start_basic() {
2312 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2314 let content = "# `rumdl` is a linter\n";
2315 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2316 let result = rule.check(&ctx).unwrap();
2317 assert!(
2319 result.is_empty(),
2320 "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
2321 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2322 );
2323 }
2324
2325 #[test]
2326 fn test_sentence_case_code_at_start_incorrect_capitalization() {
2327 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2329 let content = "# `rumdl` Is a Linter\n";
2330 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2331 let result = rule.check(&ctx).unwrap();
2332 assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
2334 assert!(
2335 result[0].message.contains("`rumdl` is a linter"),
2336 "Should suggest lowercase after code. Got: {:?}",
2337 result[0].message
2338 );
2339 }
2340
2341 #[test]
2342 fn test_sentence_case_code_at_start_fix() {
2343 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2344 let content = "# `rumdl` Is A Linter\n";
2345 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2346 let fixed = rule.fix(&ctx).unwrap();
2347 assert!(
2348 fixed.contains("# `rumdl` is a linter"),
2349 "Should fix to lowercase after code. Got: {fixed:?}"
2350 );
2351 }
2352
2353 #[test]
2354 fn test_sentence_case_text_at_start_still_capitalizes() {
2355 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2357 let content = "# the quick brown fox\n";
2358 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2359 let result = rule.check(&ctx).unwrap();
2360 assert_eq!(result.len(), 1);
2361 assert!(
2362 result[0].message.contains("The quick brown fox"),
2363 "Text-first heading should capitalize first word. Got: {:?}",
2364 result[0].message
2365 );
2366 }
2367
2368 #[test]
2369 fn test_sentence_case_link_at_start() {
2370 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2372 let content = "# [api](api.md) reference guide\n";
2373 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2374 let result = rule.check(&ctx).unwrap();
2375 assert_eq!(result.len(), 1);
2376 assert!(result[0].message.contains("[Api](api.md) reference guide"));
2377 }
2378
2379 #[test]
2380 fn test_sentence_case_link_preserves_acronyms() {
2381 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2383 let content = "# [API](api.md) Reference Guide\n";
2384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2385 let result = rule.check(&ctx).unwrap();
2386 assert_eq!(result.len(), 1);
2387 assert!(
2389 result[0].message.contains("[API](api.md) reference guide"),
2390 "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
2391 result[0].message
2392 );
2393 }
2394
2395 #[test]
2396 fn test_sentence_case_link_preserves_brand_names() {
2397 let config = MD063Config {
2399 enabled: true,
2400 style: HeadingCapStyle::SentenceCase,
2401 preserve_cased_words: true,
2402 ..Default::default()
2403 };
2404 let rule = MD063HeadingCapitalization::from_config_struct(config);
2405 let content = "# [iPhone](iphone.md) Features Guide\n";
2406 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2407 let result = rule.check(&ctx).unwrap();
2408 assert_eq!(result.len(), 1);
2409 assert!(
2411 result[0].message.contains("[iPhone](iphone.md) features guide"),
2412 "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
2413 result[0].message
2414 );
2415 }
2416
2417 #[test]
2418 fn test_sentence_case_link_lowercases_regular_words() {
2419 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2421 let content = "# [Documentation](docs.md) Reference\n";
2422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2423 let result = rule.check(&ctx).unwrap();
2424 assert_eq!(result.len(), 1);
2425 assert!(
2426 result[0].message.contains("[Documentation](docs.md) reference"),
2427 "Should preserve the sentence-initial capital. Got: {:?}",
2428 result[0].message
2429 );
2430 }
2431
2432 #[test]
2433 fn test_sentence_case_opening_link_label_is_sentence_initial() {
2434 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2436 let content = "# [Foo bar](https://example.com)\n";
2437 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2438
2439 assert!(rule.check(&ctx).unwrap().is_empty());
2440 assert_eq!(rule.fix(&ctx).unwrap(), content);
2441 }
2442
2443 #[test]
2444 fn test_sentence_case_link_at_start_correct_already() {
2445 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2447 let content = "# [API](api.md) reference guide\n";
2448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2449 let result = rule.check(&ctx).unwrap();
2450 assert!(
2451 result.is_empty(),
2452 "Correctly cased heading with link should not be flagged. Got: {:?}",
2453 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2454 );
2455 }
2456
2457 #[test]
2458 fn test_sentence_case_link_github_preserved() {
2459 let config = MD063Config {
2461 enabled: true,
2462 style: HeadingCapStyle::SentenceCase,
2463 preserve_cased_words: true,
2464 ..Default::default()
2465 };
2466 let rule = MD063HeadingCapitalization::from_config_struct(config);
2467 let content = "# [GitHub](gh.md) Repository Setup\n";
2468 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2469 let result = rule.check(&ctx).unwrap();
2470 assert_eq!(result.len(), 1);
2471 assert!(
2472 result[0].message.contains("[GitHub](gh.md) repository setup"),
2473 "Should preserve 'GitHub'. Got: {:?}",
2474 result[0].message
2475 );
2476 }
2477
2478 #[test]
2479 fn test_sentence_case_multiple_code_spans() {
2480 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2481 let content = "# `foo` and `bar` are methods\n";
2482 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2483 let result = rule.check(&ctx).unwrap();
2484 assert!(
2486 result.is_empty(),
2487 "Should not capitalize words between/after code spans. Got: {:?}",
2488 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2489 );
2490 }
2491
2492 #[test]
2493 fn test_sentence_case_code_only_heading() {
2494 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2496 let content = "# `rumdl`\n";
2497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2498 let result = rule.check(&ctx).unwrap();
2499 assert!(
2500 result.is_empty(),
2501 "Code-only heading should be fine. Got: {:?}",
2502 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2503 );
2504 }
2505
2506 #[test]
2507 fn test_sentence_case_code_at_end() {
2508 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2510 let content = "# install the `rumdl` tool\n";
2511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2512 let result = rule.check(&ctx).unwrap();
2513 assert_eq!(result.len(), 1);
2515 assert!(
2516 result[0].message.contains("Install the `rumdl` tool"),
2517 "First word should still be capitalized when text comes first. Got: {:?}",
2518 result[0].message
2519 );
2520 }
2521
2522 #[test]
2523 fn test_sentence_case_code_in_middle() {
2524 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2526 let content = "# using the `rumdl` linter for markdown\n";
2527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2528 let result = rule.check(&ctx).unwrap();
2529 assert_eq!(result.len(), 1);
2531 assert!(
2532 result[0].message.contains("Using the `rumdl` linter for markdown"),
2533 "First word should be capitalized. Got: {:?}",
2534 result[0].message
2535 );
2536 }
2537
2538 #[test]
2539 fn test_sentence_case_preserved_word_after_code() {
2540 let config = MD063Config {
2542 enabled: true,
2543 style: HeadingCapStyle::SentenceCase,
2544 preserve_cased_words: true,
2545 ..Default::default()
2546 };
2547 let rule = MD063HeadingCapitalization::from_config_struct(config);
2548 let content = "# `swift` iPhone development\n";
2549 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2550 let result = rule.check(&ctx).unwrap();
2551 assert!(
2553 result.is_empty(),
2554 "Preserved words after code should stay. Got: {:?}",
2555 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2556 );
2557 }
2558
2559 #[test]
2560 fn test_title_case_code_at_start_still_capitalizes() {
2561 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2563 let content = "# `api` quick start guide\n";
2564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2565 let result = rule.check(&ctx).unwrap();
2566 assert_eq!(result.len(), 1);
2568 assert!(
2569 result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
2570 "Title case should capitalize major words after code. Got: {:?}",
2571 result[0].message
2572 );
2573 }
2574
2575 #[test]
2578 fn test_sentence_case_html_tag_at_start() {
2579 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2581 let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
2582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2583 let result = rule.check(&ctx).unwrap();
2584 assert_eq!(result.len(), 1);
2586 let fixed = rule.fix(&ctx).unwrap();
2587 assert_eq!(
2588 fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
2589 "Text after HTML at start should be lowercase"
2590 );
2591 }
2592
2593 #[test]
2594 fn test_sentence_case_html_tag_preserves_content() {
2595 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2597 let content = "# The <abbr>API</abbr> documentation guide\n";
2598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2599 let result = rule.check(&ctx).unwrap();
2600 assert!(
2602 result.is_empty(),
2603 "HTML tag content should be preserved. Got: {:?}",
2604 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2605 );
2606 }
2607
2608 #[test]
2609 fn test_sentence_case_html_tag_at_start_with_acronym() {
2610 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2612 let content = "# <abbr>API</abbr> Documentation Guide\n";
2613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2614 let result = rule.check(&ctx).unwrap();
2615 assert_eq!(result.len(), 1);
2616 let fixed = rule.fix(&ctx).unwrap();
2617 assert_eq!(
2618 fixed, "# <abbr>API</abbr> documentation guide\n",
2619 "Text after HTML at start should be lowercase, HTML content preserved"
2620 );
2621 }
2622
2623 #[test]
2624 fn test_sentence_case_html_tag_in_middle() {
2625 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2627 let content = "# using the <code>config</code> File\n";
2628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2629 let result = rule.check(&ctx).unwrap();
2630 assert_eq!(result.len(), 1);
2631 let fixed = rule.fix(&ctx).unwrap();
2632 assert_eq!(
2633 fixed, "# Using the <code>config</code> file\n",
2634 "First word capitalized, HTML preserved, rest lowercase"
2635 );
2636 }
2637
2638 #[test]
2639 fn test_html_tag_strong_emphasis() {
2640 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2642 let content = "# The <strong>Bold</strong> Way\n";
2643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2644 let result = rule.check(&ctx).unwrap();
2645 assert_eq!(result.len(), 1);
2646 let fixed = rule.fix(&ctx).unwrap();
2647 assert_eq!(
2648 fixed, "# The <strong>Bold</strong> way\n",
2649 "<strong> tag content should be preserved"
2650 );
2651 }
2652
2653 #[test]
2654 fn test_html_tag_with_attributes() {
2655 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2657 let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
2658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2659 let result = rule.check(&ctx).unwrap();
2660 assert_eq!(result.len(), 1);
2661 let fixed = rule.fix(&ctx).unwrap();
2662 assert_eq!(
2663 fixed, "# <span class=\"highlight\">Important</span> notice here\n",
2664 "HTML tag with attributes should be preserved"
2665 );
2666 }
2667
2668 #[test]
2669 fn test_multiple_html_tags() {
2670 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2672 let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
2673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2674 let result = rule.check(&ctx).unwrap();
2675 assert_eq!(result.len(), 1);
2676 let fixed = rule.fix(&ctx).unwrap();
2677 assert_eq!(
2678 fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
2679 "Multiple HTML tags should all be preserved"
2680 );
2681 }
2682
2683 #[test]
2684 fn test_html_and_code_mixed() {
2685 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2687 let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
2688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2689 let result = rule.check(&ctx).unwrap();
2690 assert_eq!(result.len(), 1);
2691 let fixed = rule.fix(&ctx).unwrap();
2692 assert_eq!(
2693 fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
2694 "HTML and code should both be preserved"
2695 );
2696 }
2697
2698 #[test]
2699 fn test_self_closing_html_tag() {
2700 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2702 let content = "# Line one<br/>Line Two Here\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 = rule.fix(&ctx).unwrap();
2707 assert_eq!(
2708 fixed, "# Line one<br/>line two here\n",
2709 "Self-closing HTML tags should be preserved"
2710 );
2711 }
2712
2713 #[test]
2714 fn test_title_case_with_html_tags() {
2715 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2717 let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
2718 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2719 let result = rule.check(&ctx).unwrap();
2720 assert_eq!(result.len(), 1);
2721 let fixed = rule.fix(&ctx).unwrap();
2722 assert!(
2724 fixed.contains("<kbd>ctrl</kbd>"),
2725 "HTML tag content should be preserved in title case. Got: {fixed}"
2726 );
2727 assert!(
2728 fixed.starts_with("# The ") || fixed.starts_with("# the "),
2729 "Title case should work with HTML. Got: {fixed}"
2730 );
2731 }
2732
2733 #[test]
2736 fn test_sentence_case_preserves_caret_notation() {
2737 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2739 let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
2740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2741 let result = rule.check(&ctx).unwrap();
2742 assert!(
2744 result.is_empty(),
2745 "Caret notation should be preserved. Got: {:?}",
2746 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2747 );
2748 }
2749
2750 #[test]
2751 fn test_sentence_case_caret_notation_various() {
2752 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2754
2755 let content = "## Press ^C to cancel\n";
2757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2758 let result = rule.check(&ctx).unwrap();
2759 assert!(
2760 result.is_empty(),
2761 "^C should be preserved. Got: {:?}",
2762 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2763 );
2764
2765 let content = "## Use ^Z for background\n";
2767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2768 let result = rule.check(&ctx).unwrap();
2769 assert!(
2770 result.is_empty(),
2771 "^Z should be preserved. Got: {:?}",
2772 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2773 );
2774
2775 let content = "## Press ^[ for escape\n";
2777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2778 let result = rule.check(&ctx).unwrap();
2779 assert!(
2780 result.is_empty(),
2781 "^[ should be preserved. Got: {:?}",
2782 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2783 );
2784 }
2785
2786 #[test]
2787 fn test_caret_notation_detection() {
2788 let rule = create_rule();
2789
2790 assert!(rule.is_caret_notation("^A"));
2792 assert!(rule.is_caret_notation("^Z"));
2793 assert!(rule.is_caret_notation("^C"));
2794 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")); }
2806
2807 fn create_sentence_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2814 let config = MD063Config {
2815 enabled: true,
2816 style: HeadingCapStyle::SentenceCase,
2817 ..Default::default()
2818 };
2819 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2820 rule.proper_names = names;
2821 rule
2822 }
2823
2824 #[test]
2825 fn test_sentence_case_preserves_single_word_proper_name() {
2826 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2827 let content = "# installing javascript\n";
2829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2830 let result = rule.check(&ctx).unwrap();
2831 assert_eq!(result.len(), 1, "Should flag the heading");
2832 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2833 assert!(
2834 fix_text.contains("JavaScript"),
2835 "Fix should preserve proper name 'JavaScript', got: {fix_text:?}"
2836 );
2837 assert!(
2838 !fix_text.contains("javascript"),
2839 "Fix should not have lowercase 'javascript', got: {fix_text:?}"
2840 );
2841 }
2842
2843 #[test]
2844 fn test_sentence_case_preserves_multi_word_proper_name() {
2845 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2846 let content = "# using good application features\n";
2848 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2849 let result = rule.check(&ctx).unwrap();
2850 assert_eq!(result.len(), 1, "Should flag the heading");
2851 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2852 assert!(
2853 fix_text.contains("Good Application"),
2854 "Fix should preserve 'Good Application' as a phrase, got: {fix_text:?}"
2855 );
2856 }
2857
2858 #[test]
2859 fn test_sentence_case_proper_name_at_start_of_heading() {
2860 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2861 let content = "# good application overview\n";
2863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2864 let result = rule.check(&ctx).unwrap();
2865 assert_eq!(result.len(), 1, "Should flag the heading");
2866 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2867 assert!(
2868 fix_text.contains("Good Application"),
2869 "Fix should produce 'Good Application' at start of heading, got: {fix_text:?}"
2870 );
2871 assert!(
2872 fix_text.contains("overview"),
2873 "Non-proper-name word 'overview' should be lowercase, got: {fix_text:?}"
2874 );
2875 }
2876
2877 #[test]
2878 fn test_sentence_case_with_proper_names_no_oscillation() {
2879 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2882
2883 let content = "# installing good application on your system\n";
2885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2886 let result = rule.check(&ctx).unwrap();
2887 assert_eq!(result.len(), 1);
2888 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
2889
2890 assert!(
2892 fixed_heading.contains("Good Application"),
2893 "After fix, proper name must be preserved: {fixed_heading:?}"
2894 );
2895
2896 let fixed_line = format!("{fixed_heading}\n");
2898 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
2899 let result2 = rule.check(&ctx2).unwrap();
2900 assert!(
2901 result2.is_empty(),
2902 "After one fix, heading must already satisfy both MD063 and MD044 - no oscillation. \
2903 Second pass warnings: {result2:?}"
2904 );
2905 }
2906
2907 #[test]
2908 fn test_sentence_case_proper_names_already_correct() {
2909 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2910 let content = "# Installing Good Application\n";
2912 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2913 let result = rule.check(&ctx).unwrap();
2914 assert!(
2915 result.is_empty(),
2916 "Correct sentence-case heading with proper name should not be flagged, got: {result:?}"
2917 );
2918 }
2919
2920 #[test]
2921 fn test_sentence_case_multiple_proper_names_in_heading() {
2922 let rule = create_sentence_case_rule_with_proper_names(vec!["TypeScript".to_string(), "React".to_string()]);
2923 let content = "# using typescript with react\n";
2924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2925 let result = rule.check(&ctx).unwrap();
2926 assert_eq!(result.len(), 1);
2927 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2928 assert!(
2929 fix_text.contains("TypeScript"),
2930 "Fix should preserve 'TypeScript', got: {fix_text:?}"
2931 );
2932 assert!(
2933 fix_text.contains("React"),
2934 "Fix should preserve 'React', got: {fix_text:?}"
2935 );
2936 }
2937
2938 #[test]
2939 fn test_sentence_case_unicode_casefold_expansion_before_proper_name() {
2940 let rule = create_sentence_case_rule_with_proper_names(vec!["Österreich".to_string()]);
2943 let content = "# İ österreich guide\n";
2944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2945
2946 let result = rule.check(&ctx).unwrap();
2948 assert_eq!(result.len(), 1, "Should flag heading for canonical proper-name casing");
2949 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2950 assert!(
2951 fix_text.contains("Österreich"),
2952 "Fix should preserve canonical 'Österreich', got: {fix_text:?}"
2953 );
2954 }
2955
2956 #[test]
2957 fn test_sentence_case_preserves_trailing_punctuation_on_proper_name() {
2958 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2959 let content = "# using javascript, today\n";
2960 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2961 let result = rule.check(&ctx).unwrap();
2962 assert_eq!(result.len(), 1, "Should flag heading");
2963 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2964 assert!(
2965 fix_text.contains("JavaScript,"),
2966 "Fix should preserve trailing punctuation, got: {fix_text:?}"
2967 );
2968 }
2969
2970 fn create_title_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2977 let config = MD063Config {
2978 enabled: true,
2979 style: HeadingCapStyle::TitleCase,
2980 ..Default::default()
2981 };
2982 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2983 rule.proper_names = names;
2984 rule
2985 }
2986
2987 #[test]
2988 fn test_title_case_preserves_proper_name_with_lowercase_article() {
2989 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
2993 let content = "# listening to the rolling stones today\n";
2994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2995 let result = rule.check(&ctx).unwrap();
2996 assert_eq!(result.len(), 1, "Should flag the heading");
2997 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2998 assert!(
2999 fix_text.contains("The Rolling Stones"),
3000 "Fix should preserve proper name 'The Rolling Stones', got: {fix_text:?}"
3001 );
3002 }
3003
3004 #[test]
3005 fn test_title_case_proper_name_no_oscillation() {
3006 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
3008 let content = "# listening to the rolling stones today\n";
3009 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3010 let result = rule.check(&ctx).unwrap();
3011 assert_eq!(result.len(), 1);
3012 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
3013
3014 let fixed_line = format!("{fixed_heading}\n");
3015 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
3016 let result2 = rule.check(&ctx2).unwrap();
3017 assert!(
3018 result2.is_empty(),
3019 "After one title-case fix, heading must already satisfy both rules. \
3020 Second pass warnings: {result2:?}"
3021 );
3022 }
3023
3024 #[test]
3025 fn test_title_case_unicode_casefold_expansion_before_proper_name() {
3026 let rule = create_title_case_rule_with_proper_names(vec!["Österreich".to_string()]);
3027 let content = "# İ österreich guide\n";
3028 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3029 let result = rule.check(&ctx).unwrap();
3030 assert_eq!(result.len(), 1, "Should flag the heading");
3031 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3032 assert!(
3033 fix_text.contains("Österreich"),
3034 "Fix should preserve canonical proper-name casing, got: {fix_text:?}"
3035 );
3036 }
3037
3038 #[test]
3044 fn test_from_config_loads_md044_names_into_md063() {
3045 use crate::config::{Config, RuleConfig};
3046 use crate::rule::Rule;
3047 use std::collections::BTreeMap;
3048
3049 let mut config = Config::default();
3050
3051 let mut md063_values = BTreeMap::new();
3053 md063_values.insert("style".to_string(), toml::Value::String("sentence_case".to_string()));
3054 md063_values.insert("enabled".to_string(), toml::Value::Boolean(true));
3055 config.rules.insert(
3056 "MD063".to_string(),
3057 RuleConfig {
3058 values: md063_values,
3059 severity: None,
3060 },
3061 );
3062
3063 let mut md044_values = BTreeMap::new();
3065 md044_values.insert(
3066 "names".to_string(),
3067 toml::Value::Array(vec![toml::Value::String("Good Application".to_string())]),
3068 );
3069 config.rules.insert(
3070 "MD044".to_string(),
3071 RuleConfig {
3072 values: md044_values,
3073 severity: None,
3074 },
3075 );
3076
3077 let rule = MD063HeadingCapitalization::from_config(&config);
3079
3080 let content = "# using good application features\n";
3082 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3083 let result = rule.check(&ctx).unwrap();
3084 assert_eq!(result.len(), 1, "Should flag the heading");
3085 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3086 assert!(
3087 fix_text.contains("Good Application"),
3088 "from_config should wire MD044 names into MD063; fix should preserve \
3089 'Good Application', got: {fix_text:?}"
3090 );
3091 }
3092
3093 #[test]
3094 fn test_title_case_short_word_not_confused_with_substring() {
3095 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3099
3100 let content = "# in the insert\n";
3103 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3104 let result = rule.check(&ctx).unwrap();
3105 assert_eq!(result.len(), 1, "Should flag the heading");
3106 let fix = result[0].fix.as_ref().expect("Fix should be present");
3107 assert!(
3109 fix.replacement.contains("In the Insert"),
3110 "Expected 'In the Insert', got: {:?}",
3111 fix.replacement
3112 );
3113 }
3114
3115 #[test]
3116 fn test_title_case_or_not_confused_with_orchestra() {
3117 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3118
3119 let content = "# or the orchestra\n";
3122 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3123 let result = rule.check(&ctx).unwrap();
3124 assert_eq!(result.len(), 1, "Should flag the heading");
3125 let fix = result[0].fix.as_ref().expect("Fix should be present");
3126 assert!(
3128 fix.replacement.contains("Or the Orchestra"),
3129 "Expected 'Or the Orchestra', got: {:?}",
3130 fix.replacement
3131 );
3132 }
3133
3134 #[test]
3135 fn test_all_caps_preserves_all_words() {
3136 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
3137
3138 let content = "# in the insert\n";
3139 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3140 let result = rule.check(&ctx).unwrap();
3141 assert_eq!(result.len(), 1, "Should flag the heading");
3142 let fix = result[0].fix.as_ref().expect("Fix should be present");
3143 assert!(
3144 fix.replacement.contains("IN THE INSERT"),
3145 "All caps should uppercase all words, got: {:?}",
3146 fix.replacement
3147 );
3148 }
3149
3150 #[test]
3152 fn test_title_case_numbered_prefix_lowercase_word() {
3153 let rule = create_rule();
3155 let content = "## 1. To Be a Thing\n";
3156 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3157 let result = rule.check(&ctx).unwrap();
3158 assert!(
3159 result.is_empty(),
3160 "Should not flag '## 1. To Be a Thing', got: {result:?}"
3161 );
3162
3163 let content_lower = "## 1. to be a thing\n";
3164 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3165 let result2 = rule.check(&ctx2).unwrap();
3166 assert!(!result2.is_empty(), "Should flag '## 1. to be a thing'");
3167 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3168 assert!(
3169 fix.replacement.contains("1. To Be a Thing"),
3170 "Fix should capitalize 'To', got: {:?}",
3171 fix.replacement
3172 );
3173 }
3174
3175 #[test]
3176 fn test_title_case_numbered_prefix_article() {
3177 let rule = create_rule();
3179 let content = "## 2. A Guide to the Galaxy\n";
3180 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3181 let result = rule.check(&ctx).unwrap();
3182 assert!(
3183 result.is_empty(),
3184 "Should not flag '## 2. A Guide to the Galaxy', got: {result:?}"
3185 );
3186
3187 let content_lower = "## 2. a guide to the galaxy\n";
3188 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3189 let result2 = rule.check(&ctx2).unwrap();
3190 assert!(!result2.is_empty(), "Should flag '## 2. a guide to the galaxy'");
3191 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3192 assert!(
3193 fix.replacement.contains("2. A Guide to the Galaxy"),
3194 "Fix should capitalize 'A', got: {:?}",
3195 fix.replacement
3196 );
3197 }
3198
3199 #[test]
3200 fn test_title_case_mid_sentence_period_word() {
3201 let rule = create_rule();
3203 let content = "## Step 1. Introduction to the Problem\n";
3204 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3205 let result = rule.check(&ctx).unwrap();
3206 assert!(
3207 result.is_empty(),
3208 "Should not flag '## Step 1. Introduction to the Problem', got: {result:?}"
3209 );
3210
3211 let content_lower = "## Step 1. introduction to the problem\n";
3212 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3213 let result2 = rule.check(&ctx2).unwrap();
3214 assert!(
3215 !result2.is_empty(),
3216 "Should flag '## Step 1. introduction to the problem'"
3217 );
3218 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3219 assert!(
3220 fix.replacement.contains("Step 1. Introduction to the Problem"),
3221 "Fix should capitalize 'Introduction', got: {:?}",
3222 fix.replacement
3223 );
3224 }
3225
3226 #[test]
3227 fn test_title_case_numbered_prefix_in_link_text() {
3228 let config = MD063Config {
3231 enabled: true,
3232 style: HeadingCapStyle::TitleCase,
3233 ..Default::default()
3234 };
3235 let rule = MD063HeadingCapitalization::from_config_struct(config);
3236
3237 let content = "## [1. To Be a Thing](https://example.com)\n";
3239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3240 let result = rule.check(&ctx).unwrap();
3241 assert!(
3242 result.is_empty(),
3243 "Should not flag '## [1. To Be a Thing](url)', got: {result:?}"
3244 );
3245
3246 let content_lower = "## [1. to be a thing](https://example.com)\n";
3248 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3249 let result2 = rule.check(&ctx2).unwrap();
3250 assert!(!result2.is_empty(), "Should flag '## [1. to be a thing](url)'");
3251 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3252 assert!(
3253 fix.replacement.contains("1. To Be a Thing"),
3254 "Fix should capitalize 'To' in link text, got: {:?}",
3255 fix.replacement
3256 );
3257 }
3258
3259 #[test]
3264 fn test_is_numeric_ordinal_recognises_canonical_forms() {
3265 for word in &[
3266 "1st", "2nd", "3rd", "4th", "5th", "11th", "21st", "22nd", "23rd", "100th", "1ST", "5Th", "21St", "21sT",
3267 ] {
3268 assert!(
3269 MD063HeadingCapitalization::is_numeric_ordinal(word),
3270 "expected `{word}` to be detected as a numeric ordinal"
3271 );
3272 }
3273 }
3274
3275 #[test]
3276 fn test_is_numeric_ordinal_rejects_non_ordinals() {
3277 for word in &[
3282 "first", "1stop", "ist", "5", "th", "abc", "4G", "4K", "30s", "100k", "5x", "1.5", "iPhone6S",
3283 ] {
3284 assert!(
3285 !MD063HeadingCapitalization::is_numeric_ordinal(word),
3286 "expected `{word}` NOT to be detected as a numeric ordinal"
3287 );
3288 }
3289 }
3290
3291 #[test]
3292 fn test_is_numeric_ordinal_strips_trailing_punctuation() {
3293 for word in &["5th.", "1st,", "21st!", "3rd:", "4th)", "5th's"] {
3294 assert!(
3295 MD063HeadingCapitalization::is_numeric_ordinal(word),
3296 "expected `{word}` to be detected as a numeric ordinal (with punctuation)"
3297 );
3298 }
3299 }
3300
3301 #[test]
3302 fn test_is_numeric_ordinal_ignores_wrapping_punctuation() {
3303 for word in &[
3308 "(2nd", "[2nd", "\"2nd", "'2nd", "*2nd", "(21st)", "\"3rd\"", "**5th**", "_1st_",
3309 ] {
3310 assert!(
3311 MD063HeadingCapitalization::is_numeric_ordinal(word),
3312 "expected `{word}` to be detected as a numeric ordinal"
3313 );
3314 }
3315
3316 for word in &["2-nd", "2 nd", "(", "\"\"", "()", "(nd", "(2"] {
3320 assert!(
3321 !MD063HeadingCapitalization::is_numeric_ordinal(word),
3322 "expected `{word}` NOT to be detected as a numeric ordinal"
3323 );
3324 }
3325 }
3326
3327 #[test]
3328 fn test_ordinal_wrapped_in_punctuation_survives_a_fix() {
3329 for (style, content) in [
3332 (HeadingCapStyle::SentenceCase, "# The second (2nd) attempt\n"),
3333 (HeadingCapStyle::SentenceCase, "# Ranked \"3rd\" overall\n"),
3334 (HeadingCapStyle::SentenceCase, "# Plain 2nd place\n"),
3335 (HeadingCapStyle::TitleCase, "# The Second (2nd) Attempt\n"),
3336 (HeadingCapStyle::TitleCase, "# Ranked \"3rd\" Overall\n"),
3337 ] {
3338 let rule = create_rule_with_style(style);
3339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3340 let result = rule.check(&ctx).unwrap();
3341 assert!(
3342 result.is_empty(),
3343 "{style:?} should not flag {content:?}, got: {result:?}"
3344 );
3345 assert_eq!(
3346 rule.fix(&ctx).unwrap(),
3347 content,
3348 "{style:?} must leave {content:?} alone"
3349 );
3350 }
3351
3352 for (style, content, expected) in [
3356 (
3357 HeadingCapStyle::SentenceCase,
3358 "# The Second (2nd) Attempt\n",
3359 "# The second (2nd) attempt\n",
3360 ),
3361 (
3362 HeadingCapStyle::TitleCase,
3363 "# the second (2nd) attempt\n",
3364 "# The Second (2nd) Attempt\n",
3365 ),
3366 ] {
3367 let rule = create_rule_with_style(style);
3368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3369 assert!(
3370 !rule.check(&ctx).unwrap().is_empty(),
3371 "{style:?} should flag {content:?}"
3372 );
3373 let fixed = rule.fix(&ctx).unwrap();
3374 assert_eq!(fixed, expected, "{style:?} fix of {content:?}");
3375
3376 let refixed = rule
3377 .fix(&LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None))
3378 .unwrap();
3379 assert_eq!(refixed, expected, "{style:?} second pass over {fixed:?}");
3380 }
3381 }
3382
3383 #[test]
3384 fn test_wrapped_ordinal_corrupted_by_the_old_fix_is_repaired() {
3385 for (style, content, expected) in [
3389 (
3390 HeadingCapStyle::SentenceCase,
3391 "# The second (2Nd) attempt\n",
3392 "# The second (2nd) attempt\n",
3393 ),
3394 (
3395 HeadingCapStyle::TitleCase,
3396 "# The Second (2Nd) Attempt\n",
3397 "# The Second (2nd) Attempt\n",
3398 ),
3399 (
3400 HeadingCapStyle::SentenceCase,
3401 "# Ranked \"3Rd\" overall\n",
3402 "# Ranked \"3rd\" overall\n",
3403 ),
3404 ] {
3405 let rule = create_rule_with_style(style);
3406 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3407 assert!(
3408 !rule.check(&ctx).unwrap().is_empty(),
3409 "{style:?} should flag {content:?}"
3410 );
3411 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{style:?} fix of {content:?}");
3412 }
3413 }
3414
3415 #[test]
3416 fn test_title_case_ordinal_first_word_not_flagged() {
3417 let rule = create_rule();
3418 for content in &[
3419 "# 1st Place\n",
3420 "# 2nd Edition\n",
3421 "# 3rd Time\n",
3422 "# 5th Avenue\n",
3423 "# 21st Century Skills\n",
3424 "# 100th Customer\n",
3425 ] {
3426 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3427 let result = rule.check(&ctx).unwrap();
3428 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3429 }
3430 }
3431
3432 #[test]
3433 fn test_title_case_ordinal_mid_heading_not_flagged() {
3434 let rule = create_rule();
3435 for content in &[
3436 "# May 3rd Notes\n",
3437 "# Top 100th Customer\n",
3438 "# Notes for the 5th of May\n",
3439 ] {
3440 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3441 let result = rule.check(&ctx).unwrap();
3442 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3443 }
3444 }
3445
3446 #[test]
3447 fn test_title_case_ordinal_corrupted_form_is_fixed() {
3448 let rule = create_rule();
3451 for (input, expected) in &[
3452 ("# 1St Place\n", "1st Place"),
3453 ("# 5Th Avenue\n", "5th Avenue"),
3454 ("# 21St Century Skills\n", "21st Century Skills"),
3455 ("# May 3Rd Notes\n", "May 3rd Notes"),
3456 ("# 22Nd Edition\n", "22nd Edition"),
3457 ] {
3458 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
3459 let result = rule.check(&ctx).unwrap();
3460 assert!(!result.is_empty(), "Should flag {input:?}");
3461 let fix = result[0].fix.as_ref().expect("should have a fix");
3462 assert!(
3463 fix.replacement.contains(expected),
3464 "Fix for {input:?} should contain {expected:?}, got: {:?}",
3465 fix.replacement
3466 );
3467 }
3468 }
3469
3470 #[test]
3471 fn test_title_case_ordinal_lowercase_other_words_capitalised() {
3472 let rule = create_rule();
3474 let content = "# 5th avenue\n";
3475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3476 let result = rule.check(&ctx).unwrap();
3477 assert_eq!(result.len(), 1);
3478 let fix = result[0].fix.as_ref().expect("should have a fix");
3479 assert!(
3480 fix.replacement.contains("5th Avenue"),
3481 "Fix should produce '5th Avenue', got: {:?}",
3482 fix.replacement
3483 );
3484 }
3485
3486 #[test]
3487 fn test_title_case_ordinal_with_trailing_punctuation() {
3488 let rule = create_rule();
3489 let content = "# Released on the 5th.\n";
3490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3491 let result = rule.check(&ctx).unwrap();
3492 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3493 }
3494
3495 #[test]
3496 fn test_title_case_ordinal_hyphenated() {
3497 let rule = create_rule();
3498 for content in &["# 21st-Century Skills\n", "# A 19th-Century Novel\n"] {
3499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3500 let result = rule.check(&ctx).unwrap();
3501 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3502 }
3503 }
3504
3505 #[test]
3506 fn test_sentence_case_ordinal_corrupted_form_is_fixed() {
3507 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3508 let content = "# 5Th avenue\n";
3509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3510 let result = rule.check(&ctx).unwrap();
3511 assert_eq!(result.len(), 1);
3512 let fix = result[0].fix.as_ref().expect("should have a fix");
3513 assert!(
3514 fix.replacement.contains("5th avenue"),
3515 "Fix should produce '5th avenue', got: {:?}",
3516 fix.replacement
3517 );
3518 }
3519
3520 #[test]
3521 fn test_title_case_digit_acronym_unchanged() {
3522 let rule = create_rule();
3525 for content in &["# 4G Networks\n", "# 4K Streaming\n"] {
3526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3527 let result = rule.check(&ctx).unwrap();
3528 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3529 }
3530 }
3531
3532 fn restart_rule(boundaries: &[&str]) -> MD063HeadingCapitalization {
3535 let config = MD063Config {
3536 enabled: true,
3537 style: HeadingCapStyle::SentenceCase,
3538 sentence_case_restart_after: boundaries.iter().copied().map(String::from).collect(),
3539 ..Default::default()
3540 };
3541 MD063HeadingCapitalization::from_config_struct(config)
3542 }
3543
3544 fn suggested(rule: &MD063HeadingCapitalization, content: &str) -> Option<String> {
3547 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3548 let warnings = rule.check(&ctx).unwrap();
3549 let fixed = rule.fix(&ctx).unwrap();
3550 assert_eq!(
3551 warnings.is_empty(),
3552 fixed == content,
3553 "a warning and a rewrite must agree for {content:?}"
3554 );
3555 (!warnings.is_empty()).then(|| fixed.trim_start_matches('#').trim().to_string())
3556 }
3557
3558 #[test]
3559 fn test_restart_after_capitalizes_the_word_following_a_boundary() {
3560 let rule = restart_rule(&[":"]);
3561 assert_eq!(
3562 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3563 Some("Requirement 1: Struct to logger slice conversion")
3564 );
3565 }
3566
3567 #[test]
3568 fn test_restart_after_defaults_to_no_boundaries() {
3569 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3571 assert_eq!(
3572 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3573 Some("Requirement 1: struct to logger slice conversion")
3574 );
3575 }
3576
3577 #[test]
3578 fn test_restart_after_only_honors_configured_punctuation() {
3579 let rule = restart_rule(&[":"]);
3581 assert_eq!(
3582 suggested(&rule, "# Design - Data Model Overview\n").as_deref(),
3583 Some("Design - data model overview")
3584 );
3585 assert_eq!(
3586 suggested(&rule, "# Setup; Then Run\n").as_deref(),
3587 Some("Setup; then run")
3588 );
3589
3590 let rule = restart_rule(&[";", "\u{2014}"]);
3591 assert_eq!(
3592 suggested(&rule, "# Setup; Then Run\n").as_deref(),
3593 Some("Setup; Then run")
3594 );
3595 assert_eq!(
3596 suggested(&rule, "# Part One \u{2014} The Big Idea\n").as_deref(),
3597 Some("Part one \u{2014} The big idea")
3598 );
3599 }
3600
3601 #[test]
3602 fn test_restart_after_matches_only_at_the_end_of_a_word() {
3603 let rule = restart_rule(&["-", ":"]);
3606 assert_eq!(
3607 suggested(&rule, "# Ports: Well-Known Ports Explained\n").as_deref(),
3608 Some("Ports: Well-Known ports explained")
3609 );
3610 assert_eq!(
3611 suggested(&rule, "# See https://example.com/A/B For Details\n").as_deref(),
3612 Some("See https://example.com/A/B for details")
3613 );
3614 }
3615
3616 #[test]
3617 fn test_restart_after_a_trailing_boundary_is_a_no_op() {
3618 let rule = restart_rule(&[":"]);
3619 assert_eq!(suggested(&rule, "# Setup:\n"), None);
3620 }
3621
3622 #[test]
3623 fn test_restart_after_does_not_override_preserved_words() {
3624 let rule = restart_rule(&[":"]);
3627 assert_eq!(
3628 suggested(&rule, "# Devices: iPhone And Android\n").as_deref(),
3629 Some("Devices: iPhone and android")
3630 );
3631
3632 let config = MD063Config {
3633 enabled: true,
3634 style: HeadingCapStyle::SentenceCase,
3635 sentence_case_restart_after: vec![":".to_string()],
3636 ignore_words: vec!["kubectl".to_string()],
3637 preserve_cased_words: false,
3638 ..Default::default()
3639 };
3640 let rule = MD063HeadingCapitalization::from_config_struct(config);
3641 assert_eq!(
3642 suggested(&rule, "# Tools: kubectl And Helm\n").as_deref(),
3643 Some("Tools: kubectl and helm")
3644 );
3645 }
3646
3647 #[test]
3648 fn test_restart_after_keeps_md044_canonical_forms() {
3649 let config = MD063Config {
3650 enabled: true,
3651 style: HeadingCapStyle::SentenceCase,
3652 sentence_case_restart_after: vec![":".to_string()],
3653 ..Default::default()
3654 };
3655 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3656 rule.proper_names = vec!["GitHub".to_string()];
3657
3658 assert_eq!(
3660 suggested(&rule, "# Docs: github Actions Guide\n").as_deref(),
3661 Some("Docs: GitHub actions guide")
3662 );
3663 assert_eq!(suggested(&rule, "# Docs: GitHub actions guide\n"), None);
3664 }
3665
3666 #[test]
3667 fn test_restart_after_carries_across_segments() {
3668 let rule = restart_rule(&[":"]);
3671 assert_eq!(
3672 suggested(
3673 &rule,
3674 "# Overview: [Some Link Here](https://example.com) Trailing Words\n"
3675 )
3676 .as_deref(),
3677 Some("Overview: [Some link here](https://example.com) trailing words")
3678 );
3679 assert_eq!(
3680 suggested(&rule, "# Overview: `code` Then More Words\n").as_deref(),
3681 Some("Overview: `code` then more words")
3682 );
3683 }
3684
3685 #[test]
3686 fn test_restart_after_ends_a_sentence_at_the_end_of_link_text() {
3687 let rule = restart_rule(&[":"]);
3691 assert_eq!(
3692 suggested(&rule, "# Topic [See:](https://example.com) More Words\n").as_deref(),
3693 Some("Topic [see:](https://example.com) More words")
3694 );
3695
3696 assert_eq!(
3698 suggested(&rule, "# Topic [See](https://example.com) More Words\n").as_deref(),
3699 Some("Topic [see](https://example.com) more words")
3700 );
3701 }
3702
3703 #[test]
3704 fn test_restart_after_ignores_boundaries_inside_opaque_segments() {
3705 let rule = restart_rule(&[":"]);
3708 for content in [
3709 "# Topic `see:` More Words\n",
3710 "# Topic  More Words\n",
3711 "# Topic <span title=\"x:\">y</span> More Words\n",
3712 ] {
3713 let fixed = suggested(&rule, content).expect("heading should be rewritten");
3714 assert!(
3715 fixed.ends_with("more words"),
3716 "opaque segment restarted the sentence in {content:?}: {fixed}"
3717 );
3718 }
3719 }
3720
3721 #[test]
3722 fn test_restart_after_treats_a_leading_link_as_sentence_initial() {
3723 for rule in [restart_rule(&[]), restart_rule(&[":"])] {
3726 assert_eq!(
3727 suggested(&rule, "# [Some Link Here](https://example.com) Trailing Words\n").as_deref(),
3728 Some("[Some link here](https://example.com) trailing words")
3729 );
3730 }
3731 }
3732
3733 #[test]
3734 fn test_restart_after_fix_is_idempotent() {
3735 let rule = restart_rule(&[":", ";", "-", "\u{2014}"]);
3736 for content in [
3737 "# Requirement 1: Struct to Logger Slice Conversion\n",
3738 "# Ports: Well-Known Ports Explained\n",
3739 "# Devices: iPhone And Android\n",
3740 "# Overview: [Some Link Here](https://example.com) Trailing Words\n",
3741 "# Setup:\n",
3742 ] {
3743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3744 let once = rule.fix(&ctx).unwrap();
3745 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
3746 assert_eq!(rule.fix(&ctx).unwrap(), once, "fix is not idempotent for {content:?}");
3747 }
3748 }
3749
3750 #[test]
3751 fn test_restart_after_ignores_empty_boundary_entries() {
3752 let rule = restart_rule(&[""]);
3754 assert_eq!(
3755 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3756 Some("Requirement 1: struct to logger slice conversion")
3757 );
3758 }
3759
3760 const STYLES: [HeadingCapStyle; 3] = [
3763 HeadingCapStyle::TitleCase,
3764 HeadingCapStyle::SentenceCase,
3765 HeadingCapStyle::AllCaps,
3766 ];
3767
3768 const GHERKIN_STRUCTURES: [(&str, &str, &str, &str); 6] = [
3771 (
3772 "# Feature: the system under test",
3773 "# Feature: The System Under Test",
3774 "# Feature: The system under test",
3775 "# Feature: THE SYSTEM UNDER TEST",
3776 ),
3777 (
3778 "## Background: a shared setup",
3779 "## Background: A Shared Setup",
3780 "## Background: A shared setup",
3781 "## Background: A SHARED SETUP",
3782 ),
3783 (
3784 "## Rule: money is never lost",
3785 "## Rule: Money Is Never Lost",
3786 "## Rule: Money is never lost",
3787 "## Rule: MONEY IS NEVER LOST",
3788 ),
3789 (
3790 "### Scenario: add two numbers",
3791 "### Scenario: Add Two Numbers",
3792 "### Scenario: Add two numbers",
3793 "### Scenario: ADD TWO NUMBERS",
3794 ),
3795 (
3796 "### Scenario Outline: add two numbers",
3797 "### Scenario Outline: Add Two Numbers",
3798 "### Scenario Outline: Add two numbers",
3799 "### Scenario Outline: ADD TWO NUMBERS",
3800 ),
3801 (
3802 "#### Examples: happy path",
3803 "#### Examples: Happy Path",
3804 "#### Examples: Happy path",
3805 "#### Examples: HAPPY PATH",
3806 ),
3807 ];
3808
3809 fn recased(style: HeadingCapStyle, heading: &str, flavor: crate::config::MarkdownFlavor) -> String {
3811 let rule = create_rule_with_style(style);
3812 let content = format!("{heading}\n");
3813 let ctx = LintContext::new(&content, flavor, None);
3814 let warnings = rule.check(&ctx).unwrap();
3815 let fixed = rule.fix(&ctx).unwrap();
3816 assert_eq!(
3817 warnings.is_empty(),
3818 fixed == content,
3819 "a warning and a rewrite must agree for {content:?} under {flavor:?}"
3820 );
3821 fixed.trim_end().to_string()
3822 }
3823
3824 #[test]
3825 fn test_mdg_keeps_the_keyword_of_every_structure() {
3826 for (heading, ..) in GHERKIN_STRUCTURES {
3829 let keyword = &heading[..=heading.find(':').unwrap()];
3830 for style in STYLES {
3831 let fixed = recased(style, heading, crate::config::MarkdownFlavor::MDG);
3832 assert!(
3833 fixed.starts_with(keyword),
3834 "{style:?} lost the keyword of {heading:?}: {fixed}"
3835 );
3836 }
3837 }
3838 }
3839
3840 #[test]
3841 fn test_mdg_recases_only_the_name_of_a_structure() {
3842 for (heading, title, sentence, caps) in GHERKIN_STRUCTURES {
3843 let mdg = crate::config::MarkdownFlavor::MDG;
3844 assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), title);
3845 assert_eq!(recased(HeadingCapStyle::SentenceCase, heading, mdg), sentence);
3846 assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), caps);
3847 }
3848 }
3849
3850 #[test]
3851 fn test_standard_flavor_recases_a_keyword_like_any_other_word() {
3852 let standard = crate::config::MarkdownFlavor::Standard;
3854 assert_eq!(
3855 recased(HeadingCapStyle::TitleCase, "# Feature: the system under test", standard),
3856 "# Feature: the System Under Test"
3857 );
3858 assert_eq!(
3859 recased(
3860 HeadingCapStyle::SentenceCase,
3861 "### Scenario Outline: add two numbers",
3862 standard
3863 ),
3864 "### Scenario outline: add two numbers"
3865 );
3866 assert_eq!(
3867 recased(HeadingCapStyle::AllCaps, "# Feature: the system under test", standard),
3868 "# FEATURE: THE SYSTEM UNDER TEST"
3869 );
3870 }
3871
3872 #[test]
3873 fn test_mdg_leaves_a_heading_without_a_colon_to_the_normal_rule() {
3874 for heading in ["## notes about the system", "## Notes", "# THE SYSTEM"] {
3875 for style in STYLES {
3876 assert_eq!(
3877 recased(style, heading, crate::config::MarkdownFlavor::MDG),
3878 recased(style, heading, crate::config::MarkdownFlavor::Standard),
3879 "{style:?} treated {heading:?} as a Gherkin structure"
3880 );
3881 }
3882 }
3883 }
3884
3885 #[test]
3886 fn test_mdg_splits_at_the_first_colon_only() {
3887 let mdg = crate::config::MarkdownFlavor::MDG;
3889 let heading = "## Scenario: ratio: two to one";
3890 assert_eq!(
3891 recased(HeadingCapStyle::TitleCase, heading, mdg),
3892 "## Scenario: Ratio: Two to One"
3893 );
3894 assert_eq!(
3895 recased(HeadingCapStyle::SentenceCase, heading, mdg),
3896 "## Scenario: Ratio: two to one"
3897 );
3898 assert_eq!(
3899 recased(HeadingCapStyle::AllCaps, heading, mdg),
3900 "## Scenario: RATIO: TWO TO ONE"
3901 );
3902 }
3903
3904 #[test]
3905 fn test_mdg_leaves_a_colon_behind_a_backtick_to_the_normal_rule() {
3906 for heading in [
3910 "# See `x: y` Notes",
3911 "# `a: b`",
3912 "# `code` Feature: a name",
3913 "# `x: y` Feature: a name",
3914 ] {
3915 for style in STYLES {
3916 assert_eq!(
3917 recased(style, heading, crate::config::MarkdownFlavor::MDG),
3918 recased(style, heading, crate::config::MarkdownFlavor::Standard),
3919 "{style:?} split {heading:?} at a colon inside a code span"
3920 );
3921 }
3922 }
3923 }
3924
3925 #[test]
3926 fn test_mdg_splits_at_a_keyword_colon_that_precedes_a_code_span() {
3927 let mdg = crate::config::MarkdownFlavor::MDG;
3929 let heading = "# Scenario: use `a: b` here";
3930 assert_eq!(
3931 recased(HeadingCapStyle::TitleCase, heading, mdg),
3932 "# Scenario: Use `a: b` Here"
3933 );
3934 assert_eq!(
3935 recased(HeadingCapStyle::SentenceCase, heading, mdg),
3936 "# Scenario: Use `a: b` here"
3937 );
3938 assert_eq!(
3939 recased(HeadingCapStyle::AllCaps, heading, mdg),
3940 "# Scenario: USE `a: b` HERE"
3941 );
3942 }
3943
3944 #[test]
3945 fn test_mdg_splits_at_a_keyword_colon_before_an_unbalanced_backtick() {
3946 let mdg = crate::config::MarkdownFlavor::MDG;
3949 let heading = "# Scenario: a ` b";
3950 assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), "# Scenario: A ` B");
3951 assert_eq!(
3952 recased(HeadingCapStyle::SentenceCase, heading, mdg),
3953 "# Scenario: A ` b"
3954 );
3955 assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), "# Scenario: A ` B");
3956 }
3957
3958 #[test]
3959 fn test_mdg_keeps_a_keyword_with_nothing_left_to_recase() {
3960 for style in STYLES {
3961 assert_eq!(
3962 recased(style, "# Feature:", crate::config::MarkdownFlavor::MDG),
3963 "# Feature:"
3964 );
3965 }
3966 }
3967
3968 #[test]
3969 fn test_mdg_keeps_a_custom_id_after_the_name() {
3970 assert_eq!(
3971 recased(
3972 HeadingCapStyle::TitleCase,
3973 "# Feature: the system {#overview}",
3974 crate::config::MarkdownFlavor::MDG
3975 ),
3976 "# Feature: The System {#overview}"
3977 );
3978 }
3979
3980 #[test]
3981 fn test_mdg_fix_is_idempotent() {
3982 for (heading, ..) in GHERKIN_STRUCTURES {
3983 for style in STYLES {
3984 let rule = create_rule_with_style(style);
3985 let content = format!("{heading}\n");
3986 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
3987 let once = rule.fix(&ctx).unwrap();
3988 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::MDG, None);
3989 assert_eq!(
3990 rule.fix(&ctx).unwrap(),
3991 once,
3992 "fix is not idempotent for {heading:?} ({style:?})"
3993 );
3994 }
3995 }
3996 }
3997}