1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, 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 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}
68
69impl Default for MD063HeadingCapitalization {
70 fn default() -> Self {
71 Self::new()
72 }
73}
74
75impl MD063HeadingCapitalization {
76 pub fn new() -> Self {
77 let config = MD063Config::default();
78 let lowercase_set = config.lowercase_words.iter().cloned().collect();
79 Self { config, lowercase_set }
80 }
81
82 pub fn from_config_struct(config: MD063Config) -> Self {
83 let lowercase_set = config.lowercase_words.iter().cloned().collect();
84 Self { config, lowercase_set }
85 }
86
87 fn has_internal_capitals(&self, word: &str) -> bool {
89 let chars: Vec<char> = word.chars().collect();
90 if chars.len() < 2 {
91 return false;
92 }
93
94 let first = chars[0];
95 let rest = &chars[1..];
96 let has_upper_in_rest = rest.iter().any(|c| c.is_uppercase());
97 let has_lower_in_rest = rest.iter().any(|c| c.is_lowercase());
98
99 if has_upper_in_rest && has_lower_in_rest {
101 return true;
102 }
103
104 if first.is_lowercase() && has_upper_in_rest {
106 return true;
107 }
108
109 false
110 }
111
112 fn is_all_caps_acronym(&self, word: &str) -> bool {
116 if word.len() < 2 {
118 return false;
119 }
120
121 let mut consecutive_upper = 0;
122 let mut max_consecutive = 0;
123
124 for c in word.chars() {
125 if c.is_uppercase() {
126 consecutive_upper += 1;
127 max_consecutive = max_consecutive.max(consecutive_upper);
128 } else if c.is_lowercase() {
129 return false;
131 } else {
132 consecutive_upper = 0;
134 }
135 }
136
137 max_consecutive >= 2
139 }
140
141 fn should_preserve_word(&self, word: &str) -> bool {
143 if self.config.ignore_words.iter().any(|w| w == word) {
145 return true;
146 }
147
148 if self.config.preserve_cased_words && self.has_internal_capitals(word) {
150 return true;
151 }
152
153 if self.config.preserve_cased_words && self.is_all_caps_acronym(word) {
155 return true;
156 }
157
158 if self.is_caret_notation(word) {
160 return true;
161 }
162
163 false
164 }
165
166 fn is_caret_notation(&self, word: &str) -> bool {
168 let chars: Vec<char> = word.chars().collect();
169 if chars.len() >= 2 && chars[0] == '^' {
171 let second = chars[1];
172 if second.is_ascii_uppercase() || "@[\\]^_".contains(second) {
174 return true;
175 }
176 }
177 false
178 }
179
180 fn is_lowercase_word(&self, word: &str) -> bool {
182 self.lowercase_set.contains(&word.to_lowercase())
183 }
184
185 fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
187 if word.is_empty() {
188 return word.to_string();
189 }
190
191 if self.should_preserve_word(word) {
193 return word.to_string();
194 }
195
196 if is_first || is_last {
198 return self.capitalize_first(word);
199 }
200
201 if self.is_lowercase_word(word) {
203 return word.to_lowercase();
204 }
205
206 self.capitalize_first(word)
208 }
209
210 fn capitalize_first(&self, word: &str) -> String {
212 let mut chars = word.chars();
213 match chars.next() {
214 None => String::new(),
215 Some(first) => {
216 let first_upper: String = first.to_uppercase().collect();
217 let rest: String = chars.collect();
218 format!("{}{}", first_upper, rest.to_lowercase())
219 }
220 }
221 }
222
223 fn apply_title_case(&self, text: &str) -> String {
225 let base_result = titlecase::titlecase(text);
227
228 let original_words: Vec<&str> = text.split_whitespace().collect();
230 let transformed_words: Vec<&str> = base_result.split_whitespace().collect();
231 let total_words = transformed_words.len();
232
233 let result_words: Vec<String> = transformed_words
234 .iter()
235 .enumerate()
236 .map(|(i, word)| {
237 let is_first = i == 0;
238 let is_last = i == total_words - 1;
239
240 if let Some(original_word) = original_words.get(i)
242 && self.should_preserve_word(original_word)
243 {
244 return (*original_word).to_string();
245 }
246
247 if word.contains('-') {
249 if let Some(original_word) = original_words.get(i) {
251 return self.handle_hyphenated_word_with_original(word, original_word, is_first, is_last);
252 }
253 return self.handle_hyphenated_word(word, is_first, is_last);
254 }
255
256 self.title_case_word(word, is_first, is_last)
257 })
258 .collect();
259
260 result_words.join(" ")
261 }
262
263 fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
265 let parts: Vec<&str> = word.split('-').collect();
266 let total_parts = parts.len();
267
268 let result_parts: Vec<String> = parts
269 .iter()
270 .enumerate()
271 .map(|(i, part)| {
272 let part_is_first = is_first && i == 0;
274 let part_is_last = is_last && i == total_parts - 1;
275 self.title_case_word(part, part_is_first, part_is_last)
276 })
277 .collect();
278
279 result_parts.join("-")
280 }
281
282 fn handle_hyphenated_word_with_original(
284 &self,
285 word: &str,
286 original: &str,
287 is_first: bool,
288 is_last: bool,
289 ) -> String {
290 let parts: Vec<&str> = word.split('-').collect();
291 let original_parts: Vec<&str> = original.split('-').collect();
292 let total_parts = parts.len();
293
294 let result_parts: Vec<String> = parts
295 .iter()
296 .enumerate()
297 .map(|(i, part)| {
298 if let Some(original_part) = original_parts.get(i)
300 && self.should_preserve_word(original_part)
301 {
302 return (*original_part).to_string();
303 }
304
305 let part_is_first = is_first && i == 0;
307 let part_is_last = is_last && i == total_parts - 1;
308 self.title_case_word(part, part_is_first, part_is_last)
309 })
310 .collect();
311
312 result_parts.join("-")
313 }
314
315 fn apply_sentence_case(&self, text: &str) -> String {
317 if text.is_empty() {
318 return text.to_string();
319 }
320
321 let mut result = String::new();
322 let mut current_pos = 0;
323 let mut is_first_word = true;
324
325 for word in text.split_whitespace() {
327 if let Some(pos) = text[current_pos..].find(word) {
328 let abs_pos = current_pos + pos;
329
330 result.push_str(&text[current_pos..abs_pos]);
332
333 if is_first_word {
335 if self.should_preserve_word(word) {
337 result.push_str(word);
339 } else {
340 let mut chars = word.chars();
342 if let Some(first) = chars.next() {
343 let first_upper: String = first.to_uppercase().collect();
344 result.push_str(&first_upper);
345 let rest: String = chars.collect();
346 result.push_str(&rest.to_lowercase());
347 }
348 }
349 is_first_word = false;
350 } else {
351 if self.should_preserve_word(word) {
353 result.push_str(word);
354 } else {
355 result.push_str(&word.to_lowercase());
356 }
357 }
358
359 current_pos = abs_pos + word.len();
360 }
361 }
362
363 if current_pos < text.len() {
365 result.push_str(&text[current_pos..]);
366 }
367
368 result
369 }
370
371 fn apply_all_caps(&self, text: &str) -> String {
373 if text.is_empty() {
374 return text.to_string();
375 }
376
377 let mut result = String::new();
378 let mut current_pos = 0;
379
380 for word in text.split_whitespace() {
382 if let Some(pos) = text[current_pos..].find(word) {
383 let abs_pos = current_pos + pos;
384
385 result.push_str(&text[current_pos..abs_pos]);
387
388 if self.should_preserve_word(word) {
390 result.push_str(word);
391 } else {
392 result.push_str(&word.to_uppercase());
393 }
394
395 current_pos = abs_pos + word.len();
396 }
397 }
398
399 if current_pos < text.len() {
401 result.push_str(&text[current_pos..]);
402 }
403
404 result
405 }
406
407 fn parse_segments(&self, text: &str) -> Vec<HeadingSegment> {
409 let mut segments = Vec::new();
410 let mut last_end = 0;
411
412 let mut special_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
414
415 for mat in INLINE_CODE_REGEX.find_iter(text) {
417 special_regions.push((mat.start(), mat.end(), HeadingSegment::Code(mat.as_str().to_string())));
418 }
419
420 for caps in LINK_REGEX.captures_iter(text) {
422 let full_match = caps.get(0).unwrap();
423 let text_match = caps.get(1).or_else(|| caps.get(2));
424
425 if let Some(text_m) = text_match {
426 special_regions.push((
427 full_match.start(),
428 full_match.end(),
429 HeadingSegment::Link {
430 full: full_match.as_str().to_string(),
431 text_start: text_m.start() - full_match.start(),
432 text_end: text_m.end() - full_match.start(),
433 },
434 ));
435 }
436 }
437
438 for mat in HTML_TAG_REGEX.find_iter(text) {
440 special_regions.push((mat.start(), mat.end(), HeadingSegment::Html(mat.as_str().to_string())));
441 }
442
443 special_regions.sort_by_key(|(start, _, _)| *start);
445
446 let mut filtered_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
448 for region in special_regions {
449 let overlaps = filtered_regions.iter().any(|(s, e, _)| region.0 < *e && region.1 > *s);
450 if !overlaps {
451 filtered_regions.push(region);
452 }
453 }
454
455 for (start, end, segment) in filtered_regions {
457 if start > last_end {
459 let text_segment = &text[last_end..start];
460 if !text_segment.is_empty() {
461 segments.push(HeadingSegment::Text(text_segment.to_string()));
462 }
463 }
464 segments.push(segment);
465 last_end = end;
466 }
467
468 if last_end < text.len() {
470 let remaining = &text[last_end..];
471 if !remaining.is_empty() {
472 segments.push(HeadingSegment::Text(remaining.to_string()));
473 }
474 }
475
476 if segments.is_empty() && !text.is_empty() {
478 segments.push(HeadingSegment::Text(text.to_string()));
479 }
480
481 segments
482 }
483
484 fn apply_capitalization(&self, text: &str) -> String {
486 let (main_text, custom_id) = if let Some(mat) = CUSTOM_ID_REGEX.find(text) {
488 (&text[..mat.start()], Some(mat.as_str()))
489 } else {
490 (text, None)
491 };
492
493 let segments = self.parse_segments(main_text);
495
496 let text_segments: Vec<usize> = segments
498 .iter()
499 .enumerate()
500 .filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
501 .collect();
502
503 let first_segment_is_text = segments
507 .first()
508 .map(|s| matches!(s, HeadingSegment::Text(_)))
509 .unwrap_or(false);
510
511 let last_segment_is_text = segments
515 .last()
516 .map(|s| matches!(s, HeadingSegment::Text(_)))
517 .unwrap_or(false);
518
519 let mut result_parts: Vec<String> = Vec::new();
521
522 for (i, segment) in segments.iter().enumerate() {
523 match segment {
524 HeadingSegment::Text(t) => {
525 let is_first_text = text_segments.first() == Some(&i);
526 let is_last_text = text_segments.last() == Some(&i) && last_segment_is_text;
530
531 let capitalized = match self.config.style {
532 HeadingCapStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
533 HeadingCapStyle::SentenceCase => {
534 if is_first_text && first_segment_is_text {
538 self.apply_sentence_case(t)
539 } else {
540 self.apply_sentence_case_non_first(t)
542 }
543 }
544 HeadingCapStyle::AllCaps => self.apply_all_caps(t),
545 };
546 result_parts.push(capitalized);
547 }
548 HeadingSegment::Code(c) => {
549 result_parts.push(c.clone());
550 }
551 HeadingSegment::Link {
552 full,
553 text_start,
554 text_end,
555 } => {
556 let link_text = &full[*text_start..*text_end];
558 let capitalized_text = match self.config.style {
559 HeadingCapStyle::TitleCase => self.apply_title_case(link_text),
560 HeadingCapStyle::SentenceCase => self.apply_sentence_case_non_first(link_text),
563 HeadingCapStyle::AllCaps => self.apply_all_caps(link_text),
564 };
565
566 let mut new_link = String::new();
567 new_link.push_str(&full[..*text_start]);
568 new_link.push_str(&capitalized_text);
569 new_link.push_str(&full[*text_end..]);
570 result_parts.push(new_link);
571 }
572 HeadingSegment::Html(h) => {
573 result_parts.push(h.clone());
575 }
576 }
577 }
578
579 let mut result = result_parts.join("");
580
581 if let Some(id) = custom_id {
583 result.push_str(id);
584 }
585
586 result
587 }
588
589 fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
591 let words: Vec<&str> = text.split_whitespace().collect();
592 let total_words = words.len();
593
594 if total_words == 0 {
595 return text.to_string();
596 }
597
598 let result_words: Vec<String> = words
599 .iter()
600 .enumerate()
601 .map(|(i, word)| {
602 let is_first = is_first_segment && i == 0;
603 let is_last = is_last_segment && i == total_words - 1;
604
605 if word.contains('-') {
607 return self.handle_hyphenated_word(word, is_first, is_last);
608 }
609
610 self.title_case_word(word, is_first, is_last)
611 })
612 .collect();
613
614 let mut result = String::new();
616 let mut word_iter = result_words.iter();
617 let mut in_word = false;
618
619 for c in text.chars() {
620 if c.is_whitespace() {
621 if in_word {
622 in_word = false;
623 }
624 result.push(c);
625 } else if !in_word {
626 if let Some(word) = word_iter.next() {
627 result.push_str(word);
628 }
629 in_word = true;
630 }
631 }
632
633 result
634 }
635
636 fn apply_sentence_case_non_first(&self, text: &str) -> String {
638 if text.is_empty() {
639 return text.to_string();
640 }
641
642 let lower = text.to_lowercase();
643 let mut result = String::new();
644 let mut current_pos = 0;
645
646 for word in lower.split_whitespace() {
647 if let Some(pos) = lower[current_pos..].find(word) {
648 let abs_pos = current_pos + pos;
649
650 result.push_str(&lower[current_pos..abs_pos]);
652
653 let original_word = &text[abs_pos..abs_pos + word.len()];
655 if self.should_preserve_word(original_word) {
656 result.push_str(original_word);
657 } else {
658 result.push_str(word);
659 }
660
661 current_pos = abs_pos + word.len();
662 }
663 }
664
665 if current_pos < lower.len() {
667 result.push_str(&lower[current_pos..]);
668 }
669
670 result
671 }
672
673 fn get_line_byte_range(&self, content: &str, line_num: usize, line_index: &LineIndex) -> Range<usize> {
675 let start_pos = line_index.get_line_start_byte(line_num).unwrap_or(content.len());
676 let line = content.lines().nth(line_num - 1).unwrap_or("");
677 Range {
678 start: start_pos,
679 end: start_pos + line.len(),
680 }
681 }
682
683 fn fix_atx_heading(&self, _line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
685 let indent = " ".repeat(heading.marker_column);
687 let hashes = "#".repeat(heading.level as usize);
688
689 let fixed_text = self.apply_capitalization(&heading.raw_text);
691
692 let closing = &heading.closing_sequence;
694 if heading.has_closing_sequence {
695 format!("{indent}{hashes} {fixed_text} {closing}")
696 } else {
697 format!("{indent}{hashes} {fixed_text}")
698 }
699 }
700
701 fn fix_setext_heading(&self, line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
703 let fixed_text = self.apply_capitalization(&heading.raw_text);
705
706 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
708
709 format!("{leading_ws}{fixed_text}")
710 }
711}
712
713impl Rule for MD063HeadingCapitalization {
714 fn name(&self) -> &'static str {
715 "MD063"
716 }
717
718 fn description(&self) -> &'static str {
719 "Heading capitalization"
720 }
721
722 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
723 !self.config.enabled || !ctx.likely_has_headings() || !ctx.lines.iter().any(|line| line.heading.is_some())
725 }
726
727 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
728 if !self.config.enabled {
729 return Ok(Vec::new());
730 }
731
732 let content = ctx.content;
733
734 if content.is_empty() {
735 return Ok(Vec::new());
736 }
737
738 let mut warnings = Vec::new();
739 let line_index = &ctx.line_index;
740
741 for (line_num, line_info) in ctx.lines.iter().enumerate() {
742 if let Some(heading) = &line_info.heading {
743 if heading.level < self.config.min_level || heading.level > self.config.max_level {
745 continue;
746 }
747
748 if line_info.indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
750 continue;
751 }
752
753 let original_text = &heading.raw_text;
755 let fixed_text = self.apply_capitalization(original_text);
756
757 if original_text != &fixed_text {
758 let line = line_info.content(ctx.content);
759 let style_name = match self.config.style {
760 HeadingCapStyle::TitleCase => "title case",
761 HeadingCapStyle::SentenceCase => "sentence case",
762 HeadingCapStyle::AllCaps => "ALL CAPS",
763 };
764
765 warnings.push(LintWarning {
766 rule_name: Some(self.name().to_string()),
767 line: line_num + 1,
768 column: heading.content_column + 1,
769 end_line: line_num + 1,
770 end_column: heading.content_column + 1 + original_text.len(),
771 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
772 severity: Severity::Warning,
773 fix: Some(Fix {
774 range: self.get_line_byte_range(content, line_num + 1, line_index),
775 replacement: match heading.style {
776 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
777 _ => self.fix_setext_heading(line, heading),
778 },
779 }),
780 });
781 }
782 }
783 }
784
785 Ok(warnings)
786 }
787
788 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
789 if !self.config.enabled {
790 return Ok(ctx.content.to_string());
791 }
792
793 let content = ctx.content;
794
795 if content.is_empty() {
796 return Ok(content.to_string());
797 }
798
799 let lines: Vec<&str> = content.lines().collect();
800 let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
801
802 for (line_num, line_info) in ctx.lines.iter().enumerate() {
803 if let Some(heading) = &line_info.heading {
804 if heading.level < self.config.min_level || heading.level > self.config.max_level {
806 continue;
807 }
808
809 if line_info.indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
811 continue;
812 }
813
814 let original_text = &heading.raw_text;
815 let fixed_text = self.apply_capitalization(original_text);
816
817 if original_text != &fixed_text {
818 let line = line_info.content(ctx.content);
819 fixed_lines[line_num] = match heading.style {
820 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
821 _ => self.fix_setext_heading(line, heading),
822 };
823 }
824 }
825 }
826
827 let mut result = String::with_capacity(content.len());
829 for (i, line) in fixed_lines.iter().enumerate() {
830 result.push_str(line);
831 if i < fixed_lines.len() - 1 || content.ends_with('\n') {
832 result.push('\n');
833 }
834 }
835
836 Ok(result)
837 }
838
839 fn as_any(&self) -> &dyn std::any::Any {
840 self
841 }
842
843 fn default_config_section(&self) -> Option<(String, toml::Value)> {
844 let json_value = serde_json::to_value(&self.config).ok()?;
845 Some((
846 self.name().to_string(),
847 crate::rule_config_serde::json_to_toml_value(&json_value)?,
848 ))
849 }
850
851 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
852 where
853 Self: Sized,
854 {
855 let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
856 Box::new(Self::from_config_struct(rule_config))
857 }
858}
859
860#[cfg(test)]
861mod tests {
862 use super::*;
863 use crate::lint_context::LintContext;
864
865 fn create_rule() -> MD063HeadingCapitalization {
866 let config = MD063Config {
867 enabled: true,
868 ..Default::default()
869 };
870 MD063HeadingCapitalization::from_config_struct(config)
871 }
872
873 fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
874 let config = MD063Config {
875 enabled: true,
876 style,
877 ..Default::default()
878 };
879 MD063HeadingCapitalization::from_config_struct(config)
880 }
881
882 #[test]
884 fn test_title_case_basic() {
885 let rule = create_rule();
886 let content = "# hello world\n";
887 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888 let result = rule.check(&ctx).unwrap();
889 assert_eq!(result.len(), 1);
890 assert!(result[0].message.contains("Hello World"));
891 }
892
893 #[test]
894 fn test_title_case_lowercase_words() {
895 let rule = create_rule();
896 let content = "# the quick brown fox\n";
897 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898 let result = rule.check(&ctx).unwrap();
899 assert_eq!(result.len(), 1);
900 assert!(result[0].message.contains("The Quick Brown Fox"));
902 }
903
904 #[test]
905 fn test_title_case_already_correct() {
906 let rule = create_rule();
907 let content = "# The Quick Brown Fox\n";
908 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
909 let result = rule.check(&ctx).unwrap();
910 assert!(result.is_empty(), "Already correct heading should not be flagged");
911 }
912
913 #[test]
914 fn test_title_case_hyphenated() {
915 let rule = create_rule();
916 let content = "# self-documenting code\n";
917 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
918 let result = rule.check(&ctx).unwrap();
919 assert_eq!(result.len(), 1);
920 assert!(result[0].message.contains("Self-Documenting Code"));
921 }
922
923 #[test]
925 fn test_sentence_case_basic() {
926 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
927 let content = "# The Quick Brown Fox\n";
928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
929 let result = rule.check(&ctx).unwrap();
930 assert_eq!(result.len(), 1);
931 assert!(result[0].message.contains("The quick brown fox"));
932 }
933
934 #[test]
935 fn test_sentence_case_already_correct() {
936 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
937 let content = "# The quick brown fox\n";
938 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
939 let result = rule.check(&ctx).unwrap();
940 assert!(result.is_empty());
941 }
942
943 #[test]
945 fn test_all_caps_basic() {
946 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
947 let content = "# hello world\n";
948 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
949 let result = rule.check(&ctx).unwrap();
950 assert_eq!(result.len(), 1);
951 assert!(result[0].message.contains("HELLO WORLD"));
952 }
953
954 #[test]
956 fn test_preserve_ignore_words() {
957 let config = MD063Config {
958 enabled: true,
959 ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
960 ..Default::default()
961 };
962 let rule = MD063HeadingCapitalization::from_config_struct(config);
963
964 let content = "# using iPhone on macOS\n";
965 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
966 let result = rule.check(&ctx).unwrap();
967 assert_eq!(result.len(), 1);
968 assert!(result[0].message.contains("iPhone"));
970 assert!(result[0].message.contains("macOS"));
971 }
972
973 #[test]
974 fn test_preserve_cased_words() {
975 let rule = create_rule();
976 let content = "# using GitHub actions\n";
977 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
978 let result = rule.check(&ctx).unwrap();
979 assert_eq!(result.len(), 1);
980 assert!(result[0].message.contains("GitHub"));
982 }
983
984 #[test]
986 fn test_inline_code_preserved() {
987 let rule = create_rule();
988 let content = "# using `const` in javascript\n";
989 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
990 let result = rule.check(&ctx).unwrap();
991 assert_eq!(result.len(), 1);
992 assert!(result[0].message.contains("`const`"));
994 assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
995 }
996
997 #[test]
999 fn test_level_filter() {
1000 let config = MD063Config {
1001 enabled: true,
1002 min_level: 2,
1003 max_level: 4,
1004 ..Default::default()
1005 };
1006 let rule = MD063HeadingCapitalization::from_config_struct(config);
1007
1008 let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
1009 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1010 let result = rule.check(&ctx).unwrap();
1011
1012 assert_eq!(result.len(), 2);
1014 assert_eq!(result[0].line, 2); assert_eq!(result[1].line, 3); }
1017
1018 #[test]
1020 fn test_fix_atx_heading() {
1021 let rule = create_rule();
1022 let content = "# hello world\n";
1023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1024 let fixed = rule.fix(&ctx).unwrap();
1025 assert_eq!(fixed, "# Hello World\n");
1026 }
1027
1028 #[test]
1029 fn test_fix_multiple_headings() {
1030 let rule = create_rule();
1031 let content = "# first heading\n\n## second heading\n";
1032 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1033 let fixed = rule.fix(&ctx).unwrap();
1034 assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
1035 }
1036
1037 #[test]
1039 fn test_setext_heading() {
1040 let rule = create_rule();
1041 let content = "hello world\n============\n";
1042 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1043 let result = rule.check(&ctx).unwrap();
1044 assert_eq!(result.len(), 1);
1045 assert!(result[0].message.contains("Hello World"));
1046 }
1047
1048 #[test]
1050 fn test_custom_id_preserved() {
1051 let rule = create_rule();
1052 let content = "# getting started {#intro}\n";
1053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1054 let result = rule.check(&ctx).unwrap();
1055 assert_eq!(result.len(), 1);
1056 assert!(result[0].message.contains("{#intro}"));
1058 }
1059
1060 #[test]
1061 fn test_md063_disabled_by_default() {
1062 let rule = MD063HeadingCapitalization::new();
1063 let content = "# hello world\n";
1064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1065
1066 let warnings = rule.check(&ctx).unwrap();
1068 assert_eq!(warnings.len(), 0);
1069
1070 let fixed = rule.fix(&ctx).unwrap();
1072 assert_eq!(fixed, content);
1073 }
1074
1075 #[test]
1077 fn test_preserve_all_caps_acronyms() {
1078 let rule = create_rule();
1079 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1080
1081 let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
1083 assert_eq!(fixed, "# Using API in Production\n");
1084
1085 let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
1087 assert_eq!(fixed, "# API and GPU Integration\n");
1088
1089 let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
1091 assert_eq!(fixed, "# IO Performance Guide\n");
1092
1093 let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
1095 assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
1096 }
1097
1098 #[test]
1099 fn test_preserve_acronyms_in_hyphenated_words() {
1100 let rule = create_rule();
1101 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1102
1103 let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
1105 assert_eq!(fixed, "# API-Driven Architecture\n");
1106
1107 let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
1109 assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
1110 }
1111
1112 #[test]
1113 fn test_single_letters_not_treated_as_acronyms() {
1114 let rule = create_rule();
1115 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1116
1117 let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
1119 assert_eq!(fixed, "# I Am a Heading\n");
1120 }
1121
1122 #[test]
1123 fn test_lowercase_terms_need_ignore_words() {
1124 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1125
1126 let rule = create_rule();
1128 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1129 assert_eq!(fixed, "# Using Npm Packages\n");
1130
1131 let config = MD063Config {
1133 enabled: true,
1134 ignore_words: vec!["npm".to_string()],
1135 ..Default::default()
1136 };
1137 let rule = MD063HeadingCapitalization::from_config_struct(config);
1138 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1139 assert_eq!(fixed, "# Using npm Packages\n");
1140 }
1141
1142 #[test]
1143 fn test_acronyms_with_mixed_case_preserved() {
1144 let rule = create_rule();
1145 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1146
1147 let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
1149 assert_eq!(fixed, "# Using API with GitHub\n");
1150 }
1151
1152 #[test]
1153 fn test_real_world_acronyms() {
1154 let rule = create_rule();
1155 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1156
1157 let content = "# FFI bindings for CPU optimization\n";
1159 let fixed = rule.fix(&ctx(content)).unwrap();
1160 assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
1161
1162 let content = "# DOM manipulation and SSR rendering\n";
1163 let fixed = rule.fix(&ctx(content)).unwrap();
1164 assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
1165
1166 let content = "# CVE security and RNN models\n";
1167 let fixed = rule.fix(&ctx(content)).unwrap();
1168 assert_eq!(fixed, "# CVE Security and RNN Models\n");
1169 }
1170
1171 #[test]
1172 fn test_is_all_caps_acronym() {
1173 let rule = create_rule();
1174
1175 assert!(rule.is_all_caps_acronym("API"));
1177 assert!(rule.is_all_caps_acronym("IO"));
1178 assert!(rule.is_all_caps_acronym("GPU"));
1179 assert!(rule.is_all_caps_acronym("HTTP2")); assert!(!rule.is_all_caps_acronym("A"));
1183 assert!(!rule.is_all_caps_acronym("I"));
1184
1185 assert!(!rule.is_all_caps_acronym("Api"));
1187 assert!(!rule.is_all_caps_acronym("npm"));
1188 assert!(!rule.is_all_caps_acronym("iPhone"));
1189 }
1190
1191 #[test]
1192 fn test_sentence_case_ignore_words_first_word() {
1193 let config = MD063Config {
1194 enabled: true,
1195 style: HeadingCapStyle::SentenceCase,
1196 ignore_words: vec!["nvim".to_string()],
1197 ..Default::default()
1198 };
1199 let rule = MD063HeadingCapitalization::from_config_struct(config);
1200
1201 let content = "# nvim config\n";
1203 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1204 let result = rule.check(&ctx).unwrap();
1205 assert!(
1206 result.is_empty(),
1207 "nvim in ignore-words should not be flagged. Got: {result:?}"
1208 );
1209
1210 let fixed = rule.fix(&ctx).unwrap();
1212 assert_eq!(fixed, "# nvim config\n");
1213 }
1214
1215 #[test]
1216 fn test_sentence_case_ignore_words_not_first() {
1217 let config = MD063Config {
1218 enabled: true,
1219 style: HeadingCapStyle::SentenceCase,
1220 ignore_words: vec!["nvim".to_string()],
1221 ..Default::default()
1222 };
1223 let rule = MD063HeadingCapitalization::from_config_struct(config);
1224
1225 let content = "# Using nvim editor\n";
1227 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1228 let result = rule.check(&ctx).unwrap();
1229 assert!(
1230 result.is_empty(),
1231 "nvim in ignore-words should be preserved. Got: {result:?}"
1232 );
1233 }
1234
1235 #[test]
1236 fn test_preserve_cased_words_ios() {
1237 let config = MD063Config {
1238 enabled: true,
1239 style: HeadingCapStyle::SentenceCase,
1240 preserve_cased_words: true,
1241 ..Default::default()
1242 };
1243 let rule = MD063HeadingCapitalization::from_config_struct(config);
1244
1245 let content = "## This is iOS\n";
1247 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1248 let result = rule.check(&ctx).unwrap();
1249 assert!(
1250 result.is_empty(),
1251 "iOS should be preserved with preserve-cased-words. Got: {result:?}"
1252 );
1253
1254 let fixed = rule.fix(&ctx).unwrap();
1256 assert_eq!(fixed, "## This is iOS\n");
1257 }
1258
1259 #[test]
1260 fn test_preserve_cased_words_ios_title_case() {
1261 let config = MD063Config {
1262 enabled: true,
1263 style: HeadingCapStyle::TitleCase,
1264 preserve_cased_words: true,
1265 ..Default::default()
1266 };
1267 let rule = MD063HeadingCapitalization::from_config_struct(config);
1268
1269 let content = "# developing for iOS\n";
1271 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1272 let fixed = rule.fix(&ctx).unwrap();
1273 assert_eq!(fixed, "# Developing for iOS\n");
1274 }
1275
1276 #[test]
1277 fn test_has_internal_capitals_ios() {
1278 let rule = create_rule();
1279
1280 assert!(
1282 rule.has_internal_capitals("iOS"),
1283 "iOS has mixed case (lowercase i, uppercase OS)"
1284 );
1285
1286 assert!(rule.has_internal_capitals("iPhone"));
1288 assert!(rule.has_internal_capitals("macOS"));
1289 assert!(rule.has_internal_capitals("GitHub"));
1290 assert!(rule.has_internal_capitals("JavaScript"));
1291 assert!(rule.has_internal_capitals("eBay"));
1292
1293 assert!(!rule.has_internal_capitals("API"));
1295 assert!(!rule.has_internal_capitals("GPU"));
1296
1297 assert!(!rule.has_internal_capitals("npm"));
1299 assert!(!rule.has_internal_capitals("config"));
1300
1301 assert!(!rule.has_internal_capitals("The"));
1303 assert!(!rule.has_internal_capitals("Hello"));
1304 }
1305
1306 #[test]
1307 fn test_lowercase_words_before_trailing_code() {
1308 let config = MD063Config {
1309 enabled: true,
1310 style: HeadingCapStyle::TitleCase,
1311 lowercase_words: vec![
1312 "a".to_string(),
1313 "an".to_string(),
1314 "and".to_string(),
1315 "at".to_string(),
1316 "but".to_string(),
1317 "by".to_string(),
1318 "for".to_string(),
1319 "from".to_string(),
1320 "into".to_string(),
1321 "nor".to_string(),
1322 "on".to_string(),
1323 "onto".to_string(),
1324 "or".to_string(),
1325 "the".to_string(),
1326 "to".to_string(),
1327 "upon".to_string(),
1328 "via".to_string(),
1329 "vs".to_string(),
1330 "with".to_string(),
1331 "without".to_string(),
1332 ],
1333 preserve_cased_words: true,
1334 ..Default::default()
1335 };
1336 let rule = MD063HeadingCapitalization::from_config_struct(config);
1337
1338 let content = "## subtitle with a `app`\n";
1343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1344 let result = rule.check(&ctx).unwrap();
1345
1346 assert!(!result.is_empty(), "Should flag incorrect capitalization");
1348 let fixed = rule.fix(&ctx).unwrap();
1349 assert!(
1351 fixed.contains("with a `app`"),
1352 "Expected 'with a `app`' but got: {fixed:?}"
1353 );
1354 assert!(
1355 !fixed.contains("with A `app`"),
1356 "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
1357 );
1358 assert!(
1360 fixed.contains("Subtitle with a `app`"),
1361 "Expected 'Subtitle with a `app`' but got: {fixed:?}"
1362 );
1363 }
1364
1365 #[test]
1366 fn test_lowercase_words_preserved_before_trailing_code_variant() {
1367 let config = MD063Config {
1368 enabled: true,
1369 style: HeadingCapStyle::TitleCase,
1370 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1371 ..Default::default()
1372 };
1373 let rule = MD063HeadingCapitalization::from_config_struct(config);
1374
1375 let content = "## Title with the `code`\n";
1377 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1378 let fixed = rule.fix(&ctx).unwrap();
1379 assert!(
1381 fixed.contains("with the `code`"),
1382 "Expected 'with the `code`' but got: {fixed:?}"
1383 );
1384 assert!(
1385 !fixed.contains("with The `code`"),
1386 "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
1387 );
1388 }
1389
1390 #[test]
1391 fn test_last_word_capitalized_when_no_trailing_code() {
1392 let config = MD063Config {
1395 enabled: true,
1396 style: HeadingCapStyle::TitleCase,
1397 lowercase_words: vec!["a".to_string(), "the".to_string()],
1398 ..Default::default()
1399 };
1400 let rule = MD063HeadingCapitalization::from_config_struct(config);
1401
1402 let content = "## title with a word\n";
1405 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1406 let fixed = rule.fix(&ctx).unwrap();
1407 assert!(
1409 fixed.contains("With a Word"),
1410 "Expected 'With a Word' but got: {fixed:?}"
1411 );
1412 }
1413
1414 #[test]
1415 fn test_multiple_lowercase_words_before_code() {
1416 let config = MD063Config {
1417 enabled: true,
1418 style: HeadingCapStyle::TitleCase,
1419 lowercase_words: vec![
1420 "a".to_string(),
1421 "the".to_string(),
1422 "with".to_string(),
1423 "for".to_string(),
1424 ],
1425 ..Default::default()
1426 };
1427 let rule = MD063HeadingCapitalization::from_config_struct(config);
1428
1429 let content = "## Guide for the `user`\n";
1431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1432 let fixed = rule.fix(&ctx).unwrap();
1433 assert!(
1434 fixed.contains("for the `user`"),
1435 "Expected 'for the `user`' but got: {fixed:?}"
1436 );
1437 assert!(
1438 !fixed.contains("For The `user`"),
1439 "Should not capitalize lowercase words before code. Got: {fixed:?}"
1440 );
1441 }
1442
1443 #[test]
1444 fn test_code_in_middle_normal_rules_apply() {
1445 let config = MD063Config {
1446 enabled: true,
1447 style: HeadingCapStyle::TitleCase,
1448 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1449 ..Default::default()
1450 };
1451 let rule = MD063HeadingCapitalization::from_config_struct(config);
1452
1453 let content = "## Using `const` for the code\n";
1455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1456 let fixed = rule.fix(&ctx).unwrap();
1457 assert!(
1459 fixed.contains("for the Code"),
1460 "Expected 'for the Code' but got: {fixed:?}"
1461 );
1462 }
1463
1464 #[test]
1465 fn test_link_at_end_same_as_code() {
1466 let config = MD063Config {
1467 enabled: true,
1468 style: HeadingCapStyle::TitleCase,
1469 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1470 ..Default::default()
1471 };
1472 let rule = MD063HeadingCapitalization::from_config_struct(config);
1473
1474 let content = "## Guide for the [link](./page.md)\n";
1476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1477 let fixed = rule.fix(&ctx).unwrap();
1478 assert!(
1480 fixed.contains("for the [Link]"),
1481 "Expected 'for the [Link]' but got: {fixed:?}"
1482 );
1483 assert!(
1484 !fixed.contains("for The [Link]"),
1485 "Should not capitalize 'the' before link. Got: {fixed:?}"
1486 );
1487 }
1488
1489 #[test]
1490 fn test_multiple_code_segments() {
1491 let config = MD063Config {
1492 enabled: true,
1493 style: HeadingCapStyle::TitleCase,
1494 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1495 ..Default::default()
1496 };
1497 let rule = MD063HeadingCapitalization::from_config_struct(config);
1498
1499 let content = "## Using `const` with a `variable`\n";
1501 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1502 let fixed = rule.fix(&ctx).unwrap();
1503 assert!(
1505 fixed.contains("with a `variable`"),
1506 "Expected 'with a `variable`' but got: {fixed:?}"
1507 );
1508 assert!(
1509 !fixed.contains("with A `variable`"),
1510 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1511 );
1512 }
1513
1514 #[test]
1515 fn test_code_and_link_combination() {
1516 let config = MD063Config {
1517 enabled: true,
1518 style: HeadingCapStyle::TitleCase,
1519 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1520 ..Default::default()
1521 };
1522 let rule = MD063HeadingCapitalization::from_config_struct(config);
1523
1524 let content = "## Guide for the `code` [link](./page.md)\n";
1526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1527 let fixed = rule.fix(&ctx).unwrap();
1528 assert!(
1530 fixed.contains("for the `code`"),
1531 "Expected 'for the `code`' but got: {fixed:?}"
1532 );
1533 }
1534
1535 #[test]
1536 fn test_text_after_code_capitalizes_last() {
1537 let config = MD063Config {
1538 enabled: true,
1539 style: HeadingCapStyle::TitleCase,
1540 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1541 ..Default::default()
1542 };
1543 let rule = MD063HeadingCapitalization::from_config_struct(config);
1544
1545 let content = "## Using `const` for the code\n";
1547 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1548 let fixed = rule.fix(&ctx).unwrap();
1549 assert!(
1551 fixed.contains("for the Code"),
1552 "Expected 'for the Code' but got: {fixed:?}"
1553 );
1554 }
1555
1556 #[test]
1557 fn test_preserve_cased_words_with_trailing_code() {
1558 let config = MD063Config {
1559 enabled: true,
1560 style: HeadingCapStyle::TitleCase,
1561 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1562 preserve_cased_words: true,
1563 ..Default::default()
1564 };
1565 let rule = MD063HeadingCapitalization::from_config_struct(config);
1566
1567 let content = "## Guide for iOS `app`\n";
1569 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1570 let fixed = rule.fix(&ctx).unwrap();
1571 assert!(
1573 fixed.contains("for iOS `app`"),
1574 "Expected 'for iOS `app`' but got: {fixed:?}"
1575 );
1576 assert!(
1577 !fixed.contains("For iOS `app`"),
1578 "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
1579 );
1580 }
1581
1582 #[test]
1583 fn test_ignore_words_with_trailing_code() {
1584 let config = MD063Config {
1585 enabled: true,
1586 style: HeadingCapStyle::TitleCase,
1587 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1588 ignore_words: vec!["npm".to_string()],
1589 ..Default::default()
1590 };
1591 let rule = MD063HeadingCapitalization::from_config_struct(config);
1592
1593 let content = "## Using npm with a `script`\n";
1595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1596 let fixed = rule.fix(&ctx).unwrap();
1597 assert!(
1599 fixed.contains("npm with a `script`"),
1600 "Expected 'npm with a `script`' but got: {fixed:?}"
1601 );
1602 assert!(
1603 !fixed.contains("with A `script`"),
1604 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1605 );
1606 }
1607
1608 #[test]
1609 fn test_empty_text_segment_edge_case() {
1610 let config = MD063Config {
1611 enabled: true,
1612 style: HeadingCapStyle::TitleCase,
1613 lowercase_words: vec!["a".to_string(), "with".to_string()],
1614 ..Default::default()
1615 };
1616 let rule = MD063HeadingCapitalization::from_config_struct(config);
1617
1618 let content = "## `start` with a `end`\n";
1620 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1621 let fixed = rule.fix(&ctx).unwrap();
1622 assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
1625 assert!(
1626 !fixed.contains("A `end`"),
1627 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1628 );
1629 }
1630
1631 #[test]
1632 fn test_sentence_case_with_trailing_code() {
1633 let config = MD063Config {
1634 enabled: true,
1635 style: HeadingCapStyle::SentenceCase,
1636 lowercase_words: vec!["a".to_string(), "the".to_string()],
1637 ..Default::default()
1638 };
1639 let rule = MD063HeadingCapitalization::from_config_struct(config);
1640
1641 let content = "## guide for the `user`\n";
1643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1644 let fixed = rule.fix(&ctx).unwrap();
1645 assert!(
1647 fixed.contains("Guide for the `user`"),
1648 "Expected 'Guide for the `user`' but got: {fixed:?}"
1649 );
1650 }
1651
1652 #[test]
1653 fn test_hyphenated_word_before_code() {
1654 let config = MD063Config {
1655 enabled: true,
1656 style: HeadingCapStyle::TitleCase,
1657 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1658 ..Default::default()
1659 };
1660 let rule = MD063HeadingCapitalization::from_config_struct(config);
1661
1662 let content = "## Self-contained with a `feature`\n";
1664 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1665 let fixed = rule.fix(&ctx).unwrap();
1666 assert!(
1668 fixed.contains("with a `feature`"),
1669 "Expected 'with a `feature`' but got: {fixed:?}"
1670 );
1671 }
1672
1673 #[test]
1678 fn test_sentence_case_code_at_start_basic() {
1679 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1681 let content = "# `rumdl` is a linter\n";
1682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1683 let result = rule.check(&ctx).unwrap();
1684 assert!(
1686 result.is_empty(),
1687 "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
1688 result.iter().map(|w| &w.message).collect::<Vec<_>>()
1689 );
1690 }
1691
1692 #[test]
1693 fn test_sentence_case_code_at_start_incorrect_capitalization() {
1694 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1696 let content = "# `rumdl` Is a Linter\n";
1697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1698 let result = rule.check(&ctx).unwrap();
1699 assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
1701 assert!(
1702 result[0].message.contains("`rumdl` is a linter"),
1703 "Should suggest lowercase after code. Got: {:?}",
1704 result[0].message
1705 );
1706 }
1707
1708 #[test]
1709 fn test_sentence_case_code_at_start_fix() {
1710 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1711 let content = "# `rumdl` Is A Linter\n";
1712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1713 let fixed = rule.fix(&ctx).unwrap();
1714 assert!(
1715 fixed.contains("# `rumdl` is a linter"),
1716 "Should fix to lowercase after code. Got: {fixed:?}"
1717 );
1718 }
1719
1720 #[test]
1721 fn test_sentence_case_text_at_start_still_capitalizes() {
1722 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1724 let content = "# the quick brown fox\n";
1725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1726 let result = rule.check(&ctx).unwrap();
1727 assert_eq!(result.len(), 1);
1728 assert!(
1729 result[0].message.contains("The quick brown fox"),
1730 "Text-first heading should capitalize first word. Got: {:?}",
1731 result[0].message
1732 );
1733 }
1734
1735 #[test]
1736 fn test_sentence_case_link_at_start() {
1737 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1739 let content = "# [api](api.md) reference guide\n";
1741 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1742 let result = rule.check(&ctx).unwrap();
1743 assert!(
1745 result.is_empty(),
1746 "Heading with link at start should not capitalize 'reference'. Got: {:?}",
1747 result.iter().map(|w| &w.message).collect::<Vec<_>>()
1748 );
1749 }
1750
1751 #[test]
1752 fn test_sentence_case_link_preserves_acronyms() {
1753 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1755 let content = "# [API](api.md) Reference Guide\n";
1756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1757 let result = rule.check(&ctx).unwrap();
1758 assert_eq!(result.len(), 1);
1759 assert!(
1761 result[0].message.contains("[API](api.md) reference guide"),
1762 "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
1763 result[0].message
1764 );
1765 }
1766
1767 #[test]
1768 fn test_sentence_case_link_preserves_brand_names() {
1769 let config = MD063Config {
1771 enabled: true,
1772 style: HeadingCapStyle::SentenceCase,
1773 preserve_cased_words: true,
1774 ..Default::default()
1775 };
1776 let rule = MD063HeadingCapitalization::from_config_struct(config);
1777 let content = "# [iPhone](iphone.md) Features Guide\n";
1778 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1779 let result = rule.check(&ctx).unwrap();
1780 assert_eq!(result.len(), 1);
1781 assert!(
1783 result[0].message.contains("[iPhone](iphone.md) features guide"),
1784 "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
1785 result[0].message
1786 );
1787 }
1788
1789 #[test]
1790 fn test_sentence_case_link_lowercases_regular_words() {
1791 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1793 let content = "# [Documentation](docs.md) Reference\n";
1794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1795 let result = rule.check(&ctx).unwrap();
1796 assert_eq!(result.len(), 1);
1797 assert!(
1799 result[0].message.contains("[documentation](docs.md) reference"),
1800 "Should lowercase regular link text. Got: {:?}",
1801 result[0].message
1802 );
1803 }
1804
1805 #[test]
1806 fn test_sentence_case_link_at_start_correct_already() {
1807 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1809 let content = "# [API](api.md) reference guide\n";
1810 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1811 let result = rule.check(&ctx).unwrap();
1812 assert!(
1813 result.is_empty(),
1814 "Correctly cased heading with link should not be flagged. Got: {:?}",
1815 result.iter().map(|w| &w.message).collect::<Vec<_>>()
1816 );
1817 }
1818
1819 #[test]
1820 fn test_sentence_case_link_github_preserved() {
1821 let config = MD063Config {
1823 enabled: true,
1824 style: HeadingCapStyle::SentenceCase,
1825 preserve_cased_words: true,
1826 ..Default::default()
1827 };
1828 let rule = MD063HeadingCapitalization::from_config_struct(config);
1829 let content = "# [GitHub](gh.md) Repository Setup\n";
1830 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1831 let result = rule.check(&ctx).unwrap();
1832 assert_eq!(result.len(), 1);
1833 assert!(
1834 result[0].message.contains("[GitHub](gh.md) repository setup"),
1835 "Should preserve 'GitHub'. Got: {:?}",
1836 result[0].message
1837 );
1838 }
1839
1840 #[test]
1841 fn test_sentence_case_multiple_code_spans() {
1842 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1843 let content = "# `foo` and `bar` are methods\n";
1844 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1845 let result = rule.check(&ctx).unwrap();
1846 assert!(
1848 result.is_empty(),
1849 "Should not capitalize words between/after code spans. Got: {:?}",
1850 result.iter().map(|w| &w.message).collect::<Vec<_>>()
1851 );
1852 }
1853
1854 #[test]
1855 fn test_sentence_case_code_only_heading() {
1856 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1858 let content = "# `rumdl`\n";
1859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1860 let result = rule.check(&ctx).unwrap();
1861 assert!(
1862 result.is_empty(),
1863 "Code-only heading should be fine. Got: {:?}",
1864 result.iter().map(|w| &w.message).collect::<Vec<_>>()
1865 );
1866 }
1867
1868 #[test]
1869 fn test_sentence_case_code_at_end() {
1870 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1872 let content = "# install the `rumdl` tool\n";
1873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1874 let result = rule.check(&ctx).unwrap();
1875 assert_eq!(result.len(), 1);
1877 assert!(
1878 result[0].message.contains("Install the `rumdl` tool"),
1879 "First word should still be capitalized when text comes first. Got: {:?}",
1880 result[0].message
1881 );
1882 }
1883
1884 #[test]
1885 fn test_sentence_case_code_in_middle() {
1886 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1888 let content = "# using the `rumdl` linter for markdown\n";
1889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1890 let result = rule.check(&ctx).unwrap();
1891 assert_eq!(result.len(), 1);
1893 assert!(
1894 result[0].message.contains("Using the `rumdl` linter for markdown"),
1895 "First word should be capitalized. Got: {:?}",
1896 result[0].message
1897 );
1898 }
1899
1900 #[test]
1901 fn test_sentence_case_preserved_word_after_code() {
1902 let config = MD063Config {
1904 enabled: true,
1905 style: HeadingCapStyle::SentenceCase,
1906 preserve_cased_words: true,
1907 ..Default::default()
1908 };
1909 let rule = MD063HeadingCapitalization::from_config_struct(config);
1910 let content = "# `swift` iPhone development\n";
1911 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1912 let result = rule.check(&ctx).unwrap();
1913 assert!(
1915 result.is_empty(),
1916 "Preserved words after code should stay. Got: {:?}",
1917 result.iter().map(|w| &w.message).collect::<Vec<_>>()
1918 );
1919 }
1920
1921 #[test]
1922 fn test_title_case_code_at_start_still_capitalizes() {
1923 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
1925 let content = "# `api` quick start guide\n";
1926 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1927 let result = rule.check(&ctx).unwrap();
1928 assert_eq!(result.len(), 1);
1930 assert!(
1931 result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
1932 "Title case should capitalize major words after code. Got: {:?}",
1933 result[0].message
1934 );
1935 }
1936
1937 #[test]
1940 fn test_sentence_case_html_tag_at_start() {
1941 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1943 let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
1944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1945 let result = rule.check(&ctx).unwrap();
1946 assert_eq!(result.len(), 1);
1948 let fixed = rule.fix(&ctx).unwrap();
1949 assert_eq!(
1950 fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
1951 "Text after HTML at start should be lowercase"
1952 );
1953 }
1954
1955 #[test]
1956 fn test_sentence_case_html_tag_preserves_content() {
1957 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1959 let content = "# The <abbr>API</abbr> documentation guide\n";
1960 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1961 let result = rule.check(&ctx).unwrap();
1962 assert!(
1964 result.is_empty(),
1965 "HTML tag content should be preserved. Got: {:?}",
1966 result.iter().map(|w| &w.message).collect::<Vec<_>>()
1967 );
1968 }
1969
1970 #[test]
1971 fn test_sentence_case_html_tag_at_start_with_acronym() {
1972 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1974 let content = "# <abbr>API</abbr> Documentation Guide\n";
1975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1976 let result = rule.check(&ctx).unwrap();
1977 assert_eq!(result.len(), 1);
1978 let fixed = rule.fix(&ctx).unwrap();
1979 assert_eq!(
1980 fixed, "# <abbr>API</abbr> documentation guide\n",
1981 "Text after HTML at start should be lowercase, HTML content preserved"
1982 );
1983 }
1984
1985 #[test]
1986 fn test_sentence_case_html_tag_in_middle() {
1987 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1989 let content = "# using the <code>config</code> File\n";
1990 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1991 let result = rule.check(&ctx).unwrap();
1992 assert_eq!(result.len(), 1);
1993 let fixed = rule.fix(&ctx).unwrap();
1994 assert_eq!(
1995 fixed, "# Using the <code>config</code> file\n",
1996 "First word capitalized, HTML preserved, rest lowercase"
1997 );
1998 }
1999
2000 #[test]
2001 fn test_html_tag_strong_emphasis() {
2002 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2004 let content = "# The <strong>Bold</strong> Way\n";
2005 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2006 let result = rule.check(&ctx).unwrap();
2007 assert_eq!(result.len(), 1);
2008 let fixed = rule.fix(&ctx).unwrap();
2009 assert_eq!(
2010 fixed, "# The <strong>Bold</strong> way\n",
2011 "<strong> tag content should be preserved"
2012 );
2013 }
2014
2015 #[test]
2016 fn test_html_tag_with_attributes() {
2017 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2019 let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
2020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2021 let result = rule.check(&ctx).unwrap();
2022 assert_eq!(result.len(), 1);
2023 let fixed = rule.fix(&ctx).unwrap();
2024 assert_eq!(
2025 fixed, "# <span class=\"highlight\">Important</span> notice here\n",
2026 "HTML tag with attributes should be preserved"
2027 );
2028 }
2029
2030 #[test]
2031 fn test_multiple_html_tags() {
2032 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2034 let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
2035 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2036 let result = rule.check(&ctx).unwrap();
2037 assert_eq!(result.len(), 1);
2038 let fixed = rule.fix(&ctx).unwrap();
2039 assert_eq!(
2040 fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
2041 "Multiple HTML tags should all be preserved"
2042 );
2043 }
2044
2045 #[test]
2046 fn test_html_and_code_mixed() {
2047 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2049 let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
2050 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2051 let result = rule.check(&ctx).unwrap();
2052 assert_eq!(result.len(), 1);
2053 let fixed = rule.fix(&ctx).unwrap();
2054 assert_eq!(
2055 fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
2056 "HTML and code should both be preserved"
2057 );
2058 }
2059
2060 #[test]
2061 fn test_self_closing_html_tag() {
2062 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2064 let content = "# Line one<br/>Line Two Here\n";
2065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2066 let result = rule.check(&ctx).unwrap();
2067 assert_eq!(result.len(), 1);
2068 let fixed = rule.fix(&ctx).unwrap();
2069 assert_eq!(
2070 fixed, "# Line one<br/>line two here\n",
2071 "Self-closing HTML tags should be preserved"
2072 );
2073 }
2074
2075 #[test]
2076 fn test_title_case_with_html_tags() {
2077 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2079 let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
2080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2081 let result = rule.check(&ctx).unwrap();
2082 assert_eq!(result.len(), 1);
2083 let fixed = rule.fix(&ctx).unwrap();
2084 assert!(
2086 fixed.contains("<kbd>ctrl</kbd>"),
2087 "HTML tag content should be preserved in title case. Got: {fixed}"
2088 );
2089 assert!(
2090 fixed.starts_with("# The ") || fixed.starts_with("# the "),
2091 "Title case should work with HTML. Got: {fixed}"
2092 );
2093 }
2094
2095 #[test]
2098 fn test_sentence_case_preserves_caret_notation() {
2099 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2101 let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
2102 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2103 let result = rule.check(&ctx).unwrap();
2104 assert!(
2106 result.is_empty(),
2107 "Caret notation should be preserved. Got: {:?}",
2108 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2109 );
2110 }
2111
2112 #[test]
2113 fn test_sentence_case_caret_notation_various() {
2114 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2116
2117 let content = "## Press ^C to cancel\n";
2119 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2120 let result = rule.check(&ctx).unwrap();
2121 assert!(
2122 result.is_empty(),
2123 "^C should be preserved. Got: {:?}",
2124 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2125 );
2126
2127 let content = "## Use ^Z for background\n";
2129 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2130 let result = rule.check(&ctx).unwrap();
2131 assert!(
2132 result.is_empty(),
2133 "^Z should be preserved. Got: {:?}",
2134 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2135 );
2136
2137 let content = "## Press ^[ for escape\n";
2139 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2140 let result = rule.check(&ctx).unwrap();
2141 assert!(
2142 result.is_empty(),
2143 "^[ should be preserved. Got: {:?}",
2144 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2145 );
2146 }
2147
2148 #[test]
2149 fn test_caret_notation_detection() {
2150 let rule = create_rule();
2151
2152 assert!(rule.is_caret_notation("^A"));
2154 assert!(rule.is_caret_notation("^Z"));
2155 assert!(rule.is_caret_notation("^C"));
2156 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")); }
2168}