1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
15use crate::utils::header_id_utils::{HTML_TAG_ATTRIBUTES_PATTERN, HTML_TAG_NAME_PATTERN, is_backslash_escaped};
16use crate::utils::html_elements::is_void_element;
17use crate::utils::mdg;
18use crate::utils::range_utils::byte_to_char_count;
19use regex::Regex;
20use std::collections::HashSet;
21use std::sync::LazyLock;
22
23mod md063_config;
24pub(super) use md063_config::{HeadingCapStyle, MD063Config};
25
26static INLINE_CODE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`+[^`]+`+").unwrap());
28
29static LINK_REGEX: LazyLock<Regex> =
34 LazyLock::new(|| Regex::new(r"\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)|\[([^\]]*)\]\[[^\]]*\]").unwrap());
35
36static HTML_TOKEN_REGEX: LazyLock<Regex> = LazyLock::new(|| {
42 let pattern = format!(
43 r"<!-->|<!--->|<!--.*?-->|</({HTML_TAG_NAME_PATTERN})\s*>|<({HTML_TAG_NAME_PATTERN}){HTML_TAG_ATTRIBUTES_PATTERN}\s*/?>"
44 );
45 Regex::new(&pattern).unwrap()
46});
47
48const SELF_RENDERING_ELEMENTS: &[&str] = &[
53 "img", "video", "audio", "iframe", "embed", "object", "canvas", "svg", "math", "input", "select", "textarea",
54 "button", "meter", "progress",
55];
56
57static CUSTOM_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*\{#[^}]+\}\s*$").unwrap());
59
60#[derive(Debug, Clone)]
62enum HeadingSegment {
63 Text(String),
65 Code(String),
67 Link {
69 full: String,
70 text_start: usize,
71 text_end: usize,
72 },
73 Html(String),
75 Image(String),
78}
79
80impl HeadingSegment {
81 fn renders_nothing(&self) -> bool {
86 match self {
87 HeadingSegment::Html(html) => {
88 let paints_something = HTML_TOKEN_REGEX.captures_iter(html).any(|token| {
89 token.get(2).is_some_and(|name| {
90 let name = name.as_str().to_ascii_lowercase();
91 SELF_RENDERING_ELEMENTS.contains(&name.as_str())
92 })
93 });
94 !paints_something && HTML_TOKEN_REGEX.replace_all(html, "").trim().is_empty()
95 }
96 _ => false,
97 }
98 }
99}
100
101#[derive(Clone)]
103pub struct MD063HeadingCapitalization {
104 config: MD063Config,
105 lowercase_set: HashSet<String>,
106 proper_names: Vec<String>,
109}
110
111impl Default for MD063HeadingCapitalization {
112 fn default() -> Self {
113 Self::new()
114 }
115}
116
117impl MD063HeadingCapitalization {
118 pub fn new() -> Self {
119 let config = MD063Config::default();
120 let lowercase_set = config.lowercase_words.iter().cloned().collect();
121 Self {
122 config,
123 lowercase_set,
124 proper_names: Vec::new(),
125 }
126 }
127
128 pub fn from_config_struct(config: MD063Config) -> Self {
129 let lowercase_set = config.lowercase_words.iter().cloned().collect();
130 Self {
131 config,
132 lowercase_set,
133 proper_names: Vec::new(),
134 }
135 }
136
137 fn match_case_insensitive_at(text: &str, start: usize, pattern_lower: &str) -> Option<usize> {
144 if start > text.len() || !text.is_char_boundary(start) || pattern_lower.is_empty() {
145 return None;
146 }
147
148 let mut matched_bytes = 0;
149
150 for (offset, ch) in text[start..].char_indices() {
151 if matched_bytes >= pattern_lower.len() {
152 break;
153 }
154
155 let lowered: String = ch.to_lowercase().collect();
156 if !pattern_lower[matched_bytes..].starts_with(&lowered) {
157 return None;
158 }
159
160 matched_bytes += lowered.len();
161
162 if matched_bytes == pattern_lower.len() {
163 return Some(start + offset + ch.len_utf8());
164 }
165 }
166
167 None
168 }
169
170 fn find_case_insensitive_match(text: &str, pattern_lower: &str, search_start: usize) -> Option<(usize, usize)> {
173 if pattern_lower.is_empty() || search_start >= text.len() || !text.is_char_boundary(search_start) {
174 return None;
175 }
176
177 for (offset, _) in text[search_start..].char_indices() {
178 let start = search_start + offset;
179 if let Some(end) = Self::match_case_insensitive_at(text, start, pattern_lower) {
180 return Some((start, end));
181 }
182 }
183
184 None
185 }
186
187 fn proper_name_canonical_forms(&self, text: &str) -> std::collections::HashMap<usize, &str> {
193 let mut map = std::collections::HashMap::new();
194
195 for name in &self.proper_names {
196 if name.is_empty() {
197 continue;
198 }
199 let name_lower = name.to_lowercase();
200 let canonical_words: Vec<&str> = name.split_whitespace().collect();
201 if canonical_words.is_empty() {
202 continue;
203 }
204 let mut search_start = 0;
205
206 while search_start < text.len() {
207 let Some((abs_pos, end_pos)) = Self::find_case_insensitive_match(text, &name_lower, search_start)
208 else {
209 break;
210 };
211
212 let before_ok = abs_pos == 0 || !text[..abs_pos].chars().last().is_some_and(char::is_alphanumeric);
214 let after_ok =
215 end_pos >= text.len() || !text[end_pos..].chars().next().is_some_and(char::is_alphanumeric);
216
217 if before_ok && after_ok {
218 let text_slice = &text[abs_pos..end_pos];
222 let mut word_idx = 0;
223 let mut slice_offset = 0;
224
225 for text_word in text_slice.split_whitespace() {
226 if let Some(w_rel) = text_slice[slice_offset..].find(text_word) {
227 let word_abs = abs_pos + slice_offset + w_rel;
228 if let Some(&canonical_word) = canonical_words.get(word_idx) {
229 map.insert(word_abs, canonical_word);
230 }
231 slice_offset += w_rel + text_word.len();
232 word_idx += 1;
233 }
234 }
235 }
236
237 search_start = abs_pos + text[abs_pos..].chars().next().map_or(1, char::len_utf8);
240 }
241 }
242
243 map
244 }
245
246 fn has_internal_capitals(&self, word: &str) -> bool {
248 let chars: Vec<char> = word.chars().collect();
249 if chars.len() < 2 {
250 return false;
251 }
252
253 let first = chars[0];
254 let rest = &chars[1..];
255 let has_upper_in_rest = rest.iter().any(|c| c.is_uppercase());
256 let has_lower_in_rest = rest.iter().any(|c| c.is_lowercase());
257
258 if has_upper_in_rest && has_lower_in_rest {
260 return true;
261 }
262
263 if first.is_lowercase() && has_upper_in_rest {
265 return true;
266 }
267
268 false
269 }
270
271 fn is_all_caps_acronym(&self, word: &str) -> bool {
275 if word.len() < 2 {
277 return false;
278 }
279
280 let mut consecutive_upper = 0;
281 let mut max_consecutive = 0;
282
283 for c in word.chars() {
284 if c.is_uppercase() {
285 consecutive_upper += 1;
286 max_consecutive = max_consecutive.max(consecutive_upper);
287 } else if c.is_lowercase() {
288 return false;
290 } else {
291 consecutive_upper = 0;
293 }
294 }
295
296 max_consecutive >= 2
298 }
299
300 fn should_preserve_word(&self, word: &str) -> bool {
302 if self.is_ignored_word(word) {
304 return true;
305 }
306
307 let is_ordinal = Self::is_numeric_ordinal(word);
313
314 if !is_ordinal {
315 if self.config.preserve_cased_words && self.has_internal_capitals(word) {
317 return true;
318 }
319
320 if self.config.preserve_cased_words && self.is_all_caps_acronym(word) {
322 return true;
323 }
324 }
325
326 if self.is_caret_notation(word) {
328 return true;
329 }
330
331 false
332 }
333
334 fn is_ignored_word(&self, word: &str) -> bool {
335 self.config.ignore_words.iter().any(|ignored| ignored == word)
336 }
337
338 fn is_numeric_ordinal(word: &str) -> bool {
346 let core = word.trim_matches(|c: char| !c.is_alphanumeric());
351 let bytes = core.as_bytes();
352
353 let alpha_start = match bytes.iter().position(|&b| !b.is_ascii_digit()) {
355 Some(pos) if pos > 0 => pos,
356 _ => return false,
357 };
358
359 let alpha_end = bytes[alpha_start..]
361 .iter()
362 .position(|b| !b.is_ascii_alphabetic())
363 .map_or(bytes.len(), |p| alpha_start + p);
364
365 let suffix = &core[alpha_start..alpha_end];
366 matches!(suffix.to_ascii_lowercase().as_str(), "st" | "nd" | "rd" | "th")
367 }
368
369 fn is_caret_notation(&self, word: &str) -> bool {
371 let chars: Vec<char> = word.chars().collect();
372 if chars.len() >= 2 && chars[0] == '^' {
374 let second = chars[1];
375 if second.is_ascii_uppercase() || "@[\\]^_".contains(second) {
377 return true;
378 }
379 }
380 false
381 }
382
383 fn is_lowercase_word(&self, word: &str) -> bool {
385 self.lowercase_set.contains(&word.to_lowercase())
386 }
387
388 fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
390 if word.is_empty() {
391 return word.to_string();
392 }
393
394 if self.should_preserve_word(word) {
396 return word.to_string();
397 }
398
399 if is_first || is_last {
401 return self.capitalize_first(word);
402 }
403
404 if self.is_lowercase_word(word) {
406 return Self::lowercase_preserving_composition(word);
407 }
408
409 self.capitalize_first(word)
411 }
412
413 fn apply_canonical_form_to_word(word: &str, canonical: &str) -> String {
416 let canonical_lower = canonical.to_lowercase();
417 if canonical_lower.is_empty() {
418 return canonical.to_string();
419 }
420
421 if let Some(end_pos) = Self::match_case_insensitive_at(word, 0, &canonical_lower) {
422 let mut out = String::with_capacity(canonical.len() + word.len().saturating_sub(end_pos));
423 out.push_str(canonical);
424 out.push_str(&word[end_pos..]);
425 out
426 } else {
427 canonical.to_string()
428 }
429 }
430
431 fn capitalize_first(&self, word: &str) -> String {
433 if word.is_empty() {
434 return String::new();
435 }
436
437 let first_alpha_pos = word.find(|c: char| c.is_alphabetic());
439 let Some(pos) = first_alpha_pos else {
440 return word.to_string();
441 };
442
443 let prefix = &word[..pos];
444 let suffix = &word[pos..];
445
446 if Self::is_numeric_ordinal(word) {
449 let suffix_lower = Self::lowercase_preserving_composition(suffix);
450 return format!("{prefix}{suffix_lower}");
451 }
452
453 let mut chars = suffix.chars();
454 let first = chars.next().unwrap();
455 let first_upper = Self::uppercase_preserving_composition(&first.to_string());
458 let rest: String = chars.collect();
459 let rest_lower = Self::lowercase_preserving_composition(&rest);
460 format!("{prefix}{first_upper}{rest_lower}")
461 }
462
463 fn lowercase_preserving_composition(s: &str) -> String {
466 let mut result = String::with_capacity(s.len());
467 for c in s.chars() {
468 let lower: String = c.to_lowercase().collect();
469 if lower.chars().count() == 1 {
470 result.push_str(&lower);
471 } else {
472 result.push(c);
474 }
475 }
476 result
477 }
478
479 fn sentence_case_first_person_pronouns(word: &str) -> Option<String> {
489 fn is_emphasis_marker(c: char) -> bool {
490 matches!(c, '*' | '_' | '~')
491 }
492
493 fn is_word_connector(c: char) -> bool {
497 matches!(c, '/' | '\\' | '-' | '.' | '+' | '&')
498 }
499
500 fn is_word_boundary(c: char) -> bool {
501 !c.is_alphanumeric() && !is_word_connector(c)
502 }
503
504 fn is_pronoun_at(word: &str, pos: usize) -> bool {
505 let left = word[..pos].trim_end_matches(is_emphasis_marker);
506 if !left.chars().next_back().is_none_or(is_word_boundary) {
507 return false;
508 }
509
510 let after_i = word[pos + 1..].trim_start_matches(is_emphasis_marker);
511 let Some(apostrophe) = after_i.chars().next() else {
512 return true;
513 };
514 if !matches!(apostrophe, '\'' | '’') {
515 return is_word_boundary(apostrophe);
516 }
517
518 let after_apostrophe = after_i[apostrophe.len_utf8()..].trim_start_matches(is_emphasis_marker);
519 let suffix_end = after_apostrophe
520 .find(|c: char| !c.is_ascii_alphabetic())
521 .unwrap_or(after_apostrophe.len());
522 let suffix = &after_apostrophe[..suffix_end];
523 if suffix.is_empty() {
524 return after_apostrophe.chars().next().is_none_or(is_word_boundary);
526 }
527 if !matches!(suffix.to_ascii_lowercase().as_str(), "d" | "ll" | "m" | "ve") {
528 return false;
529 }
530
531 let after_suffix = after_apostrophe[suffix_end..].trim_start_matches(is_emphasis_marker);
532 after_suffix.chars().next().is_none_or(is_word_boundary)
533 }
534
535 let pronoun_positions: Vec<usize> = word
536 .char_indices()
537 .filter_map(|(pos, c)| (c == 'I' && is_pronoun_at(word, pos)).then_some(pos))
538 .collect();
539 if pronoun_positions.is_empty() {
540 return None;
541 }
542
543 let mut result = String::with_capacity(word.len());
544 let mut copied_through = 0;
545 for pos in pronoun_positions {
546 result.push_str(&Self::lowercase_preserving_composition(&word[copied_through..pos]));
547 result.push('I');
548 copied_through = pos + 1;
549 }
550 result.push_str(&Self::lowercase_preserving_composition(&word[copied_through..]));
551 Some(result)
552 }
553
554 fn uppercase_preserving_composition(s: &str) -> String {
559 let mut result = String::with_capacity(s.len());
560 for c in s.chars() {
561 let upper: String = c.to_uppercase().collect();
562 if upper.chars().count() == 1 {
563 result.push_str(&upper);
564 } else {
565 result.push(c);
567 }
568 }
569 result
570 }
571
572 fn apply_title_case(&self, text: &str) -> String {
576 let canonical_forms = self.proper_name_canonical_forms(text);
577
578 let original_words: Vec<&str> = text.split_whitespace().collect();
579 let total_words = original_words.len();
580
581 let mut word_positions: Vec<usize> = Vec::with_capacity(original_words.len());
584 let mut pos = 0;
585 for word in &original_words {
586 if let Some(rel) = text[pos..].find(word) {
587 word_positions.push(pos + rel);
588 pos = pos + rel + word.len();
589 } else {
590 word_positions.push(usize::MAX);
591 }
592 }
593
594 let result_words: Vec<String> = original_words
595 .iter()
596 .enumerate()
597 .map(|(i, word)| {
598 let after_period = i > 0 && original_words[i - 1].ends_with('.');
599 let is_first = i == 0 || after_period;
600 let is_last = i == total_words - 1;
601
602 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
604 return Self::apply_canonical_form_to_word(word, canonical);
605 }
606
607 if self.should_preserve_word(word) {
609 return (*word).to_string();
610 }
611
612 if word.contains('-') {
614 return self.handle_hyphenated_word(word, is_first, is_last);
615 }
616
617 self.title_case_word(word, is_first, is_last)
618 })
619 .collect();
620
621 result_words.join(" ")
622 }
623
624 fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
626 let parts: Vec<&str> = word.split('-').collect();
627 let total_parts = parts.len();
628
629 let result_parts: Vec<String> = parts
630 .iter()
631 .enumerate()
632 .map(|(i, part)| {
633 let part_is_first = is_first && i == 0;
635 let part_is_last = is_last && i == total_parts - 1;
636 self.title_case_word(part, part_is_first, part_is_last)
637 })
638 .collect();
639
640 result_parts.join("-")
641 }
642
643 fn ends_sentence(&self, word: &str) -> bool {
649 self.config
650 .sentence_case_restart_after
651 .iter()
652 .any(|boundary| !boundary.is_empty() && word.ends_with(boundary.as_str()))
653 }
654
655 fn apply_sentence_case_from(&self, text: &str, starts_sentence: bool) -> String {
659 if text.is_empty() {
660 return text.to_string();
661 }
662
663 let canonical_forms = self.proper_name_canonical_forms(text);
664 let mut result = String::new();
665 let mut current_pos = 0;
666 let mut at_sentence_start = starts_sentence;
667
668 for word in text.split_whitespace() {
670 if let Some(pos) = text[current_pos..].find(word) {
671 let abs_pos = current_pos + pos;
672
673 result.push_str(&text[current_pos..abs_pos]);
675
676 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
679 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
680 } else if self.is_ignored_word(word) {
681 result.push_str(word);
683 } else if let Some(pronoun) = Self::sentence_case_first_person_pronouns(word) {
684 result.push_str(&pronoun);
687 } else if at_sentence_start {
688 if self.should_preserve_word(word) {
690 result.push_str(word);
692 } else {
693 let mut chars = word.chars();
695 if let Some(first) = chars.next() {
696 result.push_str(&Self::uppercase_preserving_composition(&first.to_string()));
697 let rest: String = chars.collect();
698 result.push_str(&Self::lowercase_preserving_composition(&rest));
699 }
700 }
701 } else {
702 if self.should_preserve_word(word) {
704 result.push_str(word);
705 } else {
706 result.push_str(&Self::lowercase_preserving_composition(word));
707 }
708 }
709
710 at_sentence_start = self.ends_sentence(word);
711 current_pos = abs_pos + word.len();
712 }
713 }
714
715 if current_pos < text.len() {
717 result.push_str(&text[current_pos..]);
718 }
719
720 result
721 }
722
723 fn apply_all_caps(&self, text: &str) -> String {
725 if text.is_empty() {
726 return text.to_string();
727 }
728
729 let canonical_forms = self.proper_name_canonical_forms(text);
730 let mut result = String::new();
731 let mut current_pos = 0;
732
733 for word in text.split_whitespace() {
735 if let Some(pos) = text[current_pos..].find(word) {
736 let abs_pos = current_pos + pos;
737
738 result.push_str(&text[current_pos..abs_pos]);
740
741 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
744 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
745 } else if self.should_preserve_word(word) {
746 result.push_str(word);
747 } else {
748 result.push_str(&Self::uppercase_preserving_composition(word));
749 }
750
751 current_pos = abs_pos + word.len();
752 }
753 }
754
755 if current_pos < text.len() {
757 result.push_str(&text[current_pos..]);
758 }
759
760 result
761 }
762
763 fn parse_segments(&self, text: &str) -> Vec<HeadingSegment> {
765 let mut segments = Vec::new();
766 let mut last_end = 0;
767
768 let mut special_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
770
771 for mat in INLINE_CODE_REGEX.find_iter(text) {
773 special_regions.push((mat.start(), mat.end(), HeadingSegment::Code(mat.as_str().to_string())));
774 }
775
776 for caps in LINK_REGEX.captures_iter(text) {
778 let full_match = caps.get(0).unwrap();
779
780 if full_match.start() >= 1 && text.as_bytes()[full_match.start() - 1] == b'!' {
784 let region_start = full_match.start() - 1;
785 special_regions.push((
786 region_start,
787 full_match.end(),
788 HeadingSegment::Image(text[region_start..full_match.end()].to_string()),
789 ));
790 continue;
791 }
792
793 let text_match = caps.get(1).or_else(|| caps.get(2));
794
795 if let Some(text_m) = text_match {
796 special_regions.push((
797 full_match.start(),
798 full_match.end(),
799 HeadingSegment::Link {
800 full: full_match.as_str().to_string(),
801 text_start: text_m.start() - full_match.start(),
802 text_end: text_m.end() - full_match.start(),
803 },
804 ));
805 }
806 }
807
808 let code_ranges: Vec<(usize, usize)> = special_regions
811 .iter()
812 .filter(|(_, _, segment)| matches!(segment, HeadingSegment::Code(_)))
813 .map(|(start, end, _)| (*start, *end))
814 .collect();
815 for (start, end) in Self::html_regions(text, &code_ranges) {
816 special_regions.push((start, end, HeadingSegment::Html(text[start..end].to_string())));
817 }
818
819 special_regions.sort_by_key(|(start, _, _)| *start);
821
822 let mut filtered_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
825 for region in special_regions {
826 let overlaps = filtered_regions.iter().any(|(s, e, _)| region.0 < *e && region.1 > *s);
827 if !overlaps {
828 filtered_regions.push(region);
829 }
830 }
831
832 for (start, end, segment) in filtered_regions {
834 if start > last_end {
836 let text_segment = &text[last_end..start];
837 if !text_segment.is_empty() {
838 segments.push(HeadingSegment::Text(text_segment.to_string()));
839 }
840 }
841 segments.push(segment);
842 last_end = end;
843 }
844
845 if last_end < text.len() {
847 let remaining = &text[last_end..];
848 if !remaining.is_empty() {
849 segments.push(HeadingSegment::Text(remaining.to_string()));
850 }
851 }
852
853 if segments.is_empty() && !text.is_empty() {
855 segments.push(HeadingSegment::Text(text.to_string()));
856 }
857
858 segments
859 }
860
861 fn html_regions(text: &str, code_ranges: &[(usize, usize)]) -> Vec<(usize, usize)> {
875 let mut regions: Vec<(usize, usize)> = Vec::new();
876 let mut open_elements: Vec<(String, usize)> = Vec::new();
877
878 let mut pos = 0;
879 while let Some(token) = HTML_TOKEN_REGEX.captures_at(text, pos) {
880 let whole = token.get(0).unwrap();
881 if code_ranges
882 .iter()
883 .any(|&(start, end)| start <= whole.start() && whole.start() < end)
884 || is_backslash_escaped(text, whole.start())
885 {
886 pos = whole.start() + 1;
888 continue;
889 }
890 pos = whole.end();
891
892 if let Some(closing) = token.get(1) {
893 let name = closing.as_str().to_ascii_lowercase();
894 if let Some(depth) = open_elements.iter().rposition(|(open_name, _)| *open_name == name) {
895 let element_start = open_elements[depth].1;
896 open_elements.truncate(depth);
897 regions.retain(|&(start, _)| start < element_start);
898 regions.push((element_start, whole.end()));
899 continue;
900 }
901 } else if let Some(opening) = token.get(2) {
902 let name = opening.as_str().to_ascii_lowercase();
903 if !whole.as_str().ends_with("/>") && !is_void_element(&name) {
904 open_elements.push((name, whole.start()));
905 }
906 }
907
908 regions.push((whole.start(), whole.end()));
909 }
910
911 regions
912 }
913
914 fn mdx_html_regions(
918 ctx: &crate::lint_context::LintContext,
919 start: usize,
920 end: usize,
921 ) -> (Vec<(usize, usize)>, bool) {
922 let html = ctx.html_tags();
923 let components = ctx.jsx_component_tags();
924 let mut tags = Vec::new();
925 for cached in [html.as_slice(), components.as_slice()] {
926 let first = cached.partition_point(|tag| tag.byte_end <= start);
927 tags.extend(cached[first..].iter().take_while(|tag| tag.byte_offset < end));
928 }
929 tags.sort_unstable_by_key(|tag| tag.byte_offset);
930 let mut regions = Vec::new();
931 let mut open = Vec::new();
932 let mut requires_source_map = false;
933 for tag in tags {
934 let source = &ctx.content[tag.byte_offset..tag.byte_end];
935 requires_source_map |= !HTML_TOKEN_REGEX
936 .find(source)
937 .is_some_and(|token| token.start() == 0 && token.end() == source.len());
938 if tag.is_closing {
939 if let Some(depth) = open.iter().rposition(|(name, _)| *name == tag.tag_name.as_str()) {
940 let (_, element_start) = open[depth];
941 open.truncate(depth);
942 regions.push((element_start, tag.byte_end));
943 }
944 } else if !tag.is_self_closing && !is_void_element(&tag.tag_name) {
945 open.push((tag.tag_name.as_str(), tag.byte_offset));
946 }
947 regions.push((tag.byte_offset, tag.byte_end));
948 }
949 (regions, requires_source_map)
950 }
951
952 fn apply_heading_capitalization(
954 &self,
955 ctx: &crate::lint_context::LintContext,
956 line_index: usize,
957 heading: &crate::lint_context::HeadingInfo,
958 ) -> String {
959 if ctx.flavor != crate::config::MarkdownFlavor::MDX {
960 return self.apply_capitalization(&heading.raw_text, ctx.flavor);
961 }
962 let line_info = &ctx.lines[line_index];
963 if heading.text_lines > 1 {
964 let first = line_index.saturating_sub(heading.text_lines - 1);
967 let start = ctx.lines[first].byte_offset;
968 let end = line_info.byte_offset + line_info.byte_len;
969 let (_, requires_source_map) = Self::mdx_html_regions(ctx, start, end);
970 if ctx.overlaps_mdx_inline_code(start, end) || requires_source_map {
971 return heading.raw_text.clone();
972 }
973 return self.apply_capitalization(&heading.raw_text, ctx.flavor);
974 }
975 let line = line_info.content(ctx.content);
976 let Some(relative_start) = line.find(&heading.raw_text) else {
977 return heading.raw_text.clone();
978 };
979 let start = line_info.byte_offset + relative_start;
980 let end = start + heading.raw_text.len();
981 let (mut ranges, _) = Self::mdx_html_regions(ctx, start, end);
982 ranges.extend(ctx.mdx_inline_code_ranges(start, end));
983 let ranges = ranges
984 .into_iter()
985 .map(|(range_start, range_end)| (range_start.max(start) - start, range_end.min(end) - start))
986 .collect();
987 self.apply_capitalization_with_ranges(&heading.raw_text, ctx.flavor, ranges)
988 }
989
990 fn apply_capitalization(&self, text: &str, flavor: crate::config::MarkdownFlavor) -> String {
992 self.apply_capitalization_with_ranges(text, flavor, Vec::new())
993 }
994
995 fn apply_capitalization_with_ranges(
996 &self,
997 text: &str,
998 flavor: crate::config::MarkdownFlavor,
999 mut ranges: Vec<(usize, usize)>,
1000 ) -> String {
1001 ranges.extend(crate::utils::jinja_utils::find_jinja_ranges(text));
1002 ranges.extend(crate::utils::shortcode_utils::shortcode_ranges(text));
1003 ranges.sort_unstable();
1004 if ranges.is_empty() {
1005 return self.apply_capitalization_to_segments(text, flavor);
1006 }
1007
1008 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
1011 for (start, end) in ranges {
1012 if let Some(last) = merged.last_mut()
1013 && start <= last.1
1014 {
1015 last.1 = last.1.max(end);
1016 } else {
1017 merged.push((start, end));
1018 }
1019 }
1020
1021 let mut delimiter = String::from("\u{e000}");
1028 while text.contains(&delimiter) {
1029 delimiter.push('\u{e000}');
1030 }
1031 let mut masked = String::with_capacity(text.len());
1032 let mut templates = Vec::with_capacity(merged.len());
1033 let mut last_end = 0;
1034 for (index, (start, end)) in merged.into_iter().enumerate() {
1035 let source = &text[start..end];
1036 let token = if flavor == crate::config::MarkdownFlavor::MDX && source.starts_with('<') {
1037 let body = if HeadingSegment::Html(source.to_string()).renders_nothing() {
1038 ""
1039 } else {
1040 "0"
1041 };
1042 format!("<span data-md063=\"{delimiter}{index}{delimiter}\">{body}</span>")
1043 } else if flavor == crate::config::MarkdownFlavor::MDX
1044 && crate::utils::mdx_comments::is_comment_only_expression(&text[start..end])
1045 {
1046 format!("<!--{delimiter}{index}{delimiter}-->")
1049 } else {
1050 format!("{delimiter}{index}{delimiter} {delimiter}{index}{delimiter}")
1051 };
1052 masked.push_str(&text[last_end..start]);
1053 masked.push_str(&token);
1054 templates.push((token, &text[start..end]));
1055 last_end = end;
1056 }
1057 masked.push_str(&text[last_end..]);
1058
1059 let capitalized = self.apply_capitalization_to_segments(&masked, flavor);
1060 let mut result = String::with_capacity(capitalized.len());
1061 let mut cursor = 0;
1062 for (token, template) in templates {
1065 let Some(offset) = capitalized[cursor..].find(&token) else {
1066 return text.to_string();
1069 };
1070 let start = cursor + offset;
1071 result.push_str(&capitalized[cursor..start]);
1072 result.push_str(template);
1073 cursor = start + token.len();
1074 }
1075 result.push_str(&capitalized[cursor..]);
1076 result
1077 }
1078
1079 fn apply_capitalization_to_segments(&self, text: &str, flavor: crate::config::MarkdownFlavor) -> String {
1081 let (main_text, custom_id) = if let Some(mat) = CUSTOM_ID_REGEX.find(text) {
1083 (&text[..mat.start()], Some(mat.as_str()))
1084 } else {
1085 (text, None)
1086 };
1087
1088 let (keyword, main_text) = if flavor == crate::config::MarkdownFlavor::MDG {
1095 mdg::keyword_split(main_text).unwrap_or(("", main_text))
1096 } else {
1097 ("", main_text)
1098 };
1099
1100 let segments = self.parse_segments(main_text);
1102
1103 let text_segments: Vec<usize> = segments
1105 .iter()
1106 .enumerate()
1107 .filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
1108 .collect();
1109
1110 let first_segment_starts_sentence = segments
1114 .iter()
1115 .find(|s| !s.renders_nothing())
1116 .is_some_and(|s| matches!(s, HeadingSegment::Text(_) | HeadingSegment::Link { .. }));
1117
1118 let last_segment_is_text = segments
1121 .iter()
1122 .rev()
1123 .find(|s| !s.renders_nothing())
1124 .is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
1125
1126 let mut result_parts: Vec<String> = Vec::new();
1128
1129 let mut at_sentence_start = first_segment_starts_sentence;
1132
1133 for (i, segment) in segments.iter().enumerate() {
1134 at_sentence_start = match segment {
1139 HeadingSegment::Text(t) => {
1140 let is_first_text = text_segments.first() == Some(&i);
1141 let is_last_text = text_segments.last() == Some(&i) && last_segment_is_text;
1145
1146 let capitalized = match self.config.style {
1147 HeadingCapStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
1148 HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(t, at_sentence_start),
1149 HeadingCapStyle::AllCaps => self.apply_all_caps(t),
1150 };
1151 let ends_sentence = self.ends_sentence(capitalized.trim_end());
1152 result_parts.push(capitalized);
1153 ends_sentence
1154 }
1155 HeadingSegment::Code(c) => {
1156 result_parts.push(c.clone());
1157 false
1158 }
1159 HeadingSegment::Link {
1160 full,
1161 text_start,
1162 text_end,
1163 } => {
1164 let link_text = &full[*text_start..*text_end];
1166 let capitalized_text = match self.config.style {
1167 HeadingCapStyle::TitleCase => self.apply_title_case(link_text),
1168 HeadingCapStyle::SentenceCase => self.apply_sentence_case_from(link_text, at_sentence_start),
1171 HeadingCapStyle::AllCaps => self.apply_all_caps(link_text),
1172 };
1173 let ends_sentence = self.ends_sentence(capitalized_text.trim_end());
1176
1177 let mut new_link = String::new();
1178 new_link.push_str(&full[..*text_start]);
1179 new_link.push_str(&capitalized_text);
1180 new_link.push_str(&full[*text_end..]);
1181 result_parts.push(new_link);
1182 ends_sentence
1183 }
1184 HeadingSegment::Html(h) => {
1185 result_parts.push(h.clone());
1189 segment.renders_nothing() && at_sentence_start
1190 }
1191 HeadingSegment::Image(img) => {
1192 result_parts.push(img.clone());
1194 false
1195 }
1196 };
1197 }
1198
1199 let mut result = String::with_capacity(text.len());
1200 result.push_str(keyword);
1201 result.push_str(&result_parts.join(""));
1202
1203 if let Some(id) = custom_id {
1205 result.push_str(id);
1206 }
1207
1208 result
1209 }
1210
1211 fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
1213 let canonical_forms = self.proper_name_canonical_forms(text);
1214 let words: Vec<&str> = text.split_whitespace().collect();
1215 let total_words = words.len();
1216
1217 if total_words == 0 {
1218 return text.to_string();
1219 }
1220
1221 let mut word_positions: Vec<usize> = Vec::with_capacity(words.len());
1224 let mut pos = 0;
1225 for word in &words {
1226 if let Some(rel) = text[pos..].find(word) {
1227 word_positions.push(pos + rel);
1228 pos = pos + rel + word.len();
1229 } else {
1230 word_positions.push(usize::MAX);
1231 }
1232 }
1233
1234 let result_words: Vec<String> = words
1235 .iter()
1236 .enumerate()
1237 .map(|(i, word)| {
1238 let after_period = i > 0 && words[i - 1].ends_with('.');
1239 let is_first = (is_first_segment && i == 0) || after_period;
1240 let is_last = is_last_segment && i == total_words - 1;
1241
1242 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
1244 return Self::apply_canonical_form_to_word(word, canonical);
1245 }
1246
1247 if word.contains('-') {
1249 return self.handle_hyphenated_word(word, is_first, is_last);
1250 }
1251
1252 self.title_case_word(word, is_first, is_last)
1253 })
1254 .collect();
1255
1256 let mut result = String::new();
1258 let mut word_iter = result_words.iter();
1259 let mut in_word = false;
1260
1261 for c in text.chars() {
1262 if c.is_whitespace() {
1263 if in_word {
1264 in_word = false;
1265 }
1266 result.push(c);
1267 } else if !in_word {
1268 if let Some(word) = word_iter.next() {
1269 result.push_str(word);
1270 }
1271 in_word = true;
1272 }
1273 }
1274
1275 result
1276 }
1277
1278 fn fix_atx_heading(&self, _line: &str, heading: &crate::lint_context::HeadingInfo, fixed_text: &str) -> String {
1280 let indent = " ".repeat(heading.marker_column);
1282 let hashes = "#".repeat(heading.level as usize);
1283
1284 let closing = &heading.closing_sequence;
1286 if heading.has_closing_sequence {
1287 format!("{indent}{hashes} {fixed_text} {closing}")
1288 } else {
1289 format!("{indent}{hashes} {fixed_text}")
1290 }
1291 }
1292
1293 fn fix_setext_heading(&self, line: &str, _heading: &crate::lint_context::HeadingInfo, fixed_text: &str) -> String {
1295 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
1297
1298 format!("{leading_ws}{fixed_text}")
1299 }
1300
1301 fn rewrite_words<'w>(line: &str, words: &mut impl Iterator<Item = &'w str>) -> String {
1304 let mut result = String::with_capacity(line.len());
1305 let mut in_word = false;
1306 for c in line.chars() {
1307 if c.is_whitespace() {
1308 in_word = false;
1309 result.push(c);
1310 } else if !in_word {
1311 if let Some(word) = words.next() {
1312 result.push_str(word);
1313 }
1314 in_word = true;
1315 }
1316 }
1317 result
1318 }
1319
1320 fn fix_setext_heading_span(
1336 &self,
1337 ctx: &crate::lint_context::LintContext,
1338 first_idx: usize,
1339 last_idx: usize,
1340 heading: &crate::lint_context::HeadingInfo,
1341 flavor: crate::config::MarkdownFlavor,
1342 ) -> Option<Vec<String>> {
1343 let bodies: Vec<(&str, &str)> = (first_idx..=last_idx)
1344 .map(|idx| {
1345 let line = ctx.lines[idx].content(ctx.content);
1346 if idx < last_idx && ctx.line_ends_with_hard_break(idx + 1) {
1347 line.split_at(line.len() - 1)
1348 } else {
1349 (line, "")
1350 }
1351 })
1352 .collect();
1353 let fixed_text = self.apply_capitalization(&heading.raw_text, flavor);
1354 let fixed_words: Vec<&str> = fixed_text.split_whitespace().collect();
1355 let original_words: usize = bodies.iter().map(|(body, _)| body.split_whitespace().count()).sum();
1356 if fixed_words.len() != original_words {
1357 return None;
1358 }
1359
1360 let mut words = fixed_words.into_iter();
1361 Some(
1362 bodies
1363 .iter()
1364 .map(|(body, hard_break)| format!("{}{hard_break}", Self::rewrite_words(body, &mut words)))
1365 .collect(),
1366 )
1367 }
1368}
1369
1370impl Rule for MD063HeadingCapitalization {
1371 fn name(&self) -> &'static str {
1372 "MD063"
1373 }
1374
1375 fn description(&self) -> &'static str {
1376 "Heading capitalization"
1377 }
1378
1379 fn category(&self) -> RuleCategory {
1380 RuleCategory::Heading
1381 }
1382
1383 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1384 !ctx.likely_has_headings() || !ctx.lines.iter().any(|line| line.heading.is_some())
1385 }
1386
1387 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1388 let content = ctx.content;
1389
1390 if content.is_empty() {
1391 return Ok(Vec::new());
1392 }
1393
1394 let mut warnings = Vec::new();
1395
1396 for (line_num, line_info) in ctx.lines.iter().enumerate() {
1397 if let Some(heading) = &line_info.heading {
1398 if heading.level < self.config.min_level || heading.level > self.config.max_level {
1400 continue;
1401 }
1402
1403 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
1405 continue;
1406 }
1407
1408 let original_text = &heading.raw_text;
1410 let fixed_text = self.apply_heading_capitalization(ctx, line_num, heading);
1411
1412 if original_text != &fixed_text {
1413 let line = line_info.content(ctx.content);
1414 let style_name = match self.config.style {
1415 HeadingCapStyle::TitleCase => "title case",
1416 HeadingCapStyle::SentenceCase => "sentence case",
1417 HeadingCapStyle::AllCaps => "ALL CAPS",
1418 };
1419
1420 if heading.text_lines > 1 {
1424 let first_idx = line_num + 1 - heading.text_lines;
1425 let first_line = ctx.lines[first_idx].content(ctx.content);
1426 let fix = self
1427 .fix_setext_heading_span(ctx, first_idx, line_num, heading, ctx.flavor)
1428 .map(|rewritten| {
1429 let range = ctx.line_content_byte_range(first_idx + 1).start
1430 ..ctx.line_content_byte_range(line_num + 1).end;
1431 Fix::new(range, rewritten.join("\n"))
1432 });
1433 warnings.push(LintWarning {
1434 rule_name: Some(self.name().to_string()),
1435 line: first_idx + 1,
1436 column: byte_to_char_count(first_line, heading.content_column),
1437 end_line: line_num + 1,
1438 end_column: line.trim_end().chars().count() + 1,
1439 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1440 severity: Severity::Warning,
1441 fix,
1442 });
1443 continue;
1444 }
1445
1446 warnings.push(LintWarning {
1447 rule_name: Some(self.name().to_string()),
1448 line: line_num + 1,
1449 column: byte_to_char_count(line, heading.content_column),
1450 end_line: line_num + 1,
1451 end_column: byte_to_char_count(line, heading.content_column) + original_text.chars().count(),
1452 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1453 severity: Severity::Warning,
1454 fix: Some(Fix::new(
1455 ctx.line_content_byte_range(line_num + 1),
1456 match heading.style {
1457 crate::lint_context::HeadingStyle::ATX => {
1458 self.fix_atx_heading(line, heading, &fixed_text)
1459 }
1460 _ => self.fix_setext_heading(line, heading, &fixed_text),
1461 },
1462 )),
1463 });
1464 }
1465 }
1466 }
1467
1468 Ok(warnings)
1469 }
1470
1471 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1472 let content = ctx.content;
1473
1474 if content.is_empty() {
1475 return Ok(content.to_string());
1476 }
1477
1478 let lines = ctx.raw_lines();
1479 let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1480
1481 for (line_num, line_info) in ctx.lines.iter().enumerate() {
1482 if ctx.is_rule_disabled(self.name(), line_num + 1) {
1484 continue;
1485 }
1486
1487 if let Some(heading) = &line_info.heading {
1488 if heading.level < self.config.min_level || heading.level > self.config.max_level {
1490 continue;
1491 }
1492
1493 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
1495 continue;
1496 }
1497
1498 let original_text = &heading.raw_text;
1499 let fixed_text = self.apply_heading_capitalization(ctx, line_num, heading);
1500
1501 if original_text != &fixed_text {
1502 let line = line_info.content(ctx.content);
1503 if heading.text_lines > 1 {
1506 let first_idx = line_num + 1 - heading.text_lines;
1507 if (first_idx..line_num).any(|idx| ctx.is_rule_disabled(self.name(), idx + 1)) {
1510 continue;
1511 }
1512 if let Some(rewritten) =
1513 self.fix_setext_heading_span(ctx, first_idx, line_num, heading, ctx.flavor)
1514 {
1515 fixed_lines[first_idx..=line_num].clone_from_slice(&rewritten);
1516 }
1517 continue;
1518 }
1519 fixed_lines[line_num] = match heading.style {
1520 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading, &fixed_text),
1521 _ => self.fix_setext_heading(line, heading, &fixed_text),
1522 };
1523 }
1524 }
1525 }
1526
1527 let mut result = String::with_capacity(content.len());
1529 for (i, line) in fixed_lines.iter().enumerate() {
1530 result.push_str(line);
1531 if i < fixed_lines.len() - 1 || content.ends_with('\n') {
1532 result.push('\n');
1533 }
1534 }
1535
1536 Ok(result)
1537 }
1538
1539 fn as_any(&self) -> &dyn std::any::Any {
1540 self
1541 }
1542
1543 crate::impl_rule_config_sections!(MD063Config);
1544
1545 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1546 where
1547 Self: Sized,
1548 {
1549 let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
1550 let md044_config =
1551 crate::rule_config_serde::load_rule_config::<crate::rules::md044_proper_names::MD044Config>(config);
1552 let mut rule = Self::from_config_struct(rule_config);
1553 rule.proper_names = md044_config.names;
1554 Box::new(rule)
1555 }
1556}
1557
1558#[cfg(test)]
1559mod tests {
1560 use super::*;
1561 use crate::lint_context::LintContext;
1562
1563 fn create_rule() -> MD063HeadingCapitalization {
1564 let config = MD063Config {
1565 enabled: true,
1566 ..Default::default()
1567 };
1568 MD063HeadingCapitalization::from_config_struct(config)
1569 }
1570
1571 fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
1572 let config = MD063Config {
1573 enabled: true,
1574 style,
1575 ..Default::default()
1576 };
1577 MD063HeadingCapitalization::from_config_struct(config)
1578 }
1579
1580 #[test]
1583 fn test_mdx_sentence_case_distinguishes_comments_from_comments_plus_code() {
1584 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1585 for (source, expected) in [
1586 ("# { /* hidden */ } hello world\n", "# { /* hidden */ } Hello world\n"),
1587 (
1588 "export const name = \"Visible\"\n\n# {/* c */ name} hello world\n",
1589 "export const name = \"Visible\"\n\n# {/* c */ name} hello world\n",
1590 ),
1591 ] {
1592 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1593 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1594 }
1595 }
1596
1597 #[test]
1598 fn test_mdx_leading_comment_does_not_consume_sentence_start() {
1599 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1600 for (source, expected) in [
1601 (
1602 "# {/* literal comment */} hello world\n",
1603 "# {/* literal comment */} Hello world\n",
1604 ),
1605 (
1606 "{/* literal comment */} hello world\n===\n",
1607 "{/* literal comment */} Hello world\n===\n",
1608 ),
1609 ] {
1610 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1611 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1612 }
1613 }
1614
1615 #[test]
1616 fn test_mdx_heading_expression_code_is_preserved() {
1617 for (style, before, after) in [
1618 (HeadingCapStyle::TitleCase, "Hello", "World"),
1619 (HeadingCapStyle::SentenceCase, "Hello", "world"),
1620 (HeadingCapStyle::AllCaps, "HELLO", "WORLD"),
1621 ] {
1622 let rule = create_rule_with_style(style);
1623 for expression in ["name", "data.text", r#""literal value""#, "name.toLowerCase()"] {
1624 let source = format!("# hello {{{expression}}} world\n");
1625 let expected = format!("# {before} {{{expression}}} {after}\n");
1626 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
1627 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1628 assert_eq!(
1629 rule.fix(&ctx).unwrap(),
1630 expected,
1631 "expression code is literal: {source}"
1632 );
1633 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::MDX, None);
1634 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1635 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1636 }
1637 }
1638 }
1639
1640 #[test]
1641 fn test_mdx_heading_link_labels_and_adjacent_expressions_preserve_code() {
1642 let rule = create_rule();
1643 for (source, expected) in [
1644 (
1645 "# hello [{name} world](target) friend\n",
1646 "# Hello [{name} World](target) Friend\n",
1647 ),
1648 ("# hello {name}{data.text} world\n", "# Hello {name}{data.text} World\n"),
1649 (
1650 "# hello {/* literal comment */} world\n",
1651 "# Hello {/* literal comment */} World\n",
1652 ),
1653 ("# 日本語 {name} world\n", "# 日本語 {name} World\n"),
1654 ] {
1655 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1656 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1657 }
1658 }
1659
1660 #[test]
1661 fn test_mdx_expression_protection_is_flavor_specific() {
1662 let rule = create_rule();
1663 let source = "# hello {name} world\n";
1664 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1665 assert_eq!(rule.fix(&ctx).unwrap(), "# Hello {Name} World\n");
1666 }
1667
1668 #[test]
1669 fn test_mdx_heading_attribute_expressions_keep_existing_html_protection() {
1670 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1671 let source = "# hello <span title={name}>world</span> friend\n";
1672 let expected = "# HELLO <span title={name}>world</span> FRIEND\n";
1673 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1674 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1675 }
1676
1677 #[test]
1678 fn test_heading_template_tags_are_preserved() {
1679 for (style, before, after) in [
1680 (HeadingCapStyle::TitleCase, "Hello", "World"),
1681 (HeadingCapStyle::SentenceCase, "Hello", "world"),
1682 (HeadingCapStyle::AllCaps, "HELLO", "WORLD"),
1683 ] {
1684 let rule = create_rule_with_style(style);
1685 for template in [
1686 "{{ name }}",
1687 "{{ data['display_name'] }}",
1688 "{{< note title=\"literal value\" >}}",
1689 "{{% note title=\"literal value\" %}}",
1690 ] {
1691 let source = format!("# hello {template} world\n");
1692 let expected = format!("# {before} {template} {after}\n");
1693 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1694 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1695 assert_eq!(rule.fix(&ctx).unwrap(), expected, "template code is literal: {source}");
1696 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
1697 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1698 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1699 }
1700 }
1701 }
1702
1703 #[test]
1704 fn test_heading_template_control_tags_and_link_labels_are_preserved() {
1705 let rule = create_rule();
1706 for (source, expected) in [
1707 (
1708 "# hello {% if enabled %}yes{% else %}no{% endif %} world\n",
1709 "# Hello {% if enabled %}Yes{% else %}No{% endif %} World\n",
1710 ),
1711 (
1712 "# hello [{{ name }} world](target) friend\n",
1713 "# Hello [{{ name }} World](target) Friend\n",
1714 ),
1715 (
1716 "# hello {{ name }} and {{ data['display_name'] }} world\n",
1717 "# Hello {{ name }} and {{ data['display_name'] }} World\n",
1718 ),
1719 ] {
1720 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1721 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1722 }
1723 }
1724
1725 #[test]
1726 fn test_heading_template_tokens_do_not_collide_with_literal_text() {
1727 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1728 let source = "# 日本語 \u{e000}0\u{e000} {{ name }} world\n";
1729 let expected = "# 日本語 \u{e000}0\u{e000} {{ name }} WORLD\n";
1730 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1731 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1732 }
1733
1734 #[test]
1735 fn test_an_escaped_tag_does_not_hide_the_element_written_inside_it() {
1736 let text = r#"\<span title='<a id="x"></a>'>foo"#;
1739 assert_eq!(MD063HeadingCapitalization::html_regions(text, &[]), vec![(14, 28)]);
1740 }
1741
1742 #[test]
1743 fn test_title_case_basic() {
1744 let rule = create_rule();
1745 let content = "# hello world\n";
1746 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1747 let result = rule.check(&ctx).unwrap();
1748 assert_eq!(result.len(), 1);
1749 assert!(result[0].message.contains("Hello World"));
1750 }
1751
1752 #[test]
1753 fn test_title_case_lowercase_words() {
1754 let rule = create_rule();
1755 let content = "# the quick brown fox\n";
1756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1757 let result = rule.check(&ctx).unwrap();
1758 assert_eq!(result.len(), 1);
1759 assert!(result[0].message.contains("The Quick Brown Fox"));
1761 }
1762
1763 #[test]
1764 fn test_title_case_already_correct() {
1765 let rule = create_rule();
1766 let content = "# The Quick Brown Fox\n";
1767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1768 let result = rule.check(&ctx).unwrap();
1769 assert!(result.is_empty(), "Already correct heading should not be flagged");
1770 }
1771
1772 #[test]
1773 fn test_title_case_hyphenated() {
1774 let rule = create_rule();
1775 let content = "# self-documenting code\n";
1776 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1777 let result = rule.check(&ctx).unwrap();
1778 assert_eq!(result.len(), 1);
1779 assert!(result[0].message.contains("Self-Documenting Code"));
1780 }
1781
1782 #[test]
1783 fn test_title_case_preserves_url_with_nested_parens() {
1784 let rule = create_rule();
1785 let content = "# guide for [the api](https://example.com/docs/v(2)beta)\n";
1787 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1788 let fixed = rule.fix(&ctx).unwrap();
1789 assert!(
1792 fixed.contains("https://example.com/docs/v(2)beta"),
1793 "URL with nested parens was corrupted: {fixed:?}"
1794 );
1795 }
1796
1797 #[test]
1798 fn test_title_case_does_not_recase_image_alt() {
1799 let rule = create_rule();
1800 let content = "# overview \n";
1801 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1802 let fixed = rule.fix(&ctx).unwrap();
1803 assert!(
1805 fixed.contains(""),
1806 "image alt text was modified: {fixed:?}"
1807 );
1808 assert!(
1809 fixed.contains("# Overview"),
1810 "surrounding prose should still be title-cased: {fixed:?}"
1811 );
1812 }
1813
1814 #[test]
1816 fn test_sentence_case_basic() {
1817 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1818 let content = "# The Quick Brown Fox\n";
1819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1820 let result = rule.check(&ctx).unwrap();
1821 assert_eq!(result.len(), 1);
1822 assert!(result[0].message.contains("The quick brown fox"));
1823 }
1824
1825 #[test]
1826 fn test_sentence_case_already_correct() {
1827 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1828 let content = "# The quick brown fox\n";
1829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1830 let result = rule.check(&ctx).unwrap();
1831 assert!(result.is_empty());
1832 }
1833
1834 #[test]
1835 fn test_sentence_case_preserves_first_person_pronoun() {
1836 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1838 let content = "# How do I debug playbooks?\n";
1839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1840
1841 assert!(rule.check(&ctx).unwrap().is_empty());
1842 assert_eq!(rule.fix(&ctx).unwrap(), content);
1843 }
1844
1845 #[test]
1846 fn test_sentence_case_preserves_first_person_contractions() {
1847 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1848
1849 for content in [
1850 "# What I'd change\n",
1851 "# Where I'll look\n",
1852 "# Why I'm here\n",
1853 "# What I've learned\n",
1854 "# What I’d change\n",
1855 "# Where I’ll look\n",
1856 "# Why I’m here\n",
1857 "# What I’ve learned\n",
1858 ] {
1859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1860 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1861 assert_eq!(rule.fix(&ctx).unwrap(), content);
1862 }
1863 }
1864
1865 #[test]
1866 fn test_sentence_case_pronoun_handles_markup_punctuation_and_suffix_case() {
1867 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1868 let cases = [
1869 ("# What (**I**) would change\n", "# What (**I**) would change\n"),
1870 ("# Why **I**'M changing it\n", "# Why **I**'m changing it\n"),
1871 ("# What I’LL change\n", "# What I’ll change\n"),
1872 ("# What I—really want\n", "# What I—really want\n"),
1873 ("# What I’d—reluctantly change\n", "# What I’d—reluctantly change\n"),
1874 ("# What I—yes—I—would do\n", "# What I—yes—I—would do\n"),
1875 ("# What [I'll change](plan.md)\n", "# What [I'll change](plan.md)\n"),
1876 ("# [What I—really want](plan.md)\n", "# [What I—really want](plan.md)\n"),
1877 ];
1878
1879 for (content, expected) in cases {
1880 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1881 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
1882 }
1883 }
1884
1885 #[test]
1886 fn test_sentence_case_pronoun_is_independent_of_cased_word_preservation() {
1887 let config = MD063Config {
1888 enabled: true,
1889 style: HeadingCapStyle::SentenceCase,
1890 preserve_cased_words: false,
1891 ..Default::default()
1892 };
1893 let rule = MD063HeadingCapitalization::from_config_struct(config);
1894 let content = "# How do I debug what I’LL change?\n";
1895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1896
1897 assert_eq!(rule.fix(&ctx).unwrap(), "# How do I debug what I’ll change?\n");
1898 }
1899
1900 #[test]
1901 fn test_sentence_case_explicit_ignore_wins_over_pronoun_normalization() {
1902 let config = MD063Config {
1903 enabled: true,
1904 style: HeadingCapStyle::SentenceCase,
1905 ignore_words: vec!["I'LL".to_string(), "I’LL".to_string()],
1906 ..Default::default()
1907 };
1908 let rule = MD063HeadingCapitalization::from_config_struct(config);
1909 let content = "# Why I'LL stay and why I’LL leave\n";
1910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1911
1912 assert!(rule.check(&ctx).unwrap().is_empty());
1913 assert_eq!(rule.fix(&ctx).unwrap(), content);
1914 }
1915
1916 #[test]
1917 fn test_sentence_case_pronoun_does_not_preserve_other_single_letters_or_compounds() {
1918 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1919 let content = "# Compare i, A, I/O, and A.I. values\n";
1920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1921
1922 assert_eq!(rule.fix(&ctx).unwrap(), "# Compare i, a, i/o, and a.i. values\n");
1923 }
1924
1925 #[test]
1927 fn test_all_caps_basic() {
1928 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1929 let content = "# hello world\n";
1930 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1931 let result = rule.check(&ctx).unwrap();
1932 assert_eq!(result.len(), 1);
1933 assert!(result[0].message.contains("HELLO WORLD"));
1934 }
1935
1936 #[test]
1938 fn test_preserve_ignore_words() {
1939 let config = MD063Config {
1940 enabled: true,
1941 ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
1942 ..Default::default()
1943 };
1944 let rule = MD063HeadingCapitalization::from_config_struct(config);
1945
1946 let content = "# using iPhone on macOS\n";
1947 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1948 let result = rule.check(&ctx).unwrap();
1949 assert_eq!(result.len(), 1);
1950 assert!(result[0].message.contains("iPhone"));
1952 assert!(result[0].message.contains("macOS"));
1953 }
1954
1955 #[test]
1956 fn test_preserve_cased_words() {
1957 let rule = create_rule();
1958 let content = "# using GitHub actions\n";
1959 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1960 let result = rule.check(&ctx).unwrap();
1961 assert_eq!(result.len(), 1);
1962 assert!(result[0].message.contains("GitHub"));
1964 }
1965
1966 #[test]
1968 fn test_inline_code_preserved() {
1969 let rule = create_rule();
1970 let content = "# using `const` in javascript\n";
1971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1972 let result = rule.check(&ctx).unwrap();
1973 assert_eq!(result.len(), 1);
1974 assert!(result[0].message.contains("`const`"));
1976 assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
1977 }
1978
1979 #[test]
1981 fn test_level_filter() {
1982 let config = MD063Config {
1983 enabled: true,
1984 min_level: 2,
1985 max_level: 4,
1986 ..Default::default()
1987 };
1988 let rule = MD063HeadingCapitalization::from_config_struct(config);
1989
1990 let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
1991 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1992 let result = rule.check(&ctx).unwrap();
1993
1994 assert_eq!(result.len(), 2);
1996 assert_eq!(result[0].line, 2); assert_eq!(result[1].line, 3); }
1999
2000 #[test]
2002 fn test_fix_atx_heading() {
2003 let rule = create_rule();
2004 let content = "# hello world\n";
2005 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2006 let fixed = rule.fix(&ctx).unwrap();
2007 assert_eq!(fixed, "# Hello World\n");
2008 }
2009
2010 #[test]
2011 fn test_fix_multiple_headings() {
2012 let rule = create_rule();
2013 let content = "# first heading\n\n## second heading\n";
2014 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2015 let fixed = rule.fix(&ctx).unwrap();
2016 assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
2017 }
2018
2019 #[test]
2021 fn test_setext_heading() {
2022 let rule = create_rule();
2023 let content = "hello world\n============\n";
2024 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2025 let result = rule.check(&ctx).unwrap();
2026 assert_eq!(result.len(), 1);
2027 assert!(result[0].message.contains("Hello World"));
2028 }
2029
2030 #[test]
2031 fn test_multi_line_setext_heading_is_capitalized_in_place() {
2032 let rule = create_rule();
2036 let content = "hello world\nand more words\n==============\n";
2037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2038 let fixed = rule.fix(&ctx).unwrap();
2039 assert_eq!(fixed, "Hello World\nand More Words\n==============\n");
2040
2041 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2042 assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent");
2043 }
2044
2045 #[test]
2046 fn test_multi_line_setext_heading_keeps_a_hard_break_backslash() {
2047 let rule = create_rule();
2050 let content = "foo bar\\\nbaz qux\n=======\n";
2051 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2052 let fixed = rule.fix(&ctx).unwrap();
2053 assert_eq!(fixed, "Foo Bar\\\nBaz Qux\n=======\n");
2054
2055 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2056 assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent");
2057 }
2058
2059 #[test]
2060 fn test_multi_line_setext_heading_keeps_a_backslash_that_is_no_hard_break() {
2061 let rule = create_rule();
2065 for (content, expected) in [
2066 ("foo `a\\\nb` tail\n===\n", "Foo `a\\\nb` Tail\n===\n"),
2067 ("foo bar\nbaz\\\n===\n", "Foo Bar\nBaz\\\n===\n"),
2068 ] {
2069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2070 let fixed = rule.fix(&ctx).unwrap();
2071 assert_eq!(fixed, expected, "{content:?}");
2072
2073 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2074 assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent: {content:?}");
2075 }
2076 }
2077
2078 #[test]
2079 fn test_multi_line_setext_heading_warning_covers_the_whole_span() {
2080 let rule = create_rule();
2083 let content = "hello world\nand more\n=====\n";
2084 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2085 let result = rule.check(&ctx).unwrap();
2086 assert_eq!(result.len(), 1, "got: {result:?}");
2087 assert_eq!(result[0].line, 1);
2088 assert_eq!(result[0].column, 1);
2089 assert_eq!(result[0].end_line, 2);
2090 assert_eq!(result[0].end_column, 9);
2091 }
2092
2093 #[test]
2094 fn test_multi_line_setext_heading_with_a_multi_byte_word_is_idempotent() {
2095 let rule = create_rule();
2099 let content = "`A`\n| à | |\n| --- | --- |\n---";
2100 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2101 let fixed = rule.fix(&ctx).unwrap();
2102 assert_eq!(fixed, "`A`\n| À | |\n| --- | --- |\n---");
2103
2104 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2105 assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent");
2106 }
2107
2108 #[test]
2109 fn test_multi_line_setext_heading_whose_words_cannot_be_mapped_back_is_reported_without_a_fix() {
2110 let rule = create_rule();
2115 let content = "see [ the guide ](guide.md) first\nand then more\n=====\n";
2116 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2117 let result = rule.check(&ctx).unwrap();
2118 assert_eq!(result.len(), 1, "got: {result:?}");
2119 assert_eq!(result[0].line, 1);
2120 assert_eq!(result[0].end_line, 2);
2121 assert!(
2122 result[0].fix.is_none(),
2123 "no rewrite is offered, got: {:?}",
2124 result[0].fix
2125 );
2126 assert_eq!(rule.fix(&ctx).unwrap(), content, "the heading is left as written");
2127
2128 let content = "see [the guide](guide.md) first\nand then more\n=====\n";
2130 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2131 let result = rule.check(&ctx).unwrap();
2132 assert_eq!(result.len(), 1, "got: {result:?}");
2133 assert!(result[0].fix.is_some(), "the control heading is fixable");
2134 assert_eq!(
2135 rule.fix(&ctx).unwrap(),
2136 "See [The Guide](guide.md) First\nand Then More\n=====\n"
2137 );
2138 }
2139
2140 #[test]
2141 fn test_fix_honours_a_suppression_on_any_line_of_a_multi_line_setext_heading() {
2142 let rule = create_rule();
2146 let content = "<!-- rumdl-disable-next-line MD063 -->\nhello\nworld\n===\n";
2147 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2148 assert_eq!(
2149 rule.fix(&ctx).unwrap(),
2150 content,
2151 "the suppressed heading is left as written"
2152 );
2153
2154 let content = "hello\nworld\n===\n";
2156 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2157 assert_eq!(rule.fix(&ctx).unwrap(), "Hello\nWorld\n===\n");
2158 }
2159
2160 #[test]
2162 fn test_custom_id_preserved() {
2163 let rule = create_rule();
2164 let content = "# getting started {#intro}\n";
2165 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2166 let result = rule.check(&ctx).unwrap();
2167 assert_eq!(result.len(), 1);
2168 assert!(result[0].message.contains("{#intro}"));
2170 }
2171
2172 #[test]
2174 fn test_skip_obsidian_tags_not_headings() {
2175 let rule = create_rule();
2176
2177 let content = "# H1\n\n#tag\n";
2179 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2180 let result = rule.check(&ctx).unwrap();
2181 assert!(
2182 result.is_empty() || result.iter().all(|w| w.line != 3),
2183 "Obsidian tag #tag should not be treated as a heading: {result:?}"
2184 );
2185 }
2186
2187 #[test]
2188 fn test_skip_invalid_atx_headings_no_space() {
2189 let rule = create_rule();
2190
2191 let content = "#notaheading\n";
2193 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2194 let result = rule.check(&ctx).unwrap();
2195 assert!(
2196 result.is_empty(),
2197 "Invalid ATX heading without space should not be flagged: {result:?}"
2198 );
2199 }
2200
2201 #[test]
2202 fn test_fix_skips_obsidian_tags() {
2203 let rule = create_rule();
2204
2205 let content = "# hello world\n\n#tag\n";
2206 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2207 let fixed = rule.fix(&ctx).unwrap();
2208 assert!(fixed.contains("#tag"), "Fix should not modify Obsidian tag #tag");
2210 assert!(fixed.contains("# Hello World"), "Fix should still fix real headings");
2211 }
2212
2213 #[test]
2214 fn test_preserve_all_caps_acronyms() {
2215 let rule = create_rule();
2216 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2217
2218 let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
2220 assert_eq!(fixed, "# Using API in Production\n");
2221
2222 let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
2224 assert_eq!(fixed, "# API and GPU Integration\n");
2225
2226 let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
2228 assert_eq!(fixed, "# IO Performance Guide\n");
2229
2230 let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
2232 assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
2233 }
2234
2235 #[test]
2236 fn test_preserve_acronyms_in_hyphenated_words() {
2237 let rule = create_rule();
2238 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2239
2240 let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
2242 assert_eq!(fixed, "# API-Driven Architecture\n");
2243
2244 let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
2246 assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
2247 }
2248
2249 #[test]
2250 fn test_single_letters_not_treated_as_acronyms() {
2251 let rule = create_rule();
2252 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2253
2254 let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
2256 assert_eq!(fixed, "# I Am a Heading\n");
2257 }
2258
2259 #[test]
2260 fn test_lowercase_terms_need_ignore_words() {
2261 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2262
2263 let rule = create_rule();
2265 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
2266 assert_eq!(fixed, "# Using Npm Packages\n");
2267
2268 let config = MD063Config {
2270 enabled: true,
2271 ignore_words: vec!["npm".to_string()],
2272 ..Default::default()
2273 };
2274 let rule = MD063HeadingCapitalization::from_config_struct(config);
2275 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
2276 assert_eq!(fixed, "# Using npm Packages\n");
2277 }
2278
2279 #[test]
2280 fn test_acronyms_with_mixed_case_preserved() {
2281 let rule = create_rule();
2282 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2283
2284 let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
2286 assert_eq!(fixed, "# Using API with GitHub\n");
2287 }
2288
2289 #[test]
2290 fn test_real_world_acronyms() {
2291 let rule = create_rule();
2292 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2293
2294 let content = "# FFI bindings for CPU optimization\n";
2296 let fixed = rule.fix(&ctx(content)).unwrap();
2297 assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
2298
2299 let content = "# DOM manipulation and SSR rendering\n";
2300 let fixed = rule.fix(&ctx(content)).unwrap();
2301 assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
2302
2303 let content = "# CVE security and RNN models\n";
2304 let fixed = rule.fix(&ctx(content)).unwrap();
2305 assert_eq!(fixed, "# CVE Security and RNN Models\n");
2306 }
2307
2308 #[test]
2309 fn test_is_all_caps_acronym() {
2310 let rule = create_rule();
2311
2312 assert!(rule.is_all_caps_acronym("API"));
2314 assert!(rule.is_all_caps_acronym("IO"));
2315 assert!(rule.is_all_caps_acronym("GPU"));
2316 assert!(rule.is_all_caps_acronym("HTTP2")); assert!(!rule.is_all_caps_acronym("A"));
2320 assert!(!rule.is_all_caps_acronym("I"));
2321
2322 assert!(!rule.is_all_caps_acronym("Api"));
2324 assert!(!rule.is_all_caps_acronym("npm"));
2325 assert!(!rule.is_all_caps_acronym("iPhone"));
2326 }
2327
2328 #[test]
2329 fn test_sentence_case_starts_after_a_leading_empty_anchor() {
2330 let config = MD063Config {
2332 enabled: true,
2333 style: HeadingCapStyle::SentenceCase,
2334 ..Default::default()
2335 };
2336 let rule = MD063HeadingCapitalization::from_config_struct(config);
2337
2338 let content = "# <a id=\"top\"></a>the beginning\n";
2339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2340 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2341 assert_eq!(rule.fix(&ctx).unwrap(), "# <a id=\"top\"></a>The beginning\n");
2342
2343 let content = "# <kbd>ctrl</kbd> the key\n";
2345 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2346 assert!(rule.check(&ctx).unwrap().is_empty());
2347
2348 for content in ["# <img src=\"x.png\"> the picture\n", "#  the picture\n"] {
2351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2352 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
2353 }
2354 }
2355
2356 #[test]
2357 fn test_sentence_case_ignore_words_first_word() {
2358 let config = MD063Config {
2359 enabled: true,
2360 style: HeadingCapStyle::SentenceCase,
2361 ignore_words: vec!["nvim".to_string()],
2362 ..Default::default()
2363 };
2364 let rule = MD063HeadingCapitalization::from_config_struct(config);
2365
2366 let content = "# nvim config\n";
2368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2369 let result = rule.check(&ctx).unwrap();
2370 assert!(
2371 result.is_empty(),
2372 "nvim in ignore-words should not be flagged. Got: {result:?}"
2373 );
2374
2375 let fixed = rule.fix(&ctx).unwrap();
2377 assert_eq!(fixed, "# nvim config\n");
2378 }
2379
2380 #[test]
2381 fn test_sentence_case_ignore_words_not_first() {
2382 let config = MD063Config {
2383 enabled: true,
2384 style: HeadingCapStyle::SentenceCase,
2385 ignore_words: vec!["nvim".to_string()],
2386 ..Default::default()
2387 };
2388 let rule = MD063HeadingCapitalization::from_config_struct(config);
2389
2390 let content = "# Using nvim editor\n";
2392 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2393 let result = rule.check(&ctx).unwrap();
2394 assert!(
2395 result.is_empty(),
2396 "nvim in ignore-words should be preserved. Got: {result:?}"
2397 );
2398 }
2399
2400 #[test]
2401 fn test_preserve_cased_words_ios() {
2402 let config = MD063Config {
2403 enabled: true,
2404 style: HeadingCapStyle::SentenceCase,
2405 preserve_cased_words: true,
2406 ..Default::default()
2407 };
2408 let rule = MD063HeadingCapitalization::from_config_struct(config);
2409
2410 let content = "## This is iOS\n";
2412 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2413 let result = rule.check(&ctx).unwrap();
2414 assert!(
2415 result.is_empty(),
2416 "iOS should be preserved with preserve-cased-words. Got: {result:?}"
2417 );
2418
2419 let fixed = rule.fix(&ctx).unwrap();
2421 assert_eq!(fixed, "## This is iOS\n");
2422 }
2423
2424 #[test]
2425 fn test_preserve_cased_words_ios_title_case() {
2426 let config = MD063Config {
2427 enabled: true,
2428 style: HeadingCapStyle::TitleCase,
2429 preserve_cased_words: true,
2430 ..Default::default()
2431 };
2432 let rule = MD063HeadingCapitalization::from_config_struct(config);
2433
2434 let content = "# developing for iOS\n";
2436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2437 let fixed = rule.fix(&ctx).unwrap();
2438 assert_eq!(fixed, "# Developing for iOS\n");
2439 }
2440
2441 #[test]
2442 fn test_has_internal_capitals_ios() {
2443 let rule = create_rule();
2444
2445 assert!(
2447 rule.has_internal_capitals("iOS"),
2448 "iOS has mixed case (lowercase i, uppercase OS)"
2449 );
2450
2451 assert!(rule.has_internal_capitals("iPhone"));
2453 assert!(rule.has_internal_capitals("macOS"));
2454 assert!(rule.has_internal_capitals("GitHub"));
2455 assert!(rule.has_internal_capitals("JavaScript"));
2456 assert!(rule.has_internal_capitals("eBay"));
2457
2458 assert!(!rule.has_internal_capitals("API"));
2460 assert!(!rule.has_internal_capitals("GPU"));
2461
2462 assert!(!rule.has_internal_capitals("npm"));
2464 assert!(!rule.has_internal_capitals("config"));
2465
2466 assert!(!rule.has_internal_capitals("The"));
2468 assert!(!rule.has_internal_capitals("Hello"));
2469 }
2470
2471 #[test]
2472 fn test_lowercase_words_before_trailing_code() {
2473 let config = MD063Config {
2474 enabled: true,
2475 style: HeadingCapStyle::TitleCase,
2476 lowercase_words: vec![
2477 "a".to_string(),
2478 "an".to_string(),
2479 "and".to_string(),
2480 "at".to_string(),
2481 "but".to_string(),
2482 "by".to_string(),
2483 "for".to_string(),
2484 "from".to_string(),
2485 "into".to_string(),
2486 "nor".to_string(),
2487 "on".to_string(),
2488 "onto".to_string(),
2489 "or".to_string(),
2490 "the".to_string(),
2491 "to".to_string(),
2492 "upon".to_string(),
2493 "via".to_string(),
2494 "vs".to_string(),
2495 "with".to_string(),
2496 "without".to_string(),
2497 ],
2498 preserve_cased_words: true,
2499 ..Default::default()
2500 };
2501 let rule = MD063HeadingCapitalization::from_config_struct(config);
2502
2503 let content = "## subtitle with a `app`\n";
2508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2509 let result = rule.check(&ctx).unwrap();
2510
2511 assert!(!result.is_empty(), "Should flag incorrect capitalization");
2513 let fixed = rule.fix(&ctx).unwrap();
2514 assert!(
2516 fixed.contains("with a `app`"),
2517 "Expected 'with a `app`' but got: {fixed:?}"
2518 );
2519 assert!(
2520 !fixed.contains("with A `app`"),
2521 "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
2522 );
2523 assert!(
2525 fixed.contains("Subtitle with a `app`"),
2526 "Expected 'Subtitle with a `app`' but got: {fixed:?}"
2527 );
2528 }
2529
2530 #[test]
2531 fn test_lowercase_words_preserved_before_trailing_code_variant() {
2532 let config = MD063Config {
2533 enabled: true,
2534 style: HeadingCapStyle::TitleCase,
2535 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2536 ..Default::default()
2537 };
2538 let rule = MD063HeadingCapitalization::from_config_struct(config);
2539
2540 let content = "## Title with the `code`\n";
2542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2543 let fixed = rule.fix(&ctx).unwrap();
2544 assert!(
2546 fixed.contains("with the `code`"),
2547 "Expected 'with the `code`' but got: {fixed:?}"
2548 );
2549 assert!(
2550 !fixed.contains("with The `code`"),
2551 "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
2552 );
2553 }
2554
2555 #[test]
2556 fn test_last_word_capitalized_when_no_trailing_code() {
2557 let config = MD063Config {
2560 enabled: true,
2561 style: HeadingCapStyle::TitleCase,
2562 lowercase_words: vec!["a".to_string(), "the".to_string()],
2563 ..Default::default()
2564 };
2565 let rule = MD063HeadingCapitalization::from_config_struct(config);
2566
2567 let content = "## title with a word\n";
2570 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2571 let fixed = rule.fix(&ctx).unwrap();
2572 assert!(
2574 fixed.contains("With a Word"),
2575 "Expected 'With a Word' but got: {fixed:?}"
2576 );
2577 }
2578
2579 #[test]
2580 fn test_multiple_lowercase_words_before_code() {
2581 let config = MD063Config {
2582 enabled: true,
2583 style: HeadingCapStyle::TitleCase,
2584 lowercase_words: vec![
2585 "a".to_string(),
2586 "the".to_string(),
2587 "with".to_string(),
2588 "for".to_string(),
2589 ],
2590 ..Default::default()
2591 };
2592 let rule = MD063HeadingCapitalization::from_config_struct(config);
2593
2594 let content = "## Guide for the `user`\n";
2596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2597 let fixed = rule.fix(&ctx).unwrap();
2598 assert!(
2599 fixed.contains("for the `user`"),
2600 "Expected 'for the `user`' but got: {fixed:?}"
2601 );
2602 assert!(
2603 !fixed.contains("For The `user`"),
2604 "Should not capitalize lowercase words before code. Got: {fixed:?}"
2605 );
2606 }
2607
2608 #[test]
2609 fn test_code_in_middle_normal_rules_apply() {
2610 let config = MD063Config {
2611 enabled: true,
2612 style: HeadingCapStyle::TitleCase,
2613 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2614 ..Default::default()
2615 };
2616 let rule = MD063HeadingCapitalization::from_config_struct(config);
2617
2618 let content = "## Using `const` for the code\n";
2620 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2621 let fixed = rule.fix(&ctx).unwrap();
2622 assert!(
2624 fixed.contains("for the Code"),
2625 "Expected 'for the Code' but got: {fixed:?}"
2626 );
2627 }
2628
2629 #[test]
2630 fn test_link_at_end_same_as_code() {
2631 let config = MD063Config {
2632 enabled: true,
2633 style: HeadingCapStyle::TitleCase,
2634 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2635 ..Default::default()
2636 };
2637 let rule = MD063HeadingCapitalization::from_config_struct(config);
2638
2639 let content = "## Guide for the [link](./page.md)\n";
2641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2642 let fixed = rule.fix(&ctx).unwrap();
2643 assert!(
2645 fixed.contains("for the [Link]"),
2646 "Expected 'for the [Link]' but got: {fixed:?}"
2647 );
2648 assert!(
2649 !fixed.contains("for The [Link]"),
2650 "Should not capitalize 'the' before link. Got: {fixed:?}"
2651 );
2652 }
2653
2654 #[test]
2655 fn test_multiple_code_segments() {
2656 let config = MD063Config {
2657 enabled: true,
2658 style: HeadingCapStyle::TitleCase,
2659 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2660 ..Default::default()
2661 };
2662 let rule = MD063HeadingCapitalization::from_config_struct(config);
2663
2664 let content = "## Using `const` with a `variable`\n";
2666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2667 let fixed = rule.fix(&ctx).unwrap();
2668 assert!(
2670 fixed.contains("with a `variable`"),
2671 "Expected 'with a `variable`' but got: {fixed:?}"
2672 );
2673 assert!(
2674 !fixed.contains("with A `variable`"),
2675 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2676 );
2677 }
2678
2679 #[test]
2680 fn test_code_and_link_combination() {
2681 let config = MD063Config {
2682 enabled: true,
2683 style: HeadingCapStyle::TitleCase,
2684 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2685 ..Default::default()
2686 };
2687 let rule = MD063HeadingCapitalization::from_config_struct(config);
2688
2689 let content = "## Guide for the `code` [link](./page.md)\n";
2691 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2692 let fixed = rule.fix(&ctx).unwrap();
2693 assert!(
2695 fixed.contains("for the `code`"),
2696 "Expected 'for the `code`' but got: {fixed:?}"
2697 );
2698 }
2699
2700 #[test]
2701 fn test_text_after_code_capitalizes_last() {
2702 let config = MD063Config {
2703 enabled: true,
2704 style: HeadingCapStyle::TitleCase,
2705 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2706 ..Default::default()
2707 };
2708 let rule = MD063HeadingCapitalization::from_config_struct(config);
2709
2710 let content = "## Using `const` for the code\n";
2712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2713 let fixed = rule.fix(&ctx).unwrap();
2714 assert!(
2716 fixed.contains("for the Code"),
2717 "Expected 'for the Code' but got: {fixed:?}"
2718 );
2719 }
2720
2721 #[test]
2722 fn test_preserve_cased_words_with_trailing_code() {
2723 let config = MD063Config {
2724 enabled: true,
2725 style: HeadingCapStyle::TitleCase,
2726 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
2727 preserve_cased_words: true,
2728 ..Default::default()
2729 };
2730 let rule = MD063HeadingCapitalization::from_config_struct(config);
2731
2732 let content = "## Guide for iOS `app`\n";
2734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2735 let fixed = rule.fix(&ctx).unwrap();
2736 assert!(
2738 fixed.contains("for iOS `app`"),
2739 "Expected 'for iOS `app`' but got: {fixed:?}"
2740 );
2741 assert!(
2742 !fixed.contains("For iOS `app`"),
2743 "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
2744 );
2745 }
2746
2747 #[test]
2748 fn test_ignore_words_with_trailing_code() {
2749 let config = MD063Config {
2750 enabled: true,
2751 style: HeadingCapStyle::TitleCase,
2752 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2753 ignore_words: vec!["npm".to_string()],
2754 ..Default::default()
2755 };
2756 let rule = MD063HeadingCapitalization::from_config_struct(config);
2757
2758 let content = "## Using npm with a `script`\n";
2760 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2761 let fixed = rule.fix(&ctx).unwrap();
2762 assert!(
2764 fixed.contains("npm with a `script`"),
2765 "Expected 'npm with a `script`' but got: {fixed:?}"
2766 );
2767 assert!(
2768 !fixed.contains("with A `script`"),
2769 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2770 );
2771 }
2772
2773 #[test]
2774 fn test_empty_text_segment_edge_case() {
2775 let config = MD063Config {
2776 enabled: true,
2777 style: HeadingCapStyle::TitleCase,
2778 lowercase_words: vec!["a".to_string(), "with".to_string()],
2779 ..Default::default()
2780 };
2781 let rule = MD063HeadingCapitalization::from_config_struct(config);
2782
2783 let content = "## `start` with a `end`\n";
2785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2786 let fixed = rule.fix(&ctx).unwrap();
2787 assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
2790 assert!(
2791 !fixed.contains("A `end`"),
2792 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
2793 );
2794 }
2795
2796 #[test]
2797 fn test_sentence_case_with_trailing_code() {
2798 let config = MD063Config {
2799 enabled: true,
2800 style: HeadingCapStyle::SentenceCase,
2801 lowercase_words: vec!["a".to_string(), "the".to_string()],
2802 ..Default::default()
2803 };
2804 let rule = MD063HeadingCapitalization::from_config_struct(config);
2805
2806 let content = "## guide for the `user`\n";
2808 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2809 let fixed = rule.fix(&ctx).unwrap();
2810 assert!(
2812 fixed.contains("Guide for the `user`"),
2813 "Expected 'Guide for the `user`' but got: {fixed:?}"
2814 );
2815 }
2816
2817 #[test]
2818 fn test_hyphenated_word_before_code() {
2819 let config = MD063Config {
2820 enabled: true,
2821 style: HeadingCapStyle::TitleCase,
2822 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2823 ..Default::default()
2824 };
2825 let rule = MD063HeadingCapitalization::from_config_struct(config);
2826
2827 let content = "## Self-contained with a `feature`\n";
2829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2830 let fixed = rule.fix(&ctx).unwrap();
2831 assert!(
2833 fixed.contains("with a `feature`"),
2834 "Expected 'with a `feature`' but got: {fixed:?}"
2835 );
2836 }
2837
2838 #[test]
2843 fn test_sentence_case_code_at_start_basic() {
2844 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2846 let content = "# `rumdl` is a linter\n";
2847 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2848 let result = rule.check(&ctx).unwrap();
2849 assert!(
2851 result.is_empty(),
2852 "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
2853 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2854 );
2855 }
2856
2857 #[test]
2858 fn test_sentence_case_code_at_start_incorrect_capitalization() {
2859 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2861 let content = "# `rumdl` Is a Linter\n";
2862 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2863 let result = rule.check(&ctx).unwrap();
2864 assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
2866 assert!(
2867 result[0].message.contains("`rumdl` is a linter"),
2868 "Should suggest lowercase after code. Got: {:?}",
2869 result[0].message
2870 );
2871 }
2872
2873 #[test]
2874 fn test_sentence_case_code_at_start_fix() {
2875 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2876 let content = "# `rumdl` Is A Linter\n";
2877 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2878 let fixed = rule.fix(&ctx).unwrap();
2879 assert!(
2880 fixed.contains("# `rumdl` is a linter"),
2881 "Should fix to lowercase after code. Got: {fixed:?}"
2882 );
2883 }
2884
2885 #[test]
2886 fn test_sentence_case_text_at_start_still_capitalizes() {
2887 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2889 let content = "# the quick brown fox\n";
2890 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2891 let result = rule.check(&ctx).unwrap();
2892 assert_eq!(result.len(), 1);
2893 assert!(
2894 result[0].message.contains("The quick brown fox"),
2895 "Text-first heading should capitalize first word. Got: {:?}",
2896 result[0].message
2897 );
2898 }
2899
2900 #[test]
2901 fn test_sentence_case_link_at_start() {
2902 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2904 let content = "# [api](api.md) reference guide\n";
2905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2906 let result = rule.check(&ctx).unwrap();
2907 assert_eq!(result.len(), 1);
2908 assert!(result[0].message.contains("[Api](api.md) reference guide"));
2909 }
2910
2911 #[test]
2912 fn test_sentence_case_link_preserves_acronyms() {
2913 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2915 let content = "# [API](api.md) Reference Guide\n";
2916 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2917 let result = rule.check(&ctx).unwrap();
2918 assert_eq!(result.len(), 1);
2919 assert!(
2921 result[0].message.contains("[API](api.md) reference guide"),
2922 "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
2923 result[0].message
2924 );
2925 }
2926
2927 #[test]
2928 fn test_sentence_case_link_preserves_brand_names() {
2929 let config = MD063Config {
2931 enabled: true,
2932 style: HeadingCapStyle::SentenceCase,
2933 preserve_cased_words: true,
2934 ..Default::default()
2935 };
2936 let rule = MD063HeadingCapitalization::from_config_struct(config);
2937 let content = "# [iPhone](iphone.md) Features Guide\n";
2938 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2939 let result = rule.check(&ctx).unwrap();
2940 assert_eq!(result.len(), 1);
2941 assert!(
2943 result[0].message.contains("[iPhone](iphone.md) features guide"),
2944 "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
2945 result[0].message
2946 );
2947 }
2948
2949 #[test]
2950 fn test_sentence_case_link_lowercases_regular_words() {
2951 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2953 let content = "# [Documentation](docs.md) Reference\n";
2954 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2955 let result = rule.check(&ctx).unwrap();
2956 assert_eq!(result.len(), 1);
2957 assert!(
2958 result[0].message.contains("[Documentation](docs.md) reference"),
2959 "Should preserve the sentence-initial capital. Got: {:?}",
2960 result[0].message
2961 );
2962 }
2963
2964 #[test]
2965 fn test_sentence_case_opening_link_label_is_sentence_initial() {
2966 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2968 let content = "# [Foo bar](https://example.com)\n";
2969 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2970
2971 assert!(rule.check(&ctx).unwrap().is_empty());
2972 assert_eq!(rule.fix(&ctx).unwrap(), content);
2973 }
2974
2975 #[test]
2976 fn test_sentence_case_link_at_start_correct_already() {
2977 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2979 let content = "# [API](api.md) reference guide\n";
2980 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2981 let result = rule.check(&ctx).unwrap();
2982 assert!(
2983 result.is_empty(),
2984 "Correctly cased heading with link should not be flagged. Got: {:?}",
2985 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2986 );
2987 }
2988
2989 #[test]
2990 fn test_sentence_case_link_github_preserved() {
2991 let config = MD063Config {
2993 enabled: true,
2994 style: HeadingCapStyle::SentenceCase,
2995 preserve_cased_words: true,
2996 ..Default::default()
2997 };
2998 let rule = MD063HeadingCapitalization::from_config_struct(config);
2999 let content = "# [GitHub](gh.md) Repository Setup\n";
3000 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3001 let result = rule.check(&ctx).unwrap();
3002 assert_eq!(result.len(), 1);
3003 assert!(
3004 result[0].message.contains("[GitHub](gh.md) repository setup"),
3005 "Should preserve 'GitHub'. Got: {:?}",
3006 result[0].message
3007 );
3008 }
3009
3010 #[test]
3011 fn test_sentence_case_multiple_code_spans() {
3012 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3013 let content = "# `foo` and `bar` are methods\n";
3014 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3015 let result = rule.check(&ctx).unwrap();
3016 assert!(
3018 result.is_empty(),
3019 "Should not capitalize words between/after code spans. Got: {:?}",
3020 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3021 );
3022 }
3023
3024 #[test]
3025 fn test_sentence_case_code_only_heading() {
3026 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3028 let content = "# `rumdl`\n";
3029 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3030 let result = rule.check(&ctx).unwrap();
3031 assert!(
3032 result.is_empty(),
3033 "Code-only heading should be fine. Got: {:?}",
3034 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3035 );
3036 }
3037
3038 #[test]
3039 fn test_sentence_case_code_at_end() {
3040 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3042 let content = "# install the `rumdl` tool\n";
3043 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3044 let result = rule.check(&ctx).unwrap();
3045 assert_eq!(result.len(), 1);
3047 assert!(
3048 result[0].message.contains("Install the `rumdl` tool"),
3049 "First word should still be capitalized when text comes first. Got: {:?}",
3050 result[0].message
3051 );
3052 }
3053
3054 #[test]
3055 fn test_sentence_case_code_in_middle() {
3056 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3058 let content = "# using the `rumdl` linter for markdown\n";
3059 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3060 let result = rule.check(&ctx).unwrap();
3061 assert_eq!(result.len(), 1);
3063 assert!(
3064 result[0].message.contains("Using the `rumdl` linter for markdown"),
3065 "First word should be capitalized. Got: {:?}",
3066 result[0].message
3067 );
3068 }
3069
3070 #[test]
3071 fn test_sentence_case_preserved_word_after_code() {
3072 let config = MD063Config {
3074 enabled: true,
3075 style: HeadingCapStyle::SentenceCase,
3076 preserve_cased_words: true,
3077 ..Default::default()
3078 };
3079 let rule = MD063HeadingCapitalization::from_config_struct(config);
3080 let content = "# `swift` iPhone development\n";
3081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3082 let result = rule.check(&ctx).unwrap();
3083 assert!(
3085 result.is_empty(),
3086 "Preserved words after code should stay. Got: {:?}",
3087 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3088 );
3089 }
3090
3091 #[test]
3092 fn test_title_case_code_at_start_still_capitalizes() {
3093 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3095 let content = "# `api` quick start guide\n";
3096 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3097 let result = rule.check(&ctx).unwrap();
3098 assert_eq!(result.len(), 1);
3100 assert!(
3101 result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
3102 "Title case should capitalize major words after code. Got: {:?}",
3103 result[0].message
3104 );
3105 }
3106
3107 #[test]
3110 fn test_sentence_case_html_tag_at_start() {
3111 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3113 let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
3114 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3115 let result = rule.check(&ctx).unwrap();
3116 assert_eq!(result.len(), 1);
3118 let fixed = rule.fix(&ctx).unwrap();
3119 assert_eq!(
3120 fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
3121 "Text after HTML at start should be lowercase"
3122 );
3123 }
3124
3125 #[test]
3126 fn test_sentence_case_html_tag_preserves_content() {
3127 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3129 let content = "# The <abbr>API</abbr> documentation guide\n";
3130 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3131 let result = rule.check(&ctx).unwrap();
3132 assert!(
3134 result.is_empty(),
3135 "HTML tag content should be preserved. Got: {:?}",
3136 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3137 );
3138 }
3139
3140 #[test]
3141 fn test_sentence_case_html_tag_at_start_with_acronym() {
3142 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3144 let content = "# <abbr>API</abbr> Documentation Guide\n";
3145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3146 let result = rule.check(&ctx).unwrap();
3147 assert_eq!(result.len(), 1);
3148 let fixed = rule.fix(&ctx).unwrap();
3149 assert_eq!(
3150 fixed, "# <abbr>API</abbr> documentation guide\n",
3151 "Text after HTML at start should be lowercase, HTML content preserved"
3152 );
3153 }
3154
3155 #[test]
3156 fn test_sentence_case_html_tag_in_middle() {
3157 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3159 let content = "# using the <code>config</code> File\n";
3160 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3161 let result = rule.check(&ctx).unwrap();
3162 assert_eq!(result.len(), 1);
3163 let fixed = rule.fix(&ctx).unwrap();
3164 assert_eq!(
3165 fixed, "# Using the <code>config</code> file\n",
3166 "First word capitalized, HTML preserved, rest lowercase"
3167 );
3168 }
3169
3170 #[test]
3171 fn test_html_tag_strong_emphasis() {
3172 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3174 let content = "# The <strong>Bold</strong> Way\n";
3175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3176 let result = rule.check(&ctx).unwrap();
3177 assert_eq!(result.len(), 1);
3178 let fixed = rule.fix(&ctx).unwrap();
3179 assert_eq!(
3180 fixed, "# The <strong>Bold</strong> way\n",
3181 "<strong> tag content should be preserved"
3182 );
3183 }
3184
3185 #[test]
3186 fn test_html_tag_with_attributes() {
3187 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3189 let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
3190 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3191 let result = rule.check(&ctx).unwrap();
3192 assert_eq!(result.len(), 1);
3193 let fixed = rule.fix(&ctx).unwrap();
3194 assert_eq!(
3195 fixed, "# <span class=\"highlight\">Important</span> notice here\n",
3196 "HTML tag with attributes should be preserved"
3197 );
3198 }
3199
3200 #[test]
3201 fn test_multiple_html_tags() {
3202 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3204 let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
3205 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3206 let result = rule.check(&ctx).unwrap();
3207 assert_eq!(result.len(), 1);
3208 let fixed = rule.fix(&ctx).unwrap();
3209 assert_eq!(
3210 fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
3211 "Multiple HTML tags should all be preserved"
3212 );
3213 }
3214
3215 #[test]
3216 fn test_html_and_code_mixed() {
3217 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3219 let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
3220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3221 let result = rule.check(&ctx).unwrap();
3222 assert_eq!(result.len(), 1);
3223 let fixed = rule.fix(&ctx).unwrap();
3224 assert_eq!(
3225 fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
3226 "HTML and code should both be preserved"
3227 );
3228 }
3229
3230 #[test]
3231 fn test_self_closing_html_tag() {
3232 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3234 let content = "# Line one<br/>Line Two Here\n";
3235 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3236 let result = rule.check(&ctx).unwrap();
3237 assert_eq!(result.len(), 1);
3238 let fixed = rule.fix(&ctx).unwrap();
3239 assert_eq!(
3240 fixed, "# Line one<br/>line two here\n",
3241 "Self-closing HTML tags should be preserved"
3242 );
3243 }
3244
3245 #[test]
3246 fn test_title_case_with_html_tags() {
3247 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3249 let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
3250 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3251 let result = rule.check(&ctx).unwrap();
3252 assert_eq!(result.len(), 1);
3253 let fixed = rule.fix(&ctx).unwrap();
3254 assert!(
3256 fixed.contains("<kbd>ctrl</kbd>"),
3257 "HTML tag content should be preserved in title case. Got: {fixed}"
3258 );
3259 assert!(
3260 fixed.starts_with("# The ") || fixed.starts_with("# the "),
3261 "Title case should work with HTML. Got: {fixed}"
3262 );
3263 }
3264
3265 #[test]
3268 fn test_sentence_case_preserves_caret_notation() {
3269 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3271 let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
3272 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3273 let result = rule.check(&ctx).unwrap();
3274 assert!(
3276 result.is_empty(),
3277 "Caret notation should be preserved. Got: {:?}",
3278 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3279 );
3280 }
3281
3282 #[test]
3283 fn test_sentence_case_caret_notation_various() {
3284 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3286
3287 let content = "## Press ^C to cancel\n";
3289 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3290 let result = rule.check(&ctx).unwrap();
3291 assert!(
3292 result.is_empty(),
3293 "^C should be preserved. Got: {:?}",
3294 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3295 );
3296
3297 let content = "## Use ^Z for background\n";
3299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3300 let result = rule.check(&ctx).unwrap();
3301 assert!(
3302 result.is_empty(),
3303 "^Z should be preserved. Got: {:?}",
3304 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3305 );
3306
3307 let content = "## Press ^[ for escape\n";
3309 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3310 let result = rule.check(&ctx).unwrap();
3311 assert!(
3312 result.is_empty(),
3313 "^[ should be preserved. Got: {:?}",
3314 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3315 );
3316 }
3317
3318 #[test]
3319 fn test_caret_notation_detection() {
3320 let rule = create_rule();
3321
3322 assert!(rule.is_caret_notation("^A"));
3324 assert!(rule.is_caret_notation("^Z"));
3325 assert!(rule.is_caret_notation("^C"));
3326 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")); }
3338
3339 fn create_sentence_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
3346 let config = MD063Config {
3347 enabled: true,
3348 style: HeadingCapStyle::SentenceCase,
3349 ..Default::default()
3350 };
3351 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3352 rule.proper_names = names;
3353 rule
3354 }
3355
3356 #[test]
3357 fn test_sentence_case_preserves_single_word_proper_name() {
3358 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
3359 let content = "# installing javascript\n";
3361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3362 let result = rule.check(&ctx).unwrap();
3363 assert_eq!(result.len(), 1, "Should flag the heading");
3364 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3365 assert!(
3366 fix_text.contains("JavaScript"),
3367 "Fix should preserve proper name 'JavaScript', got: {fix_text:?}"
3368 );
3369 assert!(
3370 !fix_text.contains("javascript"),
3371 "Fix should not have lowercase 'javascript', got: {fix_text:?}"
3372 );
3373 }
3374
3375 #[test]
3376 fn test_sentence_case_preserves_multi_word_proper_name() {
3377 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3378 let content = "# using good application features\n";
3380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3381 let result = rule.check(&ctx).unwrap();
3382 assert_eq!(result.len(), 1, "Should flag the heading");
3383 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3384 assert!(
3385 fix_text.contains("Good Application"),
3386 "Fix should preserve 'Good Application' as a phrase, got: {fix_text:?}"
3387 );
3388 }
3389
3390 #[test]
3391 fn test_sentence_case_proper_name_at_start_of_heading() {
3392 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3393 let content = "# good application overview\n";
3395 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3396 let result = rule.check(&ctx).unwrap();
3397 assert_eq!(result.len(), 1, "Should flag the heading");
3398 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3399 assert!(
3400 fix_text.contains("Good Application"),
3401 "Fix should produce 'Good Application' at start of heading, got: {fix_text:?}"
3402 );
3403 assert!(
3404 fix_text.contains("overview"),
3405 "Non-proper-name word 'overview' should be lowercase, got: {fix_text:?}"
3406 );
3407 }
3408
3409 #[test]
3410 fn test_sentence_case_with_proper_names_no_oscillation() {
3411 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3414
3415 let content = "# installing good application on your system\n";
3417 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3418 let result = rule.check(&ctx).unwrap();
3419 assert_eq!(result.len(), 1);
3420 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
3421
3422 assert!(
3424 fixed_heading.contains("Good Application"),
3425 "After fix, proper name must be preserved: {fixed_heading:?}"
3426 );
3427
3428 let fixed_line = format!("{fixed_heading}\n");
3430 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
3431 let result2 = rule.check(&ctx2).unwrap();
3432 assert!(
3433 result2.is_empty(),
3434 "After one fix, heading must already satisfy both MD063 and MD044 - no oscillation. \
3435 Second pass warnings: {result2:?}"
3436 );
3437 }
3438
3439 #[test]
3440 fn test_sentence_case_proper_names_already_correct() {
3441 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3442 let content = "# Installing Good Application\n";
3444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3445 let result = rule.check(&ctx).unwrap();
3446 assert!(
3447 result.is_empty(),
3448 "Correct sentence-case heading with proper name should not be flagged, got: {result:?}"
3449 );
3450 }
3451
3452 #[test]
3453 fn test_sentence_case_multiple_proper_names_in_heading() {
3454 let rule = create_sentence_case_rule_with_proper_names(vec!["TypeScript".to_string(), "React".to_string()]);
3455 let content = "# using typescript with react\n";
3456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3457 let result = rule.check(&ctx).unwrap();
3458 assert_eq!(result.len(), 1);
3459 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3460 assert!(
3461 fix_text.contains("TypeScript"),
3462 "Fix should preserve 'TypeScript', got: {fix_text:?}"
3463 );
3464 assert!(
3465 fix_text.contains("React"),
3466 "Fix should preserve 'React', got: {fix_text:?}"
3467 );
3468 }
3469
3470 #[test]
3471 fn test_sentence_case_unicode_casefold_expansion_before_proper_name() {
3472 let rule = create_sentence_case_rule_with_proper_names(vec!["Österreich".to_string()]);
3475 let content = "# İ österreich guide\n";
3476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3477
3478 let result = rule.check(&ctx).unwrap();
3480 assert_eq!(result.len(), 1, "Should flag heading for canonical proper-name casing");
3481 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3482 assert!(
3483 fix_text.contains("Österreich"),
3484 "Fix should preserve canonical 'Österreich', got: {fix_text:?}"
3485 );
3486 }
3487
3488 #[test]
3489 fn test_sentence_case_preserves_trailing_punctuation_on_proper_name() {
3490 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
3491 let content = "# using javascript, today\n";
3492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3493 let result = rule.check(&ctx).unwrap();
3494 assert_eq!(result.len(), 1, "Should flag heading");
3495 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3496 assert!(
3497 fix_text.contains("JavaScript,"),
3498 "Fix should preserve trailing punctuation, got: {fix_text:?}"
3499 );
3500 }
3501
3502 fn create_title_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
3509 let config = MD063Config {
3510 enabled: true,
3511 style: HeadingCapStyle::TitleCase,
3512 ..Default::default()
3513 };
3514 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3515 rule.proper_names = names;
3516 rule
3517 }
3518
3519 #[test]
3520 fn test_title_case_preserves_proper_name_with_lowercase_article() {
3521 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
3525 let content = "# listening to the rolling stones today\n";
3526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3527 let result = rule.check(&ctx).unwrap();
3528 assert_eq!(result.len(), 1, "Should flag the heading");
3529 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3530 assert!(
3531 fix_text.contains("The Rolling Stones"),
3532 "Fix should preserve proper name 'The Rolling Stones', got: {fix_text:?}"
3533 );
3534 }
3535
3536 #[test]
3537 fn test_title_case_proper_name_no_oscillation() {
3538 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
3540 let content = "# listening to the rolling stones today\n";
3541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3542 let result = rule.check(&ctx).unwrap();
3543 assert_eq!(result.len(), 1);
3544 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
3545
3546 let fixed_line = format!("{fixed_heading}\n");
3547 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
3548 let result2 = rule.check(&ctx2).unwrap();
3549 assert!(
3550 result2.is_empty(),
3551 "After one title-case fix, heading must already satisfy both rules. \
3552 Second pass warnings: {result2:?}"
3553 );
3554 }
3555
3556 #[test]
3557 fn test_title_case_unicode_casefold_expansion_before_proper_name() {
3558 let rule = create_title_case_rule_with_proper_names(vec!["Österreich".to_string()]);
3559 let content = "# İ österreich guide\n";
3560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3561 let result = rule.check(&ctx).unwrap();
3562 assert_eq!(result.len(), 1, "Should flag the heading");
3563 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3564 assert!(
3565 fix_text.contains("Österreich"),
3566 "Fix should preserve canonical proper-name casing, got: {fix_text:?}"
3567 );
3568 }
3569
3570 #[test]
3576 fn test_from_config_loads_md044_names_into_md063() {
3577 use crate::config::{Config, RuleConfig};
3578 use crate::rule::Rule;
3579 use std::collections::BTreeMap;
3580
3581 let mut config = Config::default();
3582
3583 let mut md063_values = BTreeMap::new();
3585 md063_values.insert("style".to_string(), toml::Value::String("sentence_case".to_string()));
3586 md063_values.insert("enabled".to_string(), toml::Value::Boolean(true));
3587 config.rules.insert(
3588 "MD063".to_string(),
3589 RuleConfig {
3590 values: md063_values,
3591 severity: None,
3592 },
3593 );
3594
3595 let mut md044_values = BTreeMap::new();
3597 md044_values.insert(
3598 "names".to_string(),
3599 toml::Value::Array(vec![toml::Value::String("Good Application".to_string())]),
3600 );
3601 config.rules.insert(
3602 "MD044".to_string(),
3603 RuleConfig {
3604 values: md044_values,
3605 severity: None,
3606 },
3607 );
3608
3609 let rule = MD063HeadingCapitalization::from_config(&config);
3611
3612 let content = "# using good application features\n";
3614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3615 let result = rule.check(&ctx).unwrap();
3616 assert_eq!(result.len(), 1, "Should flag the heading");
3617 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3618 assert!(
3619 fix_text.contains("Good Application"),
3620 "from_config should wire MD044 names into MD063; fix should preserve \
3621 'Good Application', got: {fix_text:?}"
3622 );
3623 }
3624
3625 #[test]
3626 fn test_title_case_short_word_not_confused_with_substring() {
3627 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3631
3632 let content = "# in the insert\n";
3635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3636 let result = rule.check(&ctx).unwrap();
3637 assert_eq!(result.len(), 1, "Should flag the heading");
3638 let fix = result[0].fix.as_ref().expect("Fix should be present");
3639 assert!(
3641 fix.replacement.contains("In the Insert"),
3642 "Expected 'In the Insert', got: {:?}",
3643 fix.replacement
3644 );
3645 }
3646
3647 #[test]
3648 fn test_title_case_or_not_confused_with_orchestra() {
3649 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3650
3651 let content = "# or the orchestra\n";
3654 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3655 let result = rule.check(&ctx).unwrap();
3656 assert_eq!(result.len(), 1, "Should flag the heading");
3657 let fix = result[0].fix.as_ref().expect("Fix should be present");
3658 assert!(
3660 fix.replacement.contains("Or the Orchestra"),
3661 "Expected 'Or the Orchestra', got: {:?}",
3662 fix.replacement
3663 );
3664 }
3665
3666 #[test]
3667 fn test_all_caps_preserves_all_words() {
3668 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
3669
3670 let content = "# in the insert\n";
3671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3672 let result = rule.check(&ctx).unwrap();
3673 assert_eq!(result.len(), 1, "Should flag the heading");
3674 let fix = result[0].fix.as_ref().expect("Fix should be present");
3675 assert!(
3676 fix.replacement.contains("IN THE INSERT"),
3677 "All caps should uppercase all words, got: {:?}",
3678 fix.replacement
3679 );
3680 }
3681
3682 #[test]
3684 fn test_title_case_numbered_prefix_lowercase_word() {
3685 let rule = create_rule();
3687 let content = "## 1. To Be a Thing\n";
3688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3689 let result = rule.check(&ctx).unwrap();
3690 assert!(
3691 result.is_empty(),
3692 "Should not flag '## 1. To Be a Thing', got: {result:?}"
3693 );
3694
3695 let content_lower = "## 1. to be a thing\n";
3696 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3697 let result2 = rule.check(&ctx2).unwrap();
3698 assert!(!result2.is_empty(), "Should flag '## 1. to be a thing'");
3699 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3700 assert!(
3701 fix.replacement.contains("1. To Be a Thing"),
3702 "Fix should capitalize 'To', got: {:?}",
3703 fix.replacement
3704 );
3705 }
3706
3707 #[test]
3708 fn test_title_case_numbered_prefix_article() {
3709 let rule = create_rule();
3711 let content = "## 2. A Guide to the Galaxy\n";
3712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3713 let result = rule.check(&ctx).unwrap();
3714 assert!(
3715 result.is_empty(),
3716 "Should not flag '## 2. A Guide to the Galaxy', got: {result:?}"
3717 );
3718
3719 let content_lower = "## 2. a guide to the galaxy\n";
3720 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3721 let result2 = rule.check(&ctx2).unwrap();
3722 assert!(!result2.is_empty(), "Should flag '## 2. a guide to the galaxy'");
3723 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3724 assert!(
3725 fix.replacement.contains("2. A Guide to the Galaxy"),
3726 "Fix should capitalize 'A', got: {:?}",
3727 fix.replacement
3728 );
3729 }
3730
3731 #[test]
3732 fn test_title_case_mid_sentence_period_word() {
3733 let rule = create_rule();
3735 let content = "## Step 1. Introduction to the Problem\n";
3736 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3737 let result = rule.check(&ctx).unwrap();
3738 assert!(
3739 result.is_empty(),
3740 "Should not flag '## Step 1. Introduction to the Problem', got: {result:?}"
3741 );
3742
3743 let content_lower = "## Step 1. introduction to the problem\n";
3744 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3745 let result2 = rule.check(&ctx2).unwrap();
3746 assert!(
3747 !result2.is_empty(),
3748 "Should flag '## Step 1. introduction to the problem'"
3749 );
3750 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3751 assert!(
3752 fix.replacement.contains("Step 1. Introduction to the Problem"),
3753 "Fix should capitalize 'Introduction', got: {:?}",
3754 fix.replacement
3755 );
3756 }
3757
3758 #[test]
3759 fn test_title_case_numbered_prefix_in_link_text() {
3760 let config = MD063Config {
3763 enabled: true,
3764 style: HeadingCapStyle::TitleCase,
3765 ..Default::default()
3766 };
3767 let rule = MD063HeadingCapitalization::from_config_struct(config);
3768
3769 let content = "## [1. To Be a Thing](https://example.com)\n";
3771 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3772 let result = rule.check(&ctx).unwrap();
3773 assert!(
3774 result.is_empty(),
3775 "Should not flag '## [1. To Be a Thing](url)', got: {result:?}"
3776 );
3777
3778 let content_lower = "## [1. to be a thing](https://example.com)\n";
3780 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
3781 let result2 = rule.check(&ctx2).unwrap();
3782 assert!(!result2.is_empty(), "Should flag '## [1. to be a thing](url)'");
3783 let fix = result2[0].fix.as_ref().expect("Should have a fix");
3784 assert!(
3785 fix.replacement.contains("1. To Be a Thing"),
3786 "Fix should capitalize 'To' in link text, got: {:?}",
3787 fix.replacement
3788 );
3789 }
3790
3791 #[test]
3796 fn test_is_numeric_ordinal_recognises_canonical_forms() {
3797 for word in &[
3798 "1st", "2nd", "3rd", "4th", "5th", "11th", "21st", "22nd", "23rd", "100th", "1ST", "5Th", "21St", "21sT",
3799 ] {
3800 assert!(
3801 MD063HeadingCapitalization::is_numeric_ordinal(word),
3802 "expected `{word}` to be detected as a numeric ordinal"
3803 );
3804 }
3805 }
3806
3807 #[test]
3808 fn test_is_numeric_ordinal_rejects_non_ordinals() {
3809 for word in &[
3814 "first", "1stop", "ist", "5", "th", "abc", "4G", "4K", "30s", "100k", "5x", "1.5", "iPhone6S",
3815 ] {
3816 assert!(
3817 !MD063HeadingCapitalization::is_numeric_ordinal(word),
3818 "expected `{word}` NOT to be detected as a numeric ordinal"
3819 );
3820 }
3821 }
3822
3823 #[test]
3824 fn test_is_numeric_ordinal_strips_trailing_punctuation() {
3825 for word in &["5th.", "1st,", "21st!", "3rd:", "4th)", "5th's"] {
3826 assert!(
3827 MD063HeadingCapitalization::is_numeric_ordinal(word),
3828 "expected `{word}` to be detected as a numeric ordinal (with punctuation)"
3829 );
3830 }
3831 }
3832
3833 #[test]
3834 fn test_is_numeric_ordinal_ignores_wrapping_punctuation() {
3835 for word in &[
3840 "(2nd", "[2nd", "\"2nd", "'2nd", "*2nd", "(21st)", "\"3rd\"", "**5th**", "_1st_",
3841 ] {
3842 assert!(
3843 MD063HeadingCapitalization::is_numeric_ordinal(word),
3844 "expected `{word}` to be detected as a numeric ordinal"
3845 );
3846 }
3847
3848 for word in &["2-nd", "2 nd", "(", "\"\"", "()", "(nd", "(2"] {
3852 assert!(
3853 !MD063HeadingCapitalization::is_numeric_ordinal(word),
3854 "expected `{word}` NOT to be detected as a numeric ordinal"
3855 );
3856 }
3857 }
3858
3859 #[test]
3860 fn test_ordinal_wrapped_in_punctuation_survives_a_fix() {
3861 for (style, content) in [
3864 (HeadingCapStyle::SentenceCase, "# The second (2nd) attempt\n"),
3865 (HeadingCapStyle::SentenceCase, "# Ranked \"3rd\" overall\n"),
3866 (HeadingCapStyle::SentenceCase, "# Plain 2nd place\n"),
3867 (HeadingCapStyle::TitleCase, "# The Second (2nd) Attempt\n"),
3868 (HeadingCapStyle::TitleCase, "# Ranked \"3rd\" Overall\n"),
3869 ] {
3870 let rule = create_rule_with_style(style);
3871 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3872 let result = rule.check(&ctx).unwrap();
3873 assert!(
3874 result.is_empty(),
3875 "{style:?} should not flag {content:?}, got: {result:?}"
3876 );
3877 assert_eq!(
3878 rule.fix(&ctx).unwrap(),
3879 content,
3880 "{style:?} must leave {content:?} alone"
3881 );
3882 }
3883
3884 for (style, content, expected) in [
3888 (
3889 HeadingCapStyle::SentenceCase,
3890 "# The Second (2nd) Attempt\n",
3891 "# The second (2nd) attempt\n",
3892 ),
3893 (
3894 HeadingCapStyle::TitleCase,
3895 "# the second (2nd) attempt\n",
3896 "# The Second (2nd) Attempt\n",
3897 ),
3898 ] {
3899 let rule = create_rule_with_style(style);
3900 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3901 assert!(
3902 !rule.check(&ctx).unwrap().is_empty(),
3903 "{style:?} should flag {content:?}"
3904 );
3905 let fixed = rule.fix(&ctx).unwrap();
3906 assert_eq!(fixed, expected, "{style:?} fix of {content:?}");
3907
3908 let refixed = rule
3909 .fix(&LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None))
3910 .unwrap();
3911 assert_eq!(refixed, expected, "{style:?} second pass over {fixed:?}");
3912 }
3913 }
3914
3915 #[test]
3916 fn test_wrapped_ordinal_corrupted_by_the_old_fix_is_repaired() {
3917 for (style, content, expected) in [
3921 (
3922 HeadingCapStyle::SentenceCase,
3923 "# The second (2Nd) attempt\n",
3924 "# The second (2nd) attempt\n",
3925 ),
3926 (
3927 HeadingCapStyle::TitleCase,
3928 "# The Second (2Nd) Attempt\n",
3929 "# The Second (2nd) Attempt\n",
3930 ),
3931 (
3932 HeadingCapStyle::SentenceCase,
3933 "# Ranked \"3Rd\" overall\n",
3934 "# Ranked \"3rd\" overall\n",
3935 ),
3936 ] {
3937 let rule = create_rule_with_style(style);
3938 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3939 assert!(
3940 !rule.check(&ctx).unwrap().is_empty(),
3941 "{style:?} should flag {content:?}"
3942 );
3943 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{style:?} fix of {content:?}");
3944 }
3945 }
3946
3947 #[test]
3948 fn test_title_case_ordinal_first_word_not_flagged() {
3949 let rule = create_rule();
3950 for content in &[
3951 "# 1st Place\n",
3952 "# 2nd Edition\n",
3953 "# 3rd Time\n",
3954 "# 5th Avenue\n",
3955 "# 21st Century Skills\n",
3956 "# 100th Customer\n",
3957 ] {
3958 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3959 let result = rule.check(&ctx).unwrap();
3960 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3961 }
3962 }
3963
3964 #[test]
3965 fn test_title_case_ordinal_mid_heading_not_flagged() {
3966 let rule = create_rule();
3967 for content in &[
3968 "# May 3rd Notes\n",
3969 "# Top 100th Customer\n",
3970 "# Notes for the 5th of May\n",
3971 ] {
3972 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3973 let result = rule.check(&ctx).unwrap();
3974 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
3975 }
3976 }
3977
3978 #[test]
3979 fn test_title_case_ordinal_corrupted_form_is_fixed() {
3980 let rule = create_rule();
3983 for (input, expected) in &[
3984 ("# 1St Place\n", "1st Place"),
3985 ("# 5Th Avenue\n", "5th Avenue"),
3986 ("# 21St Century Skills\n", "21st Century Skills"),
3987 ("# May 3Rd Notes\n", "May 3rd Notes"),
3988 ("# 22Nd Edition\n", "22nd Edition"),
3989 ] {
3990 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
3991 let result = rule.check(&ctx).unwrap();
3992 assert!(!result.is_empty(), "Should flag {input:?}");
3993 let fix = result[0].fix.as_ref().expect("should have a fix");
3994 assert!(
3995 fix.replacement.contains(expected),
3996 "Fix for {input:?} should contain {expected:?}, got: {:?}",
3997 fix.replacement
3998 );
3999 }
4000 }
4001
4002 #[test]
4003 fn test_title_case_ordinal_lowercase_other_words_capitalised() {
4004 let rule = create_rule();
4006 let content = "# 5th avenue\n";
4007 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4008 let result = rule.check(&ctx).unwrap();
4009 assert_eq!(result.len(), 1);
4010 let fix = result[0].fix.as_ref().expect("should have a fix");
4011 assert!(
4012 fix.replacement.contains("5th Avenue"),
4013 "Fix should produce '5th Avenue', got: {:?}",
4014 fix.replacement
4015 );
4016 }
4017
4018 #[test]
4019 fn test_title_case_ordinal_with_trailing_punctuation() {
4020 let rule = create_rule();
4021 let content = "# Released on the 5th.\n";
4022 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4023 let result = rule.check(&ctx).unwrap();
4024 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4025 }
4026
4027 #[test]
4028 fn test_title_case_ordinal_hyphenated() {
4029 let rule = create_rule();
4030 for content in &["# 21st-Century Skills\n", "# A 19th-Century Novel\n"] {
4031 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4032 let result = rule.check(&ctx).unwrap();
4033 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4034 }
4035 }
4036
4037 #[test]
4038 fn test_sentence_case_ordinal_corrupted_form_is_fixed() {
4039 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
4040 let content = "# 5Th avenue\n";
4041 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4042 let result = rule.check(&ctx).unwrap();
4043 assert_eq!(result.len(), 1);
4044 let fix = result[0].fix.as_ref().expect("should have a fix");
4045 assert!(
4046 fix.replacement.contains("5th avenue"),
4047 "Fix should produce '5th avenue', got: {:?}",
4048 fix.replacement
4049 );
4050 }
4051
4052 #[test]
4053 fn test_title_case_digit_acronym_unchanged() {
4054 let rule = create_rule();
4057 for content in &["# 4G Networks\n", "# 4K Streaming\n"] {
4058 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4059 let result = rule.check(&ctx).unwrap();
4060 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4061 }
4062 }
4063
4064 fn restart_rule(boundaries: &[&str]) -> MD063HeadingCapitalization {
4067 let config = MD063Config {
4068 enabled: true,
4069 style: HeadingCapStyle::SentenceCase,
4070 sentence_case_restart_after: boundaries.iter().copied().map(String::from).collect(),
4071 ..Default::default()
4072 };
4073 MD063HeadingCapitalization::from_config_struct(config)
4074 }
4075
4076 fn suggested(rule: &MD063HeadingCapitalization, content: &str) -> Option<String> {
4079 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4080 let warnings = rule.check(&ctx).unwrap();
4081 let fixed = rule.fix(&ctx).unwrap();
4082 assert_eq!(
4083 warnings.is_empty(),
4084 fixed == content,
4085 "a warning and a rewrite must agree for {content:?}"
4086 );
4087 (!warnings.is_empty()).then(|| fixed.trim_start_matches('#').trim().to_string())
4088 }
4089
4090 #[test]
4091 fn test_restart_after_capitalizes_the_word_following_a_boundary() {
4092 let rule = restart_rule(&[":"]);
4093 assert_eq!(
4094 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
4095 Some("Requirement 1: Struct to logger slice conversion")
4096 );
4097 }
4098
4099 #[test]
4100 fn test_restart_after_defaults_to_no_boundaries() {
4101 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
4103 assert_eq!(
4104 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
4105 Some("Requirement 1: struct to logger slice conversion")
4106 );
4107 }
4108
4109 #[test]
4110 fn test_restart_after_only_honors_configured_punctuation() {
4111 let rule = restart_rule(&[":"]);
4113 assert_eq!(
4114 suggested(&rule, "# Design - Data Model Overview\n").as_deref(),
4115 Some("Design - data model overview")
4116 );
4117 assert_eq!(
4118 suggested(&rule, "# Setup; Then Run\n").as_deref(),
4119 Some("Setup; then run")
4120 );
4121
4122 let rule = restart_rule(&[";", "\u{2014}"]);
4123 assert_eq!(
4124 suggested(&rule, "# Setup; Then Run\n").as_deref(),
4125 Some("Setup; Then run")
4126 );
4127 assert_eq!(
4128 suggested(&rule, "# Part One \u{2014} The Big Idea\n").as_deref(),
4129 Some("Part one \u{2014} The big idea")
4130 );
4131 }
4132
4133 #[test]
4134 fn test_restart_after_matches_only_at_the_end_of_a_word() {
4135 let rule = restart_rule(&["-", ":"]);
4138 assert_eq!(
4139 suggested(&rule, "# Ports: Well-Known Ports Explained\n").as_deref(),
4140 Some("Ports: Well-Known ports explained")
4141 );
4142 assert_eq!(
4143 suggested(&rule, "# See https://example.com/A/B For Details\n").as_deref(),
4144 Some("See https://example.com/A/B for details")
4145 );
4146 }
4147
4148 #[test]
4149 fn test_restart_after_a_trailing_boundary_is_a_no_op() {
4150 let rule = restart_rule(&[":"]);
4151 assert_eq!(suggested(&rule, "# Setup:\n"), None);
4152 }
4153
4154 #[test]
4155 fn test_restart_after_does_not_override_preserved_words() {
4156 let rule = restart_rule(&[":"]);
4159 assert_eq!(
4160 suggested(&rule, "# Devices: iPhone And Android\n").as_deref(),
4161 Some("Devices: iPhone and android")
4162 );
4163
4164 let config = MD063Config {
4165 enabled: true,
4166 style: HeadingCapStyle::SentenceCase,
4167 sentence_case_restart_after: vec![":".to_string()],
4168 ignore_words: vec!["kubectl".to_string()],
4169 preserve_cased_words: false,
4170 ..Default::default()
4171 };
4172 let rule = MD063HeadingCapitalization::from_config_struct(config);
4173 assert_eq!(
4174 suggested(&rule, "# Tools: kubectl And Helm\n").as_deref(),
4175 Some("Tools: kubectl and helm")
4176 );
4177 }
4178
4179 #[test]
4180 fn test_restart_after_keeps_md044_canonical_forms() {
4181 let config = MD063Config {
4182 enabled: true,
4183 style: HeadingCapStyle::SentenceCase,
4184 sentence_case_restart_after: vec![":".to_string()],
4185 ..Default::default()
4186 };
4187 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
4188 rule.proper_names = vec!["GitHub".to_string()];
4189
4190 assert_eq!(
4192 suggested(&rule, "# Docs: github Actions Guide\n").as_deref(),
4193 Some("Docs: GitHub actions guide")
4194 );
4195 assert_eq!(suggested(&rule, "# Docs: GitHub actions guide\n"), None);
4196 }
4197
4198 #[test]
4199 fn test_restart_after_carries_across_segments() {
4200 let rule = restart_rule(&[":"]);
4203 assert_eq!(
4204 suggested(
4205 &rule,
4206 "# Overview: [Some Link Here](https://example.com) Trailing Words\n"
4207 )
4208 .as_deref(),
4209 Some("Overview: [Some link here](https://example.com) trailing words")
4210 );
4211 assert_eq!(
4212 suggested(&rule, "# Overview: `code` Then More Words\n").as_deref(),
4213 Some("Overview: `code` then more words")
4214 );
4215 }
4216
4217 #[test]
4218 fn test_restart_after_ends_a_sentence_at_the_end_of_link_text() {
4219 let rule = restart_rule(&[":"]);
4223 assert_eq!(
4224 suggested(&rule, "# Topic [See:](https://example.com) More Words\n").as_deref(),
4225 Some("Topic [see:](https://example.com) More words")
4226 );
4227
4228 assert_eq!(
4230 suggested(&rule, "# Topic [See](https://example.com) More Words\n").as_deref(),
4231 Some("Topic [see](https://example.com) more words")
4232 );
4233 }
4234
4235 #[test]
4236 fn test_restart_after_ignores_boundaries_inside_opaque_segments() {
4237 let rule = restart_rule(&[":"]);
4240 for content in [
4241 "# Topic `see:` More Words\n",
4242 "# Topic  More Words\n",
4243 "# Topic <span title=\"x:\">y</span> More Words\n",
4244 ] {
4245 let fixed = suggested(&rule, content).expect("heading should be rewritten");
4246 assert!(
4247 fixed.ends_with("more words"),
4248 "opaque segment restarted the sentence in {content:?}: {fixed}"
4249 );
4250 }
4251 }
4252
4253 #[test]
4254 fn test_restart_after_treats_a_leading_link_as_sentence_initial() {
4255 for rule in [restart_rule(&[]), restart_rule(&[":"])] {
4258 assert_eq!(
4259 suggested(&rule, "# [Some Link Here](https://example.com) Trailing Words\n").as_deref(),
4260 Some("[Some link here](https://example.com) trailing words")
4261 );
4262 }
4263 }
4264
4265 #[test]
4266 fn test_restart_after_fix_is_idempotent() {
4267 let rule = restart_rule(&[":", ";", "-", "\u{2014}"]);
4268 for content in [
4269 "# Requirement 1: Struct to Logger Slice Conversion\n",
4270 "# Ports: Well-Known Ports Explained\n",
4271 "# Devices: iPhone And Android\n",
4272 "# Overview: [Some Link Here](https://example.com) Trailing Words\n",
4273 "# Setup:\n",
4274 ] {
4275 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4276 let once = rule.fix(&ctx).unwrap();
4277 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
4278 assert_eq!(rule.fix(&ctx).unwrap(), once, "fix is not idempotent for {content:?}");
4279 }
4280 }
4281
4282 #[test]
4283 fn test_restart_after_ignores_empty_boundary_entries() {
4284 let rule = restart_rule(&[""]);
4286 assert_eq!(
4287 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
4288 Some("Requirement 1: struct to logger slice conversion")
4289 );
4290 }
4291
4292 const STYLES: [HeadingCapStyle; 3] = [
4295 HeadingCapStyle::TitleCase,
4296 HeadingCapStyle::SentenceCase,
4297 HeadingCapStyle::AllCaps,
4298 ];
4299
4300 const GHERKIN_STRUCTURES: [(&str, &str, &str, &str); 6] = [
4303 (
4304 "# Feature: the system under test",
4305 "# Feature: The System Under Test",
4306 "# Feature: The system under test",
4307 "# Feature: THE SYSTEM UNDER TEST",
4308 ),
4309 (
4310 "## Background: a shared setup",
4311 "## Background: A Shared Setup",
4312 "## Background: A shared setup",
4313 "## Background: A SHARED SETUP",
4314 ),
4315 (
4316 "## Rule: money is never lost",
4317 "## Rule: Money Is Never Lost",
4318 "## Rule: Money is never lost",
4319 "## Rule: MONEY IS NEVER LOST",
4320 ),
4321 (
4322 "### Scenario: add two numbers",
4323 "### Scenario: Add Two Numbers",
4324 "### Scenario: Add two numbers",
4325 "### Scenario: ADD TWO NUMBERS",
4326 ),
4327 (
4328 "### Scenario Outline: add two numbers",
4329 "### Scenario Outline: Add Two Numbers",
4330 "### Scenario Outline: Add two numbers",
4331 "### Scenario Outline: ADD TWO NUMBERS",
4332 ),
4333 (
4334 "#### Examples: happy path",
4335 "#### Examples: Happy Path",
4336 "#### Examples: Happy path",
4337 "#### Examples: HAPPY PATH",
4338 ),
4339 ];
4340
4341 fn recased(style: HeadingCapStyle, heading: &str, flavor: crate::config::MarkdownFlavor) -> String {
4343 let rule = create_rule_with_style(style);
4344 let content = format!("{heading}\n");
4345 let ctx = LintContext::new(&content, flavor, None);
4346 let warnings = rule.check(&ctx).unwrap();
4347 let fixed = rule.fix(&ctx).unwrap();
4348 assert_eq!(
4349 warnings.is_empty(),
4350 fixed == content,
4351 "a warning and a rewrite must agree for {content:?} under {flavor:?}"
4352 );
4353 fixed.trim_end().to_string()
4354 }
4355
4356 #[test]
4357 fn test_mdg_keeps_the_keyword_of_every_structure() {
4358 for (heading, ..) in GHERKIN_STRUCTURES {
4361 let keyword = &heading[..=heading.find(':').unwrap()];
4362 for style in STYLES {
4363 let fixed = recased(style, heading, crate::config::MarkdownFlavor::MDG);
4364 assert!(
4365 fixed.starts_with(keyword),
4366 "{style:?} lost the keyword of {heading:?}: {fixed}"
4367 );
4368 }
4369 }
4370 }
4371
4372 #[test]
4373 fn test_mdg_recases_only_the_name_of_a_structure() {
4374 for (heading, title, sentence, caps) in GHERKIN_STRUCTURES {
4375 let mdg = crate::config::MarkdownFlavor::MDG;
4376 assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), title);
4377 assert_eq!(recased(HeadingCapStyle::SentenceCase, heading, mdg), sentence);
4378 assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), caps);
4379 }
4380 }
4381
4382 #[test]
4383 fn test_standard_flavor_recases_a_keyword_like_any_other_word() {
4384 let standard = crate::config::MarkdownFlavor::Standard;
4386 assert_eq!(
4387 recased(HeadingCapStyle::TitleCase, "# Feature: the system under test", standard),
4388 "# Feature: the System Under Test"
4389 );
4390 assert_eq!(
4391 recased(
4392 HeadingCapStyle::SentenceCase,
4393 "### Scenario Outline: add two numbers",
4394 standard
4395 ),
4396 "### Scenario outline: add two numbers"
4397 );
4398 assert_eq!(
4399 recased(HeadingCapStyle::AllCaps, "# Feature: the system under test", standard),
4400 "# FEATURE: THE SYSTEM UNDER TEST"
4401 );
4402 }
4403
4404 #[test]
4405 fn test_mdg_leaves_a_heading_without_a_colon_to_the_normal_rule() {
4406 for heading in ["## notes about the system", "## Notes", "# THE SYSTEM"] {
4407 for style in STYLES {
4408 assert_eq!(
4409 recased(style, heading, crate::config::MarkdownFlavor::MDG),
4410 recased(style, heading, crate::config::MarkdownFlavor::Standard),
4411 "{style:?} treated {heading:?} as a Gherkin structure"
4412 );
4413 }
4414 }
4415 }
4416
4417 #[test]
4418 fn test_mdg_splits_at_the_first_colon_only() {
4419 let mdg = crate::config::MarkdownFlavor::MDG;
4421 let heading = "## Scenario: ratio: two to one";
4422 assert_eq!(
4423 recased(HeadingCapStyle::TitleCase, heading, mdg),
4424 "## Scenario: Ratio: Two to One"
4425 );
4426 assert_eq!(
4427 recased(HeadingCapStyle::SentenceCase, heading, mdg),
4428 "## Scenario: Ratio: two to one"
4429 );
4430 assert_eq!(
4431 recased(HeadingCapStyle::AllCaps, heading, mdg),
4432 "## Scenario: RATIO: TWO TO ONE"
4433 );
4434 }
4435
4436 #[test]
4437 fn test_mdg_leaves_a_colon_behind_a_backtick_to_the_normal_rule() {
4438 for heading in [
4442 "# See `x: y` Notes",
4443 "# `a: b`",
4444 "# `code` Feature: a name",
4445 "# `x: y` Feature: a name",
4446 ] {
4447 for style in STYLES {
4448 assert_eq!(
4449 recased(style, heading, crate::config::MarkdownFlavor::MDG),
4450 recased(style, heading, crate::config::MarkdownFlavor::Standard),
4451 "{style:?} split {heading:?} at a colon inside a code span"
4452 );
4453 }
4454 }
4455 }
4456
4457 #[test]
4458 fn test_mdg_splits_at_a_keyword_colon_that_precedes_a_code_span() {
4459 let mdg = crate::config::MarkdownFlavor::MDG;
4461 let heading = "# Scenario: use `a: b` here";
4462 assert_eq!(
4463 recased(HeadingCapStyle::TitleCase, heading, mdg),
4464 "# Scenario: Use `a: b` Here"
4465 );
4466 assert_eq!(
4467 recased(HeadingCapStyle::SentenceCase, heading, mdg),
4468 "# Scenario: Use `a: b` here"
4469 );
4470 assert_eq!(
4471 recased(HeadingCapStyle::AllCaps, heading, mdg),
4472 "# Scenario: USE `a: b` HERE"
4473 );
4474 }
4475
4476 #[test]
4477 fn test_mdg_splits_at_a_keyword_colon_before_an_unbalanced_backtick() {
4478 let mdg = crate::config::MarkdownFlavor::MDG;
4481 let heading = "# Scenario: a ` b";
4482 assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), "# Scenario: A ` B");
4483 assert_eq!(
4484 recased(HeadingCapStyle::SentenceCase, heading, mdg),
4485 "# Scenario: A ` b"
4486 );
4487 assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), "# Scenario: A ` B");
4488 }
4489
4490 #[test]
4491 fn test_mdg_keeps_a_keyword_with_nothing_left_to_recase() {
4492 for style in STYLES {
4493 assert_eq!(
4494 recased(style, "# Feature:", crate::config::MarkdownFlavor::MDG),
4495 "# Feature:"
4496 );
4497 }
4498 }
4499
4500 #[test]
4501 fn test_mdg_keeps_a_custom_id_after_the_name() {
4502 assert_eq!(
4503 recased(
4504 HeadingCapStyle::TitleCase,
4505 "# Feature: the system {#overview}",
4506 crate::config::MarkdownFlavor::MDG
4507 ),
4508 "# Feature: The System {#overview}"
4509 );
4510 }
4511
4512 #[test]
4513 fn test_mdg_fix_is_idempotent() {
4514 for (heading, ..) in GHERKIN_STRUCTURES {
4515 for style in STYLES {
4516 let rule = create_rule_with_style(style);
4517 let content = format!("{heading}\n");
4518 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
4519 let once = rule.fix(&ctx).unwrap();
4520 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::MDG, None);
4521 assert_eq!(
4522 rule.fix(&ctx).unwrap(),
4523 once,
4524 "fix is not idempotent for {heading:?} ({style:?})"
4525 );
4526 }
4527 }
4528 }
4529}
4530
4531#[cfg(test)]
4532mod shortcode_delimiter_tests {
4533 use super::*;
4534
4535 #[test]
4536 fn heading_capitalization_preserves_shortcode_raw_arguments() {
4537 let rule = MD063HeadingCapitalization::from_config_struct(MD063Config {
4538 enabled: true,
4539 style: HeadingCapStyle::TitleCase,
4540 ..Default::default()
4541 });
4542 for (open, close) in [("{{<", ">}}"), ("{{%", "%}}")] {
4543 let tag = format!("{open} note title=`literal {close} friend` {close}");
4544 assert_eq!(
4545 rule.apply_capitalization(&format!("hello {tag} world"), crate::config::MarkdownFlavor::Hugo),
4546 format!("Hello {tag} World")
4547 );
4548 }
4549 }
4550}
4551
4552#[cfg(test)]
4553mod mdx_tag_capitalization_tests {
4554 use super::*;
4555 use crate::rule::Rule;
4556
4557 fn fixed(style: HeadingCapStyle, source: &str) -> String {
4558 let rule = MD063HeadingCapitalization::from_config_struct(MD063Config {
4559 enabled: true,
4560 style,
4561 ..Default::default()
4562 });
4563 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
4564 rule.fix(&ctx).unwrap()
4565 }
4566
4567 #[test]
4568 fn mdx_heading_capitalization_preserves_native_jsx_elements() {
4569 for name in ["span", "Card", "ui.Card", "svg:path"] {
4570 let element = format!("<{name} onClick={{() => null}} title=\"literal friend\">world</{name}>");
4571 let source = format!("# hello {element} after\n");
4572 for (style, before, after) in [
4573 (HeadingCapStyle::TitleCase, "Hello", "After"),
4574 (HeadingCapStyle::SentenceCase, "Hello", "after"),
4575 (HeadingCapStyle::AllCaps, "HELLO", "AFTER"),
4576 ] {
4577 assert_eq!(fixed(style, &source), format!("# {before} {element} {after}\n"));
4578 }
4579 }
4580 }
4581
4582 #[test]
4583 fn mdx_heading_capitalization_preserves_nested_member_elements() {
4584 let element = "<ui.Card title='literal friend'>world <span>inner</span></ui.Card>";
4585 let source = format!("# hello {element} after\n");
4586 assert_eq!(
4587 fixed(HeadingCapStyle::TitleCase, &source),
4588 format!("# Hello {element} After\n")
4589 );
4590 }
4591
4592 #[test]
4593 fn mdx_heading_tag_protection_preserves_existing_visible_word_context() {
4594 assert_eq!(
4595 fixed(HeadingCapStyle::SentenceCase, "# <span></span> hello world\n"),
4596 "# <span></span> Hello world\n"
4597 );
4598 assert_eq!(
4599 fixed(HeadingCapStyle::SentenceCase, "# <img alt='friend'/> hello world\n"),
4600 "# <img alt='friend'/> hello world\n"
4601 );
4602 assert_eq!(
4603 fixed(HeadingCapStyle::TitleCase, "# <span>visible</span> and the world\n"),
4604 "# <span>visible</span> And the World\n"
4605 );
4606 }
4607
4608 #[test]
4609 fn mdx_setext_member_attributes_preserve_their_source_map() {
4610 let member = "hello <ui.Card title=\"literal\nfriend\">world</ui.Card> after\n===\n";
4611 assert_eq!(fixed(HeadingCapStyle::TitleCase, member), member);
4612 let html = "hello <span title=\"literal\nfriend\">world</span> after\n===\n";
4613 assert_eq!(
4614 fixed(HeadingCapStyle::TitleCase, html),
4615 "Hello <span title=\"literal\nfriend\">world</span> After\n===\n"
4616 );
4617 }
4618}