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 !ctx.likely_has_headings() || !ctx.lines.iter().any(|line| line.heading.is_some())
724 }
725
726 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
727 let content = ctx.content;
728
729 if content.is_empty() {
730 return Ok(Vec::new());
731 }
732
733 let mut warnings = Vec::new();
734 let line_index = &ctx.line_index;
735
736 for (line_num, line_info) in ctx.lines.iter().enumerate() {
737 if let Some(heading) = &line_info.heading {
738 if heading.level < self.config.min_level || heading.level > self.config.max_level {
740 continue;
741 }
742
743 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
745 continue;
746 }
747
748 let original_text = &heading.raw_text;
750 let fixed_text = self.apply_capitalization(original_text);
751
752 if original_text != &fixed_text {
753 let line = line_info.content(ctx.content);
754 let style_name = match self.config.style {
755 HeadingCapStyle::TitleCase => "title case",
756 HeadingCapStyle::SentenceCase => "sentence case",
757 HeadingCapStyle::AllCaps => "ALL CAPS",
758 };
759
760 warnings.push(LintWarning {
761 rule_name: Some(self.name().to_string()),
762 line: line_num + 1,
763 column: heading.content_column + 1,
764 end_line: line_num + 1,
765 end_column: heading.content_column + 1 + original_text.len(),
766 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
767 severity: Severity::Warning,
768 fix: Some(Fix {
769 range: self.get_line_byte_range(content, line_num + 1, line_index),
770 replacement: match heading.style {
771 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
772 _ => self.fix_setext_heading(line, heading),
773 },
774 }),
775 });
776 }
777 }
778 }
779
780 Ok(warnings)
781 }
782
783 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
784 let content = ctx.content;
785
786 if content.is_empty() {
787 return Ok(content.to_string());
788 }
789
790 let lines = ctx.raw_lines();
791 let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
792
793 for (line_num, line_info) in ctx.lines.iter().enumerate() {
794 if let Some(heading) = &line_info.heading {
795 if heading.level < self.config.min_level || heading.level > self.config.max_level {
797 continue;
798 }
799
800 if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
802 continue;
803 }
804
805 let original_text = &heading.raw_text;
806 let fixed_text = self.apply_capitalization(original_text);
807
808 if original_text != &fixed_text {
809 let line = line_info.content(ctx.content);
810 fixed_lines[line_num] = match heading.style {
811 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
812 _ => self.fix_setext_heading(line, heading),
813 };
814 }
815 }
816 }
817
818 let mut result = String::with_capacity(content.len());
820 for (i, line) in fixed_lines.iter().enumerate() {
821 result.push_str(line);
822 if i < fixed_lines.len() - 1 || content.ends_with('\n') {
823 result.push('\n');
824 }
825 }
826
827 Ok(result)
828 }
829
830 fn as_any(&self) -> &dyn std::any::Any {
831 self
832 }
833
834 fn default_config_section(&self) -> Option<(String, toml::Value)> {
835 let json_value = serde_json::to_value(&self.config).ok()?;
836 Some((
837 self.name().to_string(),
838 crate::rule_config_serde::json_to_toml_value(&json_value)?,
839 ))
840 }
841
842 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
843 where
844 Self: Sized,
845 {
846 let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
847 Box::new(Self::from_config_struct(rule_config))
848 }
849}
850
851#[cfg(test)]
852mod tests {
853 use super::*;
854 use crate::lint_context::LintContext;
855
856 fn create_rule() -> MD063HeadingCapitalization {
857 let config = MD063Config {
858 enabled: true,
859 ..Default::default()
860 };
861 MD063HeadingCapitalization::from_config_struct(config)
862 }
863
864 fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
865 let config = MD063Config {
866 enabled: true,
867 style,
868 ..Default::default()
869 };
870 MD063HeadingCapitalization::from_config_struct(config)
871 }
872
873 #[test]
875 fn test_title_case_basic() {
876 let rule = create_rule();
877 let content = "# hello world\n";
878 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
879 let result = rule.check(&ctx).unwrap();
880 assert_eq!(result.len(), 1);
881 assert!(result[0].message.contains("Hello World"));
882 }
883
884 #[test]
885 fn test_title_case_lowercase_words() {
886 let rule = create_rule();
887 let content = "# the quick brown fox\n";
888 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
889 let result = rule.check(&ctx).unwrap();
890 assert_eq!(result.len(), 1);
891 assert!(result[0].message.contains("The Quick Brown Fox"));
893 }
894
895 #[test]
896 fn test_title_case_already_correct() {
897 let rule = create_rule();
898 let content = "# The Quick Brown Fox\n";
899 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
900 let result = rule.check(&ctx).unwrap();
901 assert!(result.is_empty(), "Already correct heading should not be flagged");
902 }
903
904 #[test]
905 fn test_title_case_hyphenated() {
906 let rule = create_rule();
907 let content = "# self-documenting code\n";
908 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
909 let result = rule.check(&ctx).unwrap();
910 assert_eq!(result.len(), 1);
911 assert!(result[0].message.contains("Self-Documenting Code"));
912 }
913
914 #[test]
916 fn test_sentence_case_basic() {
917 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
918 let content = "# The Quick Brown Fox\n";
919 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
920 let result = rule.check(&ctx).unwrap();
921 assert_eq!(result.len(), 1);
922 assert!(result[0].message.contains("The quick brown fox"));
923 }
924
925 #[test]
926 fn test_sentence_case_already_correct() {
927 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
928 let content = "# The quick brown fox\n";
929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
930 let result = rule.check(&ctx).unwrap();
931 assert!(result.is_empty());
932 }
933
934 #[test]
936 fn test_all_caps_basic() {
937 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
938 let content = "# hello world\n";
939 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
940 let result = rule.check(&ctx).unwrap();
941 assert_eq!(result.len(), 1);
942 assert!(result[0].message.contains("HELLO WORLD"));
943 }
944
945 #[test]
947 fn test_preserve_ignore_words() {
948 let config = MD063Config {
949 enabled: true,
950 ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
951 ..Default::default()
952 };
953 let rule = MD063HeadingCapitalization::from_config_struct(config);
954
955 let content = "# using iPhone on macOS\n";
956 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
957 let result = rule.check(&ctx).unwrap();
958 assert_eq!(result.len(), 1);
959 assert!(result[0].message.contains("iPhone"));
961 assert!(result[0].message.contains("macOS"));
962 }
963
964 #[test]
965 fn test_preserve_cased_words() {
966 let rule = create_rule();
967 let content = "# using GitHub actions\n";
968 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
969 let result = rule.check(&ctx).unwrap();
970 assert_eq!(result.len(), 1);
971 assert!(result[0].message.contains("GitHub"));
973 }
974
975 #[test]
977 fn test_inline_code_preserved() {
978 let rule = create_rule();
979 let content = "# using `const` in javascript\n";
980 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
981 let result = rule.check(&ctx).unwrap();
982 assert_eq!(result.len(), 1);
983 assert!(result[0].message.contains("`const`"));
985 assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
986 }
987
988 #[test]
990 fn test_level_filter() {
991 let config = MD063Config {
992 enabled: true,
993 min_level: 2,
994 max_level: 4,
995 ..Default::default()
996 };
997 let rule = MD063HeadingCapitalization::from_config_struct(config);
998
999 let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
1000 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1001 let result = rule.check(&ctx).unwrap();
1002
1003 assert_eq!(result.len(), 2);
1005 assert_eq!(result[0].line, 2); assert_eq!(result[1].line, 3); }
1008
1009 #[test]
1011 fn test_fix_atx_heading() {
1012 let rule = create_rule();
1013 let content = "# hello world\n";
1014 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1015 let fixed = rule.fix(&ctx).unwrap();
1016 assert_eq!(fixed, "# Hello World\n");
1017 }
1018
1019 #[test]
1020 fn test_fix_multiple_headings() {
1021 let rule = create_rule();
1022 let content = "# first heading\n\n## second heading\n";
1023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1024 let fixed = rule.fix(&ctx).unwrap();
1025 assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
1026 }
1027
1028 #[test]
1030 fn test_setext_heading() {
1031 let rule = create_rule();
1032 let content = "hello world\n============\n";
1033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1034 let result = rule.check(&ctx).unwrap();
1035 assert_eq!(result.len(), 1);
1036 assert!(result[0].message.contains("Hello World"));
1037 }
1038
1039 #[test]
1041 fn test_custom_id_preserved() {
1042 let rule = create_rule();
1043 let content = "# getting started {#intro}\n";
1044 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1045 let result = rule.check(&ctx).unwrap();
1046 assert_eq!(result.len(), 1);
1047 assert!(result[0].message.contains("{#intro}"));
1049 }
1050
1051 #[test]
1053 fn test_preserve_all_caps_acronyms() {
1054 let rule = create_rule();
1055 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1056
1057 let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
1059 assert_eq!(fixed, "# Using API in Production\n");
1060
1061 let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
1063 assert_eq!(fixed, "# API and GPU Integration\n");
1064
1065 let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
1067 assert_eq!(fixed, "# IO Performance Guide\n");
1068
1069 let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
1071 assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
1072 }
1073
1074 #[test]
1075 fn test_preserve_acronyms_in_hyphenated_words() {
1076 let rule = create_rule();
1077 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1078
1079 let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
1081 assert_eq!(fixed, "# API-Driven Architecture\n");
1082
1083 let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
1085 assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
1086 }
1087
1088 #[test]
1089 fn test_single_letters_not_treated_as_acronyms() {
1090 let rule = create_rule();
1091 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1092
1093 let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
1095 assert_eq!(fixed, "# I Am a Heading\n");
1096 }
1097
1098 #[test]
1099 fn test_lowercase_terms_need_ignore_words() {
1100 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1101
1102 let rule = create_rule();
1104 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1105 assert_eq!(fixed, "# Using Npm Packages\n");
1106
1107 let config = MD063Config {
1109 enabled: true,
1110 ignore_words: vec!["npm".to_string()],
1111 ..Default::default()
1112 };
1113 let rule = MD063HeadingCapitalization::from_config_struct(config);
1114 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1115 assert_eq!(fixed, "# Using npm Packages\n");
1116 }
1117
1118 #[test]
1119 fn test_acronyms_with_mixed_case_preserved() {
1120 let rule = create_rule();
1121 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1122
1123 let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
1125 assert_eq!(fixed, "# Using API with GitHub\n");
1126 }
1127
1128 #[test]
1129 fn test_real_world_acronyms() {
1130 let rule = create_rule();
1131 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1132
1133 let content = "# FFI bindings for CPU optimization\n";
1135 let fixed = rule.fix(&ctx(content)).unwrap();
1136 assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
1137
1138 let content = "# DOM manipulation and SSR rendering\n";
1139 let fixed = rule.fix(&ctx(content)).unwrap();
1140 assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
1141
1142 let content = "# CVE security and RNN models\n";
1143 let fixed = rule.fix(&ctx(content)).unwrap();
1144 assert_eq!(fixed, "# CVE Security and RNN Models\n");
1145 }
1146
1147 #[test]
1148 fn test_is_all_caps_acronym() {
1149 let rule = create_rule();
1150
1151 assert!(rule.is_all_caps_acronym("API"));
1153 assert!(rule.is_all_caps_acronym("IO"));
1154 assert!(rule.is_all_caps_acronym("GPU"));
1155 assert!(rule.is_all_caps_acronym("HTTP2")); assert!(!rule.is_all_caps_acronym("A"));
1159 assert!(!rule.is_all_caps_acronym("I"));
1160
1161 assert!(!rule.is_all_caps_acronym("Api"));
1163 assert!(!rule.is_all_caps_acronym("npm"));
1164 assert!(!rule.is_all_caps_acronym("iPhone"));
1165 }
1166
1167 #[test]
1168 fn test_sentence_case_ignore_words_first_word() {
1169 let config = MD063Config {
1170 enabled: true,
1171 style: HeadingCapStyle::SentenceCase,
1172 ignore_words: vec!["nvim".to_string()],
1173 ..Default::default()
1174 };
1175 let rule = MD063HeadingCapitalization::from_config_struct(config);
1176
1177 let content = "# nvim config\n";
1179 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1180 let result = rule.check(&ctx).unwrap();
1181 assert!(
1182 result.is_empty(),
1183 "nvim in ignore-words should not be flagged. Got: {result:?}"
1184 );
1185
1186 let fixed = rule.fix(&ctx).unwrap();
1188 assert_eq!(fixed, "# nvim config\n");
1189 }
1190
1191 #[test]
1192 fn test_sentence_case_ignore_words_not_first() {
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 = "# Using nvim editor\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 be preserved. Got: {result:?}"
1208 );
1209 }
1210
1211 #[test]
1212 fn test_preserve_cased_words_ios() {
1213 let config = MD063Config {
1214 enabled: true,
1215 style: HeadingCapStyle::SentenceCase,
1216 preserve_cased_words: true,
1217 ..Default::default()
1218 };
1219 let rule = MD063HeadingCapitalization::from_config_struct(config);
1220
1221 let content = "## This is iOS\n";
1223 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1224 let result = rule.check(&ctx).unwrap();
1225 assert!(
1226 result.is_empty(),
1227 "iOS should be preserved with preserve-cased-words. Got: {result:?}"
1228 );
1229
1230 let fixed = rule.fix(&ctx).unwrap();
1232 assert_eq!(fixed, "## This is iOS\n");
1233 }
1234
1235 #[test]
1236 fn test_preserve_cased_words_ios_title_case() {
1237 let config = MD063Config {
1238 enabled: true,
1239 style: HeadingCapStyle::TitleCase,
1240 preserve_cased_words: true,
1241 ..Default::default()
1242 };
1243 let rule = MD063HeadingCapitalization::from_config_struct(config);
1244
1245 let content = "# developing for iOS\n";
1247 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1248 let fixed = rule.fix(&ctx).unwrap();
1249 assert_eq!(fixed, "# Developing for iOS\n");
1250 }
1251
1252 #[test]
1253 fn test_has_internal_capitals_ios() {
1254 let rule = create_rule();
1255
1256 assert!(
1258 rule.has_internal_capitals("iOS"),
1259 "iOS has mixed case (lowercase i, uppercase OS)"
1260 );
1261
1262 assert!(rule.has_internal_capitals("iPhone"));
1264 assert!(rule.has_internal_capitals("macOS"));
1265 assert!(rule.has_internal_capitals("GitHub"));
1266 assert!(rule.has_internal_capitals("JavaScript"));
1267 assert!(rule.has_internal_capitals("eBay"));
1268
1269 assert!(!rule.has_internal_capitals("API"));
1271 assert!(!rule.has_internal_capitals("GPU"));
1272
1273 assert!(!rule.has_internal_capitals("npm"));
1275 assert!(!rule.has_internal_capitals("config"));
1276
1277 assert!(!rule.has_internal_capitals("The"));
1279 assert!(!rule.has_internal_capitals("Hello"));
1280 }
1281
1282 #[test]
1283 fn test_lowercase_words_before_trailing_code() {
1284 let config = MD063Config {
1285 enabled: true,
1286 style: HeadingCapStyle::TitleCase,
1287 lowercase_words: vec![
1288 "a".to_string(),
1289 "an".to_string(),
1290 "and".to_string(),
1291 "at".to_string(),
1292 "but".to_string(),
1293 "by".to_string(),
1294 "for".to_string(),
1295 "from".to_string(),
1296 "into".to_string(),
1297 "nor".to_string(),
1298 "on".to_string(),
1299 "onto".to_string(),
1300 "or".to_string(),
1301 "the".to_string(),
1302 "to".to_string(),
1303 "upon".to_string(),
1304 "via".to_string(),
1305 "vs".to_string(),
1306 "with".to_string(),
1307 "without".to_string(),
1308 ],
1309 preserve_cased_words: true,
1310 ..Default::default()
1311 };
1312 let rule = MD063HeadingCapitalization::from_config_struct(config);
1313
1314 let content = "## subtitle with a `app`\n";
1319 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1320 let result = rule.check(&ctx).unwrap();
1321
1322 assert!(!result.is_empty(), "Should flag incorrect capitalization");
1324 let fixed = rule.fix(&ctx).unwrap();
1325 assert!(
1327 fixed.contains("with a `app`"),
1328 "Expected 'with a `app`' but got: {fixed:?}"
1329 );
1330 assert!(
1331 !fixed.contains("with A `app`"),
1332 "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
1333 );
1334 assert!(
1336 fixed.contains("Subtitle with a `app`"),
1337 "Expected 'Subtitle with a `app`' but got: {fixed:?}"
1338 );
1339 }
1340
1341 #[test]
1342 fn test_lowercase_words_preserved_before_trailing_code_variant() {
1343 let config = MD063Config {
1344 enabled: true,
1345 style: HeadingCapStyle::TitleCase,
1346 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1347 ..Default::default()
1348 };
1349 let rule = MD063HeadingCapitalization::from_config_struct(config);
1350
1351 let content = "## Title with the `code`\n";
1353 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1354 let fixed = rule.fix(&ctx).unwrap();
1355 assert!(
1357 fixed.contains("with the `code`"),
1358 "Expected 'with the `code`' but got: {fixed:?}"
1359 );
1360 assert!(
1361 !fixed.contains("with The `code`"),
1362 "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
1363 );
1364 }
1365
1366 #[test]
1367 fn test_last_word_capitalized_when_no_trailing_code() {
1368 let config = MD063Config {
1371 enabled: true,
1372 style: HeadingCapStyle::TitleCase,
1373 lowercase_words: vec!["a".to_string(), "the".to_string()],
1374 ..Default::default()
1375 };
1376 let rule = MD063HeadingCapitalization::from_config_struct(config);
1377
1378 let content = "## title with a word\n";
1381 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1382 let fixed = rule.fix(&ctx).unwrap();
1383 assert!(
1385 fixed.contains("With a Word"),
1386 "Expected 'With a Word' but got: {fixed:?}"
1387 );
1388 }
1389
1390 #[test]
1391 fn test_multiple_lowercase_words_before_code() {
1392 let config = MD063Config {
1393 enabled: true,
1394 style: HeadingCapStyle::TitleCase,
1395 lowercase_words: vec![
1396 "a".to_string(),
1397 "the".to_string(),
1398 "with".to_string(),
1399 "for".to_string(),
1400 ],
1401 ..Default::default()
1402 };
1403 let rule = MD063HeadingCapitalization::from_config_struct(config);
1404
1405 let content = "## Guide for the `user`\n";
1407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1408 let fixed = rule.fix(&ctx).unwrap();
1409 assert!(
1410 fixed.contains("for the `user`"),
1411 "Expected 'for the `user`' but got: {fixed:?}"
1412 );
1413 assert!(
1414 !fixed.contains("For The `user`"),
1415 "Should not capitalize lowercase words before code. Got: {fixed:?}"
1416 );
1417 }
1418
1419 #[test]
1420 fn test_code_in_middle_normal_rules_apply() {
1421 let config = MD063Config {
1422 enabled: true,
1423 style: HeadingCapStyle::TitleCase,
1424 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1425 ..Default::default()
1426 };
1427 let rule = MD063HeadingCapitalization::from_config_struct(config);
1428
1429 let content = "## Using `const` for the code\n";
1431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1432 let fixed = rule.fix(&ctx).unwrap();
1433 assert!(
1435 fixed.contains("for the Code"),
1436 "Expected 'for the Code' but got: {fixed:?}"
1437 );
1438 }
1439
1440 #[test]
1441 fn test_link_at_end_same_as_code() {
1442 let config = MD063Config {
1443 enabled: true,
1444 style: HeadingCapStyle::TitleCase,
1445 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1446 ..Default::default()
1447 };
1448 let rule = MD063HeadingCapitalization::from_config_struct(config);
1449
1450 let content = "## Guide for the [link](./page.md)\n";
1452 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1453 let fixed = rule.fix(&ctx).unwrap();
1454 assert!(
1456 fixed.contains("for the [Link]"),
1457 "Expected 'for the [Link]' but got: {fixed:?}"
1458 );
1459 assert!(
1460 !fixed.contains("for The [Link]"),
1461 "Should not capitalize 'the' before link. Got: {fixed:?}"
1462 );
1463 }
1464
1465 #[test]
1466 fn test_multiple_code_segments() {
1467 let config = MD063Config {
1468 enabled: true,
1469 style: HeadingCapStyle::TitleCase,
1470 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1471 ..Default::default()
1472 };
1473 let rule = MD063HeadingCapitalization::from_config_struct(config);
1474
1475 let content = "## Using `const` with a `variable`\n";
1477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1478 let fixed = rule.fix(&ctx).unwrap();
1479 assert!(
1481 fixed.contains("with a `variable`"),
1482 "Expected 'with a `variable`' but got: {fixed:?}"
1483 );
1484 assert!(
1485 !fixed.contains("with A `variable`"),
1486 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1487 );
1488 }
1489
1490 #[test]
1491 fn test_code_and_link_combination() {
1492 let config = MD063Config {
1493 enabled: true,
1494 style: HeadingCapStyle::TitleCase,
1495 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1496 ..Default::default()
1497 };
1498 let rule = MD063HeadingCapitalization::from_config_struct(config);
1499
1500 let content = "## Guide for the `code` [link](./page.md)\n";
1502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1503 let fixed = rule.fix(&ctx).unwrap();
1504 assert!(
1506 fixed.contains("for the `code`"),
1507 "Expected 'for the `code`' but got: {fixed:?}"
1508 );
1509 }
1510
1511 #[test]
1512 fn test_text_after_code_capitalizes_last() {
1513 let config = MD063Config {
1514 enabled: true,
1515 style: HeadingCapStyle::TitleCase,
1516 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1517 ..Default::default()
1518 };
1519 let rule = MD063HeadingCapitalization::from_config_struct(config);
1520
1521 let content = "## Using `const` for the code\n";
1523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1524 let fixed = rule.fix(&ctx).unwrap();
1525 assert!(
1527 fixed.contains("for the Code"),
1528 "Expected 'for the Code' but got: {fixed:?}"
1529 );
1530 }
1531
1532 #[test]
1533 fn test_preserve_cased_words_with_trailing_code() {
1534 let config = MD063Config {
1535 enabled: true,
1536 style: HeadingCapStyle::TitleCase,
1537 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1538 preserve_cased_words: true,
1539 ..Default::default()
1540 };
1541 let rule = MD063HeadingCapitalization::from_config_struct(config);
1542
1543 let content = "## Guide for iOS `app`\n";
1545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1546 let fixed = rule.fix(&ctx).unwrap();
1547 assert!(
1549 fixed.contains("for iOS `app`"),
1550 "Expected 'for iOS `app`' but got: {fixed:?}"
1551 );
1552 assert!(
1553 !fixed.contains("For iOS `app`"),
1554 "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
1555 );
1556 }
1557
1558 #[test]
1559 fn test_ignore_words_with_trailing_code() {
1560 let config = MD063Config {
1561 enabled: true,
1562 style: HeadingCapStyle::TitleCase,
1563 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1564 ignore_words: vec!["npm".to_string()],
1565 ..Default::default()
1566 };
1567 let rule = MD063HeadingCapitalization::from_config_struct(config);
1568
1569 let content = "## Using npm with a `script`\n";
1571 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1572 let fixed = rule.fix(&ctx).unwrap();
1573 assert!(
1575 fixed.contains("npm with a `script`"),
1576 "Expected 'npm with a `script`' but got: {fixed:?}"
1577 );
1578 assert!(
1579 !fixed.contains("with A `script`"),
1580 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1581 );
1582 }
1583
1584 #[test]
1585 fn test_empty_text_segment_edge_case() {
1586 let config = MD063Config {
1587 enabled: true,
1588 style: HeadingCapStyle::TitleCase,
1589 lowercase_words: vec!["a".to_string(), "with".to_string()],
1590 ..Default::default()
1591 };
1592 let rule = MD063HeadingCapitalization::from_config_struct(config);
1593
1594 let content = "## `start` with a `end`\n";
1596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1597 let fixed = rule.fix(&ctx).unwrap();
1598 assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
1601 assert!(
1602 !fixed.contains("A `end`"),
1603 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1604 );
1605 }
1606
1607 #[test]
1608 fn test_sentence_case_with_trailing_code() {
1609 let config = MD063Config {
1610 enabled: true,
1611 style: HeadingCapStyle::SentenceCase,
1612 lowercase_words: vec!["a".to_string(), "the".to_string()],
1613 ..Default::default()
1614 };
1615 let rule = MD063HeadingCapitalization::from_config_struct(config);
1616
1617 let content = "## guide for the `user`\n";
1619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1620 let fixed = rule.fix(&ctx).unwrap();
1621 assert!(
1623 fixed.contains("Guide for the `user`"),
1624 "Expected 'Guide for the `user`' but got: {fixed:?}"
1625 );
1626 }
1627
1628 #[test]
1629 fn test_hyphenated_word_before_code() {
1630 let config = MD063Config {
1631 enabled: true,
1632 style: HeadingCapStyle::TitleCase,
1633 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1634 ..Default::default()
1635 };
1636 let rule = MD063HeadingCapitalization::from_config_struct(config);
1637
1638 let content = "## Self-contained with a `feature`\n";
1640 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1641 let fixed = rule.fix(&ctx).unwrap();
1642 assert!(
1644 fixed.contains("with a `feature`"),
1645 "Expected 'with a `feature`' but got: {fixed:?}"
1646 );
1647 }
1648
1649 #[test]
1654 fn test_sentence_case_code_at_start_basic() {
1655 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1657 let content = "# `rumdl` is a linter\n";
1658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1659 let result = rule.check(&ctx).unwrap();
1660 assert!(
1662 result.is_empty(),
1663 "Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
1664 result.iter().map(|w| &w.message).collect::<Vec<_>>()
1665 );
1666 }
1667
1668 #[test]
1669 fn test_sentence_case_code_at_start_incorrect_capitalization() {
1670 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1672 let content = "# `rumdl` Is a Linter\n";
1673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1674 let result = rule.check(&ctx).unwrap();
1675 assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
1677 assert!(
1678 result[0].message.contains("`rumdl` is a linter"),
1679 "Should suggest lowercase after code. Got: {:?}",
1680 result[0].message
1681 );
1682 }
1683
1684 #[test]
1685 fn test_sentence_case_code_at_start_fix() {
1686 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1687 let content = "# `rumdl` Is A Linter\n";
1688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1689 let fixed = rule.fix(&ctx).unwrap();
1690 assert!(
1691 fixed.contains("# `rumdl` is a linter"),
1692 "Should fix to lowercase after code. Got: {fixed:?}"
1693 );
1694 }
1695
1696 #[test]
1697 fn test_sentence_case_text_at_start_still_capitalizes() {
1698 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1700 let content = "# the quick brown fox\n";
1701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1702 let result = rule.check(&ctx).unwrap();
1703 assert_eq!(result.len(), 1);
1704 assert!(
1705 result[0].message.contains("The quick brown fox"),
1706 "Text-first heading should capitalize first word. Got: {:?}",
1707 result[0].message
1708 );
1709 }
1710
1711 #[test]
1712 fn test_sentence_case_link_at_start() {
1713 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1715 let content = "# [api](api.md) reference guide\n";
1717 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1718 let result = rule.check(&ctx).unwrap();
1719 assert!(
1721 result.is_empty(),
1722 "Heading with link at start should not capitalize 'reference'. Got: {:?}",
1723 result.iter().map(|w| &w.message).collect::<Vec<_>>()
1724 );
1725 }
1726
1727 #[test]
1728 fn test_sentence_case_link_preserves_acronyms() {
1729 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1731 let content = "# [API](api.md) Reference Guide\n";
1732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1733 let result = rule.check(&ctx).unwrap();
1734 assert_eq!(result.len(), 1);
1735 assert!(
1737 result[0].message.contains("[API](api.md) reference guide"),
1738 "Should preserve acronym 'API' but lowercase following text. Got: {:?}",
1739 result[0].message
1740 );
1741 }
1742
1743 #[test]
1744 fn test_sentence_case_link_preserves_brand_names() {
1745 let config = MD063Config {
1747 enabled: true,
1748 style: HeadingCapStyle::SentenceCase,
1749 preserve_cased_words: true,
1750 ..Default::default()
1751 };
1752 let rule = MD063HeadingCapitalization::from_config_struct(config);
1753 let content = "# [iPhone](iphone.md) Features Guide\n";
1754 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1755 let result = rule.check(&ctx).unwrap();
1756 assert_eq!(result.len(), 1);
1757 assert!(
1759 result[0].message.contains("[iPhone](iphone.md) features guide"),
1760 "Should preserve 'iPhone' but lowercase following text. Got: {:?}",
1761 result[0].message
1762 );
1763 }
1764
1765 #[test]
1766 fn test_sentence_case_link_lowercases_regular_words() {
1767 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1769 let content = "# [Documentation](docs.md) Reference\n";
1770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1771 let result = rule.check(&ctx).unwrap();
1772 assert_eq!(result.len(), 1);
1773 assert!(
1775 result[0].message.contains("[documentation](docs.md) reference"),
1776 "Should lowercase regular link text. Got: {:?}",
1777 result[0].message
1778 );
1779 }
1780
1781 #[test]
1782 fn test_sentence_case_link_at_start_correct_already() {
1783 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1785 let content = "# [API](api.md) reference guide\n";
1786 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1787 let result = rule.check(&ctx).unwrap();
1788 assert!(
1789 result.is_empty(),
1790 "Correctly cased heading with link should not be flagged. Got: {:?}",
1791 result.iter().map(|w| &w.message).collect::<Vec<_>>()
1792 );
1793 }
1794
1795 #[test]
1796 fn test_sentence_case_link_github_preserved() {
1797 let config = MD063Config {
1799 enabled: true,
1800 style: HeadingCapStyle::SentenceCase,
1801 preserve_cased_words: true,
1802 ..Default::default()
1803 };
1804 let rule = MD063HeadingCapitalization::from_config_struct(config);
1805 let content = "# [GitHub](gh.md) Repository Setup\n";
1806 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1807 let result = rule.check(&ctx).unwrap();
1808 assert_eq!(result.len(), 1);
1809 assert!(
1810 result[0].message.contains("[GitHub](gh.md) repository setup"),
1811 "Should preserve 'GitHub'. Got: {:?}",
1812 result[0].message
1813 );
1814 }
1815
1816 #[test]
1817 fn test_sentence_case_multiple_code_spans() {
1818 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1819 let content = "# `foo` and `bar` are methods\n";
1820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1821 let result = rule.check(&ctx).unwrap();
1822 assert!(
1824 result.is_empty(),
1825 "Should not capitalize words between/after code spans. Got: {:?}",
1826 result.iter().map(|w| &w.message).collect::<Vec<_>>()
1827 );
1828 }
1829
1830 #[test]
1831 fn test_sentence_case_code_only_heading() {
1832 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1834 let content = "# `rumdl`\n";
1835 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1836 let result = rule.check(&ctx).unwrap();
1837 assert!(
1838 result.is_empty(),
1839 "Code-only heading should be fine. Got: {:?}",
1840 result.iter().map(|w| &w.message).collect::<Vec<_>>()
1841 );
1842 }
1843
1844 #[test]
1845 fn test_sentence_case_code_at_end() {
1846 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1848 let content = "# install the `rumdl` tool\n";
1849 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1850 let result = rule.check(&ctx).unwrap();
1851 assert_eq!(result.len(), 1);
1853 assert!(
1854 result[0].message.contains("Install the `rumdl` tool"),
1855 "First word should still be capitalized when text comes first. Got: {:?}",
1856 result[0].message
1857 );
1858 }
1859
1860 #[test]
1861 fn test_sentence_case_code_in_middle() {
1862 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1864 let content = "# using the `rumdl` linter for markdown\n";
1865 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1866 let result = rule.check(&ctx).unwrap();
1867 assert_eq!(result.len(), 1);
1869 assert!(
1870 result[0].message.contains("Using the `rumdl` linter for markdown"),
1871 "First word should be capitalized. Got: {:?}",
1872 result[0].message
1873 );
1874 }
1875
1876 #[test]
1877 fn test_sentence_case_preserved_word_after_code() {
1878 let config = MD063Config {
1880 enabled: true,
1881 style: HeadingCapStyle::SentenceCase,
1882 preserve_cased_words: true,
1883 ..Default::default()
1884 };
1885 let rule = MD063HeadingCapitalization::from_config_struct(config);
1886 let content = "# `swift` iPhone development\n";
1887 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1888 let result = rule.check(&ctx).unwrap();
1889 assert!(
1891 result.is_empty(),
1892 "Preserved words after code should stay. Got: {:?}",
1893 result.iter().map(|w| &w.message).collect::<Vec<_>>()
1894 );
1895 }
1896
1897 #[test]
1898 fn test_title_case_code_at_start_still_capitalizes() {
1899 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
1901 let content = "# `api` quick start guide\n";
1902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1903 let result = rule.check(&ctx).unwrap();
1904 assert_eq!(result.len(), 1);
1906 assert!(
1907 result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
1908 "Title case should capitalize major words after code. Got: {:?}",
1909 result[0].message
1910 );
1911 }
1912
1913 #[test]
1916 fn test_sentence_case_html_tag_at_start() {
1917 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1919 let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
1920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1921 let result = rule.check(&ctx).unwrap();
1922 assert_eq!(result.len(), 1);
1924 let fixed = rule.fix(&ctx).unwrap();
1925 assert_eq!(
1926 fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
1927 "Text after HTML at start should be lowercase"
1928 );
1929 }
1930
1931 #[test]
1932 fn test_sentence_case_html_tag_preserves_content() {
1933 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1935 let content = "# The <abbr>API</abbr> documentation guide\n";
1936 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1937 let result = rule.check(&ctx).unwrap();
1938 assert!(
1940 result.is_empty(),
1941 "HTML tag content should be preserved. Got: {:?}",
1942 result.iter().map(|w| &w.message).collect::<Vec<_>>()
1943 );
1944 }
1945
1946 #[test]
1947 fn test_sentence_case_html_tag_at_start_with_acronym() {
1948 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1950 let content = "# <abbr>API</abbr> Documentation Guide\n";
1951 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1952 let result = rule.check(&ctx).unwrap();
1953 assert_eq!(result.len(), 1);
1954 let fixed = rule.fix(&ctx).unwrap();
1955 assert_eq!(
1956 fixed, "# <abbr>API</abbr> documentation guide\n",
1957 "Text after HTML at start should be lowercase, HTML content preserved"
1958 );
1959 }
1960
1961 #[test]
1962 fn test_sentence_case_html_tag_in_middle() {
1963 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1965 let content = "# using the <code>config</code> File\n";
1966 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1967 let result = rule.check(&ctx).unwrap();
1968 assert_eq!(result.len(), 1);
1969 let fixed = rule.fix(&ctx).unwrap();
1970 assert_eq!(
1971 fixed, "# Using the <code>config</code> file\n",
1972 "First word capitalized, HTML preserved, rest lowercase"
1973 );
1974 }
1975
1976 #[test]
1977 fn test_html_tag_strong_emphasis() {
1978 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1980 let content = "# The <strong>Bold</strong> Way\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 let fixed = rule.fix(&ctx).unwrap();
1985 assert_eq!(
1986 fixed, "# The <strong>Bold</strong> way\n",
1987 "<strong> tag content should be preserved"
1988 );
1989 }
1990
1991 #[test]
1992 fn test_html_tag_with_attributes() {
1993 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
1995 let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
1996 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1997 let result = rule.check(&ctx).unwrap();
1998 assert_eq!(result.len(), 1);
1999 let fixed = rule.fix(&ctx).unwrap();
2000 assert_eq!(
2001 fixed, "# <span class=\"highlight\">Important</span> notice here\n",
2002 "HTML tag with attributes should be preserved"
2003 );
2004 }
2005
2006 #[test]
2007 fn test_multiple_html_tags() {
2008 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2010 let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
2011 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2012 let result = rule.check(&ctx).unwrap();
2013 assert_eq!(result.len(), 1);
2014 let fixed = rule.fix(&ctx).unwrap();
2015 assert_eq!(
2016 fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
2017 "Multiple HTML tags should all be preserved"
2018 );
2019 }
2020
2021 #[test]
2022 fn test_html_and_code_mixed() {
2023 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2025 let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
2026 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2027 let result = rule.check(&ctx).unwrap();
2028 assert_eq!(result.len(), 1);
2029 let fixed = rule.fix(&ctx).unwrap();
2030 assert_eq!(
2031 fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
2032 "HTML and code should both be preserved"
2033 );
2034 }
2035
2036 #[test]
2037 fn test_self_closing_html_tag() {
2038 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2040 let content = "# Line one<br/>Line Two Here\n";
2041 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2042 let result = rule.check(&ctx).unwrap();
2043 assert_eq!(result.len(), 1);
2044 let fixed = rule.fix(&ctx).unwrap();
2045 assert_eq!(
2046 fixed, "# Line one<br/>line two here\n",
2047 "Self-closing HTML tags should be preserved"
2048 );
2049 }
2050
2051 #[test]
2052 fn test_title_case_with_html_tags() {
2053 let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
2055 let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
2056 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2057 let result = rule.check(&ctx).unwrap();
2058 assert_eq!(result.len(), 1);
2059 let fixed = rule.fix(&ctx).unwrap();
2060 assert!(
2062 fixed.contains("<kbd>ctrl</kbd>"),
2063 "HTML tag content should be preserved in title case. Got: {fixed}"
2064 );
2065 assert!(
2066 fixed.starts_with("# The ") || fixed.starts_with("# the "),
2067 "Title case should work with HTML. Got: {fixed}"
2068 );
2069 }
2070
2071 #[test]
2074 fn test_sentence_case_preserves_caret_notation() {
2075 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2077 let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
2078 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2079 let result = rule.check(&ctx).unwrap();
2080 assert!(
2082 result.is_empty(),
2083 "Caret notation should be preserved. Got: {:?}",
2084 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2085 );
2086 }
2087
2088 #[test]
2089 fn test_sentence_case_caret_notation_various() {
2090 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
2092
2093 let content = "## Press ^C to cancel\n";
2095 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2096 let result = rule.check(&ctx).unwrap();
2097 assert!(
2098 result.is_empty(),
2099 "^C should be preserved. Got: {:?}",
2100 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2101 );
2102
2103 let content = "## Use ^Z for background\n";
2105 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2106 let result = rule.check(&ctx).unwrap();
2107 assert!(
2108 result.is_empty(),
2109 "^Z should be preserved. Got: {:?}",
2110 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2111 );
2112
2113 let content = "## Press ^[ for escape\n";
2115 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2116 let result = rule.check(&ctx).unwrap();
2117 assert!(
2118 result.is_empty(),
2119 "^[ should be preserved. Got: {:?}",
2120 result.iter().map(|w| &w.message).collect::<Vec<_>>()
2121 );
2122 }
2123
2124 #[test]
2125 fn test_caret_notation_detection() {
2126 let rule = create_rule();
2127
2128 assert!(rule.is_caret_notation("^A"));
2130 assert!(rule.is_caret_notation("^Z"));
2131 assert!(rule.is_caret_notation("^C"));
2132 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")); }
2144}