1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
15use crate::utils::range_utils::LineIndex;
16use regex::Regex;
17use std::collections::HashSet;
18use std::ops::Range;
19use std::sync::LazyLock;
20
21mod md063_config;
22pub(super) use md063_config::{HeadingCapStyle, MD063Config};
23
24static INLINE_CODE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`+[^`]+`+").unwrap());
26
27static LINK_REGEX: LazyLock<Regex> =
29 LazyLock::new(|| Regex::new(r"\[([^\]]*)\]\([^)]*\)|\[([^\]]*)\]\[[^\]]*\]").unwrap());
30
31static HTML_TAG_REGEX: LazyLock<Regex> = LazyLock::new(|| {
36 let tags = "kbd|abbr|code|span|sub|sup|mark|cite|dfn|var|samp|small|strong|em|b|i|u|s|q|br|wbr";
38 let pattern = format!(r"<({tags})(?:\s[^>]*)?>.*?</({tags})>|<({tags})(?:\s[^>]*)?\s*/?>");
39 Regex::new(&pattern).unwrap()
40});
41
42static CUSTOM_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*\{#[^}]+\}\s*$").unwrap());
44
45#[derive(Debug, Clone)]
47enum HeadingSegment {
48 Text(String),
50 Code(String),
52 Link {
54 full: String,
55 text_start: usize,
56 text_end: usize,
57 },
58 Html(String),
60}
61
62#[derive(Clone)]
64pub struct MD063HeadingCapitalization {
65 config: MD063Config,
66 lowercase_set: HashSet<String>,
67 proper_names: Vec<String>,
70}
71
72impl Default for MD063HeadingCapitalization {
73 fn default() -> Self {
74 Self::new()
75 }
76}
77
78impl MD063HeadingCapitalization {
79 pub fn new() -> Self {
80 let config = MD063Config::default();
81 let lowercase_set = config.lowercase_words.iter().cloned().collect();
82 Self {
83 config,
84 lowercase_set,
85 proper_names: Vec::new(),
86 }
87 }
88
89 pub fn from_config_struct(config: MD063Config) -> Self {
90 let lowercase_set = config.lowercase_words.iter().cloned().collect();
91 Self {
92 config,
93 lowercase_set,
94 proper_names: Vec::new(),
95 }
96 }
97
98 fn match_case_insensitive_at(text: &str, start: usize, pattern_lower: &str) -> Option<usize> {
105 if start > text.len() || !text.is_char_boundary(start) || pattern_lower.is_empty() {
106 return None;
107 }
108
109 let mut matched_bytes = 0;
110
111 for (offset, ch) in text[start..].char_indices() {
112 if matched_bytes >= pattern_lower.len() {
113 break;
114 }
115
116 let lowered: String = ch.to_lowercase().collect();
117 if !pattern_lower[matched_bytes..].starts_with(&lowered) {
118 return None;
119 }
120
121 matched_bytes += lowered.len();
122
123 if matched_bytes == pattern_lower.len() {
124 return Some(start + offset + ch.len_utf8());
125 }
126 }
127
128 None
129 }
130
131 fn find_case_insensitive_match(text: &str, pattern_lower: &str, search_start: usize) -> Option<(usize, usize)> {
134 if pattern_lower.is_empty() || search_start >= text.len() || !text.is_char_boundary(search_start) {
135 return None;
136 }
137
138 for (offset, _) in text[search_start..].char_indices() {
139 let start = search_start + offset;
140 if let Some(end) = Self::match_case_insensitive_at(text, start, pattern_lower) {
141 return Some((start, end));
142 }
143 }
144
145 None
146 }
147
148 fn proper_name_canonical_forms(&self, text: &str) -> std::collections::HashMap<usize, &str> {
154 let mut map = std::collections::HashMap::new();
155
156 for name in &self.proper_names {
157 if name.is_empty() {
158 continue;
159 }
160 let name_lower = name.to_lowercase();
161 let canonical_words: Vec<&str> = name.split_whitespace().collect();
162 if canonical_words.is_empty() {
163 continue;
164 }
165 let mut search_start = 0;
166
167 while search_start < text.len() {
168 let Some((abs_pos, end_pos)) = Self::find_case_insensitive_match(text, &name_lower, search_start)
169 else {
170 break;
171 };
172
173 let before_ok = abs_pos == 0 || !text[..abs_pos].chars().last().is_some_and(char::is_alphanumeric);
175 let after_ok =
176 end_pos >= text.len() || !text[end_pos..].chars().next().is_some_and(char::is_alphanumeric);
177
178 if before_ok && after_ok {
179 let text_slice = &text[abs_pos..end_pos];
183 let mut word_idx = 0;
184 let mut slice_offset = 0;
185
186 for text_word in text_slice.split_whitespace() {
187 if let Some(w_rel) = text_slice[slice_offset..].find(text_word) {
188 let word_abs = abs_pos + slice_offset + w_rel;
189 if let Some(&canonical_word) = canonical_words.get(word_idx) {
190 map.insert(word_abs, canonical_word);
191 }
192 slice_offset += w_rel + text_word.len();
193 word_idx += 1;
194 }
195 }
196 }
197
198 search_start = abs_pos + text[abs_pos..].chars().next().map_or(1, char::len_utf8);
201 }
202 }
203
204 map
205 }
206
207 fn has_internal_capitals(&self, word: &str) -> bool {
209 let chars: Vec<char> = word.chars().collect();
210 if chars.len() < 2 {
211 return false;
212 }
213
214 let first = chars[0];
215 let rest = &chars[1..];
216 let has_upper_in_rest = rest.iter().any(|c| c.is_uppercase());
217 let has_lower_in_rest = rest.iter().any(|c| c.is_lowercase());
218
219 if has_upper_in_rest && has_lower_in_rest {
221 return true;
222 }
223
224 if first.is_lowercase() && has_upper_in_rest {
226 return true;
227 }
228
229 false
230 }
231
232 fn is_all_caps_acronym(&self, word: &str) -> bool {
236 if word.len() < 2 {
238 return false;
239 }
240
241 let mut consecutive_upper = 0;
242 let mut max_consecutive = 0;
243
244 for c in word.chars() {
245 if c.is_uppercase() {
246 consecutive_upper += 1;
247 max_consecutive = max_consecutive.max(consecutive_upper);
248 } else if c.is_lowercase() {
249 return false;
251 } else {
252 consecutive_upper = 0;
254 }
255 }
256
257 max_consecutive >= 2
259 }
260
261 fn should_preserve_word(&self, word: &str) -> bool {
263 if self.config.ignore_words.iter().any(|w| w == word) {
265 return true;
266 }
267
268 if self.config.preserve_cased_words && self.has_internal_capitals(word) {
270 return true;
271 }
272
273 if self.config.preserve_cased_words && self.is_all_caps_acronym(word) {
275 return true;
276 }
277
278 if self.is_caret_notation(word) {
280 return true;
281 }
282
283 false
284 }
285
286 fn is_caret_notation(&self, word: &str) -> bool {
288 let chars: Vec<char> = word.chars().collect();
289 if chars.len() >= 2 && chars[0] == '^' {
291 let second = chars[1];
292 if second.is_ascii_uppercase() || "@[\\]^_".contains(second) {
294 return true;
295 }
296 }
297 false
298 }
299
300 fn is_lowercase_word(&self, word: &str) -> bool {
302 self.lowercase_set.contains(&word.to_lowercase())
303 }
304
305 fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
307 if word.is_empty() {
308 return word.to_string();
309 }
310
311 if self.should_preserve_word(word) {
313 return word.to_string();
314 }
315
316 if is_first || is_last {
318 return self.capitalize_first(word);
319 }
320
321 if self.is_lowercase_word(word) {
323 return Self::lowercase_preserving_composition(word);
324 }
325
326 self.capitalize_first(word)
328 }
329
330 fn apply_canonical_form_to_word(word: &str, canonical: &str) -> String {
333 let canonical_lower = canonical.to_lowercase();
334 if canonical_lower.is_empty() {
335 return canonical.to_string();
336 }
337
338 if let Some(end_pos) = Self::match_case_insensitive_at(word, 0, &canonical_lower) {
339 let mut out = String::with_capacity(canonical.len() + word.len().saturating_sub(end_pos));
340 out.push_str(canonical);
341 out.push_str(&word[end_pos..]);
342 out
343 } else {
344 canonical.to_string()
345 }
346 }
347
348 fn capitalize_first(&self, word: &str) -> String {
350 if word.is_empty() {
351 return String::new();
352 }
353
354 let first_alpha_pos = word.find(|c: char| c.is_alphabetic());
356 let Some(pos) = first_alpha_pos else {
357 return word.to_string();
358 };
359
360 let prefix = &word[..pos];
361 let mut chars = word[pos..].chars();
362 let first = chars.next().unwrap();
363 let first_upper = Self::uppercase_preserving_composition(&first.to_string());
366 let rest: String = chars.collect();
367 let rest_lower = Self::lowercase_preserving_composition(&rest);
368 format!("{prefix}{first_upper}{rest_lower}")
369 }
370
371 fn lowercase_preserving_composition(s: &str) -> String {
374 let mut result = String::with_capacity(s.len());
375 for c in s.chars() {
376 let lower: String = c.to_lowercase().collect();
377 if lower.chars().count() == 1 {
378 result.push_str(&lower);
379 } else {
380 result.push(c);
382 }
383 }
384 result
385 }
386
387 fn uppercase_preserving_composition(s: &str) -> String {
392 let mut result = String::with_capacity(s.len());
393 for c in s.chars() {
394 let upper: String = c.to_uppercase().collect();
395 if upper.chars().count() == 1 {
396 result.push_str(&upper);
397 } else {
398 result.push(c);
400 }
401 }
402 result
403 }
404
405 fn apply_title_case(&self, text: &str) -> String {
409 let canonical_forms = self.proper_name_canonical_forms(text);
410
411 let original_words: Vec<&str> = text.split_whitespace().collect();
412 let total_words = original_words.len();
413
414 let mut word_positions: Vec<usize> = Vec::with_capacity(original_words.len());
417 let mut pos = 0;
418 for word in &original_words {
419 if let Some(rel) = text[pos..].find(word) {
420 word_positions.push(pos + rel);
421 pos = pos + rel + word.len();
422 } else {
423 word_positions.push(usize::MAX);
424 }
425 }
426
427 let result_words: Vec<String> = original_words
428 .iter()
429 .enumerate()
430 .map(|(i, word)| {
431 let after_period = i > 0 && original_words[i - 1].ends_with('.');
432 let is_first = i == 0 || after_period;
433 let is_last = i == total_words - 1;
434
435 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
437 return Self::apply_canonical_form_to_word(word, canonical);
438 }
439
440 if self.should_preserve_word(word) {
442 return (*word).to_string();
443 }
444
445 if word.contains('-') {
447 return self.handle_hyphenated_word(word, is_first, is_last);
448 }
449
450 self.title_case_word(word, is_first, is_last)
451 })
452 .collect();
453
454 result_words.join(" ")
455 }
456
457 fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
459 let parts: Vec<&str> = word.split('-').collect();
460 let total_parts = parts.len();
461
462 let result_parts: Vec<String> = parts
463 .iter()
464 .enumerate()
465 .map(|(i, part)| {
466 let part_is_first = is_first && i == 0;
468 let part_is_last = is_last && i == total_parts - 1;
469 self.title_case_word(part, part_is_first, part_is_last)
470 })
471 .collect();
472
473 result_parts.join("-")
474 }
475
476 fn apply_sentence_case(&self, text: &str) -> String {
478 if text.is_empty() {
479 return text.to_string();
480 }
481
482 let canonical_forms = self.proper_name_canonical_forms(text);
483 let mut result = String::new();
484 let mut current_pos = 0;
485 let mut is_first_word = true;
486
487 for word in text.split_whitespace() {
489 if let Some(pos) = text[current_pos..].find(word) {
490 let abs_pos = current_pos + pos;
491
492 result.push_str(&text[current_pos..abs_pos]);
494
495 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
498 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
499 is_first_word = false;
500 } else if is_first_word {
501 if self.should_preserve_word(word) {
503 result.push_str(word);
505 } else {
506 let mut chars = word.chars();
508 if let Some(first) = chars.next() {
509 result.push_str(&Self::uppercase_preserving_composition(&first.to_string()));
510 let rest: String = chars.collect();
511 result.push_str(&Self::lowercase_preserving_composition(&rest));
512 }
513 }
514 is_first_word = false;
515 } else {
516 if self.should_preserve_word(word) {
518 result.push_str(word);
519 } else {
520 result.push_str(&Self::lowercase_preserving_composition(word));
521 }
522 }
523
524 current_pos = abs_pos + word.len();
525 }
526 }
527
528 if current_pos < text.len() {
530 result.push_str(&text[current_pos..]);
531 }
532
533 result
534 }
535
536 fn apply_all_caps(&self, text: &str) -> String {
538 if text.is_empty() {
539 return text.to_string();
540 }
541
542 let canonical_forms = self.proper_name_canonical_forms(text);
543 let mut result = String::new();
544 let mut current_pos = 0;
545
546 for word in text.split_whitespace() {
548 if let Some(pos) = text[current_pos..].find(word) {
549 let abs_pos = current_pos + pos;
550
551 result.push_str(&text[current_pos..abs_pos]);
553
554 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
557 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
558 } else if self.should_preserve_word(word) {
559 result.push_str(word);
560 } else {
561 result.push_str(&Self::uppercase_preserving_composition(word));
562 }
563
564 current_pos = abs_pos + word.len();
565 }
566 }
567
568 if current_pos < text.len() {
570 result.push_str(&text[current_pos..]);
571 }
572
573 result
574 }
575
576 fn parse_segments(&self, text: &str) -> Vec<HeadingSegment> {
578 let mut segments = Vec::new();
579 let mut last_end = 0;
580
581 let mut special_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
583
584 for mat in INLINE_CODE_REGEX.find_iter(text) {
586 special_regions.push((mat.start(), mat.end(), HeadingSegment::Code(mat.as_str().to_string())));
587 }
588
589 for caps in LINK_REGEX.captures_iter(text) {
591 let full_match = caps.get(0).unwrap();
592 let text_match = caps.get(1).or_else(|| caps.get(2));
593
594 if let Some(text_m) = text_match {
595 special_regions.push((
596 full_match.start(),
597 full_match.end(),
598 HeadingSegment::Link {
599 full: full_match.as_str().to_string(),
600 text_start: text_m.start() - full_match.start(),
601 text_end: text_m.end() - full_match.start(),
602 },
603 ));
604 }
605 }
606
607 for mat in HTML_TAG_REGEX.find_iter(text) {
609 special_regions.push((mat.start(), mat.end(), HeadingSegment::Html(mat.as_str().to_string())));
610 }
611
612 special_regions.sort_by_key(|(start, _, _)| *start);
614
615 let mut filtered_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
617 for region in special_regions {
618 let overlaps = filtered_regions.iter().any(|(s, e, _)| region.0 < *e && region.1 > *s);
619 if !overlaps {
620 filtered_regions.push(region);
621 }
622 }
623
624 for (start, end, segment) in filtered_regions {
626 if start > last_end {
628 let text_segment = &text[last_end..start];
629 if !text_segment.is_empty() {
630 segments.push(HeadingSegment::Text(text_segment.to_string()));
631 }
632 }
633 segments.push(segment);
634 last_end = end;
635 }
636
637 if last_end < text.len() {
639 let remaining = &text[last_end..];
640 if !remaining.is_empty() {
641 segments.push(HeadingSegment::Text(remaining.to_string()));
642 }
643 }
644
645 if segments.is_empty() && !text.is_empty() {
647 segments.push(HeadingSegment::Text(text.to_string()));
648 }
649
650 segments
651 }
652
653 fn apply_capitalization(&self, text: &str) -> String {
655 let (main_text, custom_id) = if let Some(mat) = CUSTOM_ID_REGEX.find(text) {
657 (&text[..mat.start()], Some(mat.as_str()))
658 } else {
659 (text, None)
660 };
661
662 let segments = self.parse_segments(main_text);
664
665 let text_segments: Vec<usize> = segments
667 .iter()
668 .enumerate()
669 .filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
670 .collect();
671
672 let first_segment_is_text = segments.first().is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
676
677 let last_segment_is_text = segments.last().is_some_and(|s| matches!(s, HeadingSegment::Text(_)));
681
682 let mut result_parts: Vec<String> = Vec::new();
684
685 for (i, segment) in segments.iter().enumerate() {
686 match segment {
687 HeadingSegment::Text(t) => {
688 let is_first_text = text_segments.first() == Some(&i);
689 let is_last_text = text_segments.last() == Some(&i) && last_segment_is_text;
693
694 let capitalized = match self.config.style {
695 HeadingCapStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
696 HeadingCapStyle::SentenceCase => {
697 if is_first_text && first_segment_is_text {
701 self.apply_sentence_case(t)
702 } else {
703 self.apply_sentence_case_non_first(t)
705 }
706 }
707 HeadingCapStyle::AllCaps => self.apply_all_caps(t),
708 };
709 result_parts.push(capitalized);
710 }
711 HeadingSegment::Code(c) => {
712 result_parts.push(c.clone());
713 }
714 HeadingSegment::Link {
715 full,
716 text_start,
717 text_end,
718 } => {
719 let link_text = &full[*text_start..*text_end];
721 let capitalized_text = match self.config.style {
722 HeadingCapStyle::TitleCase => self.apply_title_case(link_text),
723 HeadingCapStyle::SentenceCase => self.apply_sentence_case_non_first(link_text),
726 HeadingCapStyle::AllCaps => self.apply_all_caps(link_text),
727 };
728
729 let mut new_link = String::new();
730 new_link.push_str(&full[..*text_start]);
731 new_link.push_str(&capitalized_text);
732 new_link.push_str(&full[*text_end..]);
733 result_parts.push(new_link);
734 }
735 HeadingSegment::Html(h) => {
736 result_parts.push(h.clone());
738 }
739 }
740 }
741
742 let mut result = result_parts.join("");
743
744 if let Some(id) = custom_id {
746 result.push_str(id);
747 }
748
749 result
750 }
751
752 fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
754 let canonical_forms = self.proper_name_canonical_forms(text);
755 let words: Vec<&str> = text.split_whitespace().collect();
756 let total_words = words.len();
757
758 if total_words == 0 {
759 return text.to_string();
760 }
761
762 let mut word_positions: Vec<usize> = Vec::with_capacity(words.len());
765 let mut pos = 0;
766 for word in &words {
767 if let Some(rel) = text[pos..].find(word) {
768 word_positions.push(pos + rel);
769 pos = pos + rel + word.len();
770 } else {
771 word_positions.push(usize::MAX);
772 }
773 }
774
775 let result_words: Vec<String> = words
776 .iter()
777 .enumerate()
778 .map(|(i, word)| {
779 let after_period = i > 0 && words[i - 1].ends_with('.');
780 let is_first = (is_first_segment && i == 0) || after_period;
781 let is_last = is_last_segment && i == total_words - 1;
782
783 if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
785 return Self::apply_canonical_form_to_word(word, canonical);
786 }
787
788 if word.contains('-') {
790 return self.handle_hyphenated_word(word, is_first, is_last);
791 }
792
793 self.title_case_word(word, is_first, is_last)
794 })
795 .collect();
796
797 let mut result = String::new();
799 let mut word_iter = result_words.iter();
800 let mut in_word = false;
801
802 for c in text.chars() {
803 if c.is_whitespace() {
804 if in_word {
805 in_word = false;
806 }
807 result.push(c);
808 } else if !in_word {
809 if let Some(word) = word_iter.next() {
810 result.push_str(word);
811 }
812 in_word = true;
813 }
814 }
815
816 result
817 }
818
819 fn apply_sentence_case_non_first(&self, text: &str) -> String {
821 if text.is_empty() {
822 return text.to_string();
823 }
824
825 let canonical_forms = self.proper_name_canonical_forms(text);
826 let mut result = String::new();
827 let mut current_pos = 0;
828
829 for word in text.split_whitespace() {
832 if let Some(pos) = text[current_pos..].find(word) {
833 let abs_pos = current_pos + pos;
834
835 result.push_str(&text[current_pos..abs_pos]);
837
838 if let Some(&canonical) = canonical_forms.get(&abs_pos) {
840 result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
841 } else if self.should_preserve_word(word) {
842 result.push_str(word);
843 } else {
844 result.push_str(&Self::lowercase_preserving_composition(word));
845 }
846
847 current_pos = abs_pos + word.len();
848 }
849 }
850
851 if current_pos < text.len() {
853 result.push_str(&text[current_pos..]);
854 }
855
856 result
857 }
858
859 fn get_line_byte_range(&self, content: &str, line_num: usize, line_index: &LineIndex) -> Range<usize> {
861 let start_pos = line_index.get_line_start_byte(line_num).unwrap_or(content.len());
862 let line = content.lines().nth(line_num - 1).unwrap_or("");
863 Range {
864 start: start_pos,
865 end: start_pos + line.len(),
866 }
867 }
868
869 fn fix_atx_heading(&self, _line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
871 let indent = " ".repeat(heading.marker_column);
873 let hashes = "#".repeat(heading.level as usize);
874
875 let fixed_text = self.apply_capitalization(&heading.raw_text);
877
878 let closing = &heading.closing_sequence;
880 if heading.has_closing_sequence {
881 format!("{indent}{hashes} {fixed_text} {closing}")
882 } else {
883 format!("{indent}{hashes} {fixed_text}")
884 }
885 }
886
887 fn fix_setext_heading(&self, line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
889 let fixed_text = self.apply_capitalization(&heading.raw_text);
891
892 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
894
895 format!("{leading_ws}{fixed_text}")
896 }
897}
898
899impl Rule for MD063HeadingCapitalization {
900 fn name(&self) -> &'static str {
901 "MD063"
902 }
903
904 fn description(&self) -> &'static str {
905 "Heading capitalization"
906 }
907
908 fn category(&self) -> RuleCategory {
909 RuleCategory::Heading
910 }
911
912 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
913 !ctx.likely_has_headings() || !ctx.lines.iter().any(|line| line.heading.is_some())
914 }
915
916 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
917 let content = ctx.content;
918
919 if content.is_empty() {
920 return Ok(Vec::new());
921 }
922
923 let mut warnings = Vec::new();
924 let line_index = &ctx.line_index;
925
926 for (line_num, line_info) in ctx.lines.iter().enumerate() {
927 if let Some(heading) = &line_info.heading {
928 if heading.level < self.config.min_level || heading.level > self.config.max_level {
930 continue;
931 }
932
933 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
935 continue;
936 }
937
938 if !heading.is_valid {
940 continue;
941 }
942
943 let original_text = &heading.raw_text;
945 let fixed_text = self.apply_capitalization(original_text);
946
947 if original_text != &fixed_text {
948 let line = line_info.content(ctx.content);
949 let style_name = match self.config.style {
950 HeadingCapStyle::TitleCase => "title case",
951 HeadingCapStyle::SentenceCase => "sentence case",
952 HeadingCapStyle::AllCaps => "ALL CAPS",
953 };
954
955 warnings.push(LintWarning {
956 rule_name: Some(self.name().to_string()),
957 line: line_num + 1,
958 column: heading.content_column + 1,
959 end_line: line_num + 1,
960 end_column: heading.content_column + 1 + original_text.len(),
961 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
962 severity: Severity::Warning,
963 fix: Some(Fix {
964 range: self.get_line_byte_range(content, line_num + 1, line_index),
965 replacement: match heading.style {
966 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
967 _ => self.fix_setext_heading(line, heading),
968 },
969 }),
970 });
971 }
972 }
973 }
974
975 Ok(warnings)
976 }
977
978 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
979 let content = ctx.content;
980
981 if content.is_empty() {
982 return Ok(content.to_string());
983 }
984
985 let lines = ctx.raw_lines();
986 let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
987
988 for (line_num, line_info) in ctx.lines.iter().enumerate() {
989 if ctx.is_rule_disabled(self.name(), line_num + 1) {
991 continue;
992 }
993
994 if let Some(heading) = &line_info.heading {
995 if heading.level < self.config.min_level || heading.level > self.config.max_level {
997 continue;
998 }
999
1000 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
1002 continue;
1003 }
1004
1005 if !heading.is_valid {
1007 continue;
1008 }
1009
1010 let original_text = &heading.raw_text;
1011 let fixed_text = self.apply_capitalization(original_text);
1012
1013 if original_text != &fixed_text {
1014 let line = line_info.content(ctx.content);
1015 fixed_lines[line_num] = match heading.style {
1016 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
1017 _ => self.fix_setext_heading(line, heading),
1018 };
1019 }
1020 }
1021 }
1022
1023 let mut result = String::with_capacity(content.len());
1025 for (i, line) in fixed_lines.iter().enumerate() {
1026 result.push_str(line);
1027 if i < fixed_lines.len() - 1 || content.ends_with('\n') {
1028 result.push('\n');
1029 }
1030 }
1031
1032 Ok(result)
1033 }
1034
1035 fn as_any(&self) -> &dyn std::any::Any {
1036 self
1037 }
1038
1039 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1040 let json_value = serde_json::to_value(&self.config).ok()?;
1041 Some((
1042 self.name().to_string(),
1043 crate::rule_config_serde::json_to_toml_value(&json_value)?,
1044 ))
1045 }
1046
1047 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1048 where
1049 Self: Sized,
1050 {
1051 let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
1052 let md044_config =
1053 crate::rule_config_serde::load_rule_config::<crate::rules::md044_proper_names::MD044Config>(config);
1054 let mut rule = Self::from_config_struct(rule_config);
1055 rule.proper_names = md044_config.names;
1056 Box::new(rule)
1057 }
1058}
1059
1060#[cfg(test)]
1061mod tests {
1062 use super::*;
1063 use crate::lint_context::LintContext;
1064
1065 fn create_rule() -> MD063HeadingCapitalization {
1066 let config = MD063Config {
1067 enabled: true,
1068 ..Default::default()
1069 };
1070 MD063HeadingCapitalization::from_config_struct(config)
1071 }
1072
1073 fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
1074 let config = MD063Config {
1075 enabled: true,
1076 style,
1077 ..Default::default()
1078 };
1079 MD063HeadingCapitalization::from_config_struct(config)
1080 }
1081
1082 #[test]
1084 fn test_title_case_basic() {
1085 let rule = create_rule();
1086 let content = "# hello world\n";
1087 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1088 let result = rule.check(&ctx).unwrap();
1089 assert_eq!(result.len(), 1);
1090 assert!(result[0].message.contains("Hello World"));
1091 }
1092
1093 #[test]
1094 fn test_title_case_lowercase_words() {
1095 let rule = create_rule();
1096 let content = "# the quick brown fox\n";
1097 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1098 let result = rule.check(&ctx).unwrap();
1099 assert_eq!(result.len(), 1);
1100 assert!(result[0].message.contains("The Quick Brown Fox"));
1102 }
1103
1104 #[test]
1105 fn test_title_case_already_correct() {
1106 let rule = create_rule();
1107 let content = "# The Quick Brown Fox\n";
1108 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1109 let result = rule.check(&ctx).unwrap();
1110 assert!(result.is_empty(), "Already correct heading should not be flagged");
1111 }
1112
1113 #[test]
1114 fn test_title_case_hyphenated() {
1115 let rule = create_rule();
1116 let content = "# self-documenting code\n";
1117 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1118 let result = rule.check(&ctx).unwrap();
1119 assert_eq!(result.len(), 1);
1120 assert!(result[0].message.contains("Self-Documenting Code"));
1121 }
1122
1123 #[test]
1125 fn test_sentence_case_basic() {
1126 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1127 let content = "# The Quick Brown Fox\n";
1128 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1129 let result = rule.check(&ctx).unwrap();
1130 assert_eq!(result.len(), 1);
1131 assert!(result[0].message.contains("The quick brown fox"));
1132 }
1133
1134 #[test]
1135 fn test_sentence_case_already_correct() {
1136 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1137 let content = "# The quick brown fox\n";
1138 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1139 let result = rule.check(&ctx).unwrap();
1140 assert!(result.is_empty());
1141 }
1142
1143 #[test]
1145 fn test_all_caps_basic() {
1146 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
1147 let content = "# hello world\n";
1148 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1149 let result = rule.check(&ctx).unwrap();
1150 assert_eq!(result.len(), 1);
1151 assert!(result[0].message.contains("HELLO WORLD"));
1152 }
1153
1154 #[test]
1156 fn test_preserve_ignore_words() {
1157 let config = MD063Config {
1158 enabled: true,
1159 ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
1160 ..Default::default()
1161 };
1162 let rule = MD063HeadingCapitalization::from_config_struct(config);
1163
1164 let content = "# using iPhone on macOS\n";
1165 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1166 let result = rule.check(&ctx).unwrap();
1167 assert_eq!(result.len(), 1);
1168 assert!(result[0].message.contains("iPhone"));
1170 assert!(result[0].message.contains("macOS"));
1171 }
1172
1173 #[test]
1174 fn test_preserve_cased_words() {
1175 let rule = create_rule();
1176 let content = "# using GitHub actions\n";
1177 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1178 let result = rule.check(&ctx).unwrap();
1179 assert_eq!(result.len(), 1);
1180 assert!(result[0].message.contains("GitHub"));
1182 }
1183
1184 #[test]
1186 fn test_inline_code_preserved() {
1187 let rule = create_rule();
1188 let content = "# using `const` in javascript\n";
1189 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1190 let result = rule.check(&ctx).unwrap();
1191 assert_eq!(result.len(), 1);
1192 assert!(result[0].message.contains("`const`"));
1194 assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
1195 }
1196
1197 #[test]
1199 fn test_level_filter() {
1200 let config = MD063Config {
1201 enabled: true,
1202 min_level: 2,
1203 max_level: 4,
1204 ..Default::default()
1205 };
1206 let rule = MD063HeadingCapitalization::from_config_struct(config);
1207
1208 let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
1209 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1210 let result = rule.check(&ctx).unwrap();
1211
1212 assert_eq!(result.len(), 2);
1214 assert_eq!(result[0].line, 2); assert_eq!(result[1].line, 3); }
1217
1218 #[test]
1220 fn test_fix_atx_heading() {
1221 let rule = create_rule();
1222 let content = "# hello world\n";
1223 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1224 let fixed = rule.fix(&ctx).unwrap();
1225 assert_eq!(fixed, "# Hello World\n");
1226 }
1227
1228 #[test]
1229 fn test_fix_multiple_headings() {
1230 let rule = create_rule();
1231 let content = "# first heading\n\n## second heading\n";
1232 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1233 let fixed = rule.fix(&ctx).unwrap();
1234 assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
1235 }
1236
1237 #[test]
1239 fn test_setext_heading() {
1240 let rule = create_rule();
1241 let content = "hello world\n============\n";
1242 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1243 let result = rule.check(&ctx).unwrap();
1244 assert_eq!(result.len(), 1);
1245 assert!(result[0].message.contains("Hello World"));
1246 }
1247
1248 #[test]
1250 fn test_custom_id_preserved() {
1251 let rule = create_rule();
1252 let content = "# getting started {#intro}\n";
1253 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1254 let result = rule.check(&ctx).unwrap();
1255 assert_eq!(result.len(), 1);
1256 assert!(result[0].message.contains("{#intro}"));
1258 }
1259
1260 #[test]
1262 fn test_skip_obsidian_tags_not_headings() {
1263 let rule = create_rule();
1264
1265 let content = "# H1\n\n#tag\n";
1267 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1268 let result = rule.check(&ctx).unwrap();
1269 assert!(
1270 result.is_empty() || result.iter().all(|w| w.line != 3),
1271 "Obsidian tag #tag should not be treated as a heading: {result:?}"
1272 );
1273 }
1274
1275 #[test]
1276 fn test_skip_invalid_atx_headings_no_space() {
1277 let rule = create_rule();
1278
1279 let content = "#notaheading\n";
1281 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1282 let result = rule.check(&ctx).unwrap();
1283 assert!(
1284 result.is_empty(),
1285 "Invalid ATX heading without space should not be flagged: {result:?}"
1286 );
1287 }
1288
1289 #[test]
1290 fn test_fix_skips_obsidian_tags() {
1291 let rule = create_rule();
1292
1293 let content = "# hello world\n\n#tag\n";
1294 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1295 let fixed = rule.fix(&ctx).unwrap();
1296 assert!(fixed.contains("#tag"), "Fix should not modify Obsidian tag #tag");
1298 assert!(fixed.contains("# Hello World"), "Fix should still fix real headings");
1299 }
1300
1301 #[test]
1302 fn test_preserve_all_caps_acronyms() {
1303 let rule = create_rule();
1304 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1305
1306 let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
1308 assert_eq!(fixed, "# Using API in Production\n");
1309
1310 let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
1312 assert_eq!(fixed, "# API and GPU Integration\n");
1313
1314 let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
1316 assert_eq!(fixed, "# IO Performance Guide\n");
1317
1318 let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
1320 assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
1321 }
1322
1323 #[test]
1324 fn test_preserve_acronyms_in_hyphenated_words() {
1325 let rule = create_rule();
1326 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1327
1328 let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
1330 assert_eq!(fixed, "# API-Driven Architecture\n");
1331
1332 let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
1334 assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
1335 }
1336
1337 #[test]
1338 fn test_single_letters_not_treated_as_acronyms() {
1339 let rule = create_rule();
1340 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1341
1342 let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
1344 assert_eq!(fixed, "# I Am a Heading\n");
1345 }
1346
1347 #[test]
1348 fn test_lowercase_terms_need_ignore_words() {
1349 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1350
1351 let rule = create_rule();
1353 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1354 assert_eq!(fixed, "# Using Npm Packages\n");
1355
1356 let config = MD063Config {
1358 enabled: true,
1359 ignore_words: vec!["npm".to_string()],
1360 ..Default::default()
1361 };
1362 let rule = MD063HeadingCapitalization::from_config_struct(config);
1363 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1364 assert_eq!(fixed, "# Using npm Packages\n");
1365 }
1366
1367 #[test]
1368 fn test_acronyms_with_mixed_case_preserved() {
1369 let rule = create_rule();
1370 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1371
1372 let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
1374 assert_eq!(fixed, "# Using API with GitHub\n");
1375 }
1376
1377 #[test]
1378 fn test_real_world_acronyms() {
1379 let rule = create_rule();
1380 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1381
1382 let content = "# FFI bindings for CPU optimization\n";
1384 let fixed = rule.fix(&ctx(content)).unwrap();
1385 assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
1386
1387 let content = "# DOM manipulation and SSR rendering\n";
1388 let fixed = rule.fix(&ctx(content)).unwrap();
1389 assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
1390
1391 let content = "# CVE security and RNN models\n";
1392 let fixed = rule.fix(&ctx(content)).unwrap();
1393 assert_eq!(fixed, "# CVE Security and RNN Models\n");
1394 }
1395
1396 #[test]
1397 fn test_is_all_caps_acronym() {
1398 let rule = create_rule();
1399
1400 assert!(rule.is_all_caps_acronym("API"));
1402 assert!(rule.is_all_caps_acronym("IO"));
1403 assert!(rule.is_all_caps_acronym("GPU"));
1404 assert!(rule.is_all_caps_acronym("HTTP2")); assert!(!rule.is_all_caps_acronym("A"));
1408 assert!(!rule.is_all_caps_acronym("I"));
1409
1410 assert!(!rule.is_all_caps_acronym("Api"));
1412 assert!(!rule.is_all_caps_acronym("npm"));
1413 assert!(!rule.is_all_caps_acronym("iPhone"));
1414 }
1415
1416 #[test]
1417 fn test_sentence_case_ignore_words_first_word() {
1418 let config = MD063Config {
1419 enabled: true,
1420 style: HeadingCapStyle::SentenceCase,
1421 ignore_words: vec!["nvim".to_string()],
1422 ..Default::default()
1423 };
1424 let rule = MD063HeadingCapitalization::from_config_struct(config);
1425
1426 let content = "# nvim config\n";
1428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1429 let result = rule.check(&ctx).unwrap();
1430 assert!(
1431 result.is_empty(),
1432 "nvim in ignore-words should not be flagged. Got: {result:?}"
1433 );
1434
1435 let fixed = rule.fix(&ctx).unwrap();
1437 assert_eq!(fixed, "# nvim config\n");
1438 }
1439
1440 #[test]
1441 fn test_sentence_case_ignore_words_not_first() {
1442 let config = MD063Config {
1443 enabled: true,
1444 style: HeadingCapStyle::SentenceCase,
1445 ignore_words: vec!["nvim".to_string()],
1446 ..Default::default()
1447 };
1448 let rule = MD063HeadingCapitalization::from_config_struct(config);
1449
1450 let content = "# Using nvim editor\n";
1452 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1453 let result = rule.check(&ctx).unwrap();
1454 assert!(
1455 result.is_empty(),
1456 "nvim in ignore-words should be preserved. Got: {result:?}"
1457 );
1458 }
1459
1460 #[test]
1461 fn test_preserve_cased_words_ios() {
1462 let config = MD063Config {
1463 enabled: true,
1464 style: HeadingCapStyle::SentenceCase,
1465 preserve_cased_words: true,
1466 ..Default::default()
1467 };
1468 let rule = MD063HeadingCapitalization::from_config_struct(config);
1469
1470 let content = "## This is iOS\n";
1472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1473 let result = rule.check(&ctx).unwrap();
1474 assert!(
1475 result.is_empty(),
1476 "iOS should be preserved with preserve-cased-words. Got: {result:?}"
1477 );
1478
1479 let fixed = rule.fix(&ctx).unwrap();
1481 assert_eq!(fixed, "## This is iOS\n");
1482 }
1483
1484 #[test]
1485 fn test_preserve_cased_words_ios_title_case() {
1486 let config = MD063Config {
1487 enabled: true,
1488 style: HeadingCapStyle::TitleCase,
1489 preserve_cased_words: true,
1490 ..Default::default()
1491 };
1492 let rule = MD063HeadingCapitalization::from_config_struct(config);
1493
1494 let content = "# developing for iOS\n";
1496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1497 let fixed = rule.fix(&ctx).unwrap();
1498 assert_eq!(fixed, "# Developing for iOS\n");
1499 }
1500
1501 #[test]
1502 fn test_has_internal_capitals_ios() {
1503 let rule = create_rule();
1504
1505 assert!(
1507 rule.has_internal_capitals("iOS"),
1508 "iOS has mixed case (lowercase i, uppercase OS)"
1509 );
1510
1511 assert!(rule.has_internal_capitals("iPhone"));
1513 assert!(rule.has_internal_capitals("macOS"));
1514 assert!(rule.has_internal_capitals("GitHub"));
1515 assert!(rule.has_internal_capitals("JavaScript"));
1516 assert!(rule.has_internal_capitals("eBay"));
1517
1518 assert!(!rule.has_internal_capitals("API"));
1520 assert!(!rule.has_internal_capitals("GPU"));
1521
1522 assert!(!rule.has_internal_capitals("npm"));
1524 assert!(!rule.has_internal_capitals("config"));
1525
1526 assert!(!rule.has_internal_capitals("The"));
1528 assert!(!rule.has_internal_capitals("Hello"));
1529 }
1530
1531 #[test]
1532 fn test_lowercase_words_before_trailing_code() {
1533 let config = MD063Config {
1534 enabled: true,
1535 style: HeadingCapStyle::TitleCase,
1536 lowercase_words: vec![
1537 "a".to_string(),
1538 "an".to_string(),
1539 "and".to_string(),
1540 "at".to_string(),
1541 "but".to_string(),
1542 "by".to_string(),
1543 "for".to_string(),
1544 "from".to_string(),
1545 "into".to_string(),
1546 "nor".to_string(),
1547 "on".to_string(),
1548 "onto".to_string(),
1549 "or".to_string(),
1550 "the".to_string(),
1551 "to".to_string(),
1552 "upon".to_string(),
1553 "via".to_string(),
1554 "vs".to_string(),
1555 "with".to_string(),
1556 "without".to_string(),
1557 ],
1558 preserve_cased_words: true,
1559 ..Default::default()
1560 };
1561 let rule = MD063HeadingCapitalization::from_config_struct(config);
1562
1563 let content = "## subtitle with a `app`\n";
1568 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1569 let result = rule.check(&ctx).unwrap();
1570
1571 assert!(!result.is_empty(), "Should flag incorrect capitalization");
1573 let fixed = rule.fix(&ctx).unwrap();
1574 assert!(
1576 fixed.contains("with a `app`"),
1577 "Expected 'with a `app`' but got: {fixed:?}"
1578 );
1579 assert!(
1580 !fixed.contains("with A `app`"),
1581 "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
1582 );
1583 assert!(
1585 fixed.contains("Subtitle with a `app`"),
1586 "Expected 'Subtitle with a `app`' but got: {fixed:?}"
1587 );
1588 }
1589
1590 #[test]
1591 fn test_lowercase_words_preserved_before_trailing_code_variant() {
1592 let config = MD063Config {
1593 enabled: true,
1594 style: HeadingCapStyle::TitleCase,
1595 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1596 ..Default::default()
1597 };
1598 let rule = MD063HeadingCapitalization::from_config_struct(config);
1599
1600 let content = "## Title with the `code`\n";
1602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1603 let fixed = rule.fix(&ctx).unwrap();
1604 assert!(
1606 fixed.contains("with the `code`"),
1607 "Expected 'with the `code`' but got: {fixed:?}"
1608 );
1609 assert!(
1610 !fixed.contains("with The `code`"),
1611 "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
1612 );
1613 }
1614
1615 #[test]
1616 fn test_last_word_capitalized_when_no_trailing_code() {
1617 let config = MD063Config {
1620 enabled: true,
1621 style: HeadingCapStyle::TitleCase,
1622 lowercase_words: vec!["a".to_string(), "the".to_string()],
1623 ..Default::default()
1624 };
1625 let rule = MD063HeadingCapitalization::from_config_struct(config);
1626
1627 let content = "## title with a word\n";
1630 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1631 let fixed = rule.fix(&ctx).unwrap();
1632 assert!(
1634 fixed.contains("With a Word"),
1635 "Expected 'With a Word' but got: {fixed:?}"
1636 );
1637 }
1638
1639 #[test]
1640 fn test_multiple_lowercase_words_before_code() {
1641 let config = MD063Config {
1642 enabled: true,
1643 style: HeadingCapStyle::TitleCase,
1644 lowercase_words: vec![
1645 "a".to_string(),
1646 "the".to_string(),
1647 "with".to_string(),
1648 "for".to_string(),
1649 ],
1650 ..Default::default()
1651 };
1652 let rule = MD063HeadingCapitalization::from_config_struct(config);
1653
1654 let content = "## Guide for the `user`\n";
1656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1657 let fixed = rule.fix(&ctx).unwrap();
1658 assert!(
1659 fixed.contains("for the `user`"),
1660 "Expected 'for the `user`' but got: {fixed:?}"
1661 );
1662 assert!(
1663 !fixed.contains("For The `user`"),
1664 "Should not capitalize lowercase words before code. Got: {fixed:?}"
1665 );
1666 }
1667
1668 #[test]
1669 fn test_code_in_middle_normal_rules_apply() {
1670 let config = MD063Config {
1671 enabled: true,
1672 style: HeadingCapStyle::TitleCase,
1673 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1674 ..Default::default()
1675 };
1676 let rule = MD063HeadingCapitalization::from_config_struct(config);
1677
1678 let content = "## Using `const` for the code\n";
1680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1681 let fixed = rule.fix(&ctx).unwrap();
1682 assert!(
1684 fixed.contains("for the Code"),
1685 "Expected 'for the Code' but got: {fixed:?}"
1686 );
1687 }
1688
1689 #[test]
1690 fn test_link_at_end_same_as_code() {
1691 let config = MD063Config {
1692 enabled: true,
1693 style: HeadingCapStyle::TitleCase,
1694 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1695 ..Default::default()
1696 };
1697 let rule = MD063HeadingCapitalization::from_config_struct(config);
1698
1699 let content = "## Guide for the [link](./page.md)\n";
1701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1702 let fixed = rule.fix(&ctx).unwrap();
1703 assert!(
1705 fixed.contains("for the [Link]"),
1706 "Expected 'for the [Link]' but got: {fixed:?}"
1707 );
1708 assert!(
1709 !fixed.contains("for The [Link]"),
1710 "Should not capitalize 'the' before link. Got: {fixed:?}"
1711 );
1712 }
1713
1714 #[test]
1715 fn test_multiple_code_segments() {
1716 let config = MD063Config {
1717 enabled: true,
1718 style: HeadingCapStyle::TitleCase,
1719 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1720 ..Default::default()
1721 };
1722 let rule = MD063HeadingCapitalization::from_config_struct(config);
1723
1724 let content = "## Using `const` with a `variable`\n";
1726 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1727 let fixed = rule.fix(&ctx).unwrap();
1728 assert!(
1730 fixed.contains("with a `variable`"),
1731 "Expected 'with a `variable`' but got: {fixed:?}"
1732 );
1733 assert!(
1734 !fixed.contains("with A `variable`"),
1735 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1736 );
1737 }
1738
1739 #[test]
1740 fn test_code_and_link_combination() {
1741 let config = MD063Config {
1742 enabled: true,
1743 style: HeadingCapStyle::TitleCase,
1744 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1745 ..Default::default()
1746 };
1747 let rule = MD063HeadingCapitalization::from_config_struct(config);
1748
1749 let content = "## Guide for the `code` [link](./page.md)\n";
1751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1752 let fixed = rule.fix(&ctx).unwrap();
1753 assert!(
1755 fixed.contains("for the `code`"),
1756 "Expected 'for the `code`' but got: {fixed:?}"
1757 );
1758 }
1759
1760 #[test]
1761 fn test_text_after_code_capitalizes_last() {
1762 let config = MD063Config {
1763 enabled: true,
1764 style: HeadingCapStyle::TitleCase,
1765 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1766 ..Default::default()
1767 };
1768 let rule = MD063HeadingCapitalization::from_config_struct(config);
1769
1770 let content = "## Using `const` for the code\n";
1772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1773 let fixed = rule.fix(&ctx).unwrap();
1774 assert!(
1776 fixed.contains("for the Code"),
1777 "Expected 'for the Code' but got: {fixed:?}"
1778 );
1779 }
1780
1781 #[test]
1782 fn test_preserve_cased_words_with_trailing_code() {
1783 let config = MD063Config {
1784 enabled: true,
1785 style: HeadingCapStyle::TitleCase,
1786 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1787 preserve_cased_words: true,
1788 ..Default::default()
1789 };
1790 let rule = MD063HeadingCapitalization::from_config_struct(config);
1791
1792 let content = "## Guide for iOS `app`\n";
1794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1795 let fixed = rule.fix(&ctx).unwrap();
1796 assert!(
1798 fixed.contains("for iOS `app`"),
1799 "Expected 'for iOS `app`' but got: {fixed:?}"
1800 );
1801 assert!(
1802 !fixed.contains("For iOS `app`"),
1803 "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
1804 );
1805 }
1806
1807 #[test]
1808 fn test_ignore_words_with_trailing_code() {
1809 let config = MD063Config {
1810 enabled: true,
1811 style: HeadingCapStyle::TitleCase,
1812 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1813 ignore_words: vec!["npm".to_string()],
1814 ..Default::default()
1815 };
1816 let rule = MD063HeadingCapitalization::from_config_struct(config);
1817
1818 let content = "## Using npm with a `script`\n";
1820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1821 let fixed = rule.fix(&ctx).unwrap();
1822 assert!(
1824 fixed.contains("npm with a `script`"),
1825 "Expected 'npm with a `script`' but got: {fixed:?}"
1826 );
1827 assert!(
1828 !fixed.contains("with A `script`"),
1829 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1830 );
1831 }
1832
1833 #[test]
1834 fn test_empty_text_segment_edge_case() {
1835 let config = MD063Config {
1836 enabled: true,
1837 style: HeadingCapStyle::TitleCase,
1838 lowercase_words: vec!["a".to_string(), "with".to_string()],
1839 ..Default::default()
1840 };
1841 let rule = MD063HeadingCapitalization::from_config_struct(config);
1842
1843 let content = "## `start` with a `end`\n";
1845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1846 let fixed = rule.fix(&ctx).unwrap();
1847 assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
1850 assert!(
1851 !fixed.contains("A `end`"),
1852 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1853 );
1854 }
1855
1856 #[test]
1857 fn test_sentence_case_with_trailing_code() {
1858 let config = MD063Config {
1859 enabled: true,
1860 style: HeadingCapStyle::SentenceCase,
1861 lowercase_words: vec!["a".to_string(), "the".to_string()],
1862 ..Default::default()
1863 };
1864 let rule = MD063HeadingCapitalization::from_config_struct(config);
1865
1866 let content = "## guide for the `user`\n";
1868 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1869 let fixed = rule.fix(&ctx).unwrap();
1870 assert!(
1872 fixed.contains("Guide for the `user`"),
1873 "Expected 'Guide for the `user`' but got: {fixed:?}"
1874 );
1875 }
1876
1877 #[test]
1878 fn test_hyphenated_word_before_code() {
1879 let config = MD063Config {
1880 enabled: true,
1881 style: HeadingCapStyle::TitleCase,
1882 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1883 ..Default::default()
1884 };
1885 let rule = MD063HeadingCapitalization::from_config_struct(config);
1886
1887 let content = "## Self-contained with a `feature`\n";
1889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1890 let fixed = rule.fix(&ctx).unwrap();
1891 assert!(
1893 fixed.contains("with a `feature`"),
1894 "Expected 'with a `feature`' but got: {fixed:?}"
1895 );
1896 }
1897
1898 #[test]
1903 fn test_sentence_case_code_at_start_basic() {
1904 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1906 let content = "# `rumdl` is a linter\n";
1907 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1908 let result = rule.check(&ctx).unwrap();
1909 assert!(
1911 result.is_empty(),
1912 "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
1913 result.iter().map(|w| &w.message).collect::<Vec<_>>()
1914 );
1915 }
1916
1917 #[test]
1918 fn test_sentence_case_code_at_start_incorrect_capitalization() {
1919 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1921 let content = "# `rumdl` Is a Linter\n";
1922 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1923 let result = rule.check(&ctx).unwrap();
1924 assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
1926 assert!(
1927 result[0].message.contains("`rumdl` is a linter"),
1928 "Should suggest lowercase after code. Got: {:?}",
1929 result[0].message
1930 );
1931 }
1932
1933 #[test]
1934 fn test_sentence_case_code_at_start_fix() {
1935 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1936 let content = "# `rumdl` Is A Linter\n";
1937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1938 let fixed = rule.fix(&ctx).unwrap();
1939 assert!(
1940 fixed.contains("# `rumdl` is a linter"),
1941 "Should fix to lowercase after code. Got: {fixed:?}"
1942 );
1943 }
1944
1945 #[test]
1946 fn test_sentence_case_text_at_start_still_capitalizes() {
1947 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1949 let content = "# the quick brown fox\n";
1950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1951 let result = rule.check(&ctx).unwrap();
1952 assert_eq!(result.len(), 1);
1953 assert!(
1954 result[0].message.contains("The quick brown fox"),
1955 "Text-first heading should capitalize first word. Got: {:?}",
1956 result[0].message
1957 );
1958 }
1959
1960 #[test]
1961 fn test_sentence_case_link_at_start() {
1962 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1964 let content = "# [api](api.md) reference guide\n";
1966 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1967 let result = rule.check(&ctx).unwrap();
1968 assert!(
1970 result.is_empty(),
1971 "Heading with link at start should not capitalize 'reference'. Got: {:?}",
1972 result.iter().map(|w| &w.message).collect::<Vec<_>>()
1973 );
1974 }
1975
1976 #[test]
1977 fn test_sentence_case_link_preserves_acronyms() {
1978 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1980 let content = "# [API](api.md) Reference Guide\n";
1981 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1982 let result = rule.check(&ctx).unwrap();
1983 assert_eq!(result.len(), 1);
1984 assert!(
1986 result[0].message.contains("[API](api.md) reference guide"),
1987 "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
1988 result[0].message
1989 );
1990 }
1991
1992 #[test]
1993 fn test_sentence_case_link_preserves_brand_names() {
1994 let config = MD063Config {
1996 enabled: true,
1997 style: HeadingCapStyle::SentenceCase,
1998 preserve_cased_words: true,
1999 ..Default::default()
2000 };
2001 let rule = MD063HeadingCapitalization::from_config_struct(config);
2002 let content = "# [iPhone](iphone.md) Features Guide\n";
2003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2004 let result = rule.check(&ctx).unwrap();
2005 assert_eq!(result.len(), 1);
2006 assert!(
2008 result[0].message.contains("[iPhone](iphone.md) features guide"),
2009 "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
2010 result[0].message
2011 );
2012 }
2013
2014 #[test]
2015 fn test_sentence_case_link_lowercases_regular_words() {
2016 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2018 let content = "# [Documentation](docs.md) Reference\n";
2019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2020 let result = rule.check(&ctx).unwrap();
2021 assert_eq!(result.len(), 1);
2022 assert!(
2024 result[0].message.contains("[documentation](docs.md) reference"),
2025 "Should lowercase regular link text. Got: {:?}",
2026 result[0].message
2027 );
2028 }
2029
2030 #[test]
2031 fn test_sentence_case_link_at_start_correct_already() {
2032 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2034 let content = "# [API](api.md) reference guide\n";
2035 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2036 let result = rule.check(&ctx).unwrap();
2037 assert!(
2038 result.is_empty(),
2039 "Correctly cased heading with link should not be flagged. Got: {:?}",
2040 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2041 );
2042 }
2043
2044 #[test]
2045 fn test_sentence_case_link_github_preserved() {
2046 let config = MD063Config {
2048 enabled: true,
2049 style: HeadingCapStyle::SentenceCase,
2050 preserve_cased_words: true,
2051 ..Default::default()
2052 };
2053 let rule = MD063HeadingCapitalization::from_config_struct(config);
2054 let content = "# [GitHub](gh.md) Repository Setup\n";
2055 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2056 let result = rule.check(&ctx).unwrap();
2057 assert_eq!(result.len(), 1);
2058 assert!(
2059 result[0].message.contains("[GitHub](gh.md) repository setup"),
2060 "Should preserve 'GitHub'. Got: {:?}",
2061 result[0].message
2062 );
2063 }
2064
2065 #[test]
2066 fn test_sentence_case_multiple_code_spans() {
2067 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2068 let content = "# `foo` and `bar` are methods\n";
2069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2070 let result = rule.check(&ctx).unwrap();
2071 assert!(
2073 result.is_empty(),
2074 "Should not capitalize words between/after code spans. Got: {:?}",
2075 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2076 );
2077 }
2078
2079 #[test]
2080 fn test_sentence_case_code_only_heading() {
2081 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2083 let content = "# `rumdl`\n";
2084 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2085 let result = rule.check(&ctx).unwrap();
2086 assert!(
2087 result.is_empty(),
2088 "Code-only heading should be fine. Got: {:?}",
2089 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2090 );
2091 }
2092
2093 #[test]
2094 fn test_sentence_case_code_at_end() {
2095 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2097 let content = "# install the `rumdl` tool\n";
2098 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2099 let result = rule.check(&ctx).unwrap();
2100 assert_eq!(result.len(), 1);
2102 assert!(
2103 result[0].message.contains("Install the `rumdl` tool"),
2104 "First word should still be capitalized when text comes first. Got: {:?}",
2105 result[0].message
2106 );
2107 }
2108
2109 #[test]
2110 fn test_sentence_case_code_in_middle() {
2111 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2113 let content = "# using the `rumdl` linter for markdown\n";
2114 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2115 let result = rule.check(&ctx).unwrap();
2116 assert_eq!(result.len(), 1);
2118 assert!(
2119 result[0].message.contains("Using the `rumdl` linter for markdown"),
2120 "First word should be capitalized. Got: {:?}",
2121 result[0].message
2122 );
2123 }
2124
2125 #[test]
2126 fn test_sentence_case_preserved_word_after_code() {
2127 let config = MD063Config {
2129 enabled: true,
2130 style: HeadingCapStyle::SentenceCase,
2131 preserve_cased_words: true,
2132 ..Default::default()
2133 };
2134 let rule = MD063HeadingCapitalization::from_config_struct(config);
2135 let content = "# `swift` iPhone development\n";
2136 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2137 let result = rule.check(&ctx).unwrap();
2138 assert!(
2140 result.is_empty(),
2141 "Preserved words after code should stay. Got: {:?}",
2142 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2143 );
2144 }
2145
2146 #[test]
2147 fn test_title_case_code_at_start_still_capitalizes() {
2148 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2150 let content = "# `api` quick start guide\n";
2151 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2152 let result = rule.check(&ctx).unwrap();
2153 assert_eq!(result.len(), 1);
2155 assert!(
2156 result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
2157 "Title case should capitalize major words after code. Got: {:?}",
2158 result[0].message
2159 );
2160 }
2161
2162 #[test]
2165 fn test_sentence_case_html_tag_at_start() {
2166 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2168 let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
2169 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2170 let result = rule.check(&ctx).unwrap();
2171 assert_eq!(result.len(), 1);
2173 let fixed = rule.fix(&ctx).unwrap();
2174 assert_eq!(
2175 fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
2176 "Text after HTML at start should be lowercase"
2177 );
2178 }
2179
2180 #[test]
2181 fn test_sentence_case_html_tag_preserves_content() {
2182 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2184 let content = "# The <abbr>API</abbr> documentation guide\n";
2185 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2186 let result = rule.check(&ctx).unwrap();
2187 assert!(
2189 result.is_empty(),
2190 "HTML tag content should be preserved. Got: {:?}",
2191 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2192 );
2193 }
2194
2195 #[test]
2196 fn test_sentence_case_html_tag_at_start_with_acronym() {
2197 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2199 let content = "# <abbr>API</abbr> Documentation Guide\n";
2200 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2201 let result = rule.check(&ctx).unwrap();
2202 assert_eq!(result.len(), 1);
2203 let fixed = rule.fix(&ctx).unwrap();
2204 assert_eq!(
2205 fixed, "# <abbr>API</abbr> documentation guide\n",
2206 "Text after HTML at start should be lowercase, HTML content preserved"
2207 );
2208 }
2209
2210 #[test]
2211 fn test_sentence_case_html_tag_in_middle() {
2212 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2214 let content = "# using the <code>config</code> File\n";
2215 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2216 let result = rule.check(&ctx).unwrap();
2217 assert_eq!(result.len(), 1);
2218 let fixed = rule.fix(&ctx).unwrap();
2219 assert_eq!(
2220 fixed, "# Using the <code>config</code> file\n",
2221 "First word capitalized, HTML preserved, rest lowercase"
2222 );
2223 }
2224
2225 #[test]
2226 fn test_html_tag_strong_emphasis() {
2227 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2229 let content = "# The <strong>Bold</strong> Way\n";
2230 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2231 let result = rule.check(&ctx).unwrap();
2232 assert_eq!(result.len(), 1);
2233 let fixed = rule.fix(&ctx).unwrap();
2234 assert_eq!(
2235 fixed, "# The <strong>Bold</strong> way\n",
2236 "<strong> tag content should be preserved"
2237 );
2238 }
2239
2240 #[test]
2241 fn test_html_tag_with_attributes() {
2242 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2244 let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
2245 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2246 let result = rule.check(&ctx).unwrap();
2247 assert_eq!(result.len(), 1);
2248 let fixed = rule.fix(&ctx).unwrap();
2249 assert_eq!(
2250 fixed, "# <span class=\"highlight\">Important</span> notice here\n",
2251 "HTML tag with attributes should be preserved"
2252 );
2253 }
2254
2255 #[test]
2256 fn test_multiple_html_tags() {
2257 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2259 let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
2260 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2261 let result = rule.check(&ctx).unwrap();
2262 assert_eq!(result.len(), 1);
2263 let fixed = rule.fix(&ctx).unwrap();
2264 assert_eq!(
2265 fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
2266 "Multiple HTML tags should all be preserved"
2267 );
2268 }
2269
2270 #[test]
2271 fn test_html_and_code_mixed() {
2272 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2274 let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
2275 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2276 let result = rule.check(&ctx).unwrap();
2277 assert_eq!(result.len(), 1);
2278 let fixed = rule.fix(&ctx).unwrap();
2279 assert_eq!(
2280 fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
2281 "HTML and code should both be preserved"
2282 );
2283 }
2284
2285 #[test]
2286 fn test_self_closing_html_tag() {
2287 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2289 let content = "# Line one<br/>Line Two Here\n";
2290 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2291 let result = rule.check(&ctx).unwrap();
2292 assert_eq!(result.len(), 1);
2293 let fixed = rule.fix(&ctx).unwrap();
2294 assert_eq!(
2295 fixed, "# Line one<br/>line two here\n",
2296 "Self-closing HTML tags should be preserved"
2297 );
2298 }
2299
2300 #[test]
2301 fn test_title_case_with_html_tags() {
2302 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2304 let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
2305 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2306 let result = rule.check(&ctx).unwrap();
2307 assert_eq!(result.len(), 1);
2308 let fixed = rule.fix(&ctx).unwrap();
2309 assert!(
2311 fixed.contains("<kbd>ctrl</kbd>"),
2312 "HTML tag content should be preserved in title case. Got: {fixed}"
2313 );
2314 assert!(
2315 fixed.starts_with("# The ") || fixed.starts_with("# the "),
2316 "Title case should work with HTML. Got: {fixed}"
2317 );
2318 }
2319
2320 #[test]
2323 fn test_sentence_case_preserves_caret_notation() {
2324 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2326 let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
2327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2328 let result = rule.check(&ctx).unwrap();
2329 assert!(
2331 result.is_empty(),
2332 "Caret notation should be preserved. Got: {:?}",
2333 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2334 );
2335 }
2336
2337 #[test]
2338 fn test_sentence_case_caret_notation_various() {
2339 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2341
2342 let content = "## Press ^C to cancel\n";
2344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2345 let result = rule.check(&ctx).unwrap();
2346 assert!(
2347 result.is_empty(),
2348 "^C should be preserved. Got: {:?}",
2349 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2350 );
2351
2352 let content = "## Use ^Z for background\n";
2354 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2355 let result = rule.check(&ctx).unwrap();
2356 assert!(
2357 result.is_empty(),
2358 "^Z should be preserved. Got: {:?}",
2359 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2360 );
2361
2362 let content = "## Press ^[ for escape\n";
2364 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2365 let result = rule.check(&ctx).unwrap();
2366 assert!(
2367 result.is_empty(),
2368 "^[ should be preserved. Got: {:?}",
2369 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2370 );
2371 }
2372
2373 #[test]
2374 fn test_caret_notation_detection() {
2375 let rule = create_rule();
2376
2377 assert!(rule.is_caret_notation("^A"));
2379 assert!(rule.is_caret_notation("^Z"));
2380 assert!(rule.is_caret_notation("^C"));
2381 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")); }
2393
2394 fn create_sentence_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2401 let config = MD063Config {
2402 enabled: true,
2403 style: HeadingCapStyle::SentenceCase,
2404 ..Default::default()
2405 };
2406 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2407 rule.proper_names = names;
2408 rule
2409 }
2410
2411 #[test]
2412 fn test_sentence_case_preserves_single_word_proper_name() {
2413 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2414 let content = "# installing javascript\n";
2416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2417 let result = rule.check(&ctx).unwrap();
2418 assert_eq!(result.len(), 1, "Should flag the heading");
2419 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2420 assert!(
2421 fix_text.contains("JavaScript"),
2422 "Fix should preserve proper name 'JavaScript', got: {fix_text:?}"
2423 );
2424 assert!(
2425 !fix_text.contains("javascript"),
2426 "Fix should not have lowercase 'javascript', got: {fix_text:?}"
2427 );
2428 }
2429
2430 #[test]
2431 fn test_sentence_case_preserves_multi_word_proper_name() {
2432 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2433 let content = "# using good application features\n";
2435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2436 let result = rule.check(&ctx).unwrap();
2437 assert_eq!(result.len(), 1, "Should flag the heading");
2438 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2439 assert!(
2440 fix_text.contains("Good Application"),
2441 "Fix should preserve 'Good Application' as a phrase, got: {fix_text:?}"
2442 );
2443 }
2444
2445 #[test]
2446 fn test_sentence_case_proper_name_at_start_of_heading() {
2447 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2448 let content = "# good application overview\n";
2450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2451 let result = rule.check(&ctx).unwrap();
2452 assert_eq!(result.len(), 1, "Should flag the heading");
2453 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2454 assert!(
2455 fix_text.contains("Good Application"),
2456 "Fix should produce 'Good Application' at start of heading, got: {fix_text:?}"
2457 );
2458 assert!(
2459 fix_text.contains("overview"),
2460 "Non-proper-name word 'overview' should be lowercase, got: {fix_text:?}"
2461 );
2462 }
2463
2464 #[test]
2465 fn test_sentence_case_with_proper_names_no_oscillation() {
2466 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2469
2470 let content = "# installing good application on your system\n";
2472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2473 let result = rule.check(&ctx).unwrap();
2474 assert_eq!(result.len(), 1);
2475 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
2476
2477 assert!(
2479 fixed_heading.contains("Good Application"),
2480 "After fix, proper name must be preserved: {fixed_heading:?}"
2481 );
2482
2483 let fixed_line = format!("{fixed_heading}\n");
2485 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
2486 let result2 = rule.check(&ctx2).unwrap();
2487 assert!(
2488 result2.is_empty(),
2489 "After one fix, heading must already satisfy both MD063 and MD044 - no oscillation. \
2490 Second pass warnings: {result2:?}"
2491 );
2492 }
2493
2494 #[test]
2495 fn test_sentence_case_proper_names_already_correct() {
2496 let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
2497 let content = "# Installing Good Application\n";
2499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2500 let result = rule.check(&ctx).unwrap();
2501 assert!(
2502 result.is_empty(),
2503 "Correct sentence-case heading with proper name should not be flagged, got: {result:?}"
2504 );
2505 }
2506
2507 #[test]
2508 fn test_sentence_case_multiple_proper_names_in_heading() {
2509 let rule = create_sentence_case_rule_with_proper_names(vec!["TypeScript".to_string(), "React".to_string()]);
2510 let content = "# using typescript with react\n";
2511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2512 let result = rule.check(&ctx).unwrap();
2513 assert_eq!(result.len(), 1);
2514 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2515 assert!(
2516 fix_text.contains("TypeScript"),
2517 "Fix should preserve 'TypeScript', got: {fix_text:?}"
2518 );
2519 assert!(
2520 fix_text.contains("React"),
2521 "Fix should preserve 'React', got: {fix_text:?}"
2522 );
2523 }
2524
2525 #[test]
2526 fn test_sentence_case_unicode_casefold_expansion_before_proper_name() {
2527 let rule = create_sentence_case_rule_with_proper_names(vec!["Österreich".to_string()]);
2530 let content = "# İ österreich guide\n";
2531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2532
2533 let result = rule.check(&ctx).unwrap();
2535 assert_eq!(result.len(), 1, "Should flag heading for canonical proper-name casing");
2536 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2537 assert!(
2538 fix_text.contains("Österreich"),
2539 "Fix should preserve canonical 'Österreich', got: {fix_text:?}"
2540 );
2541 }
2542
2543 #[test]
2544 fn test_sentence_case_preserves_trailing_punctuation_on_proper_name() {
2545 let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
2546 let content = "# using javascript, today\n";
2547 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2548 let result = rule.check(&ctx).unwrap();
2549 assert_eq!(result.len(), 1, "Should flag heading");
2550 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2551 assert!(
2552 fix_text.contains("JavaScript,"),
2553 "Fix should preserve trailing punctuation, got: {fix_text:?}"
2554 );
2555 }
2556
2557 fn create_title_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
2564 let config = MD063Config {
2565 enabled: true,
2566 style: HeadingCapStyle::TitleCase,
2567 ..Default::default()
2568 };
2569 let mut rule = MD063HeadingCapitalization::from_config_struct(config);
2570 rule.proper_names = names;
2571 rule
2572 }
2573
2574 #[test]
2575 fn test_title_case_preserves_proper_name_with_lowercase_article() {
2576 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
2580 let content = "# listening to the rolling stones today\n";
2581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2582 let result = rule.check(&ctx).unwrap();
2583 assert_eq!(result.len(), 1, "Should flag the heading");
2584 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2585 assert!(
2586 fix_text.contains("The Rolling Stones"),
2587 "Fix should preserve proper name 'The Rolling Stones', got: {fix_text:?}"
2588 );
2589 }
2590
2591 #[test]
2592 fn test_title_case_proper_name_no_oscillation() {
2593 let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
2595 let content = "# listening to the rolling stones today\n";
2596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2597 let result = rule.check(&ctx).unwrap();
2598 assert_eq!(result.len(), 1);
2599 let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
2600
2601 let fixed_line = format!("{fixed_heading}\n");
2602 let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
2603 let result2 = rule.check(&ctx2).unwrap();
2604 assert!(
2605 result2.is_empty(),
2606 "After one title-case fix, heading must already satisfy both rules. \
2607 Second pass warnings: {result2:?}"
2608 );
2609 }
2610
2611 #[test]
2612 fn test_title_case_unicode_casefold_expansion_before_proper_name() {
2613 let rule = create_title_case_rule_with_proper_names(vec!["Österreich".to_string()]);
2614 let content = "# İ österreich guide\n";
2615 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2616 let result = rule.check(&ctx).unwrap();
2617 assert_eq!(result.len(), 1, "Should flag the heading");
2618 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2619 assert!(
2620 fix_text.contains("Österreich"),
2621 "Fix should preserve canonical proper-name casing, got: {fix_text:?}"
2622 );
2623 }
2624
2625 #[test]
2631 fn test_from_config_loads_md044_names_into_md063() {
2632 use crate::config::{Config, RuleConfig};
2633 use crate::rule::Rule;
2634 use std::collections::BTreeMap;
2635
2636 let mut config = Config::default();
2637
2638 let mut md063_values = BTreeMap::new();
2640 md063_values.insert("style".to_string(), toml::Value::String("sentence_case".to_string()));
2641 md063_values.insert("enabled".to_string(), toml::Value::Boolean(true));
2642 config.rules.insert(
2643 "MD063".to_string(),
2644 RuleConfig {
2645 values: md063_values,
2646 severity: None,
2647 },
2648 );
2649
2650 let mut md044_values = BTreeMap::new();
2652 md044_values.insert(
2653 "names".to_string(),
2654 toml::Value::Array(vec![toml::Value::String("Good Application".to_string())]),
2655 );
2656 config.rules.insert(
2657 "MD044".to_string(),
2658 RuleConfig {
2659 values: md044_values,
2660 severity: None,
2661 },
2662 );
2663
2664 let rule = MD063HeadingCapitalization::from_config(&config);
2666
2667 let content = "# using good application features\n";
2669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2670 let result = rule.check(&ctx).unwrap();
2671 assert_eq!(result.len(), 1, "Should flag the heading");
2672 let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
2673 assert!(
2674 fix_text.contains("Good Application"),
2675 "from_config should wire MD044 names into MD063; fix should preserve \
2676 'Good Application', got: {fix_text:?}"
2677 );
2678 }
2679
2680 #[test]
2681 fn test_title_case_short_word_not_confused_with_substring() {
2682 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2686
2687 let content = "# in the insert\n";
2690 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2691 let result = rule.check(&ctx).unwrap();
2692 assert_eq!(result.len(), 1, "Should flag the heading");
2693 let fix = result[0].fix.as_ref().expect("Fix should be present");
2694 assert!(
2696 fix.replacement.contains("In the Insert"),
2697 "Expected 'In the Insert', got: {:?}",
2698 fix.replacement
2699 );
2700 }
2701
2702 #[test]
2703 fn test_title_case_or_not_confused_with_orchestra() {
2704 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2705
2706 let content = "# or the orchestra\n";
2709 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2710 let result = rule.check(&ctx).unwrap();
2711 assert_eq!(result.len(), 1, "Should flag the heading");
2712 let fix = result[0].fix.as_ref().expect("Fix should be present");
2713 assert!(
2715 fix.replacement.contains("Or the Orchestra"),
2716 "Expected 'Or the Orchestra', got: {:?}",
2717 fix.replacement
2718 );
2719 }
2720
2721 #[test]
2722 fn test_all_caps_preserves_all_words() {
2723 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
2724
2725 let content = "# in the insert\n";
2726 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2727 let result = rule.check(&ctx).unwrap();
2728 assert_eq!(result.len(), 1, "Should flag the heading");
2729 let fix = result[0].fix.as_ref().expect("Fix should be present");
2730 assert!(
2731 fix.replacement.contains("IN THE INSERT"),
2732 "All caps should uppercase all words, got: {:?}",
2733 fix.replacement
2734 );
2735 }
2736
2737 #[test]
2739 fn test_title_case_numbered_prefix_lowercase_word() {
2740 let rule = create_rule();
2742 let content = "## 1. To Be a Thing\n";
2743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2744 let result = rule.check(&ctx).unwrap();
2745 assert!(
2746 result.is_empty(),
2747 "Should not flag '## 1. To Be a Thing', got: {result:?}"
2748 );
2749
2750 let content_lower = "## 1. to be a thing\n";
2751 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2752 let result2 = rule.check(&ctx2).unwrap();
2753 assert!(!result2.is_empty(), "Should flag '## 1. to be a thing'");
2754 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2755 assert!(
2756 fix.replacement.contains("1. To Be a Thing"),
2757 "Fix should capitalize 'To', got: {:?}",
2758 fix.replacement
2759 );
2760 }
2761
2762 #[test]
2763 fn test_title_case_numbered_prefix_article() {
2764 let rule = create_rule();
2766 let content = "## 2. A Guide to the Galaxy\n";
2767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2768 let result = rule.check(&ctx).unwrap();
2769 assert!(
2770 result.is_empty(),
2771 "Should not flag '## 2. A Guide to the Galaxy', got: {result:?}"
2772 );
2773
2774 let content_lower = "## 2. a guide to the galaxy\n";
2775 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2776 let result2 = rule.check(&ctx2).unwrap();
2777 assert!(!result2.is_empty(), "Should flag '## 2. a guide to the galaxy'");
2778 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2779 assert!(
2780 fix.replacement.contains("2. A Guide to the Galaxy"),
2781 "Fix should capitalize 'A', got: {:?}",
2782 fix.replacement
2783 );
2784 }
2785
2786 #[test]
2787 fn test_title_case_mid_sentence_period_word() {
2788 let rule = create_rule();
2790 let content = "## Step 1. Introduction to the Problem\n";
2791 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2792 let result = rule.check(&ctx).unwrap();
2793 assert!(
2794 result.is_empty(),
2795 "Should not flag '## Step 1. Introduction to the Problem', got: {result:?}"
2796 );
2797
2798 let content_lower = "## Step 1. introduction to the problem\n";
2799 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2800 let result2 = rule.check(&ctx2).unwrap();
2801 assert!(
2802 !result2.is_empty(),
2803 "Should flag '## Step 1. introduction to the problem'"
2804 );
2805 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2806 assert!(
2807 fix.replacement.contains("Step 1. Introduction to the Problem"),
2808 "Fix should capitalize 'Introduction', got: {:?}",
2809 fix.replacement
2810 );
2811 }
2812
2813 #[test]
2814 fn test_title_case_numbered_prefix_in_link_text() {
2815 let config = MD063Config {
2818 enabled: true,
2819 style: HeadingCapStyle::TitleCase,
2820 ..Default::default()
2821 };
2822 let rule = MD063HeadingCapitalization::from_config_struct(config);
2823
2824 let content = "## [1. To Be a Thing](https://example.com)\n";
2826 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2827 let result = rule.check(&ctx).unwrap();
2828 assert!(
2829 result.is_empty(),
2830 "Should not flag '## [1. To Be a Thing](url)', got: {result:?}"
2831 );
2832
2833 let content_lower = "## [1. to be a thing](https://example.com)\n";
2835 let ctx2 = LintContext::new(content_lower, crate::config::MarkdownFlavor::Standard, None);
2836 let result2 = rule.check(&ctx2).unwrap();
2837 assert!(!result2.is_empty(), "Should flag '## [1. to be a thing](url)'");
2838 let fix = result2[0].fix.as_ref().expect("Should have a fix");
2839 assert!(
2840 fix.replacement.contains("1. To Be a Thing"),
2841 "Fix should capitalize 'To' in link text, got: {:?}",
2842 fix.replacement
2843 );
2844 }
2845}