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::{HashMap, HashSet};
21use std::hash::Hash;
22use std::sync::LazyLock;
23
24mod md063_config;
25pub(super) use md063_config::{HeadingCapStyle, MD063Config};
26
27struct OpenElements<N> {
32 elements: Vec<(N, usize)>,
33 counts: Option<HashMap<N, usize>>,
34}
35
36impl<N: Clone + Eq + Hash> OpenElements<N> {
37 fn new() -> Self {
38 Self {
39 elements: Vec::new(),
40 counts: None,
41 }
42 }
43
44 fn push(&mut self, name: N, start: usize) {
45 if let Some(counts) = &mut self.counts {
46 *counts.entry(name.clone()).or_default() += 1;
47 }
48 self.elements.push((name, start));
49 }
50
51 fn close(&mut self, name: &N) -> Option<usize> {
52 if let Some(counts) = &self.counts
53 && !counts.contains_key(name)
54 {
55 return None;
56 }
57 if let Some(depth) = self.elements.iter().rposition(|(open_name, _)| open_name == name) {
58 let start = self.elements[depth].1;
59 if let Some(counts) = &mut self.counts {
60 for (name, _) in self.elements.drain(depth..) {
61 let count = counts.get_mut(&name).unwrap();
62 *count -= 1;
63 if *count == 0 {
64 counts.remove(&name);
65 }
66 }
67 if self.elements.is_empty() {
70 self.counts = None;
71 }
72 } else {
73 self.elements.truncate(depth);
74 }
75 Some(start)
76 } else {
77 if self.counts.is_none() && !self.elements.is_empty() {
78 let mut counts = HashMap::new();
79 for (name, _) in &self.elements {
80 *counts.entry(name.clone()).or_default() += 1;
81 }
82 self.counts = Some(counts);
83 }
84 None
85 }
86 }
87}
88
89static INLINE_CODE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`+[^`]+`+").unwrap());
91
92static LINK_REGEX: LazyLock<Regex> =
97 LazyLock::new(|| Regex::new(r"\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)|\[([^\]]*)\]\[[^\]]*\]").unwrap());
98
99static HTML_TOKEN_REGEX: LazyLock<Regex> = LazyLock::new(|| {
105 let pattern = format!(
106 r"<!-->|<!--->|<!--.*?-->|</({HTML_TAG_NAME_PATTERN})\s*>|<({HTML_TAG_NAME_PATTERN}){HTML_TAG_ATTRIBUTES_PATTERN}\s*/?>"
107 );
108 Regex::new(&pattern).unwrap()
109});
110
111const SELF_RENDERING_ELEMENTS: &[&str] = &[
116 "img", "video", "audio", "iframe", "embed", "object", "canvas", "svg", "math", "input", "select", "textarea",
117 "button", "meter", "progress",
118];
119
120static CUSTOM_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*\{#[^}]+\}\s*$").unwrap());
122
123#[derive(Debug, Clone)]
125enum HeadingSegment {
126 Text(String),
128 Code(String),
130 Link {
132 full: String,
133 text_start: usize,
134 text_end: usize,
135 },
136 Html(String),
138 Image(String),
141}
142
143impl HeadingSegment {
144 fn renders_nothing(&self) -> bool {
149 match self {
150 HeadingSegment::Html(html) => {
151 let paints_something = HTML_TOKEN_REGEX.captures_iter(html).any(|token| {
152 token.get(2).is_some_and(|name| {
153 let name = name.as_str().to_ascii_lowercase();
154 SELF_RENDERING_ELEMENTS.contains(&name.as_str())
155 })
156 });
157 !paints_something && HTML_TOKEN_REGEX.replace_all(html, "").trim().is_empty()
158 }
159 _ => false,
160 }
161 }
162}
163
164#[derive(Clone)]
166pub struct MD063HeadingCapitalization {
167 config: MD063Config,
168 lowercase_set: HashSet<String>,
169 proper_names: Vec<String>,
172}
173
174#[derive(Clone, Copy)]
176enum ResolvedStyle {
177 TitleCase,
178 SentenceCase,
179 AllCaps,
180}
181
182impl ResolvedStyle {
183 const ALL: [Self; 3] = [Self::TitleCase, Self::SentenceCase, Self::AllCaps];
184
185 fn name(self) -> &'static str {
186 match self {
187 Self::TitleCase => "title case",
188 Self::SentenceCase => "sentence case",
189 Self::AllCaps => "ALL CAPS",
190 }
191 }
192}
193
194impl Default for MD063HeadingCapitalization {
195 fn default() -> Self {
196 Self::new()
197 }
198}
199
200impl MD063HeadingCapitalization {
201 fn eligible_headings<'a>(
202 &'a self,
203 ctx: &'a crate::lint_context::LintContext<'_>,
204 ) -> impl Iterator<Item = crate::lint_context::ParsedHeading<'a>> + 'a {
205 let edges = crate::utils::blockquote::container_edges(ctx);
206 ctx.headings().filter(move |parsed| {
207 let heading = parsed.heading;
208 let line_index = parsed.line_num - 1;
209 heading.level >= self.config.min_level
210 && heading.level <= self.config.max_level
211 && !(!parsed.is_blockquote()
212 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX)
213 && crate::utils::blockquote::is_indented_past_edge(
214 &edges,
215 line_index,
216 parsed.line_info.content(ctx.content),
217 ))
218 })
219 }
220
221 fn enabled_headings<'a>(
224 &'a self,
225 ctx: &'a crate::lint_context::LintContext<'_>,
226 ) -> impl Iterator<Item = crate::lint_context::ParsedHeading<'a>> + 'a {
227 self.eligible_headings(ctx).filter(|parsed| {
228 let first_line = parsed.line_num + 1 - parsed.heading.text_lines;
230 !(first_line..=parsed.line_num).any(|line| ctx.is_rule_disabled(self.name(), line))
231 })
232 }
233
234 fn resolve_style(&self, ctx: &crate::lint_context::LintContext) -> ResolvedStyle {
235 match self.config.style {
236 HeadingCapStyle::TitleCase => return ResolvedStyle::TitleCase,
237 HeadingCapStyle::SentenceCase => return ResolvedStyle::SentenceCase,
238 HeadingCapStyle::AllCaps => return ResolvedStyle::AllCaps,
239 HeadingCapStyle::Consistent => {}
240 }
241
242 let mut candidates = 0b111_u8;
243 for parsed in self.enabled_headings(ctx) {
244 let mut valid = 0_u8;
245 for (index, style) in ResolvedStyle::ALL.into_iter().enumerate() {
246 if self.apply_heading_capitalization(ctx, parsed.line_num - 1, parsed.heading, style)
247 == parsed.heading.raw_text
248 {
249 valid |= 1 << index;
250 }
251 }
252 if valid == 0 {
254 continue;
255 }
256 let remaining = candidates & valid;
257 if remaining == 0 {
258 break;
259 }
260 candidates = remaining;
261 if candidates.is_power_of_two() {
262 break;
263 }
264 }
265 ResolvedStyle::ALL[candidates.trailing_zeros() as usize]
271 }
272
273 pub fn new() -> Self {
274 let config = MD063Config::default();
275 let lowercase_set = config.lowercase_words.iter().cloned().collect();
276 Self {
277 config,
278 lowercase_set,
279 proper_names: Vec::new(),
280 }
281 }
282
283 pub fn from_config_struct(config: MD063Config) -> Self {
284 let lowercase_set = config.lowercase_words.iter().cloned().collect();
285 Self {
286 config,
287 lowercase_set,
288 proper_names: Vec::new(),
289 }
290 }
291
292 fn match_case_insensitive_at(text: &str, start: usize, pattern_lower: &str) -> Option<usize> {
299 if start > text.len() || !text.is_char_boundary(start) || pattern_lower.is_empty() {
300 return None;
301 }
302
303 let mut matched_bytes = 0;
304
305 for (offset, ch) in text[start..].char_indices() {
306 if matched_bytes >= pattern_lower.len() {
307 break;
308 }
309
310 let lowered: String = ch.to_lowercase().collect();
311 if !pattern_lower[matched_bytes..].starts_with(&lowered) {
312 return None;
313 }
314
315 matched_bytes += lowered.len();
316
317 if matched_bytes == pattern_lower.len() {
318 return Some(start + offset + ch.len_utf8());
319 }
320 }
321
322 None
323 }
324
325 fn find_case_insensitive_match(text: &str, pattern_lower: &str, search_start: usize) -> Option<(usize, usize)> {
328 if pattern_lower.is_empty() || search_start >= text.len() || !text.is_char_boundary(search_start) {
329 return None;
330 }
331
332 for (offset, _) in text[search_start..].char_indices() {
333 let start = search_start + offset;
334 if let Some(end) = Self::match_case_insensitive_at(text, start, pattern_lower) {
335 return Some((start, end));
336 }
337 }
338
339 None
340 }
341
342 fn proper_name_canonical_forms(&self, text: &str) -> std::collections::HashMap<usize, &str> {
348 let mut map = std::collections::HashMap::new();
349
350 for name in &self.proper_names {
351 if name.is_empty() {
352 continue;
353 }
354 let name_lower = name.to_lowercase();
355 let canonical_words: Vec<&str> = name.split_whitespace().collect();
356 if canonical_words.is_empty() {
357 continue;
358 }
359 let mut search_start = 0;
360
361 while search_start < text.len() {
362 let Some((abs_pos, end_pos)) = Self::find_case_insensitive_match(text, &name_lower, search_start)
363 else {
364 break;
365 };
366
367 let before_ok = abs_pos == 0 || !text[..abs_pos].chars().last().is_some_and(char::is_alphanumeric);
369 let after_ok =
370 end_pos >= text.len() || !text[end_pos..].chars().next().is_some_and(char::is_alphanumeric);
371
372 if before_ok && after_ok {
373 let text_slice = &text[abs_pos..end_pos];
377 let mut word_idx = 0;
378 let mut slice_offset = 0;
379
380 for text_word in text_slice.split_whitespace() {
381 if let Some(w_rel) = text_slice[slice_offset..].find(text_word) {
382 let word_abs = abs_pos + slice_offset + w_rel;
383 if let Some(&canonical_word) = canonical_words.get(word_idx) {
384 map.insert(word_abs, canonical_word);
385 }
386 slice_offset += w_rel + text_word.len();
387 word_idx += 1;
388 }
389 }
390 }
391
392 search_start = abs_pos + text[abs_pos..].chars().next().map_or(1, char::len_utf8);
395 }
396 }
397
398 map
399 }
400
401 fn has_internal_capitals(&self, word: &str) -> bool {
403 let chars: Vec<char> = word.chars().collect();
404 if chars.len() < 2 {
405 return false;
406 }
407
408 let first = chars[0];
409 let rest = &chars[1..];
410 let has_upper_in_rest = rest.iter().any(|c| c.is_uppercase());
411 let has_lower_in_rest = rest.iter().any(|c| c.is_lowercase());
412
413 if has_upper_in_rest && has_lower_in_rest {
415 return true;
416 }
417
418 if first.is_lowercase() && has_upper_in_rest {
420 return true;
421 }
422
423 false
424 }
425
426 fn is_all_caps_acronym(&self, word: &str) -> bool {
430 if word.len() < 2 {
432 return false;
433 }
434
435 let mut consecutive_upper = 0;
436 let mut max_consecutive = 0;
437
438 for c in word.chars() {
439 if c.is_uppercase() {
440 consecutive_upper += 1;
441 max_consecutive = max_consecutive.max(consecutive_upper);
442 } else if c.is_lowercase() {
443 return false;
445 } else {
446 consecutive_upper = 0;
448 }
449 }
450
451 max_consecutive >= 2
453 }
454
455 fn should_preserve_word(&self, word: &str) -> bool {
457 if self.is_ignored_word(word) {
459 return true;
460 }
461
462 let is_ordinal = Self::is_numeric_ordinal(word);
468
469 if !is_ordinal {
470 if self.config.preserve_cased_words && self.has_internal_capitals(word) {
472 return true;
473 }
474
475 if self.config.preserve_cased_words && self.is_all_caps_acronym(word) {
477 return true;
478 }
479 }
480
481 if self.is_caret_notation(word) {
483 return true;
484 }
485
486 false
487 }
488
489 fn is_ignored_word(&self, word: &str) -> bool {
495 let mut candidate = word;
496 loop {
497 if self.config.ignore_words.iter().any(|ignored| ignored == candidate) {
498 return true;
499 }
500 match candidate.char_indices().next_back() {
501 Some((i, c)) if Self::is_trailing_punctuation(c) => candidate = &candidate[..i],
502 _ => return false,
503 }
504 }
505 }
506
507 fn is_trailing_punctuation(c: char) -> bool {
512 matches!(
513 c,
514 '.' | ','
515 | ';'
516 | ':'
517 | '!'
518 | '?'
519 | ')'
520 | ']'
521 | '}'
522 | '"'
523 | '\''
524 | '\u{2026}'
525 | '\u{2019}'
526 | '\u{201d}'
527 | '\u{203a}'
528 | '\u{bb}'
529 | '\u{3001}'
530 | '\u{3002}'
531 | '\u{3009}'
532 | '\u{300b}'
533 | '\u{300d}'
534 | '\u{300f}'
535 | '\u{3011}'
536 | '\u{ff01}'
537 | '\u{ff09}'
538 | '\u{ff0c}'
539 | '\u{ff0e}'
540 | '\u{ff1a}'
541 | '\u{ff1b}'
542 | '\u{ff1f}'
543 )
544 }
545
546 fn is_numeric_ordinal(word: &str) -> bool {
554 let core = word.trim_matches(|c: char| !c.is_alphanumeric());
559 let bytes = core.as_bytes();
560
561 let alpha_start = match bytes.iter().position(|&b| !b.is_ascii_digit()) {
563 Some(pos) if pos > 0 => pos,
564 _ => return false,
565 };
566
567 let alpha_end = bytes[alpha_start..]
569 .iter()
570 .position(|b| !b.is_ascii_alphabetic())
571 .map_or(bytes.len(), |p| alpha_start + p);
572
573 let suffix = &core[alpha_start..alpha_end];
574 matches!(suffix.to_ascii_lowercase().as_str(), "st" | "nd" | "rd" | "th")
575 }
576
577 fn is_caret_notation(&self, word: &str) -> bool {
579 let chars: Vec<char> = word.chars().collect();
580 if chars.len() >= 2 && chars[0] == '^' {
582 let second = chars[1];
583 if second.is_ascii_uppercase() || "@[\\]^_".contains(second) {
585 return true;
586 }
587 }
588 false
589 }
590
591 fn is_lowercase_word(&self, word: &str) -> bool {
593 self.lowercase_set.contains(&word.to_lowercase())
594 }
595
596 fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
598 if word.is_empty() {
599 return word.to_string();
600 }
601
602 if self.should_preserve_word(word) {
604 return word.to_string();
605 }
606
607 if is_first || is_last {
609 return self.capitalize_first(word);
610 }
611
612 if self.is_lowercase_word(word) {
614 return Self::lowercase_preserving_composition(word);
615 }
616
617 self.capitalize_first(word)
619 }
620
621 fn apply_canonical_form_to_word(word: &str, canonical: &str) -> String {
624 let canonical_lower = canonical.to_lowercase();
625 if canonical_lower.is_empty() {
626 return canonical.to_string();
627 }
628
629 if let Some(end_pos) = Self::match_case_insensitive_at(word, 0, &canonical_lower) {
630 let mut out = String::with_capacity(canonical.len() + word.len().saturating_sub(end_pos));
631 out.push_str(canonical);
632 out.push_str(&word[end_pos..]);
633 out
634 } else {
635 canonical.to_string()
636 }
637 }
638
639 fn capitalize_first(&self, word: &str) -> String {
641 if word.is_empty() {
642 return String::new();
643 }
644
645 let first_alpha_pos = word.find(|c: char| c.is_alphabetic());
647 let Some(pos) = first_alpha_pos else {
648 return word.to_string();
649 };
650
651 let prefix = &word[..pos];
652 let suffix = &word[pos..];
653
654 if Self::is_numeric_ordinal(word) {
657 let suffix_lower = Self::lowercase_preserving_composition(suffix);
658 return format!("{prefix}{suffix_lower}");
659 }
660
661 let mut chars = suffix.chars();
662 let first = chars.next().unwrap();
663 let first_upper = Self::uppercase_preserving_composition(&first.to_string());
666 let rest: String = chars.collect();
667 let rest_lower = Self::lowercase_preserving_composition(&rest);
668 format!("{prefix}{first_upper}{rest_lower}")
669 }
670
671 fn lowercase_preserving_composition(s: &str) -> String {
674 let mut result = String::with_capacity(s.len());
675 for c in s.chars() {
676 let lower: String = c.to_lowercase().collect();
677 if lower.chars().count() == 1 {
678 result.push_str(&lower);
679 } else {
680 result.push(c);
682 }
683 }
684 result
685 }
686
687 fn sentence_case_first_person_pronouns(word: &str) -> Option<String> {
697 fn is_emphasis_marker(c: char) -> bool {
698 matches!(c, '*' | '_' | '~')
699 }
700
701 fn is_word_connector(c: char) -> bool {
705 matches!(c, '/' | '\\' | '-' | '.' | '+' | '&')
706 }
707
708 fn is_word_boundary(c: char) -> bool {
709 !c.is_alphanumeric() && !is_word_connector(c)
710 }
711
712 fn is_pronoun_at(word: &str, pos: usize) -> bool {
713 let left = word[..pos].trim_end_matches(is_emphasis_marker);
714 if !left.chars().next_back().is_none_or(is_word_boundary) {
715 return false;
716 }
717
718 let after_i = word[pos + 1..].trim_start_matches(is_emphasis_marker);
719 let Some(apostrophe) = after_i.chars().next() else {
720 return true;
721 };
722 if !matches!(apostrophe, '\'' | '’') {
723 return is_word_boundary(apostrophe);
724 }
725
726 let after_apostrophe = after_i[apostrophe.len_utf8()..].trim_start_matches(is_emphasis_marker);
727 let suffix_end = after_apostrophe
728 .find(|c: char| !c.is_ascii_alphabetic())
729 .unwrap_or(after_apostrophe.len());
730 let suffix = &after_apostrophe[..suffix_end];
731 if suffix.is_empty() {
732 return after_apostrophe.chars().next().is_none_or(is_word_boundary);
734 }
735 if !matches!(suffix.to_ascii_lowercase().as_str(), "d" | "ll" | "m" | "ve") {
736 return false;
737 }
738
739 let after_suffix = after_apostrophe[suffix_end..].trim_start_matches(is_emphasis_marker);
740 after_suffix.chars().next().is_none_or(is_word_boundary)
741 }
742
743 let pronoun_positions: Vec<usize> = word
744 .char_indices()
745 .filter_map(|(pos, c)| (c == 'I' && is_pronoun_at(word, pos)).then_some(pos))
746 .collect();
747 if pronoun_positions.is_empty() {
748 return None;
749 }
750
751 let mut result = String::with_capacity(word.len());
752 let mut copied_through = 0;
753 for pos in pronoun_positions {
754 result.push_str(&Self::lowercase_preserving_composition(&word[copied_through..pos]));
755 result.push('I');
756 copied_through = pos + 1;
757 }
758 result.push_str(&Self::lowercase_preserving_composition(&word[copied_through..]));
759 Some(result)
760 }
761
762 fn uppercase_preserving_composition(s: &str) -> String {
767 let mut result = String::with_capacity(s.len());
768 for c in s.chars() {
769 let upper: String = c.to_uppercase().collect();
770 if upper.chars().count() == 1 {
771 result.push_str(&upper);
772 } else {
773 result.push(c);
775 }
776 }
777 result
778 }
779
780 fn apply_title_case(&self, text: &str) -> String {
784 let canonical_forms = self.proper_name_canonical_forms(text);
785
786 let original_words: Vec<&str> = text.split_whitespace().collect();
787 let total_words = original_words.len();
788
789 let mut word_positions: Vec<usize> = Vec::with_capacity(original_words.len());
792 let mut pos = 0;
793 for word in &original_words {
794 if let Some(rel) = text[pos..].find(word) {
795 word_positions.push(pos + rel);
796 pos = pos + rel + word.len();
797 } else {
798 word_positions.push(usize::MAX);
799 }
800 }
801
802 let result_words: Vec<String> = original_words
803 .iter()
804 .enumerate()
805 .map(|(i, word)| {
806 let after_period = i > 0 && original_words[i - 1].ends_with('.');
807 let is_first = i == 0 || after_period;
808 let is_last = i == total_words - 1;
809
810 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
812 return Self::apply_canonical_form_to_word(word, canonical);
813 }
814
815 if self.should_preserve_word(word) {
817 return (*word).to_string();
818 }
819
820 if self.is_ignored_word(word) {
822 return (*word).to_string();
823 }
824
825 if word.contains('-') {
827 return self.handle_hyphenated_word(word, is_first, is_last);
828 }
829
830 self.title_case_word(word, is_first, is_last)
831 })
832 .collect();
833
834 result_words.join(" ")
835 }
836
837 fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
839 let parts: Vec<&str> = word.split('-').collect();
840 let total_parts = parts.len();
841
842 let result_parts: Vec<String> = parts
843 .iter()
844 .enumerate()
845 .map(|(i, part)| {
846 let part_is_first = is_first && i == 0;
848 let part_is_last = is_last && i == total_parts - 1;
849 self.title_case_word(part, part_is_first, part_is_last)
850 })
851 .collect();
852
853 result_parts.join("-")
854 }
855
856 fn ends_sentence(&self, word: &str) -> bool {
862 self.config
863 .sentence_case_restart_after
864 .iter()
865 .any(|boundary| !boundary.is_empty() && word.ends_with(boundary.as_str()))
866 }
867
868 fn apply_sentence_case_from(&self, text: &str, starts_sentence: bool) -> String {
872 if text.is_empty() {
873 return text.to_string();
874 }
875
876 let canonical_forms = self.proper_name_canonical_forms(text);
877 let mut result = String::new();
878 let mut current_pos = 0;
879 let mut at_sentence_start = starts_sentence;
880
881 for word in text.split_whitespace() {
883 if let Some(pos) = text[current_pos..].find(word) {
884 let abs_pos = current_pos + pos;
885
886 result.push_str(&text[current_pos..abs_pos]);
888
889 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
892 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
893 } else if self.is_ignored_word(word) {
894 result.push_str(word);
896 } else if let Some(pronoun) = Self::sentence_case_first_person_pronouns(word) {
897 result.push_str(&pronoun);
900 } else if at_sentence_start {
901 if self.should_preserve_word(word) {
903 result.push_str(word);
905 } else {
906 let mut chars = word.chars();
908 if let Some(first) = chars.next() {
909 result.push_str(&Self::uppercase_preserving_composition(&first.to_string()));
910 let rest: String = chars.collect();
911 result.push_str(&Self::lowercase_preserving_composition(&rest));
912 }
913 }
914 } else {
915 if self.should_preserve_word(word) {
917 result.push_str(word);
918 } else {
919 result.push_str(&Self::lowercase_preserving_composition(word));
920 }
921 }
922
923 at_sentence_start = self.ends_sentence(word);
924 current_pos = abs_pos + word.len();
925 }
926 }
927
928 if current_pos < text.len() {
930 result.push_str(&text[current_pos..]);
931 }
932
933 result
934 }
935
936 fn apply_all_caps(&self, text: &str) -> String {
938 if text.is_empty() {
939 return text.to_string();
940 }
941
942 let canonical_forms = self.proper_name_canonical_forms(text);
943 let mut result = String::new();
944 let mut current_pos = 0;
945
946 for word in text.split_whitespace() {
948 if let Some(pos) = text[current_pos..].find(word) {
949 let abs_pos = current_pos + pos;
950
951 result.push_str(&text[current_pos..abs_pos]);
953
954 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
957 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
958 } else if self.should_preserve_word(word) {
959 result.push_str(word);
960 } else {
961 result.push_str(&Self::uppercase_preserving_composition(word));
962 }
963
964 current_pos = abs_pos + word.len();
965 }
966 }
967
968 if current_pos < text.len() {
970 result.push_str(&text[current_pos..]);
971 }
972
973 result
974 }
975
976 fn parse_segments(&self, text: &str) -> Vec<HeadingSegment> {
978 let mut segments = Vec::new();
979 let mut last_end = 0;
980
981 let mut special_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
983
984 for mat in INLINE_CODE_REGEX.find_iter(text) {
986 special_regions.push((mat.start(), mat.end(), HeadingSegment::Code(mat.as_str().to_string())));
987 }
988
989 for caps in LINK_REGEX.captures_iter(text) {
991 let full_match = caps.get(0).unwrap();
992
993 if full_match.start() >= 1 && text.as_bytes()[full_match.start() - 1] == b'!' {
997 let region_start = full_match.start() - 1;
998 special_regions.push((
999 region_start,
1000 full_match.end(),
1001 HeadingSegment::Image(text[region_start..full_match.end()].to_string()),
1002 ));
1003 continue;
1004 }
1005
1006 let text_match = caps.get(1).or_else(|| caps.get(2));
1007
1008 if let Some(text_m) = text_match {
1009 special_regions.push((
1010 full_match.start(),
1011 full_match.end(),
1012 HeadingSegment::Link {
1013 full: full_match.as_str().to_string(),
1014 text_start: text_m.start() - full_match.start(),
1015 text_end: text_m.end() - full_match.start(),
1016 },
1017 ));
1018 }
1019 }
1020
1021 let code_ranges: Vec<(usize, usize)> = special_regions
1024 .iter()
1025 .filter(|(_, _, segment)| matches!(segment, HeadingSegment::Code(_)))
1026 .map(|(start, end, _)| (*start, *end))
1027 .collect();
1028 for (start, end) in Self::html_regions(text, &code_ranges) {
1029 special_regions.push((start, end, HeadingSegment::Html(text[start..end].to_string())));
1030 }
1031
1032 special_regions.sort_by_key(|(start, _, _)| *start);
1034
1035 let mut filtered_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
1039 for region in special_regions {
1040 let overlaps = filtered_regions.last().is_some_and(|(_, end, _)| region.0 < *end);
1041 if !overlaps {
1042 filtered_regions.push(region);
1043 }
1044 }
1045
1046 for (start, end, segment) in filtered_regions {
1048 if start > last_end {
1050 let text_segment = &text[last_end..start];
1051 if !text_segment.is_empty() {
1052 segments.push(HeadingSegment::Text(text_segment.to_string()));
1053 }
1054 }
1055 segments.push(segment);
1056 last_end = end;
1057 }
1058
1059 if last_end < text.len() {
1061 let remaining = &text[last_end..];
1062 if !remaining.is_empty() {
1063 segments.push(HeadingSegment::Text(remaining.to_string()));
1064 }
1065 }
1066
1067 if segments.is_empty() && !text.is_empty() {
1069 segments.push(HeadingSegment::Text(text.to_string()));
1070 }
1071
1072 segments
1073 }
1074
1075 fn html_regions(text: &str, code_ranges: &[(usize, usize)]) -> Vec<(usize, usize)> {
1089 let mut regions: Vec<(usize, usize)> = Vec::new();
1090 let mut open_elements = OpenElements::new();
1091
1092 let mut code_index = 0;
1094 let mut pos = 0;
1095 while let Some(token) = HTML_TOKEN_REGEX.captures_at(text, pos) {
1096 let whole = token.get(0).unwrap();
1097 while code_ranges
1098 .get(code_index)
1099 .is_some_and(|&(_, end)| end <= whole.start())
1100 {
1101 code_index += 1;
1102 }
1103 if code_ranges
1104 .get(code_index)
1105 .is_some_and(|&(start, _)| start <= whole.start())
1106 || is_backslash_escaped(text, whole.start())
1107 {
1108 pos = whole.start() + 1;
1110 continue;
1111 }
1112 pos = whole.end();
1113
1114 if let Some(closing) = token.get(1) {
1115 let name = closing.as_str().to_ascii_lowercase();
1116 if let Some(element_start) = open_elements.close(&name) {
1117 while regions.last().is_some_and(|&(start, _)| start >= element_start) {
1120 regions.pop();
1121 }
1122 regions.push((element_start, whole.end()));
1123 continue;
1124 }
1125 } else if let Some(opening) = token.get(2) {
1126 let name = opening.as_str().to_ascii_lowercase();
1127 if !whole.as_str().ends_with("/>") && !is_void_element(&name) {
1128 open_elements.push(name, whole.start());
1129 }
1130 }
1131
1132 regions.push((whole.start(), whole.end()));
1133 }
1134
1135 regions
1136 }
1137
1138 fn mdx_html_regions(
1142 ctx: &crate::lint_context::LintContext,
1143 start: usize,
1144 end: usize,
1145 ) -> (Vec<(usize, usize)>, bool) {
1146 let html = ctx.html_tags();
1147 let components = ctx.jsx_component_tags();
1148 let mut tags = Vec::new();
1149 for cached in [html.as_slice(), components.as_slice()] {
1150 let first = cached.partition_point(|tag| tag.byte_end <= start);
1151 tags.extend(cached[first..].iter().take_while(|tag| tag.byte_offset < end));
1152 }
1153 tags.sort_unstable_by_key(|tag| tag.byte_offset);
1154 let mut regions = Vec::new();
1155 let mut open = OpenElements::new();
1156 let mut requires_source_map = false;
1157 for tag in tags {
1158 let source = &ctx.content[tag.byte_offset..tag.byte_end];
1159 requires_source_map |= !HTML_TOKEN_REGEX
1160 .find(source)
1161 .is_some_and(|token| token.start() == 0 && token.end() == source.len());
1162 if tag.is_closing {
1163 if let Some(element_start) = open.close(&tag.tag_name.as_str()) {
1164 regions.push((element_start, tag.byte_end));
1165 }
1166 } else if !tag.is_self_closing && !is_void_element(&tag.tag_name) {
1167 open.push(tag.tag_name.as_str(), tag.byte_offset);
1168 }
1169 regions.push((tag.byte_offset, tag.byte_end));
1170 }
1171 (regions, requires_source_map)
1172 }
1173
1174 fn apply_heading_capitalization(
1176 &self,
1177 ctx: &crate::lint_context::LintContext,
1178 line_index: usize,
1179 heading: &crate::lint_context::HeadingInfo,
1180 style: ResolvedStyle,
1181 ) -> String {
1182 if ctx.flavor != crate::config::MarkdownFlavor::MDX {
1183 return self.apply_capitalization(&heading.raw_text, ctx.flavor, style);
1184 }
1185 let line_info = &ctx.lines[line_index];
1186 if heading.text_lines > 1 {
1187 let first = line_index.saturating_sub(heading.text_lines - 1);
1190 let start = ctx.lines[first].byte_offset;
1191 let end = line_info.byte_offset + line_info.byte_len;
1192 let (_, requires_source_map) = Self::mdx_html_regions(ctx, start, end);
1193 if ctx.overlaps_mdx_inline_code(start, end) || requires_source_map {
1194 return heading.raw_text.clone();
1195 }
1196 return self.apply_capitalization(&heading.raw_text, ctx.flavor, style);
1197 }
1198 let line = line_info.content(ctx.content);
1199 let Some(relative_start) = line.find(&heading.raw_text) else {
1200 return heading.raw_text.clone();
1201 };
1202 let start = line_info.byte_offset + relative_start;
1203 let end = start + heading.raw_text.len();
1204 let (mut ranges, _) = Self::mdx_html_regions(ctx, start, end);
1205 ranges.extend(ctx.mdx_inline_code_ranges(start, end));
1206 let ranges = ranges
1207 .into_iter()
1208 .map(|(range_start, range_end)| (range_start.max(start) - start, range_end.min(end) - start))
1209 .collect();
1210 self.apply_capitalization_with_ranges(&heading.raw_text, ctx.flavor, ranges, style)
1211 }
1212
1213 fn apply_capitalization(&self, text: &str, flavor: crate::config::MarkdownFlavor, style: ResolvedStyle) -> String {
1215 self.apply_capitalization_with_ranges(text, flavor, Vec::new(), style)
1216 }
1217
1218 fn apply_capitalization_with_ranges(
1219 &self,
1220 text: &str,
1221 flavor: crate::config::MarkdownFlavor,
1222 mut ranges: Vec<(usize, usize)>,
1223 style: ResolvedStyle,
1224 ) -> String {
1225 ranges.extend(crate::utils::jinja_utils::find_jinja_ranges(text));
1226 ranges.extend(crate::utils::shortcode_utils::shortcode_ranges(text));
1227 ranges.sort_unstable();
1228 if ranges.is_empty() {
1229 return self.apply_capitalization_to_segments(text, flavor, style);
1230 }
1231
1232 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
1235 for (start, end) in ranges {
1236 if let Some(last) = merged.last_mut()
1237 && start <= last.1
1238 {
1239 last.1 = last.1.max(end);
1240 } else {
1241 merged.push((start, end));
1242 }
1243 }
1244
1245 let mut delimiter = String::from("\u{e000}");
1252 while text.contains(&delimiter) {
1253 delimiter.push('\u{e000}');
1254 }
1255 let mut masked = String::with_capacity(text.len());
1256 let mut templates = Vec::with_capacity(merged.len());
1257 let mut last_end = 0;
1258 for (index, (start, end)) in merged.into_iter().enumerate() {
1259 let source = &text[start..end];
1260 let token = if flavor == crate::config::MarkdownFlavor::MDX && source.starts_with('<') {
1261 let body = if HeadingSegment::Html(source.to_string()).renders_nothing() {
1262 ""
1263 } else {
1264 "0"
1265 };
1266 format!("<span data-md063=\"{delimiter}{index}{delimiter}\">{body}</span>")
1267 } else if flavor == crate::config::MarkdownFlavor::MDX
1268 && crate::utils::mdx_comments::is_comment_only_expression(&text[start..end])
1269 {
1270 format!("<!--{delimiter}{index}{delimiter}-->")
1273 } else {
1274 format!("{delimiter}{index}{delimiter} {delimiter}{index}{delimiter}")
1275 };
1276 masked.push_str(&text[last_end..start]);
1277 masked.push_str(&token);
1278 templates.push((token, &text[start..end]));
1279 last_end = end;
1280 }
1281 masked.push_str(&text[last_end..]);
1282
1283 let capitalized = self.apply_capitalization_to_segments(&masked, flavor, style);
1284 let mut result = String::with_capacity(capitalized.len());
1285 let mut cursor = 0;
1286 for (token, template) in templates {
1289 let Some(offset) = capitalized[cursor..].find(&token) else {
1290 return text.to_string();
1293 };
1294 let start = cursor + offset;
1295 result.push_str(&capitalized[cursor..start]);
1296 result.push_str(template);
1297 cursor = start + token.len();
1298 }
1299 result.push_str(&capitalized[cursor..]);
1300 result
1301 }
1302
1303 fn apply_capitalization_to_segments(
1305 &self,
1306 text: &str,
1307 flavor: crate::config::MarkdownFlavor,
1308 style: ResolvedStyle,
1309 ) -> String {
1310 let (main_text, custom_id) = if let Some(mat) = CUSTOM_ID_REGEX.find(text) {
1312 (&text[..mat.start()], Some(mat.as_str()))
1313 } else {
1314 (text, None)
1315 };
1316
1317 let (keyword, main_text) = if flavor == crate::config::MarkdownFlavor::MDG {
1324 mdg::keyword_split(main_text).unwrap_or(("", main_text))
1325 } else {
1326 ("", main_text)
1327 };
1328
1329 let segments = self.parse_segments(main_text);
1331
1332 let text_segments: Vec<usize> = segments
1334 .iter()
1335 .enumerate()
1336 .filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
1337 .collect();
1338
1339 let first_segment_starts_sentence = segments
1343 .iter()
1344 .find(|s| !s.renders_nothing())
1345 .is_some_and(|s| matches!(s, HeadingSegment::Text(_) | HeadingSegment::Link { .. }));
1346
1347 let last_segment_is_text = segments
1350 .iter()
1351 .rev()
1352 .find(|s| !s.renders_nothing())
1353 .is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
1354
1355 let mut result_parts: Vec<String> = Vec::new();
1357
1358 let mut at_sentence_start = first_segment_starts_sentence;
1361
1362 for (i, segment) in segments.iter().enumerate() {
1363 at_sentence_start = match segment {
1368 HeadingSegment::Text(t) => {
1369 let is_first_text = text_segments.first() == Some(&i);
1370 let is_last_text = text_segments.last() == Some(&i) && last_segment_is_text;
1374
1375 let capitalized = match style {
1376 ResolvedStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
1377 ResolvedStyle::SentenceCase => self.apply_sentence_case_from(t, at_sentence_start),
1378 ResolvedStyle::AllCaps => self.apply_all_caps(t),
1379 };
1380 let ends_sentence = self.ends_sentence(capitalized.trim_end());
1381 result_parts.push(capitalized);
1382 ends_sentence
1383 }
1384 HeadingSegment::Code(c) => {
1385 result_parts.push(c.clone());
1386 false
1387 }
1388 HeadingSegment::Link {
1389 full,
1390 text_start,
1391 text_end,
1392 } => {
1393 let link_text = &full[*text_start..*text_end];
1395 let capitalized_text = match style {
1396 ResolvedStyle::TitleCase => self.apply_title_case(link_text),
1397 ResolvedStyle::SentenceCase => self.apply_sentence_case_from(link_text, at_sentence_start),
1400 ResolvedStyle::AllCaps => self.apply_all_caps(link_text),
1401 };
1402 let ends_sentence = self.ends_sentence(capitalized_text.trim_end());
1405
1406 let mut new_link = String::new();
1407 new_link.push_str(&full[..*text_start]);
1408 new_link.push_str(&capitalized_text);
1409 new_link.push_str(&full[*text_end..]);
1410 result_parts.push(new_link);
1411 ends_sentence
1412 }
1413 HeadingSegment::Html(h) => {
1414 result_parts.push(h.clone());
1418 segment.renders_nothing() && at_sentence_start
1419 }
1420 HeadingSegment::Image(img) => {
1421 result_parts.push(img.clone());
1423 false
1424 }
1425 };
1426 }
1427
1428 let mut result = String::with_capacity(text.len());
1429 result.push_str(keyword);
1430 result.push_str(&result_parts.join(""));
1431
1432 if let Some(id) = custom_id {
1434 result.push_str(id);
1435 }
1436
1437 result
1438 }
1439
1440 fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
1442 let canonical_forms = self.proper_name_canonical_forms(text);
1443 let words: Vec<&str> = text.split_whitespace().collect();
1444 let total_words = words.len();
1445
1446 if total_words == 0 {
1447 return text.to_string();
1448 }
1449
1450 let mut word_positions: Vec<usize> = Vec::with_capacity(words.len());
1453 let mut pos = 0;
1454 for word in &words {
1455 if let Some(rel) = text[pos..].find(word) {
1456 word_positions.push(pos + rel);
1457 pos = pos + rel + word.len();
1458 } else {
1459 word_positions.push(usize::MAX);
1460 }
1461 }
1462
1463 let result_words: Vec<String> = words
1464 .iter()
1465 .enumerate()
1466 .map(|(i, word)| {
1467 let after_period = i > 0 && words[i - 1].ends_with('.');
1468 let is_first = (is_first_segment && i == 0) || after_period;
1469 let is_last = is_last_segment && i == total_words - 1;
1470
1471 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
1473 return Self::apply_canonical_form_to_word(word, canonical);
1474 }
1475
1476 if self.is_ignored_word(word) {
1478 return (*word).to_string();
1479 }
1480
1481 if word.contains('-') {
1483 return self.handle_hyphenated_word(word, is_first, is_last);
1484 }
1485
1486 self.title_case_word(word, is_first, is_last)
1487 })
1488 .collect();
1489
1490 let mut result = String::new();
1492 let mut word_iter = result_words.iter();
1493 let mut in_word = false;
1494
1495 for c in text.chars() {
1496 if c.is_whitespace() {
1497 if in_word {
1498 in_word = false;
1499 }
1500 result.push(c);
1501 } else if !in_word {
1502 if let Some(word) = word_iter.next() {
1503 result.push_str(word);
1504 }
1505 in_word = true;
1506 }
1507 }
1508
1509 result
1510 }
1511
1512 fn fix_atx_heading(&self, line: &str, heading: &crate::lint_context::HeadingInfo, fixed_text: &str) -> String {
1514 let indent = &line[..heading.marker_column];
1517 let hashes = "#".repeat(heading.level as usize);
1518
1519 let closing = &heading.closing_sequence;
1521 if heading.has_closing_sequence {
1522 format!("{indent}{hashes} {fixed_text} {closing}")
1523 } else {
1524 format!("{indent}{hashes} {fixed_text}")
1525 }
1526 }
1527
1528 fn fix_setext_heading(&self, line: &str, heading: &crate::lint_context::HeadingInfo, fixed_text: &str) -> String {
1530 let leading_ws = &line[..heading.content_column];
1533
1534 format!("{leading_ws}{fixed_text}")
1535 }
1536
1537 fn rewrite_words<'w>(line: &str, words: &mut impl Iterator<Item = &'w str>) -> String {
1540 let mut result = String::with_capacity(line.len());
1541 let mut in_word = false;
1542 for c in line.chars() {
1543 if c.is_whitespace() {
1544 in_word = false;
1545 result.push(c);
1546 } else if !in_word {
1547 if let Some(word) = words.next() {
1548 result.push_str(word);
1549 }
1550 in_word = true;
1551 }
1552 }
1553 result
1554 }
1555
1556 fn fix_setext_heading_span(
1572 &self,
1573 ctx: &crate::lint_context::LintContext,
1574 first_idx: usize,
1575 last_idx: usize,
1576 heading: &crate::lint_context::HeadingInfo,
1577 quoted: bool,
1578 fixed_text: &str,
1579 ) -> Option<Vec<String>> {
1580 let quote_depth = if quoted {
1581 ctx.lines
1582 .get(last_idx + 1)
1583 .and_then(|underline| crate::utils::blockquote::parse_blockquote_prefix(underline.content(ctx.content)))
1584 .map_or(0, |prefix| prefix.nesting_level)
1585 } else {
1586 0
1587 };
1588 let bodies: Vec<(&str, &str, &str)> = (first_idx..=last_idx)
1589 .map(|idx| {
1590 let line = ctx.lines[idx].content(ctx.content);
1591 let (prefix, line) = if idx == first_idx {
1595 line.split_at(heading.content_column)
1596 } else if quoted {
1597 let margin = crate::utils::blockquote::quote_margin_len(line, quote_depth).unwrap_or(0);
1600 line.split_at(margin)
1601 } else {
1602 ("", line)
1603 };
1604 if idx < last_idx && ctx.line_ends_with_hard_break(idx + 1) {
1605 let (body, hard_break) = line.split_at(line.len() - 1);
1606 (prefix, body, hard_break)
1607 } else {
1608 (prefix, line, "")
1609 }
1610 })
1611 .collect();
1612 let fixed_words: Vec<&str> = fixed_text.split_whitespace().collect();
1613 let original_words: usize = bodies.iter().map(|(_, body, _)| body.split_whitespace().count()).sum();
1614 if fixed_words.len() != original_words {
1615 return None;
1616 }
1617
1618 let mut words = fixed_words.into_iter();
1619 Some(
1620 bodies
1621 .iter()
1622 .map(|(prefix, body, hard_break)| {
1623 format!("{prefix}{}{hard_break}", Self::rewrite_words(body, &mut words))
1624 })
1625 .collect(),
1626 )
1627 }
1628}
1629
1630impl Rule for MD063HeadingCapitalization {
1631 fn name(&self) -> &'static str {
1632 "MD063"
1633 }
1634
1635 fn description(&self) -> &'static str {
1636 "Heading capitalization"
1637 }
1638
1639 fn category(&self) -> RuleCategory {
1640 RuleCategory::Heading
1641 }
1642
1643 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1644 !ctx.likely_has_headings() || ctx.headings().next().is_none()
1645 }
1646
1647 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1648 let content = ctx.content;
1649
1650 if content.is_empty() {
1651 return Ok(Vec::new());
1652 }
1653
1654 let mut warnings = Vec::new();
1655
1656 let style = self.resolve_style(ctx);
1657 for parsed in self.eligible_headings(ctx) {
1658 let line_num = parsed.line_num - 1;
1659 let line_info = parsed.line_info;
1660 let heading = parsed.heading;
1661 let original_text = &heading.raw_text;
1663 let fixed_text = self.apply_heading_capitalization(ctx, line_num, heading, style);
1664
1665 if original_text != &fixed_text {
1666 let line = line_info.content(ctx.content);
1667 let style_name = style.name();
1668
1669 if heading.text_lines > 1 {
1673 let first_idx = line_num + 1 - heading.text_lines;
1674 let first_line = ctx.lines[first_idx].content(ctx.content);
1675 let fix = self
1676 .fix_setext_heading_span(ctx, first_idx, line_num, heading, parsed.is_blockquote(), &fixed_text)
1677 .map(|rewritten| {
1678 let range = ctx.line_content_byte_range(first_idx + 1).start
1679 ..ctx.line_content_byte_range(line_num + 1).end;
1680 Fix::new(range, rewritten.join("\n"))
1681 });
1682 warnings.push(LintWarning {
1683 rule_name: Some(self.name().to_string()),
1684 line: first_idx + 1,
1685 column: byte_to_char_count(first_line, heading.content_column),
1686 end_line: line_num + 1,
1687 end_column: line.trim_end().chars().count() + 1,
1688 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1689 severity: Severity::Warning,
1690 fix,
1691 });
1692 continue;
1693 }
1694
1695 warnings.push(LintWarning {
1696 rule_name: Some(self.name().to_string()),
1697 line: line_num + 1,
1698 column: byte_to_char_count(line, heading.content_column),
1699 end_line: line_num + 1,
1700 end_column: byte_to_char_count(line, heading.content_column) + original_text.chars().count(),
1701 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
1702 severity: Severity::Warning,
1703 fix: Some(Fix::new(
1704 ctx.line_content_byte_range(line_num + 1),
1705 match heading.style {
1706 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading, &fixed_text),
1707 _ => self.fix_setext_heading(line, heading, &fixed_text),
1708 },
1709 )),
1710 });
1711 }
1712 }
1713
1714 Ok(warnings)
1715 }
1716
1717 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1718 let content = ctx.content;
1719
1720 if content.is_empty() {
1721 return Ok(content.to_string());
1722 }
1723
1724 let lines = ctx.raw_lines();
1725 let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1726
1727 let style = self.resolve_style(ctx);
1728 for parsed in self.enabled_headings(ctx) {
1729 let line_num = parsed.line_num - 1;
1730 let line_info = parsed.line_info;
1731 let heading = parsed.heading;
1732 let original_text = &heading.raw_text;
1733 let fixed_text = self.apply_heading_capitalization(ctx, line_num, heading, style);
1734
1735 if original_text != &fixed_text {
1736 let line = line_info.content(ctx.content);
1737 if heading.text_lines > 1 {
1740 let first_idx = line_num + 1 - heading.text_lines;
1741 if let Some(rewritten) = self.fix_setext_heading_span(
1742 ctx,
1743 first_idx,
1744 line_num,
1745 heading,
1746 parsed.is_blockquote(),
1747 &fixed_text,
1748 ) {
1749 fixed_lines[first_idx..=line_num].clone_from_slice(&rewritten);
1750 }
1751 continue;
1752 }
1753 fixed_lines[line_num] = match heading.style {
1754 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading, &fixed_text),
1755 _ => self.fix_setext_heading(line, heading, &fixed_text),
1756 };
1757 }
1758 }
1759
1760 let mut result = String::with_capacity(content.len());
1762 for (i, line) in fixed_lines.iter().enumerate() {
1763 result.push_str(line);
1764 if i < fixed_lines.len() - 1 || content.ends_with('\n') {
1765 result.push('\n');
1766 }
1767 }
1768
1769 Ok(result)
1770 }
1771
1772 fn as_any(&self) -> &dyn std::any::Any {
1773 self
1774 }
1775
1776 crate::impl_rule_config_sections!(MD063Config);
1777
1778 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1779 where
1780 Self: Sized,
1781 {
1782 let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
1783 let md044_config =
1784 crate::rule_config_serde::load_rule_config::<crate::rules::md044_proper_names::MD044Config>(config);
1785 let mut rule = Self::from_config_struct(rule_config);
1786 rule.proper_names = md044_config.names;
1787 Box::new(rule)
1788 }
1789}
1790
1791#[cfg(test)]
1792mod tests {
1793 use super::*;
1794 use crate::lint_context::LintContext;
1795
1796 #[test]
1797 fn test_open_elements_match_stack_pairing() {
1798 let operations = [(true, "a"), (true, "A"), (false, "a"), (false, "A"), (false, "missing")];
1801 for mut sequence in 0..operations.len().pow(6) {
1802 let mut indexed = OpenElements::new();
1803 let mut original = Vec::new();
1804 for start in 0..6 {
1805 let (opening, name) = operations[sequence % operations.len()];
1806 sequence /= operations.len();
1807 if opening {
1808 indexed.push(name, start);
1809 original.push((name, start));
1810 } else {
1811 let expected = original
1812 .iter()
1813 .rposition(|(open_name, _)| *open_name == name)
1814 .map(|depth| {
1815 let start = original[depth].1;
1816 original.truncate(depth);
1817 start
1818 });
1819 assert_eq!(indexed.close(&name), expected);
1820 }
1821 assert_eq!(indexed.elements, original);
1822 if let Some(counts) = &indexed.counts {
1823 let mut expected = HashMap::new();
1824 for (name, _) in &original {
1825 *expected.entry(*name).or_default() += 1;
1826 }
1827 assert_eq!(*counts, expected);
1828 }
1829 }
1830 }
1831 }
1832
1833 #[test]
1834 fn test_paired_elements_do_not_build_a_name_index() {
1835 let mut elements = OpenElements::new();
1836 assert_eq!(elements.close(&"missing"), None);
1837 elements.push("a", 0);
1838 elements.push("b", 3);
1839 elements.push("a", 6);
1840 assert_eq!(elements.close(&"a"), Some(6));
1841 assert_eq!(elements.close(&"a"), Some(0));
1842 assert!(elements.elements.is_empty());
1843 assert!(elements.counts.is_none());
1844
1845 elements.push("a", 10);
1846 assert_eq!(elements.close(&"missing"), None);
1847 assert!(elements.counts.is_some());
1848 assert_eq!(elements.close(&"a"), Some(10));
1849 assert!(elements.counts.is_none());
1850 elements.push("b", 20);
1851 assert_eq!(elements.close(&"b"), Some(20));
1852 assert!(elements.counts.is_none());
1853 }
1854
1855 fn create_rule() -> MD063HeadingCapitalization {
1856 let config = MD063Config {
1857 enabled: true,
1858 ..Default::default()
1859 };
1860 MD063HeadingCapitalization::from_config_struct(config)
1861 }
1862
1863 fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
1864 let config = MD063Config {
1865 enabled: true,
1866 style,
1867 ..Default::default()
1868 };
1869 MD063HeadingCapitalization::from_config_struct(config)
1870 }
1871
1872 #[test]
1873 fn test_heading_indent_counts_from_the_list_item_content_column() {
1874 let rule = create_rule();
1875 for (source, expected) in [
1876 ("- x\n\n ## hello world\n", "- x\n\n ## Hello World\n"),
1877 ("- x\n\n ## hello world\n", "- x\n\n ## Hello World\n"),
1878 ("1. x\n\n ## hello world\n", "1. x\n\n ## Hello World\n"),
1879 ("10. x\n\n ## hello world\n", "10. x\n\n ## Hello World\n"),
1880 ("10. x\n\n ## hello world\n", "10. x\n\n ## Hello World\n"),
1881 ("- x\n\n\t## hello world\n", "- x\n\n\t## Hello World\n"),
1882 ] {
1883 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1884 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source:?}");
1885 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source:?}");
1886 }
1887 }
1888
1889 #[test]
1890 fn test_heading_four_columns_past_the_list_item_content_column_is_code() {
1891 let rule = create_rule();
1892 for source in [
1893 "- x\n\n ## hello world\n",
1894 "1. x\n\n ## hello world\n",
1895 "10. x\n\n ## hello world\n",
1896 "- x\n\n\t\t## hello world\n",
1897 " ## hello world\n",
1898 ] {
1899 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1900 assert!(rule.check(&ctx).unwrap().is_empty(), "{source:?}");
1901 assert_eq!(rule.fix(&ctx).unwrap(), source, "{source:?}");
1902 }
1903 }
1904
1905 #[test]
1908 fn test_mdx_sentence_case_distinguishes_comments_from_comments_plus_code() {
1909 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1910 for (source, expected) in [
1911 ("# { /* hidden */ } hello world\n", "# { /* hidden */ } Hello world\n"),
1912 (
1913 "export const name = \"Visible\"\n\n# {/* c */ name} hello world\n",
1914 "export const name = \"Visible\"\n\n# {/* c */ name} hello world\n",
1915 ),
1916 ] {
1917 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1918 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1919 }
1920 }
1921
1922 #[test]
1923 fn test_mdx_leading_comment_does_not_consume_sentence_start() {
1924 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1925 for (source, expected) in [
1926 (
1927 "# {/* literal comment */} hello world\n",
1928 "# {/* literal comment */} Hello world\n",
1929 ),
1930 (
1931 "{/* literal comment */} hello world\n===\n",
1932 "{/* literal comment */} Hello world\n===\n",
1933 ),
1934 ] {
1935 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1936 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1937 }
1938 }
1939
1940 #[test]
1941 fn test_mdx_heading_expression_code_is_preserved() {
1942 for (style, before, after) in [
1943 (HeadingCapStyle::TitleCase, "Hello", "World"),
1944 (HeadingCapStyle::SentenceCase, "Hello", "world"),
1945 (HeadingCapStyle::AllCaps, "HELLO", "WORLD"),
1946 ] {
1947 let rule = create_rule_with_style(style);
1948 for expression in ["name", "data.text", r#""literal value""#, "name.toLowerCase()"] {
1949 let source = format!("# hello {{{expression}}} world\n");
1950 let expected = format!("# {before} {{{expression}}} {after}\n");
1951 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
1952 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1953 assert_eq!(
1954 rule.fix(&ctx).unwrap(),
1955 expected,
1956 "expression code is literal: {source}"
1957 );
1958 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::MDX, None);
1959 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1960 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1961 }
1962 }
1963 }
1964
1965 #[test]
1966 fn test_mdx_heading_link_labels_and_adjacent_expressions_preserve_code() {
1967 let rule = create_rule();
1968 for (source, expected) in [
1969 (
1970 "# hello [{name} world](target) friend\n",
1971 "# Hello [{name} World](target) Friend\n",
1972 ),
1973 ("# hello {name}{data.text} world\n", "# Hello {name}{data.text} World\n"),
1974 (
1975 "# hello {/* literal comment */} world\n",
1976 "# Hello {/* literal comment */} World\n",
1977 ),
1978 ("# 日本語 {name} world\n", "# 日本語 {name} World\n"),
1979 ] {
1980 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1981 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1982 }
1983 }
1984
1985 #[test]
1986 fn test_mdx_expression_protection_is_flavor_specific() {
1987 let rule = create_rule();
1988 let source = "# hello {name} world\n";
1989 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1990 assert_eq!(rule.fix(&ctx).unwrap(), "# Hello {Name} World\n");
1991 }
1992
1993 #[test]
1994 fn test_mdx_heading_attribute_expressions_keep_existing_html_protection() {
1995 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1996 let source = "# hello <span title={name}>world</span> friend\n";
1997 let expected = "# HELLO <span title={name}>world</span> FRIEND\n";
1998 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1999 assert_eq!(rule.fix(&ctx).unwrap(), expected);
2000 }
2001
2002 #[test]
2003 fn test_heading_template_tags_are_preserved() {
2004 for (style, before, after) in [
2005 (HeadingCapStyle::TitleCase, "Hello", "World"),
2006 (HeadingCapStyle::SentenceCase, "Hello", "world"),
2007 (HeadingCapStyle::AllCaps, "HELLO", "WORLD"),
2008 ] {
2009 let rule = create_rule_with_style(style);
2010 for template in [
2011 "{{ name }}",
2012 "{{ data['display_name'] }}",
2013 "{{< note title=\"literal value\" >}}",
2014 "{{% note title=\"literal value\" %}}",
2015 ] {
2016 let source = format!("# hello {template} world\n");
2017 let expected = format!("# {before} {template} {after}\n");
2018 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
2019 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2020 assert_eq!(rule.fix(&ctx).unwrap(), expected, "template code is literal: {source}");
2021 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
2022 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
2023 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
2024 }
2025 }
2026 }
2027
2028 #[test]
2029 fn test_heading_template_control_tags_and_link_labels_are_preserved() {
2030 let rule = create_rule();
2031 for (source, expected) in [
2032 (
2033 "# hello {% if enabled %}yes{% else %}no{% endif %} world\n",
2034 "# Hello {% if enabled %}Yes{% else %}No{% endif %} World\n",
2035 ),
2036 (
2037 "# hello [{{ name }} world](target) friend\n",
2038 "# Hello [{{ name }} World](target) Friend\n",
2039 ),
2040 (
2041 "# hello {{ name }} and {{ data['display_name'] }} world\n",
2042 "# Hello {{ name }} and {{ data['display_name'] }} World\n",
2043 ),
2044 ] {
2045 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2046 assert_eq!(rule.fix(&ctx).unwrap(), expected);
2047 }
2048 }
2049
2050 #[test]
2051 fn test_heading_template_tokens_do_not_collide_with_literal_text() {
2052 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
2053 let source = "# 日本語 \u{e000}0\u{e000} {{ name }} world\n";
2054 let expected = "# 日本語 \u{e000}0\u{e000} {{ name }} WORLD\n";
2055 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2056 assert_eq!(rule.fix(&ctx).unwrap(), expected);
2057 }
2058
2059 #[test]
2060 fn test_an_escaped_tag_does_not_hide_the_element_written_inside_it() {
2061 let text = r#"\<span title='<a id="x"></a>'>foo"#;
2064 assert_eq!(MD063HeadingCapitalization::html_regions(text, &[]), vec![(14, 28)]);
2065 }
2066
2067 #[test]
2068 fn test_title_case_basic() {
2069 let rule = create_rule();
2070 let content = "# hello world\n";
2071 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2072 let result = rule.check(&ctx).unwrap();
2073 assert_eq!(result.len(), 1);
2074 assert!(result[0].message.contains("Hello World"));
2075 }
2076
2077 #[test]
2078 fn test_title_case_lowercase_words() {
2079 let rule = create_rule();
2080 let content = "# the quick brown fox\n";
2081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2082 let result = rule.check(&ctx).unwrap();
2083 assert_eq!(result.len(), 1);
2084 assert!(result[0].message.contains("The Quick Brown Fox"));
2086 }
2087
2088 #[test]
2089 fn test_title_case_already_correct() {
2090 let rule = create_rule();
2091 let content = "# The Quick Brown Fox\n";
2092 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2093 let result = rule.check(&ctx).unwrap();
2094 assert!(result.is_empty(), "Already correct heading should not be flagged");
2095 }
2096
2097 #[test]
2098 fn test_title_case_hyphenated() {
2099 let rule = create_rule();
2100 let content = "# self-documenting code\n";
2101 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2102 let result = rule.check(&ctx).unwrap();
2103 assert_eq!(result.len(), 1);
2104 assert!(result[0].message.contains("Self-Documenting Code"));
2105 }
2106
2107 #[test]
2108 fn test_title_case_preserves_url_with_nested_parens() {
2109 let rule = create_rule();
2110 let content = "# guide for [the api](https://example.com/docs/v(2)beta)\n";
2112 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2113 let fixed = rule.fix(&ctx).unwrap();
2114 assert!(
2117 fixed.contains("https://example.com/docs/v(2)beta"),
2118 "URL with nested parens was corrupted: {fixed:?}"
2119 );
2120 }
2121
2122 #[test]
2123 fn test_title_case_does_not_recase_image_alt() {
2124 let rule = create_rule();
2125 let content = "# overview \n";
2126 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2127 let fixed = rule.fix(&ctx).unwrap();
2128 assert!(
2130 fixed.contains(""),
2131 "image alt text was modified: {fixed:?}"
2132 );
2133 assert!(
2134 fixed.contains("# Overview"),
2135 "surrounding prose should still be title-cased: {fixed:?}"
2136 );
2137 }
2138
2139 #[test]
2141 fn test_sentence_case_basic() {
2142 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2143 let content = "# The Quick Brown Fox\n";
2144 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2145 let result = rule.check(&ctx).unwrap();
2146 assert_eq!(result.len(), 1);
2147 assert!(result[0].message.contains("The quick brown fox"));
2148 }
2149
2150 #[test]
2151 fn test_sentence_case_already_correct() {
2152 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2153 let content = "# The quick brown fox\n";
2154 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2155 let result = rule.check(&ctx).unwrap();
2156 assert!(result.is_empty());
2157 }
2158
2159 #[test]
2160 fn test_sentence_case_preserves_first_person_pronoun() {
2161 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2163 let content = "# How do I debug playbooks?\n";
2164 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2165
2166 assert!(rule.check(&ctx).unwrap().is_empty());
2167 assert_eq!(rule.fix(&ctx).unwrap(), content);
2168 }
2169
2170 #[test]
2171 fn test_sentence_case_preserves_first_person_contractions() {
2172 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2173
2174 for content in [
2175 "# What I'd change\n",
2176 "# Where I'll look\n",
2177 "# Why I'm here\n",
2178 "# What I've learned\n",
2179 "# What I’d change\n",
2180 "# Where I’ll look\n",
2181 "# Why I’m here\n",
2182 "# What I’ve learned\n",
2183 ] {
2184 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2185 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
2186 assert_eq!(rule.fix(&ctx).unwrap(), content);
2187 }
2188 }
2189
2190 #[test]
2191 fn test_sentence_case_pronoun_handles_markup_punctuation_and_suffix_case() {
2192 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2193 let cases = [
2194 ("# What (**I**) would change\n", "# What (**I**) would change\n"),
2195 ("# Why **I**'M changing it\n", "# Why **I**'m changing it\n"),
2196 ("# What I’LL change\n", "# What I’ll change\n"),
2197 ("# What I—really want\n", "# What I—really want\n"),
2198 ("# What I’d—reluctantly change\n", "# What I’d—reluctantly change\n"),
2199 ("# What I—yes—I—would do\n", "# What I—yes—I—would do\n"),
2200 ("# What [I'll change](plan.md)\n", "# What [I'll change](plan.md)\n"),
2201 ("# [What I—really want](plan.md)\n", "# [What I—really want](plan.md)\n"),
2202 ];
2203
2204 for (content, expected) in cases {
2205 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2206 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
2207 }
2208 }
2209
2210 #[test]
2211 fn test_sentence_case_pronoun_is_independent_of_cased_word_preservation() {
2212 let config = MD063Config {
2213 enabled: true,
2214 style: HeadingCapStyle::SentenceCase,
2215 preserve_cased_words: false,
2216 ..Default::default()
2217 };
2218 let rule = MD063HeadingCapitalization::from_config_struct(config);
2219 let content = "# How do I debug what I’LL change?\n";
2220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2221
2222 assert_eq!(rule.fix(&ctx).unwrap(), "# How do I debug what I’ll change?\n");
2223 }
2224
2225 #[test]
2226 fn test_sentence_case_explicit_ignore_wins_over_pronoun_normalization() {
2227 let config = MD063Config {
2228 enabled: true,
2229 style: HeadingCapStyle::SentenceCase,
2230 ignore_words: vec!["I'LL".to_string(), "I’LL".to_string()],
2231 ..Default::default()
2232 };
2233 let rule = MD063HeadingCapitalization::from_config_struct(config);
2234 let content = "# Why I'LL stay and why I’LL leave\n";
2235 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2236
2237 assert!(rule.check(&ctx).unwrap().is_empty());
2238 assert_eq!(rule.fix(&ctx).unwrap(), content);
2239 }
2240
2241 #[test]
2242 fn test_sentence_case_pronoun_does_not_preserve_other_single_letters_or_compounds() {
2243 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2244 let content = "# Compare i, A, I/O, and A.I. values\n";
2245 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2246
2247 assert_eq!(rule.fix(&ctx).unwrap(), "# Compare i, a, i/o, and a.i. values\n");
2248 }
2249
2250 #[test]
2252 fn test_all_caps_basic() {
2253 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
2254 let content = "# hello world\n";
2255 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2256 let result = rule.check(&ctx).unwrap();
2257 assert_eq!(result.len(), 1);
2258 assert!(result[0].message.contains("HELLO WORLD"));
2259 }
2260
2261 #[test]
2263 fn test_preserve_ignore_words() {
2264 let config = MD063Config {
2265 enabled: true,
2266 ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
2267 ..Default::default()
2268 };
2269 let rule = MD063HeadingCapitalization::from_config_struct(config);
2270
2271 let content = "# using iPhone on macOS\n";
2272 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2273 let result = rule.check(&ctx).unwrap();
2274 assert_eq!(result.len(), 1);
2275 assert!(result[0].message.contains("iPhone"));
2277 assert!(result[0].message.contains("macOS"));
2278 }
2279
2280 #[test]
2281 fn test_preserve_cased_words() {
2282 let rule = create_rule();
2283 let content = "# using GitHub actions\n";
2284 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2285 let result = rule.check(&ctx).unwrap();
2286 assert_eq!(result.len(), 1);
2287 assert!(result[0].message.contains("GitHub"));
2289 }
2290
2291 #[test]
2293 fn test_inline_code_preserved() {
2294 let rule = create_rule();
2295 let content = "# using `const` in javascript\n";
2296 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2297 let result = rule.check(&ctx).unwrap();
2298 assert_eq!(result.len(), 1);
2299 assert!(result[0].message.contains("`const`"));
2301 assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
2302 }
2303
2304 #[test]
2306 fn test_level_filter() {
2307 let config = MD063Config {
2308 enabled: true,
2309 min_level: 2,
2310 max_level: 4,
2311 ..Default::default()
2312 };
2313 let rule = MD063HeadingCapitalization::from_config_struct(config);
2314
2315 let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
2316 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2317 let result = rule.check(&ctx).unwrap();
2318
2319 assert_eq!(result.len(), 2);
2321 assert_eq!(result[0].line, 2); assert_eq!(result[1].line, 3); }
2324
2325 #[test]
2327 fn test_fix_atx_heading() {
2328 let rule = create_rule();
2329 let content = "# hello world\n";
2330 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2331 let fixed = rule.fix(&ctx).unwrap();
2332 assert_eq!(fixed, "# Hello World\n");
2333 }
2334
2335 #[test]
2336 fn test_fix_multiple_headings() {
2337 let rule = create_rule();
2338 let content = "# first heading\n\n## second heading\n";
2339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2340 let fixed = rule.fix(&ctx).unwrap();
2341 assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
2342 }
2343
2344 #[test]
2346 fn test_setext_heading() {
2347 let rule = create_rule();
2348 let content = "hello world\n============\n";
2349 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2350 let result = rule.check(&ctx).unwrap();
2351 assert_eq!(result.len(), 1);
2352 assert!(result[0].message.contains("Hello World"));
2353 }
2354
2355 #[test]
2356 fn test_multi_line_setext_heading_is_capitalized_in_place() {
2357 let rule = create_rule();
2361 let content = "hello world\nand more words\n==============\n";
2362 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2363 let fixed = rule.fix(&ctx).unwrap();
2364 assert_eq!(fixed, "Hello World\nand More Words\n==============\n");
2365
2366 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2367 assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent");
2368 }
2369
2370 #[test]
2371 fn test_multi_line_setext_heading_keeps_a_hard_break_backslash() {
2372 let rule = create_rule();
2375 let content = "foo bar\\\nbaz qux\n=======\n";
2376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2377 let fixed = rule.fix(&ctx).unwrap();
2378 assert_eq!(fixed, "Foo Bar\\\nBaz Qux\n=======\n");
2379
2380 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2381 assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent");
2382 }
2383
2384 #[test]
2385 fn test_multi_line_setext_heading_keeps_a_backslash_that_is_no_hard_break() {
2386 let rule = create_rule();
2390 for (content, expected) in [
2391 ("foo `a\\\nb` tail\n===\n", "Foo `a\\\nb` Tail\n===\n"),
2392 ("foo bar\nbaz\\\n===\n", "Foo Bar\nBaz\\\n===\n"),
2393 ] {
2394 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2395 let fixed = rule.fix(&ctx).unwrap();
2396 assert_eq!(fixed, expected, "{content:?}");
2397
2398 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2399 assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent: {content:?}");
2400 }
2401 }
2402
2403 #[test]
2404 fn test_multi_line_setext_heading_warning_covers_the_whole_span() {
2405 let rule = create_rule();
2408 let content = "hello world\nand more\n=====\n";
2409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2410 let result = rule.check(&ctx).unwrap();
2411 assert_eq!(result.len(), 1, "got: {result:?}");
2412 assert_eq!(result[0].line, 1);
2413 assert_eq!(result[0].column, 1);
2414 assert_eq!(result[0].end_line, 2);
2415 assert_eq!(result[0].end_column, 9);
2416 }
2417
2418 #[test]
2419 fn test_multi_line_setext_heading_with_a_multi_byte_word_is_idempotent() {
2420 let rule = create_rule();
2424 let content = "`A`\n| à | |\n| --- | --- |\n---";
2425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2426 let fixed = rule.fix(&ctx).unwrap();
2427 assert_eq!(fixed, "`A`\n| À | |\n| --- | --- |\n---");
2428
2429 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2430 assert_eq!(rule.fix(&ctx).unwrap(), fixed, "fix is not idempotent");
2431 }
2432
2433 #[test]
2434 fn test_multi_line_setext_heading_whose_words_cannot_be_mapped_back_is_reported_without_a_fix() {
2435 let rule = create_rule();
2440 let content = "see [ the guide ](guide.md) first\nand then more\n=====\n";
2441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2442 let result = rule.check(&ctx).unwrap();
2443 assert_eq!(result.len(), 1, "got: {result:?}");
2444 assert_eq!(result[0].line, 1);
2445 assert_eq!(result[0].end_line, 2);
2446 assert!(
2447 result[0].fix.is_none(),
2448 "no rewrite is offered, got: {:?}",
2449 result[0].fix
2450 );
2451 assert_eq!(rule.fix(&ctx).unwrap(), content, "the heading is left as written");
2452
2453 let content = "see [the guide](guide.md) first\nand then more\n=====\n";
2455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2456 let result = rule.check(&ctx).unwrap();
2457 assert_eq!(result.len(), 1, "got: {result:?}");
2458 assert!(result[0].fix.is_some(), "the control heading is fixable");
2459 assert_eq!(
2460 rule.fix(&ctx).unwrap(),
2461 "See [The Guide](guide.md) First\nand Then More\n=====\n"
2462 );
2463 }
2464
2465 #[test]
2466 fn test_fix_honours_a_suppression_on_any_line_of_a_multi_line_setext_heading() {
2467 let rule = create_rule();
2471 let content = "<!-- rumdl-disable-next-line MD063 -->\nhello\nworld\n===\n";
2472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2473 assert_eq!(
2474 rule.fix(&ctx).unwrap(),
2475 content,
2476 "the suppressed heading is left as written"
2477 );
2478
2479 let content = "hello\nworld\n===\n";
2481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2482 assert_eq!(rule.fix(&ctx).unwrap(), "Hello\nWorld\n===\n");
2483 }
2484
2485 #[test]
2486 fn md087_tracks_heading_suppressions_across_styles_and_scopes() {
2487 use crate::config::MarkdownFlavor;
2488 use crate::rules::MD087UnusedDisableComment;
2489
2490 for style in [
2491 HeadingCapStyle::TitleCase,
2492 HeadingCapStyle::SentenceCase,
2493 HeadingCapStyle::AllCaps,
2494 HeadingCapStyle::Consistent,
2495 ] {
2496 let rule = create_rule_with_style(style);
2497 let rules: Vec<Box<dyn Rule>> = vec![Box::new(rule.clone()), Box::new(MD087UnusedDisableComment)];
2498 let lint =
2499 |content: &str| crate::lint(content, &rules, false, MarkdownFlavor::Standard, None, None).unwrap();
2500 for name in ["MD063", "heading-capitalization"] {
2501 for (directive, source) in [
2502 ("disable-line", format!("# asdf <!-- rumdl-disable-line {name} -->\n")),
2503 (
2504 "disable-next-line",
2505 format!("<!-- rumdl-disable-next-line {name} -->\n# asdf\n"),
2506 ),
2507 ("disable", format!("<!-- rumdl-disable {name} -->\n# asdf\n")),
2508 (
2509 "disable-file",
2510 format!("# asdf\n\n<!-- rumdl-disable-file {name} -->\n"),
2511 ),
2512 (
2514 "disable-next-line",
2515 format!("<!-- rumdl-disable-next-line {name} -->\nasdf\nasdf\n===\n"),
2516 ),
2517 (
2518 "disable-line",
2519 format!("asdf\nasdf <!-- rumdl-disable-line {name} -->\n===\n"),
2520 ),
2521 ] {
2522 let warnings = lint(&source);
2523 assert!(warnings.is_empty(), "{style:?}: {source:?}: {warnings:?}");
2524 let ctx = LintContext::new(&source, MarkdownFlavor::Standard, None);
2525 assert_eq!(rule.fix(&ctx).unwrap(), source, "suppressed heading must not change");
2526
2527 let valid = if style == HeadingCapStyle::AllCaps {
2529 "ASDF"
2530 } else {
2531 "Asdf"
2532 };
2533 let unused = if style == HeadingCapStyle::SentenceCase {
2534 source.replacen("asdf", valid, 1)
2535 } else {
2536 source.replace("asdf", valid)
2537 };
2538 let warnings = lint(&unused);
2539 assert_eq!(warnings.len(), 1, "{style:?}: {unused:?}: {warnings:?}");
2540 assert_eq!(warnings[0].rule_name.as_deref(), Some("MD087"));
2541 assert_eq!(warnings[0].message, format!("Unused {directive} comment: {name}"));
2542 }
2543 }
2544 let warnings = lint("# asdf\n");
2545 assert_eq!(warnings.len(), 1);
2546 assert_eq!(warnings[0].rule_name.as_deref(), Some("MD063"));
2547 }
2548 }
2549
2550 #[test]
2551 fn md087_accounting_does_not_let_disabled_headings_choose_consistent_style() {
2552 use crate::config::MarkdownFlavor;
2553 use crate::rules::MD087UnusedDisableComment;
2554
2555 let rule = create_rule_with_style(HeadingCapStyle::Consistent);
2556 let source =
2557 "# Title Case Heading <!-- rumdl-disable-line MD063 -->\n\n## Sentence case heading\n\n### More Details\n";
2558 let rules: Vec<Box<dyn Rule>> = vec![Box::new(rule.clone()), Box::new(MD087UnusedDisableComment)];
2559 let warnings = crate::lint(source, &rules, false, MarkdownFlavor::Standard, None, None).unwrap();
2560 assert_eq!(warnings.len(), 1, "{warnings:?}");
2561 assert_eq!(warnings[0].rule_name.as_deref(), Some("MD063"));
2562 assert_eq!(warnings[0].line, 5);
2563 assert!(warnings[0].message.contains("'More Details' -> 'More details'"));
2564 let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
2565 assert_eq!(rule.fix(&ctx).unwrap(), source.replace("More Details", "More details"));
2566 }
2567
2568 #[test]
2570 fn test_custom_id_preserved() {
2571 let rule = create_rule();
2572 let content = "# getting started {#intro}\n";
2573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2574 let result = rule.check(&ctx).unwrap();
2575 assert_eq!(result.len(), 1);
2576 assert!(result[0].message.contains("{#intro}"));
2578 }
2579
2580 #[test]
2582 fn test_skip_obsidian_tags_not_headings() {
2583 let rule = create_rule();
2584
2585 let content = "# H1\n\n#tag\n";
2587 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2588 let result = rule.check(&ctx).unwrap();
2589 assert!(
2590 result.is_empty() || result.iter().all(|w| w.line != 3),
2591 "Obsidian tag #tag should not be treated as a heading: {result:?}"
2592 );
2593 }
2594
2595 #[test]
2596 fn test_skip_invalid_atx_headings_no_space() {
2597 let rule = create_rule();
2598
2599 let content = "#notaheading\n";
2601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2602 let result = rule.check(&ctx).unwrap();
2603 assert!(
2604 result.is_empty(),
2605 "Invalid ATX heading without space should not be flagged: {result:?}"
2606 );
2607 }
2608
2609 #[test]
2610 fn test_fix_skips_obsidian_tags() {
2611 let rule = create_rule();
2612
2613 let content = "# hello world\n\n#tag\n";
2614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
2615 let fixed = rule.fix(&ctx).unwrap();
2616 assert!(fixed.contains("#tag"), "Fix should not modify Obsidian tag #tag");
2618 assert!(fixed.contains("# Hello World"), "Fix should still fix real headings");
2619 }
2620
2621 #[test]
2622 fn test_preserve_all_caps_acronyms() {
2623 let rule = create_rule();
2624 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2625
2626 let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
2628 assert_eq!(fixed, "# Using API in Production\n");
2629
2630 let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
2632 assert_eq!(fixed, "# API and GPU Integration\n");
2633
2634 let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
2636 assert_eq!(fixed, "# IO Performance Guide\n");
2637
2638 let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
2640 assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
2641 }
2642
2643 #[test]
2644 fn test_preserve_acronyms_in_hyphenated_words() {
2645 let rule = create_rule();
2646 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2647
2648 let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
2650 assert_eq!(fixed, "# API-Driven Architecture\n");
2651
2652 let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
2654 assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
2655 }
2656
2657 #[test]
2658 fn test_single_letters_not_treated_as_acronyms() {
2659 let rule = create_rule();
2660 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2661
2662 let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
2664 assert_eq!(fixed, "# I Am a Heading\n");
2665 }
2666
2667 #[test]
2668 fn test_lowercase_terms_need_ignore_words() {
2669 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2670
2671 let rule = create_rule();
2673 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
2674 assert_eq!(fixed, "# Using Npm Packages\n");
2675
2676 let config = MD063Config {
2678 enabled: true,
2679 ignore_words: vec!["npm".to_string()],
2680 ..Default::default()
2681 };
2682 let rule = MD063HeadingCapitalization::from_config_struct(config);
2683 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
2684 assert_eq!(fixed, "# Using npm Packages\n");
2685 }
2686
2687 #[test]
2688 fn test_acronyms_with_mixed_case_preserved() {
2689 let rule = create_rule();
2690 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2691
2692 let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
2694 assert_eq!(fixed, "# Using API with GitHub\n");
2695 }
2696
2697 #[test]
2698 fn test_real_world_acronyms() {
2699 let rule = create_rule();
2700 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
2701
2702 let content = "# FFI bindings for CPU optimization\n";
2704 let fixed = rule.fix(&ctx(content)).unwrap();
2705 assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
2706
2707 let content = "# DOM manipulation and SSR rendering\n";
2708 let fixed = rule.fix(&ctx(content)).unwrap();
2709 assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
2710
2711 let content = "# CVE security and RNN models\n";
2712 let fixed = rule.fix(&ctx(content)).unwrap();
2713 assert_eq!(fixed, "# CVE Security and RNN Models\n");
2714 }
2715
2716 #[test]
2717 fn test_is_all_caps_acronym() {
2718 let rule = create_rule();
2719
2720 assert!(rule.is_all_caps_acronym("API"));
2722 assert!(rule.is_all_caps_acronym("IO"));
2723 assert!(rule.is_all_caps_acronym("GPU"));
2724 assert!(rule.is_all_caps_acronym("HTTP2")); assert!(!rule.is_all_caps_acronym("A"));
2728 assert!(!rule.is_all_caps_acronym("I"));
2729
2730 assert!(!rule.is_all_caps_acronym("Api"));
2732 assert!(!rule.is_all_caps_acronym("npm"));
2733 assert!(!rule.is_all_caps_acronym("iPhone"));
2734 }
2735
2736 #[test]
2737 fn test_sentence_case_starts_after_a_leading_empty_anchor() {
2738 let config = MD063Config {
2740 enabled: true,
2741 style: HeadingCapStyle::SentenceCase,
2742 ..Default::default()
2743 };
2744 let rule = MD063HeadingCapitalization::from_config_struct(config);
2745
2746 let content = "# <a id=\"top\"></a>the beginning\n";
2747 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2748 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2749 assert_eq!(rule.fix(&ctx).unwrap(), "# <a id=\"top\"></a>The beginning\n");
2750
2751 let content = "# <kbd>ctrl</kbd> the key\n";
2753 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2754 assert!(rule.check(&ctx).unwrap().is_empty());
2755
2756 for content in ["# <img src=\"x.png\"> the picture\n", "#  the picture\n"] {
2759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2760 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
2761 }
2762 }
2763
2764 #[test]
2765 fn test_sentence_case_ignore_words_first_word() {
2766 let config = MD063Config {
2767 enabled: true,
2768 style: HeadingCapStyle::SentenceCase,
2769 ignore_words: vec!["nvim".to_string()],
2770 ..Default::default()
2771 };
2772 let rule = MD063HeadingCapitalization::from_config_struct(config);
2773
2774 let content = "# nvim config\n";
2776 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2777 let result = rule.check(&ctx).unwrap();
2778 assert!(
2779 result.is_empty(),
2780 "nvim in ignore-words should not be flagged. Got: {result:?}"
2781 );
2782
2783 let fixed = rule.fix(&ctx).unwrap();
2785 assert_eq!(fixed, "# nvim config\n");
2786 }
2787
2788 #[test]
2789 fn test_sentence_case_ignore_words_not_first() {
2790 let config = MD063Config {
2791 enabled: true,
2792 style: HeadingCapStyle::SentenceCase,
2793 ignore_words: vec!["nvim".to_string()],
2794 ..Default::default()
2795 };
2796 let rule = MD063HeadingCapitalization::from_config_struct(config);
2797
2798 let content = "# Using nvim editor\n";
2800 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2801 let result = rule.check(&ctx).unwrap();
2802 assert!(
2803 result.is_empty(),
2804 "nvim in ignore-words should be preserved. Got: {result:?}"
2805 );
2806 }
2807
2808 #[test]
2809 fn test_preserve_cased_words_ios() {
2810 let config = MD063Config {
2811 enabled: true,
2812 style: HeadingCapStyle::SentenceCase,
2813 preserve_cased_words: true,
2814 ..Default::default()
2815 };
2816 let rule = MD063HeadingCapitalization::from_config_struct(config);
2817
2818 let content = "## This is iOS\n";
2820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2821 let result = rule.check(&ctx).unwrap();
2822 assert!(
2823 result.is_empty(),
2824 "iOS should be preserved with preserve-cased-words. Got: {result:?}"
2825 );
2826
2827 let fixed = rule.fix(&ctx).unwrap();
2829 assert_eq!(fixed, "## This is iOS\n");
2830 }
2831
2832 #[test]
2833 fn test_preserve_cased_words_ios_title_case() {
2834 let config = MD063Config {
2835 enabled: true,
2836 style: HeadingCapStyle::TitleCase,
2837 preserve_cased_words: true,
2838 ..Default::default()
2839 };
2840 let rule = MD063HeadingCapitalization::from_config_struct(config);
2841
2842 let content = "# developing for iOS\n";
2844 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2845 let fixed = rule.fix(&ctx).unwrap();
2846 assert_eq!(fixed, "# Developing for iOS\n");
2847 }
2848
2849 #[test]
2850 fn test_has_internal_capitals_ios() {
2851 let rule = create_rule();
2852
2853 assert!(
2855 rule.has_internal_capitals("iOS"),
2856 "iOS has mixed case (lowercase i, uppercase OS)"
2857 );
2858
2859 assert!(rule.has_internal_capitals("iPhone"));
2861 assert!(rule.has_internal_capitals("macOS"));
2862 assert!(rule.has_internal_capitals("GitHub"));
2863 assert!(rule.has_internal_capitals("JavaScript"));
2864 assert!(rule.has_internal_capitals("eBay"));
2865
2866 assert!(!rule.has_internal_capitals("API"));
2868 assert!(!rule.has_internal_capitals("GPU"));
2869
2870 assert!(!rule.has_internal_capitals("npm"));
2872 assert!(!rule.has_internal_capitals("config"));
2873
2874 assert!(!rule.has_internal_capitals("The"));
2876 assert!(!rule.has_internal_capitals("Hello"));
2877 }
2878
2879 #[test]
2880 fn test_lowercase_words_before_trailing_code() {
2881 let config = MD063Config {
2882 enabled: true,
2883 style: HeadingCapStyle::TitleCase,
2884 lowercase_words: vec![
2885 "a".to_string(),
2886 "an".to_string(),
2887 "and".to_string(),
2888 "at".to_string(),
2889 "but".to_string(),
2890 "by".to_string(),
2891 "for".to_string(),
2892 "from".to_string(),
2893 "into".to_string(),
2894 "nor".to_string(),
2895 "on".to_string(),
2896 "onto".to_string(),
2897 "or".to_string(),
2898 "the".to_string(),
2899 "to".to_string(),
2900 "upon".to_string(),
2901 "via".to_string(),
2902 "vs".to_string(),
2903 "with".to_string(),
2904 "without".to_string(),
2905 ],
2906 preserve_cased_words: true,
2907 ..Default::default()
2908 };
2909 let rule = MD063HeadingCapitalization::from_config_struct(config);
2910
2911 let content = "## subtitle with a `app`\n";
2916 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2917 let result = rule.check(&ctx).unwrap();
2918
2919 assert!(!result.is_empty(), "Should flag incorrect capitalization");
2921 let fixed = rule.fix(&ctx).unwrap();
2922 assert!(
2924 fixed.contains("with a `app`"),
2925 "Expected 'with a `app`' but got: {fixed:?}"
2926 );
2927 assert!(
2928 !fixed.contains("with A `app`"),
2929 "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
2930 );
2931 assert!(
2933 fixed.contains("Subtitle with a `app`"),
2934 "Expected 'Subtitle with a `app`' but got: {fixed:?}"
2935 );
2936 }
2937
2938 #[test]
2939 fn test_lowercase_words_preserved_before_trailing_code_variant() {
2940 let config = MD063Config {
2941 enabled: true,
2942 style: HeadingCapStyle::TitleCase,
2943 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
2944 ..Default::default()
2945 };
2946 let rule = MD063HeadingCapitalization::from_config_struct(config);
2947
2948 let content = "## Title with the `code`\n";
2950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2951 let fixed = rule.fix(&ctx).unwrap();
2952 assert!(
2954 fixed.contains("with the `code`"),
2955 "Expected 'with the `code`' but got: {fixed:?}"
2956 );
2957 assert!(
2958 !fixed.contains("with The `code`"),
2959 "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
2960 );
2961 }
2962
2963 #[test]
2964 fn test_last_word_capitalized_when_no_trailing_code() {
2965 let config = MD063Config {
2968 enabled: true,
2969 style: HeadingCapStyle::TitleCase,
2970 lowercase_words: vec!["a".to_string(), "the".to_string()],
2971 ..Default::default()
2972 };
2973 let rule = MD063HeadingCapitalization::from_config_struct(config);
2974
2975 let content = "## title with a word\n";
2978 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2979 let fixed = rule.fix(&ctx).unwrap();
2980 assert!(
2982 fixed.contains("With a Word"),
2983 "Expected 'With a Word' but got: {fixed:?}"
2984 );
2985 }
2986
2987 #[test]
2988 fn test_multiple_lowercase_words_before_code() {
2989 let config = MD063Config {
2990 enabled: true,
2991 style: HeadingCapStyle::TitleCase,
2992 lowercase_words: vec![
2993 "a".to_string(),
2994 "the".to_string(),
2995 "with".to_string(),
2996 "for".to_string(),
2997 ],
2998 ..Default::default()
2999 };
3000 let rule = MD063HeadingCapitalization::from_config_struct(config);
3001
3002 let content = "## Guide for the `user`\n";
3004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3005 let fixed = rule.fix(&ctx).unwrap();
3006 assert!(
3007 fixed.contains("for the `user`"),
3008 "Expected 'for the `user`' but got: {fixed:?}"
3009 );
3010 assert!(
3011 !fixed.contains("For The `user`"),
3012 "Should not capitalize lowercase words before code. Got: {fixed:?}"
3013 );
3014 }
3015
3016 #[test]
3017 fn test_code_in_middle_normal_rules_apply() {
3018 let config = MD063Config {
3019 enabled: true,
3020 style: HeadingCapStyle::TitleCase,
3021 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
3022 ..Default::default()
3023 };
3024 let rule = MD063HeadingCapitalization::from_config_struct(config);
3025
3026 let content = "## Using `const` for the code\n";
3028 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3029 let fixed = rule.fix(&ctx).unwrap();
3030 assert!(
3032 fixed.contains("for the Code"),
3033 "Expected 'for the Code' but got: {fixed:?}"
3034 );
3035 }
3036
3037 #[test]
3038 fn test_link_at_end_same_as_code() {
3039 let config = MD063Config {
3040 enabled: true,
3041 style: HeadingCapStyle::TitleCase,
3042 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
3043 ..Default::default()
3044 };
3045 let rule = MD063HeadingCapitalization::from_config_struct(config);
3046
3047 let content = "## Guide for the [link](./page.md)\n";
3049 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3050 let fixed = rule.fix(&ctx).unwrap();
3051 assert!(
3053 fixed.contains("for the [Link]"),
3054 "Expected 'for the [Link]' but got: {fixed:?}"
3055 );
3056 assert!(
3057 !fixed.contains("for The [Link]"),
3058 "Should not capitalize 'the' before link. Got: {fixed:?}"
3059 );
3060 }
3061
3062 #[test]
3063 fn test_multiple_code_segments() {
3064 let config = MD063Config {
3065 enabled: true,
3066 style: HeadingCapStyle::TitleCase,
3067 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
3068 ..Default::default()
3069 };
3070 let rule = MD063HeadingCapitalization::from_config_struct(config);
3071
3072 let content = "## Using `const` with a `variable`\n";
3074 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3075 let fixed = rule.fix(&ctx).unwrap();
3076 assert!(
3078 fixed.contains("with a `variable`"),
3079 "Expected 'with a `variable`' but got: {fixed:?}"
3080 );
3081 assert!(
3082 !fixed.contains("with A `variable`"),
3083 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
3084 );
3085 }
3086
3087 #[test]
3088 fn test_code_and_link_combination() {
3089 let config = MD063Config {
3090 enabled: true,
3091 style: HeadingCapStyle::TitleCase,
3092 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
3093 ..Default::default()
3094 };
3095 let rule = MD063HeadingCapitalization::from_config_struct(config);
3096
3097 let content = "## Guide for the `code` [link](./page.md)\n";
3099 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3100 let fixed = rule.fix(&ctx).unwrap();
3101 assert!(
3103 fixed.contains("for the `code`"),
3104 "Expected 'for the `code`' but got: {fixed:?}"
3105 );
3106 }
3107
3108 #[test]
3109 fn test_text_after_code_capitalizes_last() {
3110 let config = MD063Config {
3111 enabled: true,
3112 style: HeadingCapStyle::TitleCase,
3113 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
3114 ..Default::default()
3115 };
3116 let rule = MD063HeadingCapitalization::from_config_struct(config);
3117
3118 let content = "## Using `const` for the code\n";
3120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3121 let fixed = rule.fix(&ctx).unwrap();
3122 assert!(
3124 fixed.contains("for the Code"),
3125 "Expected 'for the Code' but got: {fixed:?}"
3126 );
3127 }
3128
3129 #[test]
3130 fn test_preserve_cased_words_with_trailing_code() {
3131 let config = MD063Config {
3132 enabled: true,
3133 style: HeadingCapStyle::TitleCase,
3134 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
3135 preserve_cased_words: true,
3136 ..Default::default()
3137 };
3138 let rule = MD063HeadingCapitalization::from_config_struct(config);
3139
3140 let content = "## Guide for iOS `app`\n";
3142 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3143 let fixed = rule.fix(&ctx).unwrap();
3144 assert!(
3146 fixed.contains("for iOS `app`"),
3147 "Expected 'for iOS `app`' but got: {fixed:?}"
3148 );
3149 assert!(
3150 !fixed.contains("For iOS `app`"),
3151 "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
3152 );
3153 }
3154
3155 #[test]
3156 fn test_ignore_words_with_trailing_code() {
3157 let config = MD063Config {
3158 enabled: true,
3159 style: HeadingCapStyle::TitleCase,
3160 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
3161 ignore_words: vec!["npm".to_string()],
3162 ..Default::default()
3163 };
3164 let rule = MD063HeadingCapitalization::from_config_struct(config);
3165
3166 let content = "## Using npm with a `script`\n";
3168 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3169 let fixed = rule.fix(&ctx).unwrap();
3170 assert!(
3172 fixed.contains("npm with a `script`"),
3173 "Expected 'npm with a `script`' but got: {fixed:?}"
3174 );
3175 assert!(
3176 !fixed.contains("with A `script`"),
3177 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
3178 );
3179 }
3180
3181 #[test]
3182 fn test_empty_text_segment_edge_case() {
3183 let config = MD063Config {
3184 enabled: true,
3185 style: HeadingCapStyle::TitleCase,
3186 lowercase_words: vec!["a".to_string(), "with".to_string()],
3187 ..Default::default()
3188 };
3189 let rule = MD063HeadingCapitalization::from_config_struct(config);
3190
3191 let content = "## `start` with a `end`\n";
3193 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3194 let fixed = rule.fix(&ctx).unwrap();
3195 assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
3198 assert!(
3199 !fixed.contains("A `end`"),
3200 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
3201 );
3202 }
3203
3204 #[test]
3205 fn test_sentence_case_with_trailing_code() {
3206 let config = MD063Config {
3207 enabled: true,
3208 style: HeadingCapStyle::SentenceCase,
3209 lowercase_words: vec!["a".to_string(), "the".to_string()],
3210 ..Default::default()
3211 };
3212 let rule = MD063HeadingCapitalization::from_config_struct(config);
3213
3214 let content = "## guide for the `user`\n";
3216 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3217 let fixed = rule.fix(&ctx).unwrap();
3218 assert!(
3220 fixed.contains("Guide for the `user`"),
3221 "Expected 'Guide for the `user`' but got: {fixed:?}"
3222 );
3223 }
3224
3225 #[test]
3226 fn test_hyphenated_word_before_code() {
3227 let config = MD063Config {
3228 enabled: true,
3229 style: HeadingCapStyle::TitleCase,
3230 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
3231 ..Default::default()
3232 };
3233 let rule = MD063HeadingCapitalization::from_config_struct(config);
3234
3235 let content = "## Self-contained with a `feature`\n";
3237 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3238 let fixed = rule.fix(&ctx).unwrap();
3239 assert!(
3241 fixed.contains("with a `feature`"),
3242 "Expected 'with a `feature`' but got: {fixed:?}"
3243 );
3244 }
3245
3246 #[test]
3251 fn test_sentence_case_code_at_start_basic() {
3252 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3254 let content = "# `rumdl` is a linter\n";
3255 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3256 let result = rule.check(&ctx).unwrap();
3257 assert!(
3259 result.is_empty(),
3260 "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
3261 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3262 );
3263 }
3264
3265 #[test]
3266 fn test_sentence_case_code_at_start_incorrect_capitalization() {
3267 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3269 let content = "# `rumdl` Is a Linter\n";
3270 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3271 let result = rule.check(&ctx).unwrap();
3272 assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
3274 assert!(
3275 result[0].message.contains("`rumdl` is a linter"),
3276 "Should suggest lowercase after code. Got: {:?}",
3277 result[0].message
3278 );
3279 }
3280
3281 #[test]
3282 fn test_sentence_case_code_at_start_fix() {
3283 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3284 let content = "# `rumdl` Is A Linter\n";
3285 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3286 let fixed = rule.fix(&ctx).unwrap();
3287 assert!(
3288 fixed.contains("# `rumdl` is a linter"),
3289 "Should fix to lowercase after code. Got: {fixed:?}"
3290 );
3291 }
3292
3293 #[test]
3294 fn test_sentence_case_text_at_start_still_capitalizes() {
3295 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3297 let content = "# the quick brown fox\n";
3298 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3299 let result = rule.check(&ctx).unwrap();
3300 assert_eq!(result.len(), 1);
3301 assert!(
3302 result[0].message.contains("The quick brown fox"),
3303 "Text-first heading should capitalize first word. Got: {:?}",
3304 result[0].message
3305 );
3306 }
3307
3308 #[test]
3309 fn test_sentence_case_link_at_start() {
3310 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3312 let content = "# [api](api.md) reference guide\n";
3313 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3314 let result = rule.check(&ctx).unwrap();
3315 assert_eq!(result.len(), 1);
3316 assert!(result[0].message.contains("[Api](api.md) reference guide"));
3317 }
3318
3319 #[test]
3320 fn test_sentence_case_link_preserves_acronyms() {
3321 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3323 let content = "# [API](api.md) Reference Guide\n";
3324 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3325 let result = rule.check(&ctx).unwrap();
3326 assert_eq!(result.len(), 1);
3327 assert!(
3329 result[0].message.contains("[API](api.md) reference guide"),
3330 "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
3331 result[0].message
3332 );
3333 }
3334
3335 #[test]
3336 fn test_sentence_case_link_preserves_brand_names() {
3337 let config = MD063Config {
3339 enabled: true,
3340 style: HeadingCapStyle::SentenceCase,
3341 preserve_cased_words: true,
3342 ..Default::default()
3343 };
3344 let rule = MD063HeadingCapitalization::from_config_struct(config);
3345 let content = "# [iPhone](iphone.md) Features Guide\n";
3346 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3347 let result = rule.check(&ctx).unwrap();
3348 assert_eq!(result.len(), 1);
3349 assert!(
3351 result[0].message.contains("[iPhone](iphone.md) features guide"),
3352 "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
3353 result[0].message
3354 );
3355 }
3356
3357 #[test]
3358 fn test_sentence_case_link_lowercases_regular_words() {
3359 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3361 let content = "# [Documentation](docs.md) Reference\n";
3362 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3363 let result = rule.check(&ctx).unwrap();
3364 assert_eq!(result.len(), 1);
3365 assert!(
3366 result[0].message.contains("[Documentation](docs.md) reference"),
3367 "Should preserve the sentence-initial capital. Got: {:?}",
3368 result[0].message
3369 );
3370 }
3371
3372 #[test]
3373 fn test_sentence_case_opening_link_label_is_sentence_initial() {
3374 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3376 let content = "# [Foo bar](https://example.com)\n";
3377 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3378
3379 assert!(rule.check(&ctx).unwrap().is_empty());
3380 assert_eq!(rule.fix(&ctx).unwrap(), content);
3381 }
3382
3383 #[test]
3384 fn test_sentence_case_link_at_start_correct_already() {
3385 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3387 let content = "# [API](api.md) reference guide\n";
3388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3389 let result = rule.check(&ctx).unwrap();
3390 assert!(
3391 result.is_empty(),
3392 "Correctly cased heading with link should not be flagged. Got: {:?}",
3393 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3394 );
3395 }
3396
3397 #[test]
3398 fn test_sentence_case_link_github_preserved() {
3399 let config = MD063Config {
3401 enabled: true,
3402 style: HeadingCapStyle::SentenceCase,
3403 preserve_cased_words: true,
3404 ..Default::default()
3405 };
3406 let rule = MD063HeadingCapitalization::from_config_struct(config);
3407 let content = "# [GitHub](gh.md) Repository Setup\n";
3408 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3409 let result = rule.check(&ctx).unwrap();
3410 assert_eq!(result.len(), 1);
3411 assert!(
3412 result[0].message.contains("[GitHub](gh.md) repository setup"),
3413 "Should preserve 'GitHub'. Got: {:?}",
3414 result[0].message
3415 );
3416 }
3417
3418 #[test]
3419 fn test_sentence_case_multiple_code_spans() {
3420 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3421 let content = "# `foo` and `bar` are methods\n";
3422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3423 let result = rule.check(&ctx).unwrap();
3424 assert!(
3426 result.is_empty(),
3427 "Should not capitalize words between/after code spans. Got: {:?}",
3428 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3429 );
3430 }
3431
3432 #[test]
3433 fn test_sentence_case_code_only_heading() {
3434 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3436 let content = "# `rumdl`\n";
3437 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3438 let result = rule.check(&ctx).unwrap();
3439 assert!(
3440 result.is_empty(),
3441 "Code-only heading should be fine. Got: {:?}",
3442 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3443 );
3444 }
3445
3446 #[test]
3447 fn test_sentence_case_code_at_end() {
3448 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3450 let content = "# install the `rumdl` tool\n";
3451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3452 let result = rule.check(&ctx).unwrap();
3453 assert_eq!(result.len(), 1);
3455 assert!(
3456 result[0].message.contains("Install the `rumdl` tool"),
3457 "First word should still be capitalized when text comes first. Got: {:?}",
3458 result[0].message
3459 );
3460 }
3461
3462 #[test]
3463 fn test_sentence_case_code_in_middle() {
3464 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3466 let content = "# using the `rumdl` linter for markdown\n";
3467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3468 let result = rule.check(&ctx).unwrap();
3469 assert_eq!(result.len(), 1);
3471 assert!(
3472 result[0].message.contains("Using the `rumdl` linter for markdown"),
3473 "First word should be capitalized. Got: {:?}",
3474 result[0].message
3475 );
3476 }
3477
3478 #[test]
3479 fn test_sentence_case_preserved_word_after_code() {
3480 let config = MD063Config {
3482 enabled: true,
3483 style: HeadingCapStyle::SentenceCase,
3484 preserve_cased_words: true,
3485 ..Default::default()
3486 };
3487 let rule = MD063HeadingCapitalization::from_config_struct(config);
3488 let content = "# `swift` iPhone development\n";
3489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3490 let result = rule.check(&ctx).unwrap();
3491 assert!(
3493 result.is_empty(),
3494 "Preserved words after code should stay. Got: {:?}",
3495 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3496 );
3497 }
3498
3499 #[test]
3500 fn test_title_case_code_at_start_still_capitalizes() {
3501 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3503 let content = "# `api` quick start guide\n";
3504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3505 let result = rule.check(&ctx).unwrap();
3506 assert_eq!(result.len(), 1);
3508 assert!(
3509 result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
3510 "Title case should capitalize major words after code. Got: {:?}",
3511 result[0].message
3512 );
3513 }
3514
3515 #[test]
3518 fn test_sentence_case_html_tag_at_start() {
3519 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3521 let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
3522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3523 let result = rule.check(&ctx).unwrap();
3524 assert_eq!(result.len(), 1);
3526 let fixed = rule.fix(&ctx).unwrap();
3527 assert_eq!(
3528 fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
3529 "Text after HTML at start should be lowercase"
3530 );
3531 }
3532
3533 #[test]
3534 fn test_sentence_case_html_tag_preserves_content() {
3535 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3537 let content = "# The <abbr>API</abbr> documentation guide\n";
3538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3539 let result = rule.check(&ctx).unwrap();
3540 assert!(
3542 result.is_empty(),
3543 "HTML tag content should be preserved. Got: {:?}",
3544 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3545 );
3546 }
3547
3548 #[test]
3549 fn test_sentence_case_html_tag_at_start_with_acronym() {
3550 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3552 let content = "# <abbr>API</abbr> Documentation Guide\n";
3553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3554 let result = rule.check(&ctx).unwrap();
3555 assert_eq!(result.len(), 1);
3556 let fixed = rule.fix(&ctx).unwrap();
3557 assert_eq!(
3558 fixed, "# <abbr>API</abbr> documentation guide\n",
3559 "Text after HTML at start should be lowercase, HTML content preserved"
3560 );
3561 }
3562
3563 #[test]
3564 fn test_sentence_case_html_tag_in_middle() {
3565 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3567 let content = "# using the <code>config</code> File\n";
3568 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3569 let result = rule.check(&ctx).unwrap();
3570 assert_eq!(result.len(), 1);
3571 let fixed = rule.fix(&ctx).unwrap();
3572 assert_eq!(
3573 fixed, "# Using the <code>config</code> file\n",
3574 "First word capitalized, HTML preserved, rest lowercase"
3575 );
3576 }
3577
3578 #[test]
3579 fn test_html_tag_strong_emphasis() {
3580 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3582 let content = "# The <strong>Bold</strong> Way\n";
3583 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3584 let result = rule.check(&ctx).unwrap();
3585 assert_eq!(result.len(), 1);
3586 let fixed = rule.fix(&ctx).unwrap();
3587 assert_eq!(
3588 fixed, "# The <strong>Bold</strong> way\n",
3589 "<strong> tag content should be preserved"
3590 );
3591 }
3592
3593 #[test]
3594 fn test_html_tag_with_attributes() {
3595 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3597 let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
3598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3599 let result = rule.check(&ctx).unwrap();
3600 assert_eq!(result.len(), 1);
3601 let fixed = rule.fix(&ctx).unwrap();
3602 assert_eq!(
3603 fixed, "# <span class=\"highlight\">Important</span> notice here\n",
3604 "HTML tag with attributes should be preserved"
3605 );
3606 }
3607
3608 #[test]
3609 fn test_multiple_html_tags() {
3610 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3612 let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
3613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3614 let result = rule.check(&ctx).unwrap();
3615 assert_eq!(result.len(), 1);
3616 let fixed = rule.fix(&ctx).unwrap();
3617 assert_eq!(
3618 fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
3619 "Multiple HTML tags should all be preserved"
3620 );
3621 }
3622
3623 #[test]
3624 fn test_html_and_code_mixed() {
3625 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3627 let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
3628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3629 let result = rule.check(&ctx).unwrap();
3630 assert_eq!(result.len(), 1);
3631 let fixed = rule.fix(&ctx).unwrap();
3632 assert_eq!(
3633 fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
3634 "HTML and code should both be preserved"
3635 );
3636 }
3637
3638 #[test]
3639 fn test_self_closing_html_tag() {
3640 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3642 let content = "# Line one<br/>Line Two Here\n";
3643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3644 let result = rule.check(&ctx).unwrap();
3645 assert_eq!(result.len(), 1);
3646 let fixed = rule.fix(&ctx).unwrap();
3647 assert_eq!(
3648 fixed, "# Line one<br/>line two here\n",
3649 "Self-closing HTML tags should be preserved"
3650 );
3651 }
3652
3653 #[test]
3654 fn test_title_case_with_html_tags() {
3655 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
3657 let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
3658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3659 let result = rule.check(&ctx).unwrap();
3660 assert_eq!(result.len(), 1);
3661 let fixed = rule.fix(&ctx).unwrap();
3662 assert!(
3664 fixed.contains("<kbd>ctrl</kbd>"),
3665 "HTML tag content should be preserved in title case. Got: {fixed}"
3666 );
3667 assert!(
3668 fixed.starts_with("# The ") || fixed.starts_with("# the "),
3669 "Title case should work with HTML. Got: {fixed}"
3670 );
3671 }
3672
3673 #[test]
3676 fn test_sentence_case_preserves_caret_notation() {
3677 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3679 let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
3680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3681 let result = rule.check(&ctx).unwrap();
3682 assert!(
3684 result.is_empty(),
3685 "Caret notation should be preserved. Got: {:?}",
3686 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3687 );
3688 }
3689
3690 #[test]
3691 fn test_sentence_case_caret_notation_various() {
3692 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
3694
3695 let content = "## Press ^C to cancel\n";
3697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3698 let result = rule.check(&ctx).unwrap();
3699 assert!(
3700 result.is_empty(),
3701 "^C should be preserved. Got: {:?}",
3702 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3703 );
3704
3705 let content = "## Use ^Z for background\n";
3707 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3708 let result = rule.check(&ctx).unwrap();
3709 assert!(
3710 result.is_empty(),
3711 "^Z should be preserved. Got: {:?}",
3712 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3713 );
3714
3715 let content = "## Press ^[ for escape\n";
3717 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3718 let result = rule.check(&ctx).unwrap();
3719 assert!(
3720 result.is_empty(),
3721 "^[ should be preserved. Got: {:?}",
3722 result.iter().map(|w| &w.message).collect::<Vec<_>>()
3723 );
3724 }
3725
3726 #[test]
3727 fn test_caret_notation_detection() {
3728 let rule = create_rule();
3729
3730 assert!(rule.is_caret_notation("^A"));
3732 assert!(rule.is_caret_notation("^Z"));
3733 assert!(rule.is_caret_notation("^C"));
3734 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")); }
3746
3747 fn create_sentence_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
3754 let config = MD063Config {
3755 enabled: true,
3756 style: HeadingCapStyle::SentenceCase,
3757 ..Default::default()
3758 };
3759 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3760 rule.proper_names = names;
3761 rule
3762 }
3763
3764 #[test]
3765 fn test_sentence_case_preserves_single_word_proper_name() {
3766 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
3767 let content = "# installing javascript\n";
3769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3770 let result = rule.check(&ctx).unwrap();
3771 assert_eq!(result.len(), 1, "Should flag the heading");
3772 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3773 assert!(
3774 fix_text.contains("JavaScript"),
3775 "Fix should preserve proper name 'JavaScript', got: {fix_text:?}"
3776 );
3777 assert!(
3778 !fix_text.contains("javascript"),
3779 "Fix should not have lowercase 'javascript', got: {fix_text:?}"
3780 );
3781 }
3782
3783 #[test]
3784 fn test_sentence_case_preserves_multi_word_proper_name() {
3785 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3786 let content = "# using good application features\n";
3788 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3789 let result = rule.check(&ctx).unwrap();
3790 assert_eq!(result.len(), 1, "Should flag the heading");
3791 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3792 assert!(
3793 fix_text.contains("Good Application"),
3794 "Fix should preserve 'Good Application' as a phrase, got: {fix_text:?}"
3795 );
3796 }
3797
3798 #[test]
3799 fn test_sentence_case_proper_name_at_start_of_heading() {
3800 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3801 let content = "# good application overview\n";
3803 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3804 let result = rule.check(&ctx).unwrap();
3805 assert_eq!(result.len(), 1, "Should flag the heading");
3806 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3807 assert!(
3808 fix_text.contains("Good Application"),
3809 "Fix should produce 'Good Application' at start of heading, got: {fix_text:?}"
3810 );
3811 assert!(
3812 fix_text.contains("overview"),
3813 "Non-proper-name word 'overview' should be lowercase, got: {fix_text:?}"
3814 );
3815 }
3816
3817 #[test]
3818 fn test_sentence_case_with_proper_names_no_oscillation() {
3819 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3822
3823 let content = "# installing good application on your system\n";
3825 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3826 let result = rule.check(&ctx).unwrap();
3827 assert_eq!(result.len(), 1);
3828 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
3829
3830 assert!(
3832 fixed_heading.contains("Good Application"),
3833 "After fix, proper name must be preserved: {fixed_heading:?}"
3834 );
3835
3836 let fixed_line = format!("{fixed_heading}\n");
3838 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
3839 let result2 = rule.check(&ctx2).unwrap();
3840 assert!(
3841 result2.is_empty(),
3842 "After one fix, heading must already satisfy both MD063 and MD044 - no oscillation. \
3843 Second pass warnings: {result2:?}"
3844 );
3845 }
3846
3847 #[test]
3848 fn test_sentence_case_proper_names_already_correct() {
3849 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
3850 let content = "# Installing Good Application\n";
3852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3853 let result = rule.check(&ctx).unwrap();
3854 assert!(
3855 result.is_empty(),
3856 "Correct sentence-case heading with proper name should not be flagged, got: {result:?}"
3857 );
3858 }
3859
3860 #[test]
3861 fn test_sentence_case_multiple_proper_names_in_heading() {
3862 let rule = create_sentence_case_rule_with_proper_names(vec!["TypeScript".to_string(), "React".to_string()]);
3863 let content = "# using typescript with react\n";
3864 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3865 let result = rule.check(&ctx).unwrap();
3866 assert_eq!(result.len(), 1);
3867 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3868 assert!(
3869 fix_text.contains("TypeScript"),
3870 "Fix should preserve 'TypeScript', got: {fix_text:?}"
3871 );
3872 assert!(
3873 fix_text.contains("React"),
3874 "Fix should preserve 'React', got: {fix_text:?}"
3875 );
3876 }
3877
3878 #[test]
3879 fn test_sentence_case_unicode_casefold_expansion_before_proper_name() {
3880 let rule = create_sentence_case_rule_with_proper_names(vec!["Österreich".to_string()]);
3883 let content = "# İ österreich guide\n";
3884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3885
3886 let result = rule.check(&ctx).unwrap();
3888 assert_eq!(result.len(), 1, "Should flag heading for canonical proper-name casing");
3889 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3890 assert!(
3891 fix_text.contains("Österreich"),
3892 "Fix should preserve canonical 'Österreich', got: {fix_text:?}"
3893 );
3894 }
3895
3896 #[test]
3897 fn test_sentence_case_preserves_trailing_punctuation_on_proper_name() {
3898 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
3899 let content = "# using javascript, today\n";
3900 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3901 let result = rule.check(&ctx).unwrap();
3902 assert_eq!(result.len(), 1, "Should flag heading");
3903 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3904 assert!(
3905 fix_text.contains("JavaScript,"),
3906 "Fix should preserve trailing punctuation, got: {fix_text:?}"
3907 );
3908 }
3909
3910 fn create_title_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
3917 let config = MD063Config {
3918 enabled: true,
3919 style: HeadingCapStyle::TitleCase,
3920 ..Default::default()
3921 };
3922 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
3923 rule.proper_names = names;
3924 rule
3925 }
3926
3927 #[test]
3928 fn test_title_case_preserves_proper_name_with_lowercase_article() {
3929 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
3933 let content = "# listening to the rolling stones today\n";
3934 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3935 let result = rule.check(&ctx).unwrap();
3936 assert_eq!(result.len(), 1, "Should flag the heading");
3937 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3938 assert!(
3939 fix_text.contains("The Rolling Stones"),
3940 "Fix should preserve proper name 'The Rolling Stones', got: {fix_text:?}"
3941 );
3942 }
3943
3944 #[test]
3945 fn test_title_case_proper_name_no_oscillation() {
3946 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
3948 let content = "# listening to the rolling stones today\n";
3949 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3950 let result = rule.check(&ctx).unwrap();
3951 assert_eq!(result.len(), 1);
3952 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
3953
3954 let fixed_line = format!("{fixed_heading}\n");
3955 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
3956 let result2 = rule.check(&ctx2).unwrap();
3957 assert!(
3958 result2.is_empty(),
3959 "After one title-case fix, heading must already satisfy both rules. \
3960 Second pass warnings: {result2:?}"
3961 );
3962 }
3963
3964 #[test]
3965 fn test_title_case_unicode_casefold_expansion_before_proper_name() {
3966 let rule = create_title_case_rule_with_proper_names(vec!["Österreich".to_string()]);
3967 let content = "# İ österreich guide\n";
3968 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3969 let result = rule.check(&ctx).unwrap();
3970 assert_eq!(result.len(), 1, "Should flag the heading");
3971 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
3972 assert!(
3973 fix_text.contains("Österreich"),
3974 "Fix should preserve canonical proper-name casing, got: {fix_text:?}"
3975 );
3976 }
3977
3978 #[test]
3984 fn test_from_config_loads_md044_names_into_md063() {
3985 use crate::config::{Config, RuleConfig};
3986 use crate::rule::Rule;
3987 use std::collections::BTreeMap;
3988
3989 let mut config = Config::default();
3990
3991 let mut md063_values = BTreeMap::new();
3993 md063_values.insert("style".to_string(), toml::Value::String("sentence_case".to_string()));
3994 md063_values.insert("enabled".to_string(), toml::Value::Boolean(true));
3995 config.rules.insert(
3996 "MD063".to_string(),
3997 RuleConfig {
3998 values: md063_values,
3999 severity: None,
4000 },
4001 );
4002
4003 let mut md044_values = BTreeMap::new();
4005 md044_values.insert(
4006 "names".to_string(),
4007 toml::Value::Array(vec![toml::Value::String("Good Application".to_string())]),
4008 );
4009 config.rules.insert(
4010 "MD044".to_string(),
4011 RuleConfig {
4012 values: md044_values,
4013 severity: None,
4014 },
4015 );
4016
4017 let rule = MD063HeadingCapitalization::from_config(&config);
4019
4020 let content = "# using good application features\n";
4022 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4023 let result = rule.check(&ctx).unwrap();
4024 assert_eq!(result.len(), 1, "Should flag the heading");
4025 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
4026 assert!(
4027 fix_text.contains("Good Application"),
4028 "from_config should wire MD044 names into MD063; fix should preserve \
4029 'Good Application', got: {fix_text:?}"
4030 );
4031 }
4032
4033 #[test]
4034 fn test_title_case_short_word_not_confused_with_substring() {
4035 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
4039
4040 let content = "# in the insert\n";
4043 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4044 let result = rule.check(&ctx).unwrap();
4045 assert_eq!(result.len(), 1, "Should flag the heading");
4046 let fix = result[0].fix.as_ref().expect("Fix should be present");
4047 assert!(
4049 fix.replacement.contains("In the Insert"),
4050 "Expected 'In the Insert', got: {:?}",
4051 fix.replacement
4052 );
4053 }
4054
4055 #[test]
4056 fn test_title_case_or_not_confused_with_orchestra() {
4057 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
4058
4059 let content = "# or the orchestra\n";
4062 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4063 let result = rule.check(&ctx).unwrap();
4064 assert_eq!(result.len(), 1, "Should flag the heading");
4065 let fix = result[0].fix.as_ref().expect("Fix should be present");
4066 assert!(
4068 fix.replacement.contains("Or the Orchestra"),
4069 "Expected 'Or the Orchestra', got: {:?}",
4070 fix.replacement
4071 );
4072 }
4073
4074 #[test]
4075 fn test_all_caps_preserves_all_words() {
4076 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
4077
4078 let content = "# in the insert\n";
4079 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4080 let result = rule.check(&ctx).unwrap();
4081 assert_eq!(result.len(), 1, "Should flag the heading");
4082 let fix = result[0].fix.as_ref().expect("Fix should be present");
4083 assert!(
4084 fix.replacement.contains("IN THE INSERT"),
4085 "All caps should uppercase all words, got: {:?}",
4086 fix.replacement
4087 );
4088 }
4089
4090 #[test]
4092 fn test_title_case_numbered_prefix_lowercase_word() {
4093 let rule = create_rule();
4095 let content = "## 1. To Be a Thing\n";
4096 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4097 let result = rule.check(&ctx).unwrap();
4098 assert!(
4099 result.is_empty(),
4100 "Should not flag '## 1. To Be a Thing', got: {result:?}"
4101 );
4102
4103 let content_lower = "## 1. to be a thing\n";
4104 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
4105 let result2 = rule.check(&ctx2).unwrap();
4106 assert!(!result2.is_empty(), "Should flag '## 1. to be a thing'");
4107 let fix = result2[0].fix.as_ref().expect("Should have a fix");
4108 assert!(
4109 fix.replacement.contains("1. To Be a Thing"),
4110 "Fix should capitalize 'To', got: {:?}",
4111 fix.replacement
4112 );
4113 }
4114
4115 #[test]
4116 fn test_title_case_numbered_prefix_article() {
4117 let rule = create_rule();
4119 let content = "## 2. A Guide to the Galaxy\n";
4120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4121 let result = rule.check(&ctx).unwrap();
4122 assert!(
4123 result.is_empty(),
4124 "Should not flag '## 2. A Guide to the Galaxy', got: {result:?}"
4125 );
4126
4127 let content_lower = "## 2. a guide to the galaxy\n";
4128 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
4129 let result2 = rule.check(&ctx2).unwrap();
4130 assert!(!result2.is_empty(), "Should flag '## 2. a guide to the galaxy'");
4131 let fix = result2[0].fix.as_ref().expect("Should have a fix");
4132 assert!(
4133 fix.replacement.contains("2. A Guide to the Galaxy"),
4134 "Fix should capitalize 'A', got: {:?}",
4135 fix.replacement
4136 );
4137 }
4138
4139 #[test]
4140 fn test_title_case_mid_sentence_period_word() {
4141 let rule = create_rule();
4143 let content = "## Step 1. Introduction to the Problem\n";
4144 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4145 let result = rule.check(&ctx).unwrap();
4146 assert!(
4147 result.is_empty(),
4148 "Should not flag '## Step 1. Introduction to the Problem', got: {result:?}"
4149 );
4150
4151 let content_lower = "## Step 1. introduction to the problem\n";
4152 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
4153 let result2 = rule.check(&ctx2).unwrap();
4154 assert!(
4155 !result2.is_empty(),
4156 "Should flag '## Step 1. introduction to the problem'"
4157 );
4158 let fix = result2[0].fix.as_ref().expect("Should have a fix");
4159 assert!(
4160 fix.replacement.contains("Step 1. Introduction to the Problem"),
4161 "Fix should capitalize 'Introduction', got: {:?}",
4162 fix.replacement
4163 );
4164 }
4165
4166 #[test]
4167 fn test_title_case_numbered_prefix_in_link_text() {
4168 let config = MD063Config {
4171 enabled: true,
4172 style: HeadingCapStyle::TitleCase,
4173 ..Default::default()
4174 };
4175 let rule = MD063HeadingCapitalization::from_config_struct(config);
4176
4177 let content = "## [1. To Be a Thing](https://example.com)\n";
4179 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4180 let result = rule.check(&ctx).unwrap();
4181 assert!(
4182 result.is_empty(),
4183 "Should not flag '## [1. To Be a Thing](url)', got: {result:?}"
4184 );
4185
4186 let content_lower = "## [1. to be a thing](https://example.com)\n";
4188 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
4189 let result2 = rule.check(&ctx2).unwrap();
4190 assert!(!result2.is_empty(), "Should flag '## [1. to be a thing](url)'");
4191 let fix = result2[0].fix.as_ref().expect("Should have a fix");
4192 assert!(
4193 fix.replacement.contains("1. To Be a Thing"),
4194 "Fix should capitalize 'To' in link text, got: {:?}",
4195 fix.replacement
4196 );
4197 }
4198
4199 #[test]
4204 fn test_is_numeric_ordinal_recognises_canonical_forms() {
4205 for word in &[
4206 "1st", "2nd", "3rd", "4th", "5th", "11th", "21st", "22nd", "23rd", "100th", "1ST", "5Th", "21St", "21sT",
4207 ] {
4208 assert!(
4209 MD063HeadingCapitalization::is_numeric_ordinal(word),
4210 "expected `{word}` to be detected as a numeric ordinal"
4211 );
4212 }
4213 }
4214
4215 #[test]
4216 fn test_is_numeric_ordinal_rejects_non_ordinals() {
4217 for word in &[
4222 "first", "1stop", "ist", "5", "th", "abc", "4G", "4K", "30s", "100k", "5x", "1.5", "iPhone6S",
4223 ] {
4224 assert!(
4225 !MD063HeadingCapitalization::is_numeric_ordinal(word),
4226 "expected `{word}` NOT to be detected as a numeric ordinal"
4227 );
4228 }
4229 }
4230
4231 #[test]
4232 fn test_is_numeric_ordinal_strips_trailing_punctuation() {
4233 for word in &["5th.", "1st,", "21st!", "3rd:", "4th)", "5th's"] {
4234 assert!(
4235 MD063HeadingCapitalization::is_numeric_ordinal(word),
4236 "expected `{word}` to be detected as a numeric ordinal (with punctuation)"
4237 );
4238 }
4239 }
4240
4241 #[test]
4242 fn test_is_numeric_ordinal_ignores_wrapping_punctuation() {
4243 for word in &[
4248 "(2nd", "[2nd", "\"2nd", "'2nd", "*2nd", "(21st)", "\"3rd\"", "**5th**", "_1st_",
4249 ] {
4250 assert!(
4251 MD063HeadingCapitalization::is_numeric_ordinal(word),
4252 "expected `{word}` to be detected as a numeric ordinal"
4253 );
4254 }
4255
4256 for word in &["2-nd", "2 nd", "(", "\"\"", "()", "(nd", "(2"] {
4260 assert!(
4261 !MD063HeadingCapitalization::is_numeric_ordinal(word),
4262 "expected `{word}` NOT to be detected as a numeric ordinal"
4263 );
4264 }
4265 }
4266
4267 #[test]
4268 fn test_ordinal_wrapped_in_punctuation_survives_a_fix() {
4269 for (style, content) in [
4272 (HeadingCapStyle::SentenceCase, "# The second (2nd) attempt\n"),
4273 (HeadingCapStyle::SentenceCase, "# Ranked \"3rd\" overall\n"),
4274 (HeadingCapStyle::SentenceCase, "# Plain 2nd place\n"),
4275 (HeadingCapStyle::TitleCase, "# The Second (2nd) Attempt\n"),
4276 (HeadingCapStyle::TitleCase, "# Ranked \"3rd\" Overall\n"),
4277 ] {
4278 let rule = create_rule_with_style(style);
4279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4280 let result = rule.check(&ctx).unwrap();
4281 assert!(
4282 result.is_empty(),
4283 "{style:?} should not flag {content:?}, got: {result:?}"
4284 );
4285 assert_eq!(
4286 rule.fix(&ctx).unwrap(),
4287 content,
4288 "{style:?} must leave {content:?} alone"
4289 );
4290 }
4291
4292 for (style, content, expected) in [
4296 (
4297 HeadingCapStyle::SentenceCase,
4298 "# The Second (2nd) Attempt\n",
4299 "# The second (2nd) attempt\n",
4300 ),
4301 (
4302 HeadingCapStyle::TitleCase,
4303 "# the second (2nd) attempt\n",
4304 "# The Second (2nd) Attempt\n",
4305 ),
4306 ] {
4307 let rule = create_rule_with_style(style);
4308 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4309 assert!(
4310 !rule.check(&ctx).unwrap().is_empty(),
4311 "{style:?} should flag {content:?}"
4312 );
4313 let fixed = rule.fix(&ctx).unwrap();
4314 assert_eq!(fixed, expected, "{style:?} fix of {content:?}");
4315
4316 let refixed = rule
4317 .fix(&LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None))
4318 .unwrap();
4319 assert_eq!(refixed, expected, "{style:?} second pass over {fixed:?}");
4320 }
4321 }
4322
4323 #[test]
4324 fn test_wrapped_ordinal_corrupted_by_the_old_fix_is_repaired() {
4325 for (style, content, expected) in [
4329 (
4330 HeadingCapStyle::SentenceCase,
4331 "# The second (2Nd) attempt\n",
4332 "# The second (2nd) attempt\n",
4333 ),
4334 (
4335 HeadingCapStyle::TitleCase,
4336 "# The Second (2Nd) Attempt\n",
4337 "# The Second (2nd) Attempt\n",
4338 ),
4339 (
4340 HeadingCapStyle::SentenceCase,
4341 "# Ranked \"3Rd\" overall\n",
4342 "# Ranked \"3rd\" overall\n",
4343 ),
4344 ] {
4345 let rule = create_rule_with_style(style);
4346 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4347 assert!(
4348 !rule.check(&ctx).unwrap().is_empty(),
4349 "{style:?} should flag {content:?}"
4350 );
4351 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{style:?} fix of {content:?}");
4352 }
4353 }
4354
4355 #[test]
4356 fn test_title_case_ordinal_first_word_not_flagged() {
4357 let rule = create_rule();
4358 for content in &[
4359 "# 1st Place\n",
4360 "# 2nd Edition\n",
4361 "# 3rd Time\n",
4362 "# 5th Avenue\n",
4363 "# 21st Century Skills\n",
4364 "# 100th Customer\n",
4365 ] {
4366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4367 let result = rule.check(&ctx).unwrap();
4368 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4369 }
4370 }
4371
4372 #[test]
4373 fn test_title_case_ordinal_mid_heading_not_flagged() {
4374 let rule = create_rule();
4375 for content in &[
4376 "# May 3rd Notes\n",
4377 "# Top 100th Customer\n",
4378 "# Notes for the 5th of May\n",
4379 ] {
4380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4381 let result = rule.check(&ctx).unwrap();
4382 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4383 }
4384 }
4385
4386 #[test]
4387 fn test_title_case_ordinal_corrupted_form_is_fixed() {
4388 let rule = create_rule();
4391 for (input, expected) in &[
4392 ("# 1St Place\n", "1st Place"),
4393 ("# 5Th Avenue\n", "5th Avenue"),
4394 ("# 21St Century Skills\n", "21st Century Skills"),
4395 ("# May 3Rd Notes\n", "May 3rd Notes"),
4396 ("# 22Nd Edition\n", "22nd Edition"),
4397 ] {
4398 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
4399 let result = rule.check(&ctx).unwrap();
4400 assert!(!result.is_empty(), "Should flag {input:?}");
4401 let fix = result[0].fix.as_ref().expect("should have a fix");
4402 assert!(
4403 fix.replacement.contains(expected),
4404 "Fix for {input:?} should contain {expected:?}, got: {:?}",
4405 fix.replacement
4406 );
4407 }
4408 }
4409
4410 #[test]
4411 fn test_title_case_ordinal_lowercase_other_words_capitalised() {
4412 let rule = create_rule();
4414 let content = "# 5th avenue\n";
4415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4416 let result = rule.check(&ctx).unwrap();
4417 assert_eq!(result.len(), 1);
4418 let fix = result[0].fix.as_ref().expect("should have a fix");
4419 assert!(
4420 fix.replacement.contains("5th Avenue"),
4421 "Fix should produce '5th Avenue', got: {:?}",
4422 fix.replacement
4423 );
4424 }
4425
4426 #[test]
4427 fn test_title_case_ordinal_with_trailing_punctuation() {
4428 let rule = create_rule();
4429 let content = "# Released on the 5th.\n";
4430 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4431 let result = rule.check(&ctx).unwrap();
4432 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4433 }
4434
4435 #[test]
4436 fn test_title_case_ordinal_hyphenated() {
4437 let rule = create_rule();
4438 for content in &["# 21st-Century Skills\n", "# A 19th-Century Novel\n"] {
4439 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4440 let result = rule.check(&ctx).unwrap();
4441 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4442 }
4443 }
4444
4445 #[test]
4446 fn test_sentence_case_ordinal_corrupted_form_is_fixed() {
4447 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
4448 let content = "# 5Th avenue\n";
4449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4450 let result = rule.check(&ctx).unwrap();
4451 assert_eq!(result.len(), 1);
4452 let fix = result[0].fix.as_ref().expect("should have a fix");
4453 assert!(
4454 fix.replacement.contains("5th avenue"),
4455 "Fix should produce '5th avenue', got: {:?}",
4456 fix.replacement
4457 );
4458 }
4459
4460 #[test]
4461 fn test_title_case_digit_acronym_unchanged() {
4462 let rule = create_rule();
4465 for content in &["# 4G Networks\n", "# 4K Streaming\n"] {
4466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4467 let result = rule.check(&ctx).unwrap();
4468 assert!(result.is_empty(), "Should not flag {content:?}, got: {result:?}");
4469 }
4470 }
4471
4472 fn restart_rule(boundaries: &[&str]) -> MD063HeadingCapitalization {
4475 let config = MD063Config {
4476 enabled: true,
4477 style: HeadingCapStyle::SentenceCase,
4478 sentence_case_restart_after: boundaries.iter().copied().map(String::from).collect(),
4479 ..Default::default()
4480 };
4481 MD063HeadingCapitalization::from_config_struct(config)
4482 }
4483
4484 fn suggested(rule: &MD063HeadingCapitalization, content: &str) -> Option<String> {
4487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4488 let warnings = rule.check(&ctx).unwrap();
4489 let fixed = rule.fix(&ctx).unwrap();
4490 assert_eq!(
4491 warnings.is_empty(),
4492 fixed == content,
4493 "a warning and a rewrite must agree for {content:?}"
4494 );
4495 (!warnings.is_empty()).then(|| fixed.trim_start_matches('#').trim().to_string())
4496 }
4497
4498 #[test]
4499 fn test_restart_after_capitalizes_the_word_following_a_boundary() {
4500 let rule = restart_rule(&[":"]);
4501 assert_eq!(
4502 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
4503 Some("Requirement 1: Struct to logger slice conversion")
4504 );
4505 }
4506
4507 #[test]
4508 fn test_restart_after_defaults_to_no_boundaries() {
4509 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
4511 assert_eq!(
4512 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
4513 Some("Requirement 1: struct to logger slice conversion")
4514 );
4515 }
4516
4517 #[test]
4518 fn test_restart_after_only_honors_configured_punctuation() {
4519 let rule = restart_rule(&[":"]);
4521 assert_eq!(
4522 suggested(&rule, "# Design - Data Model Overview\n").as_deref(),
4523 Some("Design - data model overview")
4524 );
4525 assert_eq!(
4526 suggested(&rule, "# Setup; Then Run\n").as_deref(),
4527 Some("Setup; then run")
4528 );
4529
4530 let rule = restart_rule(&[";", "\u{2014}"]);
4531 assert_eq!(
4532 suggested(&rule, "# Setup; Then Run\n").as_deref(),
4533 Some("Setup; Then run")
4534 );
4535 assert_eq!(
4536 suggested(&rule, "# Part One \u{2014} The Big Idea\n").as_deref(),
4537 Some("Part one \u{2014} The big idea")
4538 );
4539 }
4540
4541 #[test]
4542 fn test_restart_after_matches_only_at_the_end_of_a_word() {
4543 let rule = restart_rule(&["-", ":"]);
4546 assert_eq!(
4547 suggested(&rule, "# Ports: Well-Known Ports Explained\n").as_deref(),
4548 Some("Ports: Well-Known ports explained")
4549 );
4550 assert_eq!(
4551 suggested(&rule, "# See https://example.com/A/B For Details\n").as_deref(),
4552 Some("See https://example.com/A/B for details")
4553 );
4554 }
4555
4556 #[test]
4557 fn test_restart_after_a_trailing_boundary_is_a_no_op() {
4558 let rule = restart_rule(&[":"]);
4559 assert_eq!(suggested(&rule, "# Setup:\n"), None);
4560 }
4561
4562 #[test]
4563 fn test_restart_after_does_not_override_preserved_words() {
4564 let rule = restart_rule(&[":"]);
4567 assert_eq!(
4568 suggested(&rule, "# Devices: iPhone And Android\n").as_deref(),
4569 Some("Devices: iPhone and android")
4570 );
4571
4572 let config = MD063Config {
4573 enabled: true,
4574 style: HeadingCapStyle::SentenceCase,
4575 sentence_case_restart_after: vec![":".to_string()],
4576 ignore_words: vec!["kubectl".to_string()],
4577 preserve_cased_words: false,
4578 ..Default::default()
4579 };
4580 let rule = MD063HeadingCapitalization::from_config_struct(config);
4581 assert_eq!(
4582 suggested(&rule, "# Tools: kubectl And Helm\n").as_deref(),
4583 Some("Tools: kubectl and helm")
4584 );
4585 }
4586
4587 #[test]
4588 fn test_restart_after_keeps_md044_canonical_forms() {
4589 let config = MD063Config {
4590 enabled: true,
4591 style: HeadingCapStyle::SentenceCase,
4592 sentence_case_restart_after: vec![":".to_string()],
4593 ..Default::default()
4594 };
4595 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
4596 rule.proper_names = vec!["GitHub".to_string()];
4597
4598 assert_eq!(
4600 suggested(&rule, "# Docs: github Actions Guide\n").as_deref(),
4601 Some("Docs: GitHub actions guide")
4602 );
4603 assert_eq!(suggested(&rule, "# Docs: GitHub actions guide\n"), None);
4604 }
4605
4606 #[test]
4607 fn test_restart_after_carries_across_segments() {
4608 let rule = restart_rule(&[":"]);
4611 assert_eq!(
4612 suggested(
4613 &rule,
4614 "# Overview: [Some Link Here](https://example.com) Trailing Words\n"
4615 )
4616 .as_deref(),
4617 Some("Overview: [Some link here](https://example.com) trailing words")
4618 );
4619 assert_eq!(
4620 suggested(&rule, "# Overview: `code` Then More Words\n").as_deref(),
4621 Some("Overview: `code` then more words")
4622 );
4623 }
4624
4625 #[test]
4626 fn test_restart_after_ends_a_sentence_at_the_end_of_link_text() {
4627 let rule = restart_rule(&[":"]);
4631 assert_eq!(
4632 suggested(&rule, "# Topic [See:](https://example.com) More Words\n").as_deref(),
4633 Some("Topic [see:](https://example.com) More words")
4634 );
4635
4636 assert_eq!(
4638 suggested(&rule, "# Topic [See](https://example.com) More Words\n").as_deref(),
4639 Some("Topic [see](https://example.com) more words")
4640 );
4641 }
4642
4643 #[test]
4644 fn test_restart_after_ignores_boundaries_inside_opaque_segments() {
4645 let rule = restart_rule(&[":"]);
4648 for content in [
4649 "# Topic `see:` More Words\n",
4650 "# Topic  More Words\n",
4651 "# Topic <span title=\"x:\">y</span> More Words\n",
4652 ] {
4653 let fixed = suggested(&rule, content).expect("heading should be rewritten");
4654 assert!(
4655 fixed.ends_with("more words"),
4656 "opaque segment restarted the sentence in {content:?}: {fixed}"
4657 );
4658 }
4659 }
4660
4661 #[test]
4662 fn test_restart_after_treats_a_leading_link_as_sentence_initial() {
4663 for rule in [restart_rule(&[]), restart_rule(&[":"])] {
4666 assert_eq!(
4667 suggested(&rule, "# [Some Link Here](https://example.com) Trailing Words\n").as_deref(),
4668 Some("[Some link here](https://example.com) trailing words")
4669 );
4670 }
4671 }
4672
4673 #[test]
4674 fn test_restart_after_fix_is_idempotent() {
4675 let rule = restart_rule(&[":", ";", "-", "\u{2014}"]);
4676 for content in [
4677 "# Requirement 1: Struct to Logger Slice Conversion\n",
4678 "# Ports: Well-Known Ports Explained\n",
4679 "# Devices: iPhone And Android\n",
4680 "# Overview: [Some Link Here](https://example.com) Trailing Words\n",
4681 "# Setup:\n",
4682 ] {
4683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4684 let once = rule.fix(&ctx).unwrap();
4685 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
4686 assert_eq!(rule.fix(&ctx).unwrap(), once, "fix is not idempotent for {content:?}");
4687 }
4688 }
4689
4690 #[test]
4691 fn test_restart_after_ignores_empty_boundary_entries() {
4692 let rule = restart_rule(&[""]);
4694 assert_eq!(
4695 suggested(&rule, "# Requirement 1: Struct to Logger Slice Conversion\n").as_deref(),
4696 Some("Requirement 1: struct to logger slice conversion")
4697 );
4698 }
4699
4700 const STYLES: [HeadingCapStyle; 3] = [
4703 HeadingCapStyle::TitleCase,
4704 HeadingCapStyle::SentenceCase,
4705 HeadingCapStyle::AllCaps,
4706 ];
4707
4708 const GHERKIN_STRUCTURES: [(&str, &str, &str, &str); 6] = [
4711 (
4712 "# Feature: the system under test",
4713 "# Feature: The System Under Test",
4714 "# Feature: The system under test",
4715 "# Feature: THE SYSTEM UNDER TEST",
4716 ),
4717 (
4718 "## Background: a shared setup",
4719 "## Background: A Shared Setup",
4720 "## Background: A shared setup",
4721 "## Background: A SHARED SETUP",
4722 ),
4723 (
4724 "## Rule: money is never lost",
4725 "## Rule: Money Is Never Lost",
4726 "## Rule: Money is never lost",
4727 "## Rule: MONEY IS NEVER LOST",
4728 ),
4729 (
4730 "### Scenario: add two numbers",
4731 "### Scenario: Add Two Numbers",
4732 "### Scenario: Add two numbers",
4733 "### Scenario: ADD TWO NUMBERS",
4734 ),
4735 (
4736 "### Scenario Outline: add two numbers",
4737 "### Scenario Outline: Add Two Numbers",
4738 "### Scenario Outline: Add two numbers",
4739 "### Scenario Outline: ADD TWO NUMBERS",
4740 ),
4741 (
4742 "#### Examples: happy path",
4743 "#### Examples: Happy Path",
4744 "#### Examples: Happy path",
4745 "#### Examples: HAPPY PATH",
4746 ),
4747 ];
4748
4749 fn recased(style: HeadingCapStyle, heading: &str, flavor: crate::config::MarkdownFlavor) -> String {
4751 let rule = create_rule_with_style(style);
4752 let content = format!("{heading}\n");
4753 let ctx = LintContext::new(&content, flavor, None);
4754 let warnings = rule.check(&ctx).unwrap();
4755 let fixed = rule.fix(&ctx).unwrap();
4756 assert_eq!(
4757 warnings.is_empty(),
4758 fixed == content,
4759 "a warning and a rewrite must agree for {content:?} under {flavor:?}"
4760 );
4761 fixed.trim_end().to_string()
4762 }
4763
4764 #[test]
4765 fn test_mdg_keeps_the_keyword_of_every_structure() {
4766 for (heading, ..) in GHERKIN_STRUCTURES {
4769 let keyword = &heading[..=heading.find(':').unwrap()];
4770 for style in STYLES {
4771 let fixed = recased(style, heading, crate::config::MarkdownFlavor::MDG);
4772 assert!(
4773 fixed.starts_with(keyword),
4774 "{style:?} lost the keyword of {heading:?}: {fixed}"
4775 );
4776 }
4777 }
4778 }
4779
4780 #[test]
4781 fn test_mdg_recases_only_the_name_of_a_structure() {
4782 for (heading, title, sentence, caps) in GHERKIN_STRUCTURES {
4783 let mdg = crate::config::MarkdownFlavor::MDG;
4784 assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), title);
4785 assert_eq!(recased(HeadingCapStyle::SentenceCase, heading, mdg), sentence);
4786 assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), caps);
4787 }
4788 }
4789
4790 #[test]
4791 fn test_standard_flavor_recases_a_keyword_like_any_other_word() {
4792 let standard = crate::config::MarkdownFlavor::Standard;
4794 assert_eq!(
4795 recased(HeadingCapStyle::TitleCase, "# Feature: the system under test", standard),
4796 "# Feature: the System Under Test"
4797 );
4798 assert_eq!(
4799 recased(
4800 HeadingCapStyle::SentenceCase,
4801 "### Scenario Outline: add two numbers",
4802 standard
4803 ),
4804 "### Scenario outline: add two numbers"
4805 );
4806 assert_eq!(
4807 recased(HeadingCapStyle::AllCaps, "# Feature: the system under test", standard),
4808 "# FEATURE: THE SYSTEM UNDER TEST"
4809 );
4810 }
4811
4812 #[test]
4813 fn test_mdg_leaves_a_heading_without_a_colon_to_the_normal_rule() {
4814 for heading in ["## notes about the system", "## Notes", "# THE SYSTEM"] {
4815 for style in STYLES {
4816 assert_eq!(
4817 recased(style, heading, crate::config::MarkdownFlavor::MDG),
4818 recased(style, heading, crate::config::MarkdownFlavor::Standard),
4819 "{style:?} treated {heading:?} as a Gherkin structure"
4820 );
4821 }
4822 }
4823 }
4824
4825 #[test]
4826 fn test_mdg_splits_at_the_first_colon_only() {
4827 let mdg = crate::config::MarkdownFlavor::MDG;
4829 let heading = "## Scenario: ratio: two to one";
4830 assert_eq!(
4831 recased(HeadingCapStyle::TitleCase, heading, mdg),
4832 "## Scenario: Ratio: Two to One"
4833 );
4834 assert_eq!(
4835 recased(HeadingCapStyle::SentenceCase, heading, mdg),
4836 "## Scenario: Ratio: two to one"
4837 );
4838 assert_eq!(
4839 recased(HeadingCapStyle::AllCaps, heading, mdg),
4840 "## Scenario: RATIO: TWO TO ONE"
4841 );
4842 }
4843
4844 #[test]
4845 fn test_mdg_leaves_a_colon_behind_a_backtick_to_the_normal_rule() {
4846 for heading in [
4850 "# See `x: y` Notes",
4851 "# `a: b`",
4852 "# `code` Feature: a name",
4853 "# `x: y` Feature: a name",
4854 ] {
4855 for style in STYLES {
4856 assert_eq!(
4857 recased(style, heading, crate::config::MarkdownFlavor::MDG),
4858 recased(style, heading, crate::config::MarkdownFlavor::Standard),
4859 "{style:?} split {heading:?} at a colon inside a code span"
4860 );
4861 }
4862 }
4863 }
4864
4865 #[test]
4866 fn test_mdg_splits_at_a_keyword_colon_that_precedes_a_code_span() {
4867 let mdg = crate::config::MarkdownFlavor::MDG;
4869 let heading = "# Scenario: use `a: b` here";
4870 assert_eq!(
4871 recased(HeadingCapStyle::TitleCase, heading, mdg),
4872 "# Scenario: Use `a: b` Here"
4873 );
4874 assert_eq!(
4875 recased(HeadingCapStyle::SentenceCase, heading, mdg),
4876 "# Scenario: Use `a: b` here"
4877 );
4878 assert_eq!(
4879 recased(HeadingCapStyle::AllCaps, heading, mdg),
4880 "# Scenario: USE `a: b` HERE"
4881 );
4882 }
4883
4884 #[test]
4885 fn test_mdg_splits_at_a_keyword_colon_before_an_unbalanced_backtick() {
4886 let mdg = crate::config::MarkdownFlavor::MDG;
4889 let heading = "# Scenario: a ` b";
4890 assert_eq!(recased(HeadingCapStyle::TitleCase, heading, mdg), "# Scenario: A ` B");
4891 assert_eq!(
4892 recased(HeadingCapStyle::SentenceCase, heading, mdg),
4893 "# Scenario: A ` b"
4894 );
4895 assert_eq!(recased(HeadingCapStyle::AllCaps, heading, mdg), "# Scenario: A ` B");
4896 }
4897
4898 #[test]
4899 fn test_mdg_keeps_a_keyword_with_nothing_left_to_recase() {
4900 for style in STYLES {
4901 assert_eq!(
4902 recased(style, "# Feature:", crate::config::MarkdownFlavor::MDG),
4903 "# Feature:"
4904 );
4905 }
4906 }
4907
4908 #[test]
4909 fn test_mdg_keeps_a_custom_id_after_the_name() {
4910 assert_eq!(
4911 recased(
4912 HeadingCapStyle::TitleCase,
4913 "# Feature: the system {#overview}",
4914 crate::config::MarkdownFlavor::MDG
4915 ),
4916 "# Feature: The System {#overview}"
4917 );
4918 }
4919
4920 #[test]
4921 fn test_mdg_fix_is_idempotent() {
4922 for (heading, ..) in GHERKIN_STRUCTURES {
4923 for style in STYLES {
4924 let rule = create_rule_with_style(style);
4925 let content = format!("{heading}\n");
4926 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
4927 let once = rule.fix(&ctx).unwrap();
4928 let ctx = LintContext::new(&once, crate::config::MarkdownFlavor::MDG, None);
4929 assert_eq!(
4930 rule.fix(&ctx).unwrap(),
4931 once,
4932 "fix is not idempotent for {heading:?} ({style:?})"
4933 );
4934 }
4935 }
4936 }
4937}
4938
4939#[cfg(test)]
4940mod shortcode_delimiter_tests {
4941 use super::*;
4942
4943 #[test]
4944 fn heading_capitalization_preserves_shortcode_raw_arguments() {
4945 let rule = MD063HeadingCapitalization::from_config_struct(MD063Config {
4946 enabled: true,
4947 style: HeadingCapStyle::TitleCase,
4948 ..Default::default()
4949 });
4950 for (open, close) in [("{{<", ">}}"), ("{{%", "%}}")] {
4951 let tag = format!("{open} note title=`literal {close} friend` {close}");
4952 assert_eq!(
4953 rule.apply_capitalization(
4954 &format!("hello {tag} world"),
4955 crate::config::MarkdownFlavor::Hugo,
4956 ResolvedStyle::TitleCase
4957 ),
4958 format!("Hello {tag} World")
4959 );
4960 }
4961 }
4962}
4963
4964#[cfg(test)]
4965mod mdx_tag_capitalization_tests {
4966 use super::*;
4967 use crate::rule::Rule;
4968
4969 fn fixed(style: HeadingCapStyle, source: &str) -> String {
4970 let rule = MD063HeadingCapitalization::from_config_struct(MD063Config {
4971 enabled: true,
4972 style,
4973 ..Default::default()
4974 });
4975 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
4976 rule.fix(&ctx).unwrap()
4977 }
4978
4979 #[test]
4980 fn mdx_heading_capitalization_preserves_native_jsx_elements() {
4981 for name in ["span", "Card", "ui.Card", "svg:path"] {
4982 let element = format!("<{name} onClick={{() => null}} title=\"literal friend\">world</{name}>");
4983 let source = format!("# hello {element} after\n");
4984 for (style, before, after) in [
4985 (HeadingCapStyle::TitleCase, "Hello", "After"),
4986 (HeadingCapStyle::SentenceCase, "Hello", "after"),
4987 (HeadingCapStyle::AllCaps, "HELLO", "AFTER"),
4988 ] {
4989 assert_eq!(fixed(style, &source), format!("# {before} {element} {after}\n"));
4990 }
4991 }
4992 }
4993
4994 #[test]
4995 fn mdx_heading_capitalization_preserves_nested_member_elements() {
4996 let element = "<ui.Card title='literal friend'>world <span>inner</span></ui.Card>";
4997 let source = format!("# hello {element} after\n");
4998 assert_eq!(
4999 fixed(HeadingCapStyle::TitleCase, &source),
5000 format!("# Hello {element} After\n")
5001 );
5002 }
5003
5004 #[test]
5005 fn mdx_heading_tag_protection_preserves_existing_visible_word_context() {
5006 assert_eq!(
5007 fixed(HeadingCapStyle::SentenceCase, "# <span></span> hello world\n"),
5008 "# <span></span> Hello world\n"
5009 );
5010 assert_eq!(
5011 fixed(HeadingCapStyle::SentenceCase, "# <img alt='friend'/> hello world\n"),
5012 "# <img alt='friend'/> hello world\n"
5013 );
5014 assert_eq!(
5015 fixed(HeadingCapStyle::TitleCase, "# <span>visible</span> and the world\n"),
5016 "# <span>visible</span> And the World\n"
5017 );
5018 }
5019
5020 #[test]
5021 fn mdx_setext_member_attributes_preserve_their_source_map() {
5022 let member = "hello <ui.Card title=\"literal\nfriend\">world</ui.Card> after\n===\n";
5023 assert_eq!(fixed(HeadingCapStyle::TitleCase, member), member);
5024 let html = "hello <span title=\"literal\nfriend\">world</span> after\n===\n";
5025 assert_eq!(
5026 fixed(HeadingCapStyle::TitleCase, html),
5027 "Hello <span title=\"literal\nfriend\">world</span> After\n===\n"
5028 );
5029 }
5030}