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 bytes = word.as_bytes();
347
348 let alpha_start = match bytes.iter().position(|&b| !b.is_ascii_digit()) {
350 Some(pos) if pos > 0 => pos,
351 _ => return false,
352 };
353
354 let alpha_end = bytes[alpha_start..]
356 .iter()
357 .position(|b| !b.is_ascii_alphabetic())
358 .map_or(bytes.len(), |p| alpha_start + p);
359
360 let suffix = &word[alpha_start..alpha_end];
361 matches!(suffix.to_ascii_lowercase().as_str(), "st" | "nd" | "rd" | "th")
362 }
363
364 fn is_caret_notation(&self, word: &str) -> bool {
366 let chars: Vec<char> = word.chars().collect();
367 if chars.len() >= 2 && chars[0] == '^' {
369 let second = chars[1];
370 if second.is_ascii_uppercase() || "@[\\]^_".contains(second) {
372 return true;
373 }
374 }
375 false
376 }
377
378 fn is_lowercase_word(&self, word: &str) -> bool {
380 self.lowercase_set.contains(&word.to_lowercase())
381 }
382
383 fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
385 if word.is_empty() {
386 return word.to_string();
387 }
388
389 if self.should_preserve_word(word) {
391 return word.to_string();
392 }
393
394 if is_first || is_last {
396 return self.capitalize_first(word);
397 }
398
399 if self.is_lowercase_word(word) {
401 return Self::lowercase_preserving_composition(word);
402 }
403
404 self.capitalize_first(word)
406 }
407
408 fn apply_canonical_form_to_word(word: &str, canonical: &str) -> String {
411 let canonical_lower = canonical.to_lowercase();
412 if canonical_lower.is_empty() {
413 return canonical.to_string();
414 }
415
416 if let Some(end_pos) = Self::match_case_insensitive_at(word, 0, &canonical_lower) {
417 let mut out = String::with_capacity(canonical.len() + word.len().saturating_sub(end_pos));
418 out.push_str(canonical);
419 out.push_str(&word[end_pos..]);
420 out
421 } else {
422 canonical.to_string()
423 }
424 }
425
426 fn capitalize_first(&self, word: &str) -> String {
428 if word.is_empty() {
429 return String::new();
430 }
431
432 let first_alpha_pos = word.find(|c: char| c.is_alphabetic());
434 let Some(pos) = first_alpha_pos else {
435 return word.to_string();
436 };
437
438 let prefix = &word[..pos];
439 let suffix = &word[pos..];
440
441 if Self::is_numeric_ordinal(word) {
444 let suffix_lower = Self::lowercase_preserving_composition(suffix);
445 return format!("{prefix}{suffix_lower}");
446 }
447
448 let mut chars = suffix.chars();
449 let first = chars.next().unwrap();
450 let first_upper = Self::uppercase_preserving_composition(&first.to_string());
453 let rest: String = chars.collect();
454 let rest_lower = Self::lowercase_preserving_composition(&rest);
455 format!("{prefix}{first_upper}{rest_lower}")
456 }
457
458 fn lowercase_preserving_composition(s: &str) -> String {
461 let mut result = String::with_capacity(s.len());
462 for c in s.chars() {
463 let lower: String = c.to_lowercase().collect();
464 if lower.chars().count() == 1 {
465 result.push_str(&lower);
466 } else {
467 result.push(c);
469 }
470 }
471 result
472 }
473
474 fn sentence_case_first_person_pronouns(word: &str) -> Option<String> {
484 fn is_emphasis_marker(c: char) -> bool {
485 matches!(c, '*' | '_' | '~')
486 }
487
488 fn is_word_connector(c: char) -> bool {
492 matches!(c, '/' | '\\' | '-' | '.' | '+' | '&')
493 }
494
495 fn is_word_boundary(c: char) -> bool {
496 !c.is_alphanumeric() && !is_word_connector(c)
497 }
498
499 fn is_pronoun_at(word: &str, pos: usize) -> bool {
500 let left = word[..pos].trim_end_matches(is_emphasis_marker);
501 if !left.chars().next_back().is_none_or(is_word_boundary) {
502 return false;
503 }
504
505 let after_i = word[pos + 1..].trim_start_matches(is_emphasis_marker);
506 let Some(apostrophe) = after_i.chars().next() else {
507 return true;
508 };
509 if !matches!(apostrophe, '\'' | '’') {
510 return is_word_boundary(apostrophe);
511 }
512
513 let after_apostrophe = after_i[apostrophe.len_utf8()..].trim_start_matches(is_emphasis_marker);
514 let suffix_end = after_apostrophe
515 .find(|c: char| !c.is_ascii_alphabetic())
516 .unwrap_or(after_apostrophe.len());
517 let suffix = &after_apostrophe[..suffix_end];
518 if suffix.is_empty() {
519 return after_apostrophe.chars().next().is_none_or(is_word_boundary);
521 }
522 if !matches!(suffix.to_ascii_lowercase().as_str(), "d" | "ll" | "m" | "ve") {
523 return false;
524 }
525
526 let after_suffix = after_apostrophe[suffix_end..].trim_start_matches(is_emphasis_marker);
527 after_suffix.chars().next().is_none_or(is_word_boundary)
528 }
529
530 let pronoun_positions: Vec<usize> = word
531 .char_indices()
532 .filter_map(|(pos, c)| (c == 'I' && is_pronoun_at(word, pos)).then_some(pos))
533 .collect();
534 if pronoun_positions.is_empty() {
535 return None;
536 }
537
538 let mut result = String::with_capacity(word.len());
539 let mut copied_through = 0;
540 for pos in pronoun_positions {
541 result.push_str(&Self::lowercase_preserving_composition(&word[copied_through..pos]));
542 result.push('I');
543 copied_through = pos + 1;
544 }
545 result.push_str(&Self::lowercase_preserving_composition(&word[copied_through..]));
546 Some(result)
547 }
548
549 fn uppercase_preserving_composition(s: &str) -> String {
554 let mut result = String::with_capacity(s.len());
555 for c in s.chars() {
556 let upper: String = c.to_uppercase().collect();
557 if upper.chars().count() == 1 {
558 result.push_str(&upper);
559 } else {
560 result.push(c);
562 }
563 }
564 result
565 }
566
567 fn apply_title_case(&self, text: &str) -> String {
571 let canonical_forms = self.proper_name_canonical_forms(text);
572
573 let original_words: Vec<&str> = text.split_whitespace().collect();
574 let total_words = original_words.len();
575
576 let mut word_positions: Vec<usize> = Vec::with_capacity(original_words.len());
579 let mut pos = 0;
580 for word in &original_words {
581 if let Some(rel) = text[pos..].find(word) {
582 word_positions.push(pos + rel);
583 pos = pos + rel + word.len();
584 } else {
585 word_positions.push(usize::MAX);
586 }
587 }
588
589 let result_words: Vec<String> = original_words
590 .iter()
591 .enumerate()
592 .map(|(i, word)| {
593 let after_period = i > 0 && original_words[i - 1].ends_with('.');
594 let is_first = i == 0 || after_period;
595 let is_last = i == total_words - 1;
596
597 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
599 return Self::apply_canonical_form_to_word(word, canonical);
600 }
601
602 if self.should_preserve_word(word) {
604 return (*word).to_string();
605 }
606
607 if word.contains('-') {
609 return self.handle_hyphenated_word(word, is_first, is_last);
610 }
611
612 self.title_case_word(word, is_first, is_last)
613 })
614 .collect();
615
616 result_words.join(" ")
617 }
618
619 fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
621 let parts: Vec<&str> = word.split('-').collect();
622 let total_parts = parts.len();
623
624 let result_parts: Vec<String> = parts
625 .iter()
626 .enumerate()
627 .map(|(i, part)| {
628 let part_is_first = is_first && i == 0;
630 let part_is_last = is_last && i == total_parts - 1;
631 self.title_case_word(part, part_is_first, part_is_last)
632 })
633 .collect();
634
635 result_parts.join("-")
636 }
637
638 fn ends_sentence(&self, word: &str) -> bool {
644 self.config
645 .sentence_case_restart_after
646 .iter()
647 .any(|boundary| !boundary.is_empty() && word.ends_with(boundary.as_str()))
648 }
649
650 fn apply_sentence_case_from(&self, text: &str, starts_sentence: bool) -> String {
654 if text.is_empty() {
655 return text.to_string();
656 }
657
658 let canonical_forms = self.proper_name_canonical_forms(text);
659 let mut result = String::new();
660 let mut current_pos = 0;
661 let mut at_sentence_start = starts_sentence;
662
663 for word in text.split_whitespace() {
665 if let Some(pos) = text[current_pos..].find(word) {
666 let abs_pos = current_pos + pos;
667
668 result.push_str(&text[current_pos..abs_pos]);
670
671 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
674 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
675 } else if self.is_ignored_word(word) {
676 result.push_str(word);
678 } else if let Some(pronoun) = Self::sentence_case_first_person_pronouns(word) {
679 result.push_str(&pronoun);
682 } else if at_sentence_start {
683 if self.should_preserve_word(word) {
685 result.push_str(word);
687 } else {
688 let mut chars = word.chars();
690 if let Some(first) = chars.next() {
691 result.push_str(&Self::uppercase_preserving_composition(&first.to_string()));
692 let rest: String = chars.collect();
693 result.push_str(&Self::lowercase_preserving_composition(&rest));
694 }
695 }
696 } else {
697 if self.should_preserve_word(word) {
699 result.push_str(word);
700 } else {
701 result.push_str(&Self::lowercase_preserving_composition(word));
702 }
703 }
704
705 at_sentence_start = self.ends_sentence(word);
706 current_pos = abs_pos + word.len();
707 }
708 }
709
710 if current_pos < text.len() {
712 result.push_str(&text[current_pos..]);
713 }
714
715 result
716 }
717
718 fn apply_all_caps(&self, text: &str) -> String {
720 if text.is_empty() {
721 return text.to_string();
722 }
723
724 let canonical_forms = self.proper_name_canonical_forms(text);
725 let mut result = String::new();
726 let mut current_pos = 0;
727
728 for word in text.split_whitespace() {
730 if let Some(pos) = text[current_pos..].find(word) {
731 let abs_pos = current_pos + pos;
732
733 result.push_str(&text[current_pos..abs_pos]);
735
736 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
739 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
740 } else if self.should_preserve_word(word) {
741 result.push_str(word);
742 } else {
743 result.push_str(&Self::uppercase_preserving_composition(word));
744 }
745
746 current_pos = abs_pos + word.len();
747 }
748 }
749
750 if current_pos < text.len() {
752 result.push_str(&text[current_pos..]);
753 }
754
755 result
756 }
757
758 fn parse_segments(&self, text: &str) -> Vec<HeadingSegment> {
760 let mut segments = Vec::new();
761 let mut last_end = 0;
762
763 let mut special_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
765
766 for mat in INLINE_CODE_REGEX.find_iter(text) {
768 special_regions.push((mat.start(), mat.end(), HeadingSegment::Code(mat.as_str().to_string())));
769 }
770
771 for caps in LINK_REGEX.captures_iter(text) {
773 let full_match = caps.get(0).unwrap();
774
775 if full_match.start() >= 1 && text.as_bytes()[full_match.start() - 1] == b'!' {
779 let region_start = full_match.start() - 1;
780 special_regions.push((
781 region_start,
782 full_match.end(),
783 HeadingSegment::Image(text[region_start..full_match.end()].to_string()),
784 ));
785 continue;
786 }
787
788 let text_match = caps.get(1).or_else(|| caps.get(2));
789
790 if let Some(text_m) = text_match {
791 special_regions.push((
792 full_match.start(),
793 full_match.end(),
794 HeadingSegment::Link {
795 full: full_match.as_str().to_string(),
796 text_start: text_m.start() - full_match.start(),
797 text_end: text_m.end() - full_match.start(),
798 },
799 ));
800 }
801 }
802
803 let code_ranges: Vec<(usize, usize)> = special_regions
806 .iter()
807 .filter(|(_, _, segment)| matches!(segment, HeadingSegment::Code(_)))
808 .map(|(start, end, _)| (*start, *end))
809 .collect();
810 for (start, end) in Self::html_regions(text, &code_ranges) {
811 special_regions.push((start, end, HeadingSegment::Html(text[start..end].to_string())));
812 }
813
814 special_regions.sort_by_key(|(start, _, _)| *start);
816
817 let mut filtered_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
820 for region in special_regions {
821 let overlaps = filtered_regions.iter().any(|(s, e, _)| region.0 < *e && region.1 > *s);
822 if !overlaps {
823 filtered_regions.push(region);
824 }
825 }
826
827 for (start, end, segment) in filtered_regions {
829 if start > last_end {
831 let text_segment = &text[last_end..start];
832 if !text_segment.is_empty() {
833 segments.push(HeadingSegment::Text(text_segment.to_string()));
834 }
835 }
836 segments.push(segment);
837 last_end = end;
838 }
839
840 if last_end < text.len() {
842 let remaining = &text[last_end..];
843 if !remaining.is_empty() {
844 segments.push(HeadingSegment::Text(remaining.to_string()));
845 }
846 }
847
848 if segments.is_empty() && !text.is_empty() {
850 segments.push(HeadingSegment::Text(text.to_string()));
851 }
852
853 segments
854 }
855
856 fn html_regions(text: &str, code_ranges: &[(usize, usize)]) -> Vec<(usize, usize)> {
870 let mut regions: Vec<(usize, usize)> = Vec::new();
871 let mut open_elements: Vec<(String, usize)> = Vec::new();
872
873 let mut pos = 0;
874 while let Some(token) = HTML_TOKEN_REGEX.captures_at(text, pos) {
875 let whole = token.get(0).unwrap();
876 if code_ranges
877 .iter()
878 .any(|&(start, end)| start <= whole.start() && whole.start() < end)
879 || is_backslash_escaped(text, whole.start())
880 {
881 pos = whole.start() + 1;
883 continue;
884 }
885 pos = whole.end();
886
887 if let Some(closing) = token.get(1) {
888 let name = closing.as_str().to_ascii_lowercase();
889 if let Some(depth) = open_elements.iter().rposition(|(open_name, _)| *open_name == name) {
890 let element_start = open_elements[depth].1;
891 open_elements.truncate(depth);
892 regions.retain(|&(start, _)| start < element_start);
893 regions.push((element_start, whole.end()));
894 continue;
895 }
896 } else if let Some(opening) = token.get(2) {
897 let name = opening.as_str().to_ascii_lowercase();
898 if !whole.as_str().ends_with("/>") && !is_void_element(&name) {
899 open_elements.push((name, whole.start()));
900 }
901 }
902
903 regions.push((whole.start(), whole.end()));
904 }
905
906 regions
907 }
908
909 fn apply_capitalization(&self, text: &str, flavor: crate::config::MarkdownFlavor) -> String {
911 let (main_text, custom_id) = if let Some(mat) = CUSTOM_ID_REGEX.find(text) {
913 (&text[..mat.start()], Some(mat.as_str()))
914 } else {
915 (text, None)
916 };
917
918 let (keyword, main_text) = if flavor == crate::config::MarkdownFlavor::MDG {
925 mdg::keyword_split(main_text).unwrap_or(("", main_text))
926 } else {
927 ("", main_text)
928 };
929
930 let segments = self.parse_segments(main_text);
932
933 let text_segments: Vec<usize> = segments
935 .iter()
936 .enumerate()
937 .filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
938 .collect();
939
940 let first_segment_starts_sentence = segments
944 .iter()
945 .find(|s| !s.renders_nothing())
946 .is_some_and(|s| matches!(s, HeadingSegment::Text(_) | HeadingSegment::Link { .. }));
947
948 let last_segment_is_text = segments
951 .iter()
952 .rev()
953 .find(|s| !s.renders_nothing())
954 .is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
955
956 let mut result_parts: Vec<String> = Vec::new();
958
959 let mut at_sentence_start = first_segment_starts_sentence;
962
963 for (i, segment) in segments.iter().enumerate() {
964 at_sentence_start = match segment {
969 HeadingSegment::Text(t) => {
970 let is_first_text = text_segments.first() == Some(&i);
971 let is_last_text = text_segments.last() == Some(&i) && last_segment_is_text;
975
976 let capitalized = match self.config.style {
977 HeadingCapStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
978 HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(t, at_sentence_start),
979 HeadingCapStyle::AllCaps => self.apply_all_caps(t),
980 };
981 let ends_sentence = self.ends_sentence(capitalized.trim_end());
982 result_parts.push(capitalized);
983 ends_sentence
984 }
985 HeadingSegment::Code(c) => {
986 result_parts.push(c.clone());
987 false
988 }
989 HeadingSegment::Link {
990 full,
991 text_start,
992 text_end,
993 } => {
994 let link_text = &full[*text_start..*text_end];
996 let capitalized_text = match self.config.style {
997 HeadingCapStyle::TitleCase => self.apply_title_case(link_text),
998 HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(link_text, at_sentence_start),
1001 HeadingCapStyle::AllCaps => self.apply_all_caps(link_text),
1002 };
1003 let ends_sentence = self.ends_sentence(capitalized_text.trim_end());
1006
1007 let mut new_link = String::new();
1008 new_link.push_str(&full[..*text_start]);
1009 new_link.push_str(&capitalized_text);
1010 new_link.push_str(&full[*text_end..]);
1011 result_parts.push(new_link);
1012 ends_sentence
1013 }
1014 HeadingSegment::Html(h) => {
1015 result_parts.push(h.clone());
1019 segment.renders_nothing() && at_sentence_start
1020 }
1021 HeadingSegment::Image(img) => {
1022 result_parts.push(img.clone());
1024 false
1025 }
1026 };
1027 }
1028
1029 let mut result = String::with_capacity(text.len());
1030 result.push_str(keyword);
1031 result.push_str(&result_parts.join(""));
1032
1033 if let Some(id) = custom_id {
1035 result.push_str(id);
1036 }
1037
1038 result
1039 }
1040
1041 fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
1043 let canonical_forms = self.proper_name_canonical_forms(text);
1044 let words: Vec<&str> = text.split_whitespace().collect();
1045 let total_words = words.len();
1046
1047 if total_words == 0 {
1048 return text.to_string();
1049 }
1050
1051 let mut word_positions: Vec<usize> = Vec::with_capacity(words.len());
1054 let mut pos = 0;
1055 for word in &words {
1056 if let Some(rel) = text[pos..].find(word) {
1057 word_positions.push(pos + rel);
1058 pos = pos + rel + word.len();
1059 } else {
1060 word_positions.push(usize::MAX);
1061 }
1062 }
1063
1064 let result_words: Vec<String> = words
1065 .iter()
1066 .enumerate()
1067 .map(|(i, word)| {
1068 let after_period = i > 0 && words[i - 1].ends_with('.');
1069 let is_first = (is_first_segment && i == 0) || after_period;
1070 let is_last = is_last_segment && i == total_words - 1;
1071
1072 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
1074 return Self::apply_canonical_form_to_word(word, canonical);
1075 }
1076
1077 if word.contains('-') {
1079 return self.handle_hyphenated_word(word, is_first, is_last);
1080 }
1081
1082 self.title_case_word(word, is_first, is_last)
1083 })
1084 .collect();
1085
1086 let mut result = String::new();
1088 let mut word_iter = result_words.iter();
1089 let mut in_word = false;
1090
1091 for c in text.chars() {
1092 if c.is_whitespace() {
1093 if in_word {
1094 in_word = false;
1095 }
1096 result.push(c);
1097 } else if !in_word {
1098 if let Some(word) = word_iter.next() {
1099 result.push_str(word);
1100 }
1101 in_word = true;
1102 }
1103 }
1104
1105 result
1106 }
1107
1108 fn fix_atx_heading(
1110 &self,
1111 _line: &str,
1112 heading: &crate::lint_context::HeadingInfo,
1113 flavor: crate::config::MarkdownFlavor,
1114 ) -> String {
1115 let indent = " ".repeat(heading.marker_column);
1117 let hashes = "#".repeat(heading.level as usize);
1118
1119 let fixed_text = self.apply_capitalization(&heading.raw_text, flavor);
1121
1122 let closing = &heading.closing_sequence;
1124 if heading.has_closing_sequence {
1125 format!("{indent}{hashes} {fixed_text} {closing}")
1126 } else {
1127 format!("{indent}{hashes} {fixed_text}")
1128 }
1129 }
1130
1131 fn fix_setext_heading(
1133 &self,
1134 line: &str,
1135 heading: &crate::lint_context::HeadingInfo,
1136 flavor: crate::config::MarkdownFlavor,
1137 ) -> String {
1138 let fixed_text = self.apply_capitalization(&heading.raw_text, flavor);
1140
1141 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
1143
1144 format!("{leading_ws}{fixed_text}")
1145 }
1146}
1147
1148impl Rule for MD063HeadingCapitalization {
1149 fn name(&self) -> &'static str {
1150 "MD063"
1151 }
1152
1153 fn description(&self) -> &'static str {
1154 "Heading capitalization"
1155 }
1156
1157 fn category(&self) -> RuleCategory {
1158 RuleCategory::Heading
1159 }
1160
1161 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1162 !ctx.likely_has_headings() || !ctx.lines.iter().any(|line| line.heading.is_some())
1163 }
1164
1165 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1166 let content = ctx.content;
1167
1168 if content.is_empty() {
1169 return Ok(Vec::new());
1170 }
1171
1172 let mut warnings = Vec::new();
1173
1174 for (line_num, line_info) in ctx.lines.iter().enumerate() {
1175 if let Some(heading) = &line_info.heading {
1176 if heading.level < self.config.min_level || heading.level > self.config.max_level {
1178 continue;
1179 }
1180
1181 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
1183 continue;
1184 }
1185
1186 if !heading.is_valid {
1188 continue;
1189 }
1190
1191 let original_text = &heading.raw_text;
1193 let fixed_text = self.apply_capitalization(original_text, ctx.flavor);
1194
1195 if original_text != &fixed_text {
1196 let line = line_info.content(ctx.content);
1197 let style_name = match self.config.style {
1198 HeadingCapStyle::TitleCase => "title case",
1199 HeadingCapStyle::SentenceCase => "sentence case",
1200 HeadingCapStyle::AllCaps => "ALL CAPS",
1201 };
1202
1203 warnings.push(LintWarning {
1204 rule_name: Some(self.name().to_string()),
1205 line: line_num + 1,
1206 column: byte_to_char_count(line, heading.content_column),
1207 end_line: line_num + 1,
1208 end_column: byte_to_char_count(line, heading.content_column) + original_text.chars().count(),
1209 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1210 severity: Severity::Warning,
1211 fix: Some(Fix::new(
1212 ctx.line_content_byte_range(line_num + 1),
1213 match heading.style {
1214 crate::lint_context::HeadingStyle::ATX => {
1215 self.fix_atx_heading(line, heading, ctx.flavor)
1216 }
1217 _ => self.fix_setext_heading(line, heading, ctx.flavor),
1218 },
1219 )),
1220 });
1221 }
1222 }
1223 }
1224
1225 Ok(warnings)
1226 }
1227
1228 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1229 let content = ctx.content;
1230
1231 if content.is_empty() {
1232 return Ok(content.to_string());
1233 }
1234
1235 let lines = ctx.raw_lines();
1236 let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1237
1238 for (line_num, line_info) in ctx.lines.iter().enumerate() {
1239 if ctx.is_rule_disabled(self.name(), line_num + 1) {
1241 continue;
1242 }
1243
1244 if let Some(heading) = &line_info.heading {
1245 if heading.level < self.config.min_level || heading.level > self.config.max_level {
1247 continue;
1248 }
1249
1250 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
1252 continue;
1253 }
1254
1255 if !heading.is_valid {
1257 continue;
1258 }
1259
1260 let original_text = &heading.raw_text;
1261 let fixed_text = self.apply_capitalization(original_text, ctx.flavor);
1262
1263 if original_text != &fixed_text {
1264 let line = line_info.content(ctx.content);
1265 fixed_lines[line_num] = match heading.style {
1266 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading, ctx.flavor),
1267 _ => self.fix_setext_heading(line, heading, ctx.flavor),
1268 };
1269 }
1270 }
1271 }
1272
1273 let mut result = String::with_capacity(content.len());
1275 for (i, line) in fixed_lines.iter().enumerate() {
1276 result.push_str(line);
1277 if i < fixed_lines.len() - 1 || content.ends_with('\n') {
1278 result.push('\n');
1279 }
1280 }
1281
1282 Ok(result)
1283 }
1284
1285 fn as_any(&self) -> &dyn std::any::Any {
1286 self
1287 }
1288
1289 crate::impl_rule_config_sections!(MD063Config);
1290
1291 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1292 where
1293 Self: Sized,
1294 {
1295 let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
1296 let md044_config =
1297 crate::rule_config_serde::load_rule_config::<crate::rules::md044_proper_names::MD044Config>(config);
1298 let mut rule = Self::from_config_struct(rule_config);
1299 rule.proper_names = md044_config.names;
1300 Box::new(rule)
1301 }
1302}
1303
1304#[cfg(test)]
1305mod tests {
1306 use super::*;
1307 use crate::lint_context::LintContext;
1308
1309 fn create_rule() -> MD063HeadingCapitalization {
1310 let config = MD063Config {
1311 enabled: true,
1312 ..Default::default()
1313 };
1314 MD063HeadingCapitalization::from_config_struct(config)
1315 }
1316
1317 fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
1318 let config = MD063Config {
1319 enabled: true,
1320 style,
1321 ..Default::default()
1322 };
1323 MD063HeadingCapitalization::from_config_struct(config)
1324 }
1325
1326 #[test]
1328 fn test_an_escaped_tag_does_not_hide_the_element_written_inside_it() {
1329 let text = r#"\<span title='<a id="x"></a>'>foo"#;
1332 assert_eq!(MD063HeadingCapitalization::html_regions(text, &[]), vec![(14, 28)]);
1333 }
1334
1335 #[test]
1336 fn test_title_case_basic() {
1337 let rule = create_rule();
1338 let content = "# hello world\n";
1339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1340 let result = rule.check(&ctx).unwrap();
1341 assert_eq!(result.len(), 1);
1342 assert!(result[0].message.contains("Hello World"));
1343 }
1344
1345 #[test]
1346 fn test_title_case_lowercase_words() {
1347 let rule = create_rule();
1348 let content = "# the quick brown fox\n";
1349 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1350 let result = rule.check(&ctx).unwrap();
1351 assert_eq!(result.len(), 1);
1352 assert!(result[0].message.contains("The Quick Brown Fox"));
1354 }
1355
1356 #[test]
1357 fn test_title_case_already_correct() {
1358 let rule = create_rule();
1359 let content = "# The Quick Brown Fox\n";
1360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1361 let result = rule.check(&ctx).unwrap();
1362 assert!(result.is_empty(), "Already correct heading should not be flagged");
1363 }
1364
1365 #[test]
1366 fn test_title_case_hyphenated() {
1367 let rule = create_rule();
1368 let content = "# self-documenting code\n";
1369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1370 let result = rule.check(&ctx).unwrap();
1371 assert_eq!(result.len(), 1);
1372 assert!(result[0].message.contains("Self-Documenting Code"));
1373 }
1374
1375 #[test]
1376 fn test_title_case_preserves_url_with_nested_parens() {
1377 let rule = create_rule();
1378 let content = "# guide for [the api](https://example.com/docs/v(2)beta)\n";
1380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1381 let fixed = rule.fix(&ctx).unwrap();
1382 assert!(
1385 fixed.contains("https://example.com/docs/v(2)beta"),
1386 "URL with nested parens was corrupted: {fixed:?}"
1387 );
1388 }
1389
1390 #[test]
1391 fn test_title_case_does_not_recase_image_alt() {
1392 let rule = create_rule();
1393 let content = "# overview \n";
1394 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1395 let fixed = rule.fix(&ctx).unwrap();
1396 assert!(
1398 fixed.contains(""),
1399 "image alt text was modified: {fixed:?}"
1400 );
1401 assert!(
1402 fixed.contains("# Overview"),
1403 "surrounding prose should still be title-cased: {fixed:?}"
1404 );
1405 }
1406
1407 #[test]
1409 fn test_sentence_case_basic() {
1410 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1411 let content = "# The Quick Brown Fox\n";
1412 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1413 let result = rule.check(&ctx).unwrap();
1414 assert_eq!(result.len(), 1);
1415 assert!(result[0].message.contains("The quick brown fox"));
1416 }
1417
1418 #[test]
1419 fn test_sentence_case_already_correct() {
1420 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1421 let content = "# The quick brown fox\n";
1422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1423 let result = rule.check(&ctx).unwrap();
1424 assert!(result.is_empty());
1425 }
1426
1427 #[test]
1428 fn test_sentence_case_preserves_first_person_pronoun() {
1429 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1431 let content = "# How do I debug playbooks?\n";
1432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1433
1434 assert!(rule.check(&ctx).unwrap().is_empty());
1435 assert_eq!(rule.fix(&ctx).unwrap(), content);
1436 }
1437
1438 #[test]
1439 fn test_sentence_case_preserves_first_person_contractions() {
1440 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1441
1442 for content in [
1443 "# What I'd change\n",
1444 "# Where I'll look\n",
1445 "# Why I'm here\n",
1446 "# What I've learned\n",
1447 "# What I’d change\n",
1448 "# Where I’ll look\n",
1449 "# Why I’m here\n",
1450 "# What I’ve learned\n",
1451 ] {
1452 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1453 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1454 assert_eq!(rule.fix(&ctx).unwrap(), content);
1455 }
1456 }
1457
1458 #[test]
1459 fn test_sentence_case_pronoun_handles_markup_punctuation_and_suffix_case() {
1460 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1461 let cases = [
1462 ("# What (**I**) would change\n", "# What (**I**) would change\n"),
1463 ("# Why **I**'M changing it\n", "# Why **I**'m changing it\n"),
1464 ("# What I’LL change\n", "# What I’ll change\n"),
1465 ("# What I—really want\n", "# What I—really want\n"),
1466 ("# What I’d—reluctantly change\n", "# What I’d—reluctantly change\n"),
1467 ("# What I—yes—I—would do\n", "# What I—yes—I—would do\n"),
1468 ("# What [I'll change](plan.md)\n", "# What [I'll change](plan.md)\n"),
1469 ("# [What I—really want](plan.md)\n", "# [What I—really want](plan.md)\n"),
1470 ];
1471
1472 for (content, expected) in cases {
1473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1474 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
1475 }
1476 }
1477
1478 #[test]
1479 fn test_sentence_case_pronoun_is_independent_of_cased_word_preservation() {
1480 let config = MD063Config {
1481 enabled: true,
1482 style: HeadingCapStyle::SentenceCase,
1483 preserve_cased_words: false,
1484 ..Default::default()
1485 };
1486 let rule = MD063HeadingCapitalization::from_config_struct(config);
1487 let content = "# How do I debug what I’LL change?\n";
1488 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1489
1490 assert_eq!(rule.fix(&ctx).unwrap(), "# How do I debug what I’ll change?\n");
1491 }
1492
1493 #[test]
1494 fn test_sentence_case_explicit_ignore_wins_over_pronoun_normalization() {
1495 let config = MD063Config {
1496 enabled: true,
1497 style: HeadingCapStyle::SentenceCase,
1498 ignore_words: vec!["I'LL".to_string(), "I’LL".to_string()],
1499 ..Default::default()
1500 };
1501 let rule = MD063HeadingCapitalization::from_config_struct(config);
1502 let content = "# Why I'LL stay and why I’LL leave\n";
1503 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1504
1505 assert!(rule.check(&ctx).unwrap().is_empty());
1506 assert_eq!(rule.fix(&ctx).unwrap(), content);
1507 }
1508
1509 #[test]
1510 fn test_sentence_case_pronoun_does_not_preserve_other_single_letters_or_compounds() {
1511 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1512 let content = "# Compare i, A, I/O, and A.I. values\n";
1513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1514
1515 assert_eq!(rule.fix(&ctx).unwrap(), "# Compare i, a, i/o, and a.i. values\n");
1516 }
1517
1518 #[test]
1520 fn test_all_caps_basic() {
1521 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1522 let content = "# hello world\n";
1523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1524 let result = rule.check(&ctx).unwrap();
1525 assert_eq!(result.len(), 1);
1526 assert!(result[0].message.contains("HELLO WORLD"));
1527 }
1528
1529 #[test]
1531 fn test_preserve_ignore_words() {
1532 let config = MD063Config {
1533 enabled: true,
1534 ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
1535 ..Default::default()
1536 };
1537 let rule = MD063HeadingCapitalization::from_config_struct(config);
1538
1539 let content = "# using iPhone on macOS\n";
1540 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1541 let result = rule.check(&ctx).unwrap();
1542 assert_eq!(result.len(), 1);
1543 assert!(result[0].message.contains("iPhone"));
1545 assert!(result[0].message.contains("macOS"));
1546 }
1547
1548 #[test]
1549 fn test_preserve_cased_words() {
1550 let rule = create_rule();
1551 let content = "# using GitHub actions\n";
1552 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1553 let result = rule.check(&ctx).unwrap();
1554 assert_eq!(result.len(), 1);
1555 assert!(result[0].message.contains("GitHub"));
1557 }
1558
1559 #[test]
1561 fn test_inline_code_preserved() {
1562 let rule = create_rule();
1563 let content = "# using `const` in javascript\n";
1564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1565 let result = rule.check(&ctx).unwrap();
1566 assert_eq!(result.len(), 1);
1567 assert!(result[0].message.contains("`const`"));
1569 assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
1570 }
1571
1572 #[test]
1574 fn test_level_filter() {
1575 let config = MD063Config {
1576 enabled: true,
1577 min_level: 2,
1578 max_level: 4,
1579 ..Default::default()
1580 };
1581 let rule = MD063HeadingCapitalization::from_config_struct(config);
1582
1583 let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
1584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1585 let result = rule.check(&ctx).unwrap();
1586
1587 assert_eq!(result.len(), 2);
1589 assert_eq!(result[0].line, 2); assert_eq!(result[1].line, 3); }
1592
1593 #[test]
1595 fn test_fix_atx_heading() {
1596 let rule = create_rule();
1597 let content = "# hello world\n";
1598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1599 let fixed = rule.fix(&ctx).unwrap();
1600 assert_eq!(fixed, "# Hello World\n");
1601 }
1602
1603 #[test]
1604 fn test_fix_multiple_headings() {
1605 let rule = create_rule();
1606 let content = "# first heading\n\n## second heading\n";
1607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1608 let fixed = rule.fix(&ctx).unwrap();
1609 assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
1610 }
1611
1612 #[test]
1614 fn test_setext_heading() {
1615 let rule = create_rule();
1616 let content = "hello world\n============\n";
1617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1618 let result = rule.check(&ctx).unwrap();
1619 assert_eq!(result.len(), 1);
1620 assert!(result[0].message.contains("Hello World"));
1621 }
1622
1623 #[test]
1625 fn test_custom_id_preserved() {
1626 let rule = create_rule();
1627 let content = "# getting started {#intro}\n";
1628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1629 let result = rule.check(&ctx).unwrap();
1630 assert_eq!(result.len(), 1);
1631 assert!(result[0].message.contains("{#intro}"));
1633 }
1634
1635 #[test]
1637 fn test_skip_obsidian_tags_not_headings() {
1638 let rule = create_rule();
1639
1640 let content = "# H1\n\n#tag\n";
1642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1643 let result = rule.check(&ctx).unwrap();
1644 assert!(
1645 result.is_empty() || result.iter().all(|w| w.line != 3),
1646 "Obsidian tag #tag should not be treated as a heading: {result:?}"
1647 );
1648 }
1649
1650 #[test]
1651 fn test_skip_invalid_atx_headings_no_space() {
1652 let rule = create_rule();
1653
1654 let content = "#notaheading\n";
1656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1657 let result = rule.check(&ctx).unwrap();
1658 assert!(
1659 result.is_empty(),
1660 "Invalid ATX heading without space should not be flagged: {result:?}"
1661 );
1662 }
1663
1664 #[test]
1665 fn test_fix_skips_obsidian_tags() {
1666 let rule = create_rule();
1667
1668 let content = "# hello world\n\n#tag\n";
1669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1670 let fixed = rule.fix(&ctx).unwrap();
1671 assert!(fixed.contains("#tag"), "Fix should not modify Obsidian tag #tag");
1673 assert!(fixed.contains("# Hello World"), "Fix should still fix real headings");
1674 }
1675
1676 #[test]
1677 fn test_preserve_all_caps_acronyms() {
1678 let rule = create_rule();
1679 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1680
1681 let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
1683 assert_eq!(fixed, "# Using API in Production\n");
1684
1685 let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
1687 assert_eq!(fixed, "# API and GPU Integration\n");
1688
1689 let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
1691 assert_eq!(fixed, "# IO Performance Guide\n");
1692
1693 let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
1695 assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
1696 }
1697
1698 #[test]
1699 fn test_preserve_acronyms_in_hyphenated_words() {
1700 let rule = create_rule();
1701 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1702
1703 let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
1705 assert_eq!(fixed, "# API-Driven Architecture\n");
1706
1707 let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
1709 assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
1710 }
1711
1712 #[test]
1713 fn test_single_letters_not_treated_as_acronyms() {
1714 let rule = create_rule();
1715 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1716
1717 let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
1719 assert_eq!(fixed, "# I Am a Heading\n");
1720 }
1721
1722 #[test]
1723 fn test_lowercase_terms_need_ignore_words() {
1724 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1725
1726 let rule = create_rule();
1728 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1729 assert_eq!(fixed, "# Using Npm Packages\n");
1730
1731 let config = MD063Config {
1733 enabled: true,
1734 ignore_words: vec!["npm".to_string()],
1735 ..Default::default()
1736 };
1737 let rule = MD063HeadingCapitalization::from_config_struct(config);
1738 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1739 assert_eq!(fixed, "# Using npm Packages\n");
1740 }
1741
1742 #[test]
1743 fn test_acronyms_with_mixed_case_preserved() {
1744 let rule = create_rule();
1745 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1746
1747 let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
1749 assert_eq!(fixed, "# Using API with GitHub\n");
1750 }
1751
1752 #[test]
1753 fn test_real_world_acronyms() {
1754 let rule = create_rule();
1755 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1756
1757 let content = "# FFI bindings for CPU optimization\n";
1759 let fixed = rule.fix(&ctx(content)).unwrap();
1760 assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
1761
1762 let content = "# DOM manipulation and SSR rendering\n";
1763 let fixed = rule.fix(&ctx(content)).unwrap();
1764 assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
1765
1766 let content = "# CVE security and RNN models\n";
1767 let fixed = rule.fix(&ctx(content)).unwrap();
1768 assert_eq!(fixed, "# CVE Security and RNN Models\n");
1769 }
1770
1771 #[test]
1772 fn test_is_all_caps_acronym() {
1773 let rule = create_rule();
1774
1775 assert!(rule.is_all_caps_acronym("API"));
1777 assert!(rule.is_all_caps_acronym("IO"));
1778 assert!(rule.is_all_caps_acronym("GPU"));
1779 assert!(rule.is_all_caps_acronym("HTTP2")); assert!(!rule.is_all_caps_acronym("A"));
1783 assert!(!rule.is_all_caps_acronym("I"));
1784
1785 assert!(!rule.is_all_caps_acronym("Api"));
1787 assert!(!rule.is_all_caps_acronym("npm"));
1788 assert!(!rule.is_all_caps_acronym("iPhone"));
1789 }
1790
1791 #[test]
1792 fn test_sentence_case_starts_after_a_leading_empty_anchor() {
1793 let config = MD063Config {
1795 enabled: true,
1796 style: HeadingCapStyle::SentenceCase,
1797 ..Default::default()
1798 };
1799 let rule = MD063HeadingCapitalization::from_config_struct(config);
1800
1801 let content = "# <a id=\"top\"></a>the beginning\n";
1802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1803 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1804 assert_eq!(rule.fix(&ctx).unwrap(), "# <a id=\"top\"></a>The beginning\n");
1805
1806 let content = "# <kbd>ctrl</kbd> the key\n";
1808 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1809 assert!(rule.check(&ctx).unwrap().is_empty());
1810
1811 for content in ["# <img src=\"x.png\"> the picture\n", "#  the picture\n"] {
1814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1815 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1816 }
1817 }
1818
1819 #[test]
1820 fn test_sentence_case_ignore_words_first_word() {
1821 let config = MD063Config {
1822 enabled: true,
1823 style: HeadingCapStyle::SentenceCase,
1824 ignore_words: vec!["nvim".to_string()],
1825 ..Default::default()
1826 };
1827 let rule = MD063HeadingCapitalization::from_config_struct(config);
1828
1829 let content = "# nvim config\n";
1831 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1832 let result = rule.check(&ctx).unwrap();
1833 assert!(
1834 result.is_empty(),
1835 "nvim in ignore-words should not be flagged. Got: {result:?}"
1836 );
1837
1838 let fixed = rule.fix(&ctx).unwrap();
1840 assert_eq!(fixed, "# nvim config\n");
1841 }
1842
1843 #[test]
1844 fn test_sentence_case_ignore_words_not_first() {
1845 let config = MD063Config {
1846 enabled: true,
1847 style: HeadingCapStyle::SentenceCase,
1848 ignore_words: vec!["nvim".to_string()],
1849 ..Default::default()
1850 };
1851 let rule = MD063HeadingCapitalization::from_config_struct(config);
1852
1853 let content = "# Using nvim editor\n";
1855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1856 let result = rule.check(&ctx).unwrap();
1857 assert!(
1858 result.is_empty(),
1859 "nvim in ignore-words should be preserved. Got: {result:?}"
1860 );
1861 }
1862
1863 #[test]
1864 fn test_preserve_cased_words_ios() {
1865 let config = MD063Config {
1866 enabled: true,
1867 style: HeadingCapStyle::SentenceCase,
1868 preserve_cased_words: true,
1869 ..Default::default()
1870 };
1871 let rule = MD063HeadingCapitalization::from_config_struct(config);
1872
1873 let content = "## This is iOS\n";
1875 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1876 let result = rule.check(&ctx).unwrap();
1877 assert!(
1878 result.is_empty(),
1879 "iOS should be preserved with preserve-cased-words. Got: {result:?}"
1880 );
1881
1882 let fixed = rule.fix(&ctx).unwrap();
1884 assert_eq!(fixed, "## This is iOS\n");
1885 }
1886
1887 #[test]
1888 fn test_preserve_cased_words_ios_title_case() {
1889 let config = MD063Config {
1890 enabled: true,
1891 style: HeadingCapStyle::TitleCase,
1892 preserve_cased_words: true,
1893 ..Default::default()
1894 };
1895 let rule = MD063HeadingCapitalization::from_config_struct(config);
1896
1897 let content = "# developing for iOS\n";
1899 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1900 let fixed = rule.fix(&ctx).unwrap();
1901 assert_eq!(fixed, "# Developing for iOS\n");
1902 }
1903
1904 #[test]
1905 fn test_has_internal_capitals_ios() {
1906 let rule = create_rule();
1907
1908 assert!(
1910 rule.has_internal_capitals("iOS"),
1911 "iOS has mixed case (lowercase i, uppercase OS)"
1912 );
1913
1914 assert!(rule.has_internal_capitals("iPhone"));
1916 assert!(rule.has_internal_capitals("macOS"));
1917 assert!(rule.has_internal_capitals("GitHub"));
1918 assert!(rule.has_internal_capitals("JavaScript"));
1919 assert!(rule.has_internal_capitals("eBay"));
1920
1921 assert!(!rule.has_internal_capitals("API"));
1923 assert!(!rule.has_internal_capitals("GPU"));
1924
1925 assert!(!rule.has_internal_capitals("npm"));
1927 assert!(!rule.has_internal_capitals("config"));
1928
1929 assert!(!rule.has_internal_capitals("The"));
1931 assert!(!rule.has_internal_capitals("Hello"));
1932 }
1933
1934 #[test]
1935 fn test_lowercase_words_before_trailing_code() {
1936 let config = MD063Config {
1937 enabled: true,
1938 style: HeadingCapStyle::TitleCase,
1939 lowercase_words: vec![
1940 "a".to_string(),
1941 "an".to_string(),
1942 "and".to_string(),
1943 "at".to_string(),
1944 "but".to_string(),
1945 "by".to_string(),
1946 "for".to_string(),
1947 "from".to_string(),
1948 "into".to_string(),
1949 "nor".to_string(),
1950 "on".to_string(),
1951 "onto".to_string(),
1952 "or".to_string(),
1953 "the".to_string(),
1954 "to".to_string(),
1955 "upon".to_string(),
1956 "via".to_string(),
1957 "vs".to_string(),
1958 "with".to_string(),
1959 "without".to_string(),
1960 ],
1961 preserve_cased_words: true,
1962 ..Default::default()
1963 };
1964 let rule = MD063HeadingCapitalization::from_config_struct(config);
1965
1966 let content = "## subtitle with a `app`\n";
1971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1972 let result = rule.check(&ctx).unwrap();
1973
1974 assert!(!result.is_empty(), "Should flag incorrect capitalization");
1976 let fixed = rule.fix(&ctx).unwrap();
1977 assert!(
1979 fixed.contains("with a `app`"),
1980 "Expected 'with a `app`' but got: {fixed:?}"
1981 );
1982 assert!(
1983 !fixed.contains("with A `app`"),
1984 "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
1985 );
1986 assert!(
1988 fixed.contains("Subtitle with a `app`"),
1989 "Expected 'Subtitle with a `app`' but got: {fixed:?}"
1990 );
1991 }
1992
1993 #[test]
1994 fn test_lowercase_words_preserved_before_trailing_code_variant() {
1995 let config = MD063Config {
1996 enabled: true,
1997 style: HeadingCapStyle::TitleCase,
1998 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1999 ..Default::default()
2000 };
2001 let rule = MD063HeadingCapitalization::from_config_struct(config);
2002
2003 let content = "## Title with the `code`\n";
2005 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2006 let fixed = rule.fix(&ctx).unwrap();
2007 assert!(
2009 fixed.contains("with the `code`"),
2010 "Expected 'with the `code`' but got: {fixed:?}"
2011 );
2012 assert!(
2013 !fixed.contains("with The `code`"),
2014 "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
2015 );
2016 }
2017
2018 #[test]
2019 fn test_last_word_capitalized_when_no_trailing_code() {
2020 let config = MD063Config {
2023 enabled: true,
2024 style: HeadingCapStyle::TitleCase,
2025 lowercase_words: vec!["a".to_string(), "the".to_string()],
2026 ..Default::default()
2027 };
2028 let rule = MD063HeadingCapitalization::from_config_struct(config);
2029
2030 let content = "## title with a word\n";
2033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2034 let fixed = rule.fix(&ctx).unwrap();
2035 assert!(
2037 fixed.contains("With a Word"),
2038 "Expected 'With a Word' but got: {fixed:?}"
2039 );
2040 }
2041
2042 #[test]
2043 fn test_multiple_lowercase_words_before_code() {
2044 let config = MD063Config {
2045 enabled: true,
2046 style: HeadingCapStyle::TitleCase,
2047 lowercase_words: vec![
2048 "a".to_string(),
2049 "the".to_string(),
2050 "with".to_string(),
2051 "for".to_string(),
2052 ],
2053 ..Default::default()
2054 };
2055 let rule = MD063HeadingCapitalization::from_config_struct(config);
2056
2057 let content = "## Guide for the `user`\n";
2059 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2060 let fixed = rule.fix(&ctx).unwrap();
2061 assert!(
2062 fixed.contains("for the `user`"),
2063 "Expected 'for the `user`' but got: {fixed:?}"
2064 );
2065 assert!(
2066 !fixed.contains("For The `user`"),
2067 "Should not capitalize lowercase words before code. Got: {fixed:?}"
2068 );
2069 }
2070
2071 #[test]
2072 fn test_code_in_middle_normal_rules_apply() {
2073 let config = MD063Config {
2074 enabled: true,
2075 style: HeadingCapStyle::TitleCase,
2076 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2077 ..Default::default()
2078 };
2079 let rule = MD063HeadingCapitalization::from_config_struct(config);
2080
2081 let content = "## Using `const` for the code\n";
2083 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2084 let fixed = rule.fix(&ctx).unwrap();
2085 assert!(
2087 fixed.contains("for the Code"),
2088 "Expected 'for the Code' but got: {fixed:?}"
2089 );
2090 }
2091
2092 #[test]
2093 fn test_link_at_end_same_as_code() {
2094 let config = MD063Config {
2095 enabled: true,
2096 style: HeadingCapStyle::TitleCase,
2097 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2098 ..Default::default()
2099 };
2100 let rule = MD063HeadingCapitalization::from_config_struct(config);
2101
2102 let content = "## Guide for the [link](./page.md)\n";
2104 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2105 let fixed = rule.fix(&ctx).unwrap();
2106 assert!(
2108 fixed.contains("for the [Link]"),
2109 "Expected 'for the [Link]' but got: {fixed:?}"
2110 );
2111 assert!(
2112 !fixed.contains("for The [Link]"),
2113 "Should not capitalize 'the' before link. Got: {fixed:?}"
2114 );
2115 }
2116
2117 #[test]
2118 fn test_multiple_code_segments() {
2119 let config = MD063Config {
2120 enabled: true,
2121 style: HeadingCapStyle::TitleCase,
2122 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2123 ..Default::default()
2124 };
2125 let rule = MD063HeadingCapitalization::from_config_struct(config);
2126
2127 let content = "## Using `const` with a `variable`\n";
2129 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2130 let fixed = rule.fix(&ctx).unwrap();
2131 assert!(
2133 fixed.contains("with a `variable`"),
2134 "Expected 'with a `variable`' but got: {fixed:?}"
2135 );
2136 assert!(
2137 !fixed.contains("with A `variable`"),
2138 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2139 );
2140 }
2141
2142 #[test]
2143 fn test_code_and_link_combination() {
2144 let config = MD063Config {
2145 enabled: true,
2146 style: HeadingCapStyle::TitleCase,
2147 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2148 ..Default::default()
2149 };
2150 let rule = MD063HeadingCapitalization::from_config_struct(config);
2151
2152 let content = "## Guide for the `code` [link](./page.md)\n";
2154 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2155 let fixed = rule.fix(&ctx).unwrap();
2156 assert!(
2158 fixed.contains("for the `code`"),
2159 "Expected 'for the `code`' but got: {fixed:?}"
2160 );
2161 }
2162
2163 #[test]
2164 fn test_text_after_code_capitalizes_last() {
2165 let config = MD063Config {
2166 enabled: true,
2167 style: HeadingCapStyle::TitleCase,
2168 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2169 ..Default::default()
2170 };
2171 let rule = MD063HeadingCapitalization::from_config_struct(config);
2172
2173 let content = "## Using `const` for the code\n";
2175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2176 let fixed = rule.fix(&ctx).unwrap();
2177 assert!(
2179 fixed.contains("for the Code"),
2180 "Expected 'for the Code' but got: {fixed:?}"
2181 );
2182 }
2183
2184 #[test]
2185 fn test_preserve_cased_words_with_trailing_code() {
2186 let config = MD063Config {
2187 enabled: true,
2188 style: HeadingCapStyle::TitleCase,
2189 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2190 preserve_cased_words: true,
2191 ..Default::default()
2192 };
2193 let rule = MD063HeadingCapitalization::from_config_struct(config);
2194
2195 let content = "## Guide for iOS `app`\n";
2197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2198 let fixed = rule.fix(&ctx).unwrap();
2199 assert!(
2201 fixed.contains("for iOS `app`"),
2202 "Expected 'for iOS `app`' but got: {fixed:?}"
2203 );
2204 assert!(
2205 !fixed.contains("For iOS `app`"),
2206 "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
2207 );
2208 }
2209
2210 #[test]
2211 fn test_ignore_words_with_trailing_code() {
2212 let config = MD063Config {
2213 enabled: true,
2214 style: HeadingCapStyle::TitleCase,
2215 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2216 ignore_words: vec!["npm".to_string()],
2217 ..Default::default()
2218 };
2219 let rule = MD063HeadingCapitalization::from_config_struct(config);
2220
2221 let content = "## Using npm with a `script`\n";
2223 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2224 let fixed = rule.fix(&ctx).unwrap();
2225 assert!(
2227 fixed.contains("npm with a `script`"),
2228 "Expected 'npm with a `script`' but got: {fixed:?}"
2229 );
2230 assert!(
2231 !fixed.contains("with A `script`"),
2232 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2233 );
2234 }
2235
2236 #[test]
2237 fn test_empty_text_segment_edge_case() {
2238 let config = MD063Config {
2239 enabled: true,
2240 style: HeadingCapStyle::TitleCase,
2241 lowercase_words: vec!["a".to_string(), "with".to_string()],
2242 ..Default::default()
2243 };
2244 let rule = MD063HeadingCapitalization::from_config_struct(config);
2245
2246 let content = "## `start` with a `end`\n";
2248 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2249 let fixed = rule.fix(&ctx).unwrap();
2250 assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
2253 assert!(
2254 !fixed.contains("A `end`"),
2255 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2256 );
2257 }
2258
2259 #[test]
2260 fn test_sentence_case_with_trailing_code() {
2261 let config = MD063Config {
2262 enabled: true,
2263 style: HeadingCapStyle::SentenceCase,
2264 lowercase_words: vec!["a".to_string(), "the".to_string()],
2265 ..Default::default()
2266 };
2267 let rule = MD063HeadingCapitalization::from_config_struct(config);
2268
2269 let content = "## guide for the `user`\n";
2271 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2272 let fixed = rule.fix(&ctx).unwrap();
2273 assert!(
2275 fixed.contains("Guide for the `user`"),
2276 "Expected 'Guide for the `user`' but got: {fixed:?}"
2277 );
2278 }
2279
2280 #[test]
2281 fn test_hyphenated_word_before_code() {
2282 let config = MD063Config {
2283 enabled: true,
2284 style: HeadingCapStyle::TitleCase,
2285 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2286 ..Default::default()
2287 };
2288 let rule = MD063HeadingCapitalization::from_config_struct(config);
2289
2290 let content = "## Self-contained with a `feature`\n";
2292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2293 let fixed = rule.fix(&ctx).unwrap();
2294 assert!(
2296 fixed.contains("with a `feature`"),
2297 "Expected 'with a `feature`' but got: {fixed:?}"
2298 );
2299 }
2300
2301 #[test]
2306 fn test_sentence_case_code_at_start_basic() {
2307 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2309 let content = "# `rumdl` is a linter\n";
2310 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2311 let result = rule.check(&ctx).unwrap();
2312 assert!(
2314 result.is_empty(),
2315 "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
2316 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2317 );
2318 }
2319
2320 #[test]
2321 fn test_sentence_case_code_at_start_incorrect_capitalization() {
2322 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2324 let content = "# `rumdl` Is a Linter\n";
2325 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2326 let result = rule.check(&ctx).unwrap();
2327 assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
2329 assert!(
2330 result[0].message.contains("`rumdl` is a linter"),
2331 "Should suggest lowercase after code. Got: {:?}",
2332 result[0].message
2333 );
2334 }
2335
2336 #[test]
2337 fn test_sentence_case_code_at_start_fix() {
2338 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2339 let content = "# `rumdl` Is A Linter\n";
2340 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2341 let fixed = rule.fix(&ctx).unwrap();
2342 assert!(
2343 fixed.contains("# `rumdl` is a linter"),
2344 "Should fix to lowercase after code. Got: {fixed:?}"
2345 );
2346 }
2347
2348 #[test]
2349 fn test_sentence_case_text_at_start_still_capitalizes() {
2350 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2352 let content = "# the quick brown fox\n";
2353 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2354 let result = rule.check(&ctx).unwrap();
2355 assert_eq!(result.len(), 1);
2356 assert!(
2357 result[0].message.contains("The quick brown fox"),
2358 "Text-first heading should capitalize first word. Got: {:?}",
2359 result[0].message
2360 );
2361 }
2362
2363 #[test]
2364 fn test_sentence_case_link_at_start() {
2365 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2367 let content = "# [api](api.md) reference guide\n";
2368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2369 let result = rule.check(&ctx).unwrap();
2370 assert_eq!(result.len(), 1);
2371 assert!(result[0].message.contains("[Api](api.md) reference guide"));
2372 }
2373
2374 #[test]
2375 fn test_sentence_case_link_preserves_acronyms() {
2376 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2378 let content = "# [API](api.md) Reference Guide\n";
2379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2380 let result = rule.check(&ctx).unwrap();
2381 assert_eq!(result.len(), 1);
2382 assert!(
2384 result[0].message.contains("[API](api.md) reference guide"),
2385 "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
2386 result[0].message
2387 );
2388 }
2389
2390 #[test]
2391 fn test_sentence_case_link_preserves_brand_names() {
2392 let config = MD063Config {
2394 enabled: true,
2395 style: HeadingCapStyle::SentenceCase,
2396 preserve_cased_words: true,
2397 ..Default::default()
2398 };
2399 let rule = MD063HeadingCapitalization::from_config_struct(config);
2400 let content = "# [iPhone](iphone.md) Features Guide\n";
2401 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2402 let result = rule.check(&ctx).unwrap();
2403 assert_eq!(result.len(), 1);
2404 assert!(
2406 result[0].message.contains("[iPhone](iphone.md) features guide"),
2407 "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
2408 result[0].message
2409 );
2410 }
2411
2412 #[test]
2413 fn test_sentence_case_link_lowercases_regular_words() {
2414 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2416 let content = "# [Documentation](docs.md) Reference\n";
2417 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2418 let result = rule.check(&ctx).unwrap();
2419 assert_eq!(result.len(), 1);
2420 assert!(
2421 result[0].message.contains("[Documentation](docs.md) reference"),
2422 "Should preserve the sentence-initial capital. Got: {:?}",
2423 result[0].message
2424 );
2425 }
2426
2427 #[test]
2428 fn test_sentence_case_opening_link_label_is_sentence_initial() {
2429 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2431 let content = "# [Foo bar](https://example.com)\n";
2432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2433
2434 assert!(rule.check(&ctx).unwrap().is_empty());
2435 assert_eq!(rule.fix(&ctx).unwrap(), content);
2436 }
2437
2438 #[test]
2439 fn test_sentence_case_link_at_start_correct_already() {
2440 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2442 let content = "# [API](api.md) reference guide\n";
2443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2444 let result = rule.check(&ctx).unwrap();
2445 assert!(
2446 result.is_empty(),
2447 "Correctly cased heading with link should not be flagged. Got: {:?}",
2448 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2449 );
2450 }
2451
2452 #[test]
2453 fn test_sentence_case_link_github_preserved() {
2454 let config = MD063Config {
2456 enabled: true,
2457 style: HeadingCapStyle::SentenceCase,
2458 preserve_cased_words: true,
2459 ..Default::default()
2460 };
2461 let rule = MD063HeadingCapitalization::from_config_struct(config);
2462 let content = "# [GitHub](gh.md) Repository Setup\n";
2463 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2464 let result = rule.check(&ctx).unwrap();
2465 assert_eq!(result.len(), 1);
2466 assert!(
2467 result[0].message.contains("[GitHub](gh.md) repository setup"),
2468 "Should preserve 'GitHub'. Got: {:?}",
2469 result[0].message
2470 );
2471 }
2472
2473 #[test]
2474 fn test_sentence_case_multiple_code_spans() {
2475 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2476 let content = "# `foo` and `bar` are methods\n";
2477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2478 let result = rule.check(&ctx).unwrap();
2479 assert!(
2481 result.is_empty(),
2482 "Should not capitalize words between/after code spans. Got: {:?}",
2483 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2484 );
2485 }
2486
2487 #[test]
2488 fn test_sentence_case_code_only_heading() {
2489 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2491 let content = "# `rumdl`\n";
2492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2493 let result = rule.check(&ctx).unwrap();
2494 assert!(
2495 result.is_empty(),
2496 "Code-only heading should be fine. Got: {:?}",
2497 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2498 );
2499 }
2500
2501 #[test]
2502 fn test_sentence_case_code_at_end() {
2503 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2505 let content = "# install the `rumdl` tool\n";
2506 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2507 let result = rule.check(&ctx).unwrap();
2508 assert_eq!(result.len(), 1);
2510 assert!(
2511 result[0].message.contains("Install the `rumdl` tool"),
2512 "First word should still be capitalized when text comes first. Got: {:?}",
2513 result[0].message
2514 );
2515 }
2516
2517 #[test]
2518 fn test_sentence_case_code_in_middle() {
2519 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2521 let content = "# using the `rumdl` linter for markdown\n";
2522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2523 let result = rule.check(&ctx).unwrap();
2524 assert_eq!(result.len(), 1);
2526 assert!(
2527 result[0].message.contains("Using the `rumdl` linter for markdown"),
2528 "First word should be capitalized. Got: {:?}",
2529 result[0].message
2530 );
2531 }
2532
2533 #[test]
2534 fn test_sentence_case_preserved_word_after_code() {
2535 let config = MD063Config {
2537 enabled: true,
2538 style: HeadingCapStyle::SentenceCase,
2539 preserve_cased_words: true,
2540 ..Default::default()
2541 };
2542 let rule = MD063HeadingCapitalization::from_config_struct(config);
2543 let content = "# `swift` iPhone development\n";
2544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2545 let result = rule.check(&ctx).unwrap();
2546 assert!(
2548 result.is_empty(),
2549 "Preserved words after code should stay. Got: {:?}",
2550 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2551 );
2552 }
2553
2554 #[test]
2555 fn test_title_case_code_at_start_still_capitalizes() {
2556 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2558 let content = "# `api` quick start guide\n";
2559 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2560 let result = rule.check(&ctx).unwrap();
2561 assert_eq!(result.len(), 1);
2563 assert!(
2564 result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
2565 "Title case should capitalize major words after code. Got: {:?}",
2566 result[0].message
2567 );
2568 }
2569
2570 #[test]
2573 fn test_sentence_case_html_tag_at_start() {
2574 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2576 let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
2577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2578 let result = rule.check(&ctx).unwrap();
2579 assert_eq!(result.len(), 1);
2581 let fixed = rule.fix(&ctx).unwrap();
2582 assert_eq!(
2583 fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
2584 "Text after HTML at start should be lowercase"
2585 );
2586 }
2587
2588 #[test]
2589 fn test_sentence_case_html_tag_preserves_content() {
2590 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2592 let content = "# The <abbr>API</abbr> documentation guide\n";
2593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2594 let result = rule.check(&ctx).unwrap();
2595 assert!(
2597 result.is_empty(),
2598 "HTML tag content should be preserved. Got: {:?}",
2599 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2600 );
2601 }
2602
2603 #[test]
2604 fn test_sentence_case_html_tag_at_start_with_acronym() {
2605 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2607 let content = "# <abbr>API</abbr> Documentation Guide\n";
2608 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2609 let result = rule.check(&ctx).unwrap();
2610 assert_eq!(result.len(), 1);
2611 let fixed = rule.fix(&ctx).unwrap();
2612 assert_eq!(
2613 fixed, "# <abbr>API</abbr> documentation guide\n",
2614 "Text after HTML at start should be lowercase, HTML content preserved"
2615 );
2616 }
2617
2618 #[test]
2619 fn test_sentence_case_html_tag_in_middle() {
2620 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2622 let content = "# using the <code>config</code> File\n";
2623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2624 let result = rule.check(&ctx).unwrap();
2625 assert_eq!(result.len(), 1);
2626 let fixed = rule.fix(&ctx).unwrap();
2627 assert_eq!(
2628 fixed, "# Using the <code>config</code> file\n",
2629 "First word capitalized, HTML preserved, rest lowercase"
2630 );
2631 }
2632
2633 #[test]
2634 fn test_html_tag_strong_emphasis() {
2635 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2637 let content = "# The <strong>Bold</strong> Way\n";
2638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2639 let result = rule.check(&ctx).unwrap();
2640 assert_eq!(result.len(), 1);
2641 let fixed = rule.fix(&ctx).unwrap();
2642 assert_eq!(
2643 fixed, "# The <strong>Bold</strong> way\n",
2644 "<strong> tag content should be preserved"
2645 );
2646 }
2647
2648 #[test]
2649 fn test_html_tag_with_attributes() {
2650 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2652 let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
2653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2654 let result = rule.check(&ctx).unwrap();
2655 assert_eq!(result.len(), 1);
2656 let fixed = rule.fix(&ctx).unwrap();
2657 assert_eq!(
2658 fixed, "# <span class=\"highlight\">Important</span> notice here\n",
2659 "HTML tag with attributes should be preserved"
2660 );
2661 }
2662
2663 #[test]
2664 fn test_multiple_html_tags() {
2665 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2667 let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
2668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2669 let result = rule.check(&ctx).unwrap();
2670 assert_eq!(result.len(), 1);
2671 let fixed = rule.fix(&ctx).unwrap();
2672 assert_eq!(
2673 fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
2674 "Multiple HTML tags should all be preserved"
2675 );
2676 }
2677
2678 #[test]
2679 fn test_html_and_code_mixed() {
2680 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2682 let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
2683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2684 let result = rule.check(&ctx).unwrap();
2685 assert_eq!(result.len(), 1);
2686 let fixed = rule.fix(&ctx).unwrap();
2687 assert_eq!(
2688 fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
2689 "HTML and code should both be preserved"
2690 );
2691 }
2692
2693 #[test]
2694 fn test_self_closing_html_tag() {
2695 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2697 let content = "# Line one<br/>Line Two Here\n";
2698 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2699 let result = rule.check(&ctx).unwrap();
2700 assert_eq!(result.len(), 1);
2701 let fixed = rule.fix(&ctx).unwrap();
2702 assert_eq!(
2703 fixed, "# Line one<br/>line two here\n",
2704 "Self-closing HTML tags should be preserved"
2705 );
2706 }
2707
2708 #[test]
2709 fn test_title_case_with_html_tags() {
2710 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2712 let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
2713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2714 let result = rule.check(&ctx).unwrap();
2715 assert_eq!(result.len(), 1);
2716 let fixed = rule.fix(&ctx).unwrap();
2717 assert!(
2719 fixed.contains("<kbd>ctrl</kbd>"),
2720 "HTML tag content should be preserved in title case. Got: {fixed}"
2721 );
2722 assert!(
2723 fixed.starts_with("# The ") || fixed.starts_with("# the "),
2724 "Title case should work with HTML. Got: {fixed}"
2725 );
2726 }
2727
2728 #[test]
2731 fn test_sentence_case_preserves_caret_notation() {
2732 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2734 let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
2735 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2736 let result = rule.check(&ctx).unwrap();
2737 assert!(
2739 result.is_empty(),
2740 "Caret notation should be preserved. Got: {:?}",
2741 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2742 );
2743 }
2744
2745 #[test]
2746 fn test_sentence_case_caret_notation_various() {
2747 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2749
2750 let content = "## Press ^C to cancel\n";
2752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2753 let result = rule.check(&ctx).unwrap();
2754 assert!(
2755 result.is_empty(),
2756 "^C should be preserved. Got: {:?}",
2757 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2758 );
2759
2760 let content = "## Use ^Z for background\n";
2762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2763 let result = rule.check(&ctx).unwrap();
2764 assert!(
2765 result.is_empty(),
2766 "^Z should be preserved. Got: {:?}",
2767 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2768 );
2769
2770 let content = "## Press ^[ for escape\n";
2772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2773 let result = rule.check(&ctx).unwrap();
2774 assert!(
2775 result.is_empty(),
2776 "^[ should be preserved. Got: {:?}",
2777 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2778 );
2779 }
2780
2781 #[test]
2782 fn test_caret_notation_detection() {
2783 let rule = create_rule();
2784
2785 assert!(rule.is_caret_notation("^A"));
2787 assert!(rule.is_caret_notation("^Z"));
2788 assert!(rule.is_caret_notation("^C"));
2789 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")); }
2801
2802 fn create_sentence_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2809 let config = MD063Config {
2810 enabled: true,
2811 style: HeadingCapStyle::SentenceCase,
2812 ..Default::default()
2813 };
2814 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2815 rule.proper_names = names;
2816 rule
2817 }
2818
2819 #[test]
2820 fn test_sentence_case_preserves_single_word_proper_name() {
2821 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2822 let content = "# installing javascript\n";
2824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2825 let result = rule.check(&ctx).unwrap();
2826 assert_eq!(result.len(), 1, "Should flag the heading");
2827 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2828 assert!(
2829 fix_text.contains("JavaScript"),
2830 "Fix should preserve proper name 'JavaScript', got: {fix_text:?}"
2831 );
2832 assert!(
2833 !fix_text.contains("javascript"),
2834 "Fix should not have lowercase 'javascript', got: {fix_text:?}"
2835 );
2836 }
2837
2838 #[test]
2839 fn test_sentence_case_preserves_multi_word_proper_name() {
2840 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2841 let content = "# using good application features\n";
2843 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2844 let result = rule.check(&ctx).unwrap();
2845 assert_eq!(result.len(), 1, "Should flag the heading");
2846 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2847 assert!(
2848 fix_text.contains("Good Application"),
2849 "Fix should preserve 'Good Application' as a phrase, got: {fix_text:?}"
2850 );
2851 }
2852
2853 #[test]
2854 fn test_sentence_case_proper_name_at_start_of_heading() {
2855 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2856 let content = "# good application overview\n";
2858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2859 let result = rule.check(&ctx).unwrap();
2860 assert_eq!(result.len(), 1, "Should flag the heading");
2861 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2862 assert!(
2863 fix_text.contains("Good Application"),
2864 "Fix should produce 'Good Application' at start of heading, got: {fix_text:?}"
2865 );
2866 assert!(
2867 fix_text.contains("overview"),
2868 "Non-proper-name word 'overview' should be lowercase, got: {fix_text:?}"
2869 );
2870 }
2871
2872 #[test]
2873 fn test_sentence_case_with_proper_names_no_oscillation() {
2874 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2877
2878 let content = "# installing good application on your system\n";
2880 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2881 let result = rule.check(&ctx).unwrap();
2882 assert_eq!(result.len(), 1);
2883 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
2884
2885 assert!(
2887 fixed_heading.contains("Good Application"),
2888 "After fix, proper name must be preserved: {fixed_heading:?}"
2889 );
2890
2891 let fixed_line = format!("{fixed_heading}\n");
2893 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
2894 let result2 = rule.check(&ctx2).unwrap();
2895 assert!(
2896 result2.is_empty(),
2897 "After one fix, heading must already satisfy both MD063 and MD044 - no oscillation. \
2898 Second pass warnings: {result2:?}"
2899 );
2900 }
2901
2902 #[test]
2903 fn test_sentence_case_proper_names_already_correct() {
2904 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2905 let content = "# Installing Good Application\n";
2907 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2908 let result = rule.check(&ctx).unwrap();
2909 assert!(
2910 result.is_empty(),
2911 "Correct sentence-case heading with proper name should not be flagged, got: {result:?}"
2912 );
2913 }
2914
2915 #[test]
2916 fn test_sentence_case_multiple_proper_names_in_heading() {
2917 let rule = create_sentence_case_rule_with_proper_names(vec!["TypeScript".to_string(), "React".to_string()]);
2918 let content = "# using typescript with react\n";
2919 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2920 let result = rule.check(&ctx).unwrap();
2921 assert_eq!(result.len(), 1);
2922 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2923 assert!(
2924 fix_text.contains("TypeScript"),
2925 "Fix should preserve 'TypeScript', got: {fix_text:?}"
2926 );
2927 assert!(
2928 fix_text.contains("React"),
2929 "Fix should preserve 'React', got: {fix_text:?}"
2930 );
2931 }
2932
2933 #[test]
2934 fn test_sentence_case_unicode_casefold_expansion_before_proper_name() {
2935 let rule = create_sentence_case_rule_with_proper_names(vec!["Österreich".to_string()]);
2938 let content = "# İ österreich guide\n";
2939 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2940
2941 let result = rule.check(&ctx).unwrap();
2943 assert_eq!(result.len(), 1, "Should flag heading for canonical proper-name casing");
2944 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2945 assert!(
2946 fix_text.contains("Österreich"),
2947 "Fix should preserve canonical 'Österreich', got: {fix_text:?}"
2948 );
2949 }
2950
2951 #[test]
2952 fn test_sentence_case_preserves_trailing_punctuation_on_proper_name() {
2953 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2954 let content = "# using javascript, today\n";
2955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2956 let result = rule.check(&ctx).unwrap();
2957 assert_eq!(result.len(), 1, "Should flag heading");
2958 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2959 assert!(
2960 fix_text.contains("JavaScript,"),
2961 "Fix should preserve trailing punctuation, got: {fix_text:?}"
2962 );
2963 }
2964
2965 fn create_title_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2972 let config = MD063Config {
2973 enabled: true,
2974 style: HeadingCapStyle::TitleCase,
2975 ..Default::default()
2976 };
2977 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2978 rule.proper_names = names;
2979 rule
2980 }
2981
2982 #[test]
2983 fn test_title_case_preserves_proper_name_with_lowercase_article() {
2984 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
2988 let content = "# listening to the rolling stones today\n";
2989 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2990 let result = rule.check(&ctx).unwrap();
2991 assert_eq!(result.len(), 1, "Should flag the heading");
2992 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2993 assert!(
2994 fix_text.contains("The Rolling Stones"),
2995 "Fix should preserve proper name 'The Rolling Stones', got: {fix_text:?}"
2996 );
2997 }
2998
2999 #[test]
3000 fn test_title_case_proper_name_no_oscillation() {
3001 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
3003 let content = "# listening to the rolling stones today\n";
3004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3005 let result = rule.check(&ctx).unwrap();
3006 assert_eq!(result.len(), 1);
3007 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
3008
3009 let fixed_line = format!("{fixed_heading}\n");
3010 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
3011 let result2 = rule.check(&ctx2).unwrap();
3012 assert!(
3013 result2.is_empty(),
3014 "After one title-case fix, heading must already satisfy both rules. \
3015 Second pass warnings: {result2:?}"
3016 );
3017 }
3018
3019 #[test]
3020 fn test_title_case_unicode_casefold_expansion_before_proper_name() {
3021 let rule = create_title_case_rule_with_proper_names(vec!["Österreich".to_string()]);
3022 let content = "# İ österreich guide\n";
3023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3024 let result = rule.check(&ctx).unwrap();
3025 assert_eq!(result.len(), 1, "Should flag the heading");
3026 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3027 assert!(
3028 fix_text.contains("Österreich"),
3029 "Fix should preserve canonical proper-name casing, got: {fix_text:?}"
3030 );
3031 }
3032
3033 #[test]
3039 fn test_from_config_loads_md044_names_into_md063() {
3040 use crate::config::{Config, RuleConfig};
3041 use crate::rule::Rule;
3042 use std::collections::BTreeMap;
3043
3044 let mut config = Config::default();
3045
3046 let mut md063_values = BTreeMap::new();
3048 md063_values.insert("style".to_string(), toml::Value::String("sentence_case".to_string()));
3049 md063_values.insert("enabled".to_string(), toml::Value::Boolean(true));
3050 config.rules.insert(
3051 "MD063".to_string(),
3052 RuleConfig {
3053 values: md063_values,
3054 severity: None,
3055 },
3056 );
3057
3058 let mut md044_values = BTreeMap::new();
3060 md044_values.insert(
3061 "names".to_string(),
3062 toml::Value::Array(vec![toml::Value::String("Good Application".to_string())]),
3063 );
3064 config.rules.insert(
3065 "MD044".to_string(),
3066 RuleConfig {
3067 values: md044_values,
3068 severity: None,
3069 },
3070 );
3071
3072 let rule = MD063HeadingCapitalization::from_config(&config);
3074
3075 let content = "# using good application features\n";
3077 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3078 let result = rule.check(&ctx).unwrap();
3079 assert_eq!(result.len(), 1, "Should flag the heading");
3080 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3081 assert!(
3082 fix_text.contains("Good Application"),
3083 "from_config should wire MD044 names into MD063; fix should preserve \
3084 'Good Application', got: {fix_text:?}"
3085 );
3086 }
3087
3088 #[test]
3089 fn test_title_case_short_word_not_confused_with_substring() {
3090 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3094
3095 let content = "# in the insert\n";
3098 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3099 let result = rule.check(&ctx).unwrap();
3100 assert_eq!(result.len(), 1, "Should flag the heading");
3101 let fix = result[0].fix.as_ref().expect("Fix should be present");
3102 assert!(
3104 fix.replacement.contains("In the Insert"),
3105 "Expected 'In the Insert', got: {:?}",
3106 fix.replacement
3107 );
3108 }
3109
3110 #[test]
3111 fn test_title_case_or_not_confused_with_orchestra() {
3112 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3113
3114 let content = "# or the orchestra\n";
3117 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3118 let result = rule.check(&ctx).unwrap();
3119 assert_eq!(result.len(), 1, "Should flag the heading");
3120 let fix = result[0].fix.as_ref().expect("Fix should be present");
3121 assert!(
3123 fix.replacement.contains("Or the Orchestra"),
3124 "Expected 'Or the Orchestra', got: {:?}",
3125 fix.replacement
3126 );
3127 }
3128
3129 #[test]
3130 fn test_all_caps_preserves_all_words() {
3131 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
3132
3133 let content = "# in the insert\n";
3134 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3135 let result = rule.check(&ctx).unwrap();
3136 assert_eq!(result.len(), 1, "Should flag the heading");
3137 let fix = result[0].fix.as_ref().expect("Fix should be present");
3138 assert!(
3139 fix.replacement.contains("IN THE INSERT"),
3140 "All caps should uppercase all words, got: {:?}",
3141 fix.replacement
3142 );
3143 }
3144
3145 #[test]
3147 fn test_title_case_numbered_prefix_lowercase_word() {
3148 let rule = create_rule();
3150 let content = "## 1. To Be a Thing\n";
3151 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3152 let result = rule.check(&ctx).unwrap();
3153 assert!(
3154 result.is_empty(),
3155 "Should not flag '## 1. To Be a Thing', got: {result:?}"
3156 );
3157
3158 let content_lower = "## 1. to be a thing\n";
3159 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3160 let result2 = rule.check(&ctx2).unwrap();
3161 assert!(!result2.is_empty(), "Should flag '## 1. to be a thing'");
3162 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3163 assert!(
3164 fix.replacement.contains("1. To Be a Thing"),
3165 "Fix should capitalize 'To', got: {:?}",
3166 fix.replacement
3167 );
3168 }
3169
3170 #[test]
3171 fn test_title_case_numbered_prefix_article() {
3172 let rule = create_rule();
3174 let content = "## 2. A Guide to the Galaxy\n";
3175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3176 let result = rule.check(&ctx).unwrap();
3177 assert!(
3178 result.is_empty(),
3179 "Should not flag '## 2. A Guide to the Galaxy', got: {result:?}"
3180 );
3181
3182 let content_lower = "## 2. a guide to the galaxy\n";
3183 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3184 let result2 = rule.check(&ctx2).unwrap();
3185 assert!(!result2.is_empty(), "Should flag '## 2. a guide to the galaxy'");
3186 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3187 assert!(
3188 fix.replacement.contains("2. A Guide to the Galaxy"),
3189 "Fix should capitalize 'A', got: {:?}",
3190 fix.replacement
3191 );
3192 }
3193
3194 #[test]
3195 fn test_title_case_mid_sentence_period_word() {
3196 let rule = create_rule();
3198 let content = "## Step 1. Introduction to the Problem\n";
3199 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3200 let result = rule.check(&ctx).unwrap();
3201 assert!(
3202 result.is_empty(),
3203 "Should not flag '## Step 1. Introduction to the Problem', got: {result:?}"
3204 );
3205
3206 let content_lower = "## Step 1. introduction to the problem\n";
3207 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3208 let result2 = rule.check(&ctx2).unwrap();
3209 assert!(
3210 !result2.is_empty(),
3211 "Should flag '## Step 1. introduction to the problem'"
3212 );
3213 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3214 assert!(
3215 fix.replacement.contains("Step 1. Introduction to the Problem"),
3216 "Fix should capitalize 'Introduction', got: {:?}",
3217 fix.replacement
3218 );
3219 }
3220
3221 #[test]
3222 fn test_title_case_numbered_prefix_in_link_text() {
3223 let config = MD063Config {
3226 enabled: true,
3227 style: HeadingCapStyle::TitleCase,
3228 ..Default::default()
3229 };
3230 let rule = MD063HeadingCapitalization::from_config_struct(config);
3231
3232 let content = "## [1. To Be a Thing](https://example.com)\n";
3234 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3235 let result = rule.check(&ctx).unwrap();
3236 assert!(
3237 result.is_empty(),
3238 "Should not flag '## [1. To Be a Thing](url)', got: {result:?}"
3239 );
3240
3241 let content_lower = "## [1. to be a thing](https://example.com)\n";
3243 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3244 let result2 = rule.check(&ctx2).unwrap();
3245 assert!(!result2.is_empty(), "Should flag '## [1. to be a thing](url)'");
3246 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3247 assert!(
3248 fix.replacement.contains("1. To Be a Thing"),
3249 "Fix should capitalize 'To' in link text, got: {:?}",
3250 fix.replacement
3251 );
3252 }
3253
3254 #[test]
3259 fn test_is_numeric_ordinal_recognises_canonical_forms() {
3260 for word in &[
3261 "1st", "2nd", "3rd", "4th", "5th", "11th", "21st", "22nd", "23rd", "100th", "1ST", "5Th", "21St", "21sT",
3262 ] {
3263 assert!(
3264 MD063HeadingCapitalization::is_numeric_ordinal(word),
3265 "expected `{word}` to be detected as a numeric ordinal"
3266 );
3267 }
3268 }
3269
3270 #[test]
3271 fn test_is_numeric_ordinal_rejects_non_ordinals() {
3272 for word in &[
3277 "first", "1stop", "ist", "5", "th", "abc", "4G", "4K", "30s", "100k", "5x", "1.5", "iPhone6S",
3278 ] {
3279 assert!(
3280 !MD063HeadingCapitalization::is_numeric_ordinal(word),
3281 "expected `{word}` NOT to be detected as a numeric ordinal"
3282 );
3283 }
3284 }
3285
3286 #[test]
3287 fn test_is_numeric_ordinal_strips_trailing_punctuation() {
3288 for word in &["5th.", "1st,", "21st!", "3rd:", "4th)", "5th's"] {
3289 assert!(
3290 MD063HeadingCapitalization::is_numeric_ordinal(word),
3291 "expected `{word}` to be detected as a numeric ordinal (with punctuation)"
3292 );
3293 }
3294 }
3295
3296 #[test]
3297 fn test_title_case_ordinal_first_word_not_flagged() {
3298 let rule = create_rule();
3299 for content in &[
3300 "# 1st Place\n",
3301 "# 2nd Edition\n",
3302 "# 3rd Time\n",
3303 "# 5th Avenue\n",
3304 "# 21st Century Skills\n",
3305 "# 100th Customer\n",
3306 ] {
3307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3308 let result = rule.check(&ctx).unwrap();
3309 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3310 }
3311 }
3312
3313 #[test]
3314 fn test_title_case_ordinal_mid_heading_not_flagged() {
3315 let rule = create_rule();
3316 for content in &[
3317 "# May 3rd Notes\n",
3318 "# Top 100th Customer\n",
3319 "# Notes for the 5th of May\n",
3320 ] {
3321 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3322 let result = rule.check(&ctx).unwrap();
3323 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3324 }
3325 }
3326
3327 #[test]
3328 fn test_title_case_ordinal_corrupted_form_is_fixed() {
3329 let rule = create_rule();
3332 for (input, expected) in &[
3333 ("# 1St Place\n", "1st Place"),
3334 ("# 5Th Avenue\n", "5th Avenue"),
3335 ("# 21St Century Skills\n", "21st Century Skills"),
3336 ("# May 3Rd Notes\n", "May 3rd Notes"),
3337 ("# 22Nd Edition\n", "22nd Edition"),
3338 ] {
3339 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
3340 let result = rule.check(&ctx).unwrap();
3341 assert!(!result.is_empty(), "Should flag {input:?}");
3342 let fix = result[0].fix.as_ref().expect("should have a fix");
3343 assert!(
3344 fix.replacement.contains(expected),
3345 "Fix for {input:?} should contain {expected:?}, got: {:?}",
3346 fix.replacement
3347 );
3348 }
3349 }
3350
3351 #[test]
3352 fn test_title_case_ordinal_lowercase_other_words_capitalised() {
3353 let rule = create_rule();
3355 let content = "# 5th avenue\n";
3356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3357 let result = rule.check(&ctx).unwrap();
3358 assert_eq!(result.len(), 1);
3359 let fix = result[0].fix.as_ref().expect("should have a fix");
3360 assert!(
3361 fix.replacement.contains("5th Avenue"),
3362 "Fix should produce '5th Avenue', got: {:?}",
3363 fix.replacement
3364 );
3365 }
3366
3367 #[test]
3368 fn test_title_case_ordinal_with_trailing_punctuation() {
3369 let rule = create_rule();
3370 let content = "# Released on the 5th.\n";
3371 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3372 let result = rule.check(&ctx).unwrap();
3373 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3374 }
3375
3376 #[test]
3377 fn test_title_case_ordinal_hyphenated() {
3378 let rule = create_rule();
3379 for content in &["# 21st-Century Skills\n", "# A 19th-Century Novel\n"] {
3380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3381 let result = rule.check(&ctx).unwrap();
3382 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3383 }
3384 }
3385
3386 #[test]
3387 fn test_sentence_case_ordinal_corrupted_form_is_fixed() {
3388 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3389 let content = "# 5Th avenue\n";
3390 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3391 let result = rule.check(&ctx).unwrap();
3392 assert_eq!(result.len(), 1);
3393 let fix = result[0].fix.as_ref().expect("should have a fix");
3394 assert!(
3395 fix.replacement.contains("5th avenue"),
3396 "Fix should produce '5th avenue', got: {:?}",
3397 fix.replacement
3398 );
3399 }
3400
3401 #[test]
3402 fn test_title_case_digit_acronym_unchanged() {
3403 let rule = create_rule();
3406 for content in &["# 4G Networks\n", "# 4K Streaming\n"] {
3407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3408 let result = rule.check(&ctx).unwrap();
3409 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3410 }
3411 }
3412
3413 fn restart_rule(boundaries: &[&str]) -> MD063HeadingCapitalization {
3416 let config = MD063Config {
3417 enabled: true,
3418 style: HeadingCapStyle::SentenceCase,
3419 sentence_case_restart_after: boundaries.iter().copied().map(String::from).collect(),
3420 ..Default::default()
3421 };
3422 MD063HeadingCapitalization::from_config_struct(config)
3423 }
3424
3425 fn suggested(rule: &MD063HeadingCapitalization, content: &str) -> Option<String> {
3428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3429 let warnings = rule.check(&ctx).unwrap();
3430 let fixed = rule.fix(&ctx).unwrap();
3431 assert_eq!(
3432 warnings.is_empty(),
3433 fixed == content,
3434 "a warning and a rewrite must agree for {content:?}"
3435 );
3436 (!warnings.is_empty()).then(|| fixed.trim_start_matches('#').trim().to_string())
3437 }
3438
3439 #[test]
3440 fn test_restart_after_capitalizes_the_word_following_a_boundary() {
3441 let rule = restart_rule(&[":"]);
3442 assert_eq!(
3443 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3444 Some("Requirement 1: Struct to logger slice conversion")
3445 );
3446 }
3447
3448 #[test]
3449 fn test_restart_after_defaults_to_no_boundaries() {
3450 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3452 assert_eq!(
3453 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3454 Some("Requirement 1: struct to logger slice conversion")
3455 );
3456 }
3457
3458 #[test]
3459 fn test_restart_after_only_honors_configured_punctuation() {
3460 let rule = restart_rule(&[":"]);
3462 assert_eq!(
3463 suggested(&rule, "# Design - Data Model Overview\n").as_deref(),
3464 Some("Design - data model overview")
3465 );
3466 assert_eq!(
3467 suggested(&rule, "# Setup; Then Run\n").as_deref(),
3468 Some("Setup; then run")
3469 );
3470
3471 let rule = restart_rule(&[";", "\u{2014}"]);
3472 assert_eq!(
3473 suggested(&rule, "# Setup; Then Run\n").as_deref(),
3474 Some("Setup; Then run")
3475 );
3476 assert_eq!(
3477 suggested(&rule, "# Part One \u{2014} The Big Idea\n").as_deref(),
3478 Some("Part one \u{2014} The big idea")
3479 );
3480 }
3481
3482 #[test]
3483 fn test_restart_after_matches_only_at_the_end_of_a_word() {
3484 let rule = restart_rule(&["-", ":"]);
3487 assert_eq!(
3488 suggested(&rule, "# Ports: Well-Known Ports Explained\n").as_deref(),
3489 Some("Ports: Well-Known ports explained")
3490 );
3491 assert_eq!(
3492 suggested(&rule, "# See https://example.com/A/B For Details\n").as_deref(),
3493 Some("See https://example.com/A/B for details")
3494 );
3495 }
3496
3497 #[test]
3498 fn test_restart_after_a_trailing_boundary_is_a_no_op() {
3499 let rule = restart_rule(&[":"]);
3500 assert_eq!(suggested(&rule, "# Setup:\n"), None);
3501 }
3502
3503 #[test]
3504 fn test_restart_after_does_not_override_preserved_words() {
3505 let rule = restart_rule(&[":"]);
3508 assert_eq!(
3509 suggested(&rule, "# Devices: iPhone And Android\n").as_deref(),
3510 Some("Devices: iPhone and android")
3511 );
3512
3513 let config = MD063Config {
3514 enabled: true,
3515 style: HeadingCapStyle::SentenceCase,
3516 sentence_case_restart_after: vec![":".to_string()],
3517 ignore_words: vec!["kubectl".to_string()],
3518 preserve_cased_words: false,
3519 ..Default::default()
3520 };
3521 let rule = MD063HeadingCapitalization::from_config_struct(config);
3522 assert_eq!(
3523 suggested(&rule, "# Tools: kubectl And Helm\n").as_deref(),
3524 Some("Tools: kubectl and helm")
3525 );
3526 }
3527
3528 #[test]
3529 fn test_restart_after_keeps_md044_canonical_forms() {
3530 let config = MD063Config {
3531 enabled: true,
3532 style: HeadingCapStyle::SentenceCase,
3533 sentence_case_restart_after: vec![":".to_string()],
3534 ..Default::default()
3535 };
3536 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3537 rule.proper_names = vec!["GitHub".to_string()];
3538
3539 assert_eq!(
3541 suggested(&rule, "# Docs: github Actions Guide\n").as_deref(),
3542 Some("Docs: GitHub actions guide")
3543 );
3544 assert_eq!(suggested(&rule, "# Docs: GitHub actions guide\n"), None);
3545 }
3546
3547 #[test]
3548 fn test_restart_after_carries_across_segments() {
3549 let rule = restart_rule(&[":"]);
3552 assert_eq!(
3553 suggested(
3554 &rule,
3555 "# Overview: [Some Link Here](https://example.com) Trailing Words\n"
3556 )
3557 .as_deref(),
3558 Some("Overview: [Some link here](https://example.com) trailing words")
3559 );
3560 assert_eq!(
3561 suggested(&rule, "# Overview: `code` Then More Words\n").as_deref(),
3562 Some("Overview: `code` then more words")
3563 );
3564 }
3565
3566 #[test]
3567 fn test_restart_after_ends_a_sentence_at_the_end_of_link_text() {
3568 let rule = restart_rule(&[":"]);
3572 assert_eq!(
3573 suggested(&rule, "# Topic [See:](https://example.com) More Words\n").as_deref(),
3574 Some("Topic [see:](https://example.com) More words")
3575 );
3576
3577 assert_eq!(
3579 suggested(&rule, "# Topic [See](https://example.com) More Words\n").as_deref(),
3580 Some("Topic [see](https://example.com) more words")
3581 );
3582 }
3583
3584 #[test]
3585 fn test_restart_after_ignores_boundaries_inside_opaque_segments() {
3586 let rule = restart_rule(&[":"]);
3589 for content in [
3590 "# Topic `see:` More Words\n",
3591 "# Topic  More Words\n",
3592 "# Topic <span title=\"x:\">y</span> More Words\n",
3593 ] {
3594 let fixed = suggested(&rule, content).expect("heading should be rewritten");
3595 assert!(
3596 fixed.ends_with("more words"),
3597 "opaque segment restarted the sentence in {content:?}: {fixed}"
3598 );
3599 }
3600 }
3601
3602 #[test]
3603 fn test_restart_after_treats_a_leading_link_as_sentence_initial() {
3604 for rule in [restart_rule(&[]), restart_rule(&[":"])] {
3607 assert_eq!(
3608 suggested(&rule, "# [Some Link Here](https://example.com) Trailing Words\n").as_deref(),
3609 Some("[Some link here](https://example.com) trailing words")
3610 );
3611 }
3612 }
3613
3614 #[test]
3615 fn test_restart_after_fix_is_idempotent() {
3616 let rule = restart_rule(&[":", ";", "-", "\u{2014}"]);
3617 for content in [
3618 "# Requirement 1: Struct to Logger Slice Conversion\n",
3619 "# Ports: Well-Known Ports Explained\n",
3620 "# Devices: iPhone And Android\n",
3621 "# Overview: [Some Link Here](https://example.com) Trailing Words\n",
3622 "# Setup:\n",
3623 ] {
3624 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3625 let once = rule.fix(&ctx).unwrap();
3626 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
3627 assert_eq!(rule.fix(&ctx).unwrap(), once, "fix is not idempotent for {content:?}");
3628 }
3629 }
3630
3631 #[test]
3632 fn test_restart_after_ignores_empty_boundary_entries() {
3633 let rule = restart_rule(&[""]);
3635 assert_eq!(
3636 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
3637 Some("Requirement 1: struct to logger slice conversion")
3638 );
3639 }
3640
3641 const STYLES: [HeadingCapStyle; 3] = [
3644 HeadingCapStyle::TitleCase,
3645 HeadingCapStyle::SentenceCase,
3646 HeadingCapStyle::AllCaps,
3647 ];
3648
3649 const GHERKIN_STRUCTURES: [(&str, &str, &str, &str); 6] = [
3652 (
3653 "# Feature: the system under test",
3654 "# Feature: The System Under Test",
3655 "# Feature: The system under test",
3656 "# Feature: THE SYSTEM UNDER TEST",
3657 ),
3658 (
3659 "## Background: a shared setup",
3660 "## Background: A Shared Setup",
3661 "## Background: A shared setup",
3662 "## Background: A SHARED SETUP",
3663 ),
3664 (
3665 "## Rule: money is never lost",
3666 "## Rule: Money Is Never Lost",
3667 "## Rule: Money is never lost",
3668 "## Rule: MONEY IS NEVER LOST",
3669 ),
3670 (
3671 "### Scenario: add two numbers",
3672 "### Scenario: Add Two Numbers",
3673 "### Scenario: Add two numbers",
3674 "### Scenario: ADD TWO NUMBERS",
3675 ),
3676 (
3677 "### Scenario Outline: add two numbers",
3678 "### Scenario Outline: Add Two Numbers",
3679 "### Scenario Outline: Add two numbers",
3680 "### Scenario Outline: ADD TWO NUMBERS",
3681 ),
3682 (
3683 "#### Examples: happy path",
3684 "#### Examples: Happy Path",
3685 "#### Examples: Happy path",
3686 "#### Examples: HAPPY PATH",
3687 ),
3688 ];
3689
3690 fn recased(style: HeadingCapStyle, heading: &str, flavor: crate::config::MarkdownFlavor) -> String {
3692 let rule = create_rule_with_style(style);
3693 let content = format!("{heading}\n");
3694 let ctx = LintContext::new(&content, flavor, None);
3695 let warnings = rule.check(&ctx).unwrap();
3696 let fixed = rule.fix(&ctx).unwrap();
3697 assert_eq!(
3698 warnings.is_empty(),
3699 fixed == content,
3700 "a warning and a rewrite must agree for {content:?} under {flavor:?}"
3701 );
3702 fixed.trim_end().to_string()
3703 }
3704
3705 #[test]
3706 fn test_mdg_keeps_the_keyword_of_every_structure() {
3707 for (heading, ..) in GHERKIN_STRUCTURES {
3710 let keyword = &heading[..=heading.find(':').unwrap()];
3711 for style in STYLES {
3712 let fixed = recased(style, heading, crate::config::MarkdownFlavor::MDG);
3713 assert!(
3714 fixed.starts_with(keyword),
3715 "{style:?} lost the keyword of {heading:?}: {fixed}"
3716 );
3717 }
3718 }
3719 }
3720
3721 #[test]
3722 fn test_mdg_recases_only_the_name_of_a_structure() {
3723 for (heading, title, sentence, caps) in GHERKIN_STRUCTURES {
3724 let mdg = crate::config::MarkdownFlavor::MDG;
3725 assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), title);
3726 assert_eq!(recased(HeadingCapStyle::SentenceCase, heading, mdg), sentence);
3727 assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), caps);
3728 }
3729 }
3730
3731 #[test]
3732 fn test_standard_flavor_recases_a_keyword_like_any_other_word() {
3733 let standard = crate::config::MarkdownFlavor::Standard;
3735 assert_eq!(
3736 recased(HeadingCapStyle::TitleCase, "# Feature: the system under test", standard),
3737 "# Feature: the System Under Test"
3738 );
3739 assert_eq!(
3740 recased(
3741 HeadingCapStyle::SentenceCase,
3742 "### Scenario Outline: add two numbers",
3743 standard
3744 ),
3745 "### Scenario outline: add two numbers"
3746 );
3747 assert_eq!(
3748 recased(HeadingCapStyle::AllCaps, "# Feature: the system under test", standard),
3749 "# FEATURE: THE SYSTEM UNDER TEST"
3750 );
3751 }
3752
3753 #[test]
3754 fn test_mdg_leaves_a_heading_without_a_colon_to_the_normal_rule() {
3755 for heading in ["## notes about the system", "## Notes", "# THE SYSTEM"] {
3756 for style in STYLES {
3757 assert_eq!(
3758 recased(style, heading, crate::config::MarkdownFlavor::MDG),
3759 recased(style, heading, crate::config::MarkdownFlavor::Standard),
3760 "{style:?} treated {heading:?} as a Gherkin structure"
3761 );
3762 }
3763 }
3764 }
3765
3766 #[test]
3767 fn test_mdg_splits_at_the_first_colon_only() {
3768 let mdg = crate::config::MarkdownFlavor::MDG;
3770 let heading = "## Scenario: ratio: two to one";
3771 assert_eq!(
3772 recased(HeadingCapStyle::TitleCase, heading, mdg),
3773 "## Scenario: Ratio: Two to One"
3774 );
3775 assert_eq!(
3776 recased(HeadingCapStyle::SentenceCase, heading, mdg),
3777 "## Scenario: Ratio: two to one"
3778 );
3779 assert_eq!(
3780 recased(HeadingCapStyle::AllCaps, heading, mdg),
3781 "## Scenario: RATIO: TWO TO ONE"
3782 );
3783 }
3784
3785 #[test]
3786 fn test_mdg_leaves_a_colon_behind_a_backtick_to_the_normal_rule() {
3787 for heading in [
3791 "# See `x: y` Notes",
3792 "# `a: b`",
3793 "# `code` Feature: a name",
3794 "# `x: y` Feature: a name",
3795 ] {
3796 for style in STYLES {
3797 assert_eq!(
3798 recased(style, heading, crate::config::MarkdownFlavor::MDG),
3799 recased(style, heading, crate::config::MarkdownFlavor::Standard),
3800 "{style:?} split {heading:?} at a colon inside a code span"
3801 );
3802 }
3803 }
3804 }
3805
3806 #[test]
3807 fn test_mdg_splits_at_a_keyword_colon_that_precedes_a_code_span() {
3808 let mdg = crate::config::MarkdownFlavor::MDG;
3810 let heading = "# Scenario: use `a: b` here";
3811 assert_eq!(
3812 recased(HeadingCapStyle::TitleCase, heading, mdg),
3813 "# Scenario: Use `a: b` Here"
3814 );
3815 assert_eq!(
3816 recased(HeadingCapStyle::SentenceCase, heading, mdg),
3817 "# Scenario: Use `a: b` here"
3818 );
3819 assert_eq!(
3820 recased(HeadingCapStyle::AllCaps, heading, mdg),
3821 "# Scenario: USE `a: b` HERE"
3822 );
3823 }
3824
3825 #[test]
3826 fn test_mdg_splits_at_a_keyword_colon_before_an_unbalanced_backtick() {
3827 let mdg = crate::config::MarkdownFlavor::MDG;
3830 let heading = "# Scenario: a ` b";
3831 assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), "# Scenario: A ` B");
3832 assert_eq!(
3833 recased(HeadingCapStyle::SentenceCase, heading, mdg),
3834 "# Scenario: A ` b"
3835 );
3836 assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), "# Scenario: A ` B");
3837 }
3838
3839 #[test]
3840 fn test_mdg_keeps_a_keyword_with_nothing_left_to_recase() {
3841 for style in STYLES {
3842 assert_eq!(
3843 recased(style, "# Feature:", crate::config::MarkdownFlavor::MDG),
3844 "# Feature:"
3845 );
3846 }
3847 }
3848
3849 #[test]
3850 fn test_mdg_keeps_a_custom_id_after_the_name() {
3851 assert_eq!(
3852 recased(
3853 HeadingCapStyle::TitleCase,
3854 "# Feature: the system {#overview}",
3855 crate::config::MarkdownFlavor::MDG
3856 ),
3857 "# Feature: The System {#overview}"
3858 );
3859 }
3860
3861 #[test]
3862 fn test_mdg_fix_is_idempotent() {
3863 for (heading, ..) in GHERKIN_STRUCTURES {
3864 for style in STYLES {
3865 let rule = create_rule_with_style(style);
3866 let content = format!("{heading}\n");
3867 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
3868 let once = rule.fix(&ctx).unwrap();
3869 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::MDG, None);
3870 assert_eq!(
3871 rule.fix(&ctx).unwrap(),
3872 once,
3873 "fix is not idempotent for {heading:?} ({style:?})"
3874 );
3875 }
3876 }
3877 }
3878}