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 CUSTOM_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*\{#[^}]+\}\s*$").unwrap());
33
34#[derive(Debug, Clone)]
36enum HeadingSegment {
37 Text(String),
39 Code(String),
41 Link {
43 full: String,
44 text_start: usize,
45 text_end: usize,
46 },
47}
48
49#[derive(Clone)]
51pub struct MD063HeadingCapitalization {
52 config: MD063Config,
53 lowercase_set: HashSet<String>,
54}
55
56impl Default for MD063HeadingCapitalization {
57 fn default() -> Self {
58 Self::new()
59 }
60}
61
62impl MD063HeadingCapitalization {
63 pub fn new() -> Self {
64 let config = MD063Config::default();
65 let lowercase_set = config.lowercase_words.iter().cloned().collect();
66 Self { config, lowercase_set }
67 }
68
69 pub fn from_config_struct(config: MD063Config) -> Self {
70 let lowercase_set = config.lowercase_words.iter().cloned().collect();
71 Self { config, lowercase_set }
72 }
73
74 fn has_internal_capitals(&self, word: &str) -> bool {
76 let chars: Vec<char> = word.chars().collect();
77 if chars.len() < 2 {
78 return false;
79 }
80
81 let rest = &chars[1..];
84 let has_upper = rest.iter().any(|c| c.is_uppercase());
85 let has_lower = rest.iter().any(|c| c.is_lowercase());
86 has_upper && has_lower
87 }
88
89 fn is_all_caps_acronym(&self, word: &str) -> bool {
93 if word.len() < 2 {
95 return false;
96 }
97
98 let mut consecutive_upper = 0;
99 let mut max_consecutive = 0;
100
101 for c in word.chars() {
102 if c.is_uppercase() {
103 consecutive_upper += 1;
104 max_consecutive = max_consecutive.max(consecutive_upper);
105 } else if c.is_lowercase() {
106 return false;
108 } else {
109 consecutive_upper = 0;
111 }
112 }
113
114 max_consecutive >= 2
116 }
117
118 fn should_preserve_word(&self, word: &str) -> bool {
120 if self.config.ignore_words.iter().any(|w| w == word) {
122 return true;
123 }
124
125 if self.config.preserve_cased_words && self.has_internal_capitals(word) {
127 return true;
128 }
129
130 if self.config.preserve_cased_words && self.is_all_caps_acronym(word) {
132 return true;
133 }
134
135 false
136 }
137
138 fn is_lowercase_word(&self, word: &str) -> bool {
140 self.lowercase_set.contains(&word.to_lowercase())
141 }
142
143 fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
145 if word.is_empty() {
146 return word.to_string();
147 }
148
149 if self.should_preserve_word(word) {
151 return word.to_string();
152 }
153
154 if is_first || is_last {
156 return self.capitalize_first(word);
157 }
158
159 if self.is_lowercase_word(word) {
161 return word.to_lowercase();
162 }
163
164 self.capitalize_first(word)
166 }
167
168 fn capitalize_first(&self, word: &str) -> String {
170 let mut chars = word.chars();
171 match chars.next() {
172 None => String::new(),
173 Some(first) => {
174 let first_upper: String = first.to_uppercase().collect();
175 let rest: String = chars.collect();
176 format!("{}{}", first_upper, rest.to_lowercase())
177 }
178 }
179 }
180
181 fn apply_title_case(&self, text: &str) -> String {
183 let base_result = titlecase::titlecase(text);
185
186 let original_words: Vec<&str> = text.split_whitespace().collect();
188 let transformed_words: Vec<&str> = base_result.split_whitespace().collect();
189 let total_words = transformed_words.len();
190
191 let result_words: Vec<String> = transformed_words
192 .iter()
193 .enumerate()
194 .map(|(i, word)| {
195 let is_first = i == 0;
196 let is_last = i == total_words - 1;
197
198 if let Some(original_word) = original_words.get(i)
200 && self.should_preserve_word(original_word)
201 {
202 return (*original_word).to_string();
203 }
204
205 if word.contains('-') {
207 if let Some(original_word) = original_words.get(i) {
209 return self.handle_hyphenated_word_with_original(word, original_word, is_first, is_last);
210 }
211 return self.handle_hyphenated_word(word, is_first, is_last);
212 }
213
214 self.title_case_word(word, is_first, is_last)
215 })
216 .collect();
217
218 result_words.join(" ")
219 }
220
221 fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
223 let parts: Vec<&str> = word.split('-').collect();
224 let total_parts = parts.len();
225
226 let result_parts: Vec<String> = parts
227 .iter()
228 .enumerate()
229 .map(|(i, part)| {
230 let part_is_first = is_first && i == 0;
232 let part_is_last = is_last && i == total_parts - 1;
233 self.title_case_word(part, part_is_first, part_is_last)
234 })
235 .collect();
236
237 result_parts.join("-")
238 }
239
240 fn handle_hyphenated_word_with_original(
242 &self,
243 word: &str,
244 original: &str,
245 is_first: bool,
246 is_last: bool,
247 ) -> String {
248 let parts: Vec<&str> = word.split('-').collect();
249 let original_parts: Vec<&str> = original.split('-').collect();
250 let total_parts = parts.len();
251
252 let result_parts: Vec<String> = parts
253 .iter()
254 .enumerate()
255 .map(|(i, part)| {
256 if let Some(original_part) = original_parts.get(i)
258 && self.should_preserve_word(original_part)
259 {
260 return (*original_part).to_string();
261 }
262
263 let part_is_first = is_first && i == 0;
265 let part_is_last = is_last && i == total_parts - 1;
266 self.title_case_word(part, part_is_first, part_is_last)
267 })
268 .collect();
269
270 result_parts.join("-")
271 }
272
273 fn apply_sentence_case(&self, text: &str) -> String {
275 if text.is_empty() {
276 return text.to_string();
277 }
278
279 let mut result = String::new();
280 let mut current_pos = 0;
281 let mut is_first_word = true;
282
283 for word in text.split_whitespace() {
285 if let Some(pos) = text[current_pos..].find(word) {
286 let abs_pos = current_pos + pos;
287
288 result.push_str(&text[current_pos..abs_pos]);
290
291 if is_first_word {
293 let mut chars = word.chars();
295 if let Some(first) = chars.next() {
296 let first_upper: String = first.to_uppercase().collect();
297 result.push_str(&first_upper);
298 let rest: String = chars.collect();
299 if self.should_preserve_word(word) {
300 result.push_str(&rest);
301 } else {
302 result.push_str(&rest.to_lowercase());
303 }
304 }
305 is_first_word = false;
306 } else {
307 if self.should_preserve_word(word) {
309 result.push_str(word);
310 } else {
311 result.push_str(&word.to_lowercase());
312 }
313 }
314
315 current_pos = abs_pos + word.len();
316 }
317 }
318
319 if current_pos < text.len() {
321 result.push_str(&text[current_pos..]);
322 }
323
324 result
325 }
326
327 fn apply_all_caps(&self, text: &str) -> String {
329 if text.is_empty() {
330 return text.to_string();
331 }
332
333 let mut result = String::new();
334 let mut current_pos = 0;
335
336 for word in text.split_whitespace() {
338 if let Some(pos) = text[current_pos..].find(word) {
339 let abs_pos = current_pos + pos;
340
341 result.push_str(&text[current_pos..abs_pos]);
343
344 if self.should_preserve_word(word) {
346 result.push_str(word);
347 } else {
348 result.push_str(&word.to_uppercase());
349 }
350
351 current_pos = abs_pos + word.len();
352 }
353 }
354
355 if current_pos < text.len() {
357 result.push_str(&text[current_pos..]);
358 }
359
360 result
361 }
362
363 fn parse_segments(&self, text: &str) -> Vec<HeadingSegment> {
365 let mut segments = Vec::new();
366 let mut last_end = 0;
367
368 let mut special_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
370
371 for mat in INLINE_CODE_REGEX.find_iter(text) {
373 special_regions.push((mat.start(), mat.end(), HeadingSegment::Code(mat.as_str().to_string())));
374 }
375
376 for caps in LINK_REGEX.captures_iter(text) {
378 let full_match = caps.get(0).unwrap();
379 let text_match = caps.get(1).or_else(|| caps.get(2));
380
381 if let Some(text_m) = text_match {
382 special_regions.push((
383 full_match.start(),
384 full_match.end(),
385 HeadingSegment::Link {
386 full: full_match.as_str().to_string(),
387 text_start: text_m.start() - full_match.start(),
388 text_end: text_m.end() - full_match.start(),
389 },
390 ));
391 }
392 }
393
394 special_regions.sort_by_key(|(start, _, _)| *start);
396
397 let mut filtered_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
399 for region in special_regions {
400 let overlaps = filtered_regions.iter().any(|(s, e, _)| region.0 < *e && region.1 > *s);
401 if !overlaps {
402 filtered_regions.push(region);
403 }
404 }
405
406 for (start, end, segment) in filtered_regions {
408 if start > last_end {
410 let text_segment = &text[last_end..start];
411 if !text_segment.is_empty() {
412 segments.push(HeadingSegment::Text(text_segment.to_string()));
413 }
414 }
415 segments.push(segment);
416 last_end = end;
417 }
418
419 if last_end < text.len() {
421 let remaining = &text[last_end..];
422 if !remaining.is_empty() {
423 segments.push(HeadingSegment::Text(remaining.to_string()));
424 }
425 }
426
427 if segments.is_empty() && !text.is_empty() {
429 segments.push(HeadingSegment::Text(text.to_string()));
430 }
431
432 segments
433 }
434
435 fn apply_capitalization(&self, text: &str) -> String {
437 let (main_text, custom_id) = if let Some(mat) = CUSTOM_ID_REGEX.find(text) {
439 (&text[..mat.start()], Some(mat.as_str()))
440 } else {
441 (text, None)
442 };
443
444 let segments = self.parse_segments(main_text);
446
447 let text_segments: Vec<usize> = segments
449 .iter()
450 .enumerate()
451 .filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
452 .collect();
453
454 let mut result_parts: Vec<String> = Vec::new();
456
457 for (i, segment) in segments.iter().enumerate() {
458 match segment {
459 HeadingSegment::Text(t) => {
460 let is_first_text = text_segments.first() == Some(&i);
461 let is_last_text = text_segments.last() == Some(&i);
462
463 let capitalized = match self.config.style {
464 HeadingCapStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
465 HeadingCapStyle::SentenceCase => {
466 if is_first_text {
467 self.apply_sentence_case(t)
468 } else {
469 self.apply_sentence_case_non_first(t)
471 }
472 }
473 HeadingCapStyle::AllCaps => self.apply_all_caps(t),
474 };
475 result_parts.push(capitalized);
476 }
477 HeadingSegment::Code(c) => {
478 result_parts.push(c.clone());
479 }
480 HeadingSegment::Link {
481 full,
482 text_start,
483 text_end,
484 } => {
485 let link_text = &full[*text_start..*text_end];
487 let capitalized_text = match self.config.style {
488 HeadingCapStyle::TitleCase => self.apply_title_case(link_text),
489 HeadingCapStyle::SentenceCase => link_text.to_lowercase(),
490 HeadingCapStyle::AllCaps => self.apply_all_caps(link_text),
491 };
492
493 let mut new_link = String::new();
494 new_link.push_str(&full[..*text_start]);
495 new_link.push_str(&capitalized_text);
496 new_link.push_str(&full[*text_end..]);
497 result_parts.push(new_link);
498 }
499 }
500 }
501
502 let mut result = result_parts.join("");
503
504 if let Some(id) = custom_id {
506 result.push_str(id);
507 }
508
509 result
510 }
511
512 fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
514 let words: Vec<&str> = text.split_whitespace().collect();
515 let total_words = words.len();
516
517 if total_words == 0 {
518 return text.to_string();
519 }
520
521 let result_words: Vec<String> = words
522 .iter()
523 .enumerate()
524 .map(|(i, word)| {
525 let is_first = is_first_segment && i == 0;
526 let is_last = is_last_segment && i == total_words - 1;
527
528 if word.contains('-') {
530 return self.handle_hyphenated_word(word, is_first, is_last);
531 }
532
533 self.title_case_word(word, is_first, is_last)
534 })
535 .collect();
536
537 let mut result = String::new();
539 let mut word_iter = result_words.iter();
540 let mut in_word = false;
541
542 for c in text.chars() {
543 if c.is_whitespace() {
544 if in_word {
545 in_word = false;
546 }
547 result.push(c);
548 } else if !in_word {
549 if let Some(word) = word_iter.next() {
550 result.push_str(word);
551 }
552 in_word = true;
553 }
554 }
555
556 result
557 }
558
559 fn apply_sentence_case_non_first(&self, text: &str) -> String {
561 if text.is_empty() {
562 return text.to_string();
563 }
564
565 let lower = text.to_lowercase();
566 let mut result = String::new();
567 let mut current_pos = 0;
568
569 for word in lower.split_whitespace() {
570 if let Some(pos) = lower[current_pos..].find(word) {
571 let abs_pos = current_pos + pos;
572
573 result.push_str(&lower[current_pos..abs_pos]);
575
576 let original_word = &text[abs_pos..abs_pos + word.len()];
578 if self.should_preserve_word(original_word) {
579 result.push_str(original_word);
580 } else {
581 result.push_str(word);
582 }
583
584 current_pos = abs_pos + word.len();
585 }
586 }
587
588 if current_pos < lower.len() {
590 result.push_str(&lower[current_pos..]);
591 }
592
593 result
594 }
595
596 fn get_line_byte_range(&self, content: &str, line_num: usize, line_index: &LineIndex) -> Range<usize> {
598 let start_pos = line_index.get_line_start_byte(line_num).unwrap_or(content.len());
599 let line = content.lines().nth(line_num - 1).unwrap_or("");
600 Range {
601 start: start_pos,
602 end: start_pos + line.len(),
603 }
604 }
605
606 fn fix_atx_heading(&self, _line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
608 let indent = " ".repeat(heading.marker_column);
610 let hashes = "#".repeat(heading.level as usize);
611
612 let fixed_text = self.apply_capitalization(&heading.raw_text);
614
615 let closing = &heading.closing_sequence;
617 if heading.has_closing_sequence {
618 format!("{indent}{hashes} {fixed_text} {closing}")
619 } else {
620 format!("{indent}{hashes} {fixed_text}")
621 }
622 }
623
624 fn fix_setext_heading(&self, line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
626 let fixed_text = self.apply_capitalization(&heading.raw_text);
628
629 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
631
632 format!("{leading_ws}{fixed_text}")
633 }
634}
635
636impl Rule for MD063HeadingCapitalization {
637 fn name(&self) -> &'static str {
638 "MD063"
639 }
640
641 fn description(&self) -> &'static str {
642 "Heading capitalization"
643 }
644
645 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
646 !self.config.enabled || !ctx.likely_has_headings() || !ctx.lines.iter().any(|line| line.heading.is_some())
648 }
649
650 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
651 if !self.config.enabled {
652 return Ok(Vec::new());
653 }
654
655 let content = ctx.content;
656
657 if content.is_empty() {
658 return Ok(Vec::new());
659 }
660
661 let mut warnings = Vec::new();
662 let line_index = &ctx.line_index;
663
664 for (line_num, line_info) in ctx.lines.iter().enumerate() {
665 if let Some(heading) = &line_info.heading {
666 if heading.level < self.config.min_level || heading.level > self.config.max_level {
668 continue;
669 }
670
671 if line_info.indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
673 continue;
674 }
675
676 let original_text = &heading.raw_text;
678 let fixed_text = self.apply_capitalization(original_text);
679
680 if original_text != &fixed_text {
681 let line = line_info.content(ctx.content);
682 let style_name = match self.config.style {
683 HeadingCapStyle::TitleCase => "title case",
684 HeadingCapStyle::SentenceCase => "sentence case",
685 HeadingCapStyle::AllCaps => "ALL CAPS",
686 };
687
688 warnings.push(LintWarning {
689 rule_name: Some(self.name().to_string()),
690 line: line_num + 1,
691 column: heading.content_column + 1,
692 end_line: line_num + 1,
693 end_column: heading.content_column + 1 + original_text.len(),
694 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
695 severity: Severity::Warning,
696 fix: Some(Fix {
697 range: self.get_line_byte_range(content, line_num + 1, line_index),
698 replacement: match heading.style {
699 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
700 _ => self.fix_setext_heading(line, heading),
701 },
702 }),
703 });
704 }
705 }
706 }
707
708 Ok(warnings)
709 }
710
711 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
712 if !self.config.enabled {
713 return Ok(ctx.content.to_string());
714 }
715
716 let content = ctx.content;
717
718 if content.is_empty() {
719 return Ok(content.to_string());
720 }
721
722 let lines: Vec<&str> = content.lines().collect();
723 let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
724
725 for (line_num, line_info) in ctx.lines.iter().enumerate() {
726 if let Some(heading) = &line_info.heading {
727 if heading.level < self.config.min_level || heading.level > self.config.max_level {
729 continue;
730 }
731
732 if line_info.indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
734 continue;
735 }
736
737 let original_text = &heading.raw_text;
738 let fixed_text = self.apply_capitalization(original_text);
739
740 if original_text != &fixed_text {
741 let line = line_info.content(ctx.content);
742 fixed_lines[line_num] = match heading.style {
743 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
744 _ => self.fix_setext_heading(line, heading),
745 };
746 }
747 }
748 }
749
750 let mut result = String::with_capacity(content.len());
752 for (i, line) in fixed_lines.iter().enumerate() {
753 result.push_str(line);
754 if i < fixed_lines.len() - 1 || content.ends_with('\n') {
755 result.push('\n');
756 }
757 }
758
759 Ok(result)
760 }
761
762 fn as_any(&self) -> &dyn std::any::Any {
763 self
764 }
765
766 fn default_config_section(&self) -> Option<(String, toml::Value)> {
767 let json_value = serde_json::to_value(&self.config).ok()?;
768 Some((
769 self.name().to_string(),
770 crate::rule_config_serde::json_to_toml_value(&json_value)?,
771 ))
772 }
773
774 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
775 where
776 Self: Sized,
777 {
778 let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
779 Box::new(Self::from_config_struct(rule_config))
780 }
781}
782
783#[cfg(test)]
784mod tests {
785 use super::*;
786 use crate::lint_context::LintContext;
787
788 fn create_rule() -> MD063HeadingCapitalization {
789 let config = MD063Config {
790 enabled: true,
791 ..Default::default()
792 };
793 MD063HeadingCapitalization::from_config_struct(config)
794 }
795
796 fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
797 let config = MD063Config {
798 enabled: true,
799 style,
800 ..Default::default()
801 };
802 MD063HeadingCapitalization::from_config_struct(config)
803 }
804
805 #[test]
807 fn test_title_case_basic() {
808 let rule = create_rule();
809 let content = "# hello world\n";
810 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
811 let result = rule.check(&ctx).unwrap();
812 assert_eq!(result.len(), 1);
813 assert!(result[0].message.contains("Hello World"));
814 }
815
816 #[test]
817 fn test_title_case_lowercase_words() {
818 let rule = create_rule();
819 let content = "# the quick brown fox\n";
820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
821 let result = rule.check(&ctx).unwrap();
822 assert_eq!(result.len(), 1);
823 assert!(result[0].message.contains("The Quick Brown Fox"));
825 }
826
827 #[test]
828 fn test_title_case_already_correct() {
829 let rule = create_rule();
830 let content = "# The Quick Brown Fox\n";
831 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
832 let result = rule.check(&ctx).unwrap();
833 assert!(result.is_empty(), "Already correct heading should not be flagged");
834 }
835
836 #[test]
837 fn test_title_case_hyphenated() {
838 let rule = create_rule();
839 let content = "# self-documenting code\n";
840 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
841 let result = rule.check(&ctx).unwrap();
842 assert_eq!(result.len(), 1);
843 assert!(result[0].message.contains("Self-Documenting Code"));
844 }
845
846 #[test]
848 fn test_sentence_case_basic() {
849 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
850 let content = "# The Quick Brown Fox\n";
851 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
852 let result = rule.check(&ctx).unwrap();
853 assert_eq!(result.len(), 1);
854 assert!(result[0].message.contains("The quick brown fox"));
855 }
856
857 #[test]
858 fn test_sentence_case_already_correct() {
859 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
860 let content = "# The quick brown fox\n";
861 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
862 let result = rule.check(&ctx).unwrap();
863 assert!(result.is_empty());
864 }
865
866 #[test]
868 fn test_all_caps_basic() {
869 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
870 let content = "# hello world\n";
871 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
872 let result = rule.check(&ctx).unwrap();
873 assert_eq!(result.len(), 1);
874 assert!(result[0].message.contains("HELLO WORLD"));
875 }
876
877 #[test]
879 fn test_preserve_ignore_words() {
880 let config = MD063Config {
881 enabled: true,
882 ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
883 ..Default::default()
884 };
885 let rule = MD063HeadingCapitalization::from_config_struct(config);
886
887 let content = "# using iPhone on macOS\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("iPhone"));
893 assert!(result[0].message.contains("macOS"));
894 }
895
896 #[test]
897 fn test_preserve_cased_words() {
898 let rule = create_rule();
899 let content = "# using GitHub actions\n";
900 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
901 let result = rule.check(&ctx).unwrap();
902 assert_eq!(result.len(), 1);
903 assert!(result[0].message.contains("GitHub"));
905 }
906
907 #[test]
909 fn test_inline_code_preserved() {
910 let rule = create_rule();
911 let content = "# using `const` in javascript\n";
912 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
913 let result = rule.check(&ctx).unwrap();
914 assert_eq!(result.len(), 1);
915 assert!(result[0].message.contains("`const`"));
917 assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
918 }
919
920 #[test]
922 fn test_level_filter() {
923 let config = MD063Config {
924 enabled: true,
925 min_level: 2,
926 max_level: 4,
927 ..Default::default()
928 };
929 let rule = MD063HeadingCapitalization::from_config_struct(config);
930
931 let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933 let result = rule.check(&ctx).unwrap();
934
935 assert_eq!(result.len(), 2);
937 assert_eq!(result[0].line, 2); assert_eq!(result[1].line, 3); }
940
941 #[test]
943 fn test_fix_atx_heading() {
944 let rule = create_rule();
945 let content = "# hello world\n";
946 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
947 let fixed = rule.fix(&ctx).unwrap();
948 assert_eq!(fixed, "# Hello World\n");
949 }
950
951 #[test]
952 fn test_fix_multiple_headings() {
953 let rule = create_rule();
954 let content = "# first heading\n\n## second heading\n";
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956 let fixed = rule.fix(&ctx).unwrap();
957 assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
958 }
959
960 #[test]
962 fn test_setext_heading() {
963 let rule = create_rule();
964 let content = "hello world\n============\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("Hello World"));
969 }
970
971 #[test]
973 fn test_custom_id_preserved() {
974 let rule = create_rule();
975 let content = "# getting started {#intro}\n";
976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
977 let result = rule.check(&ctx).unwrap();
978 assert_eq!(result.len(), 1);
979 assert!(result[0].message.contains("{#intro}"));
981 }
982
983 #[test]
984 fn test_md063_disabled_by_default() {
985 let rule = MD063HeadingCapitalization::new();
986 let content = "# hello world\n";
987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
988
989 let warnings = rule.check(&ctx).unwrap();
991 assert_eq!(warnings.len(), 0);
992
993 let fixed = rule.fix(&ctx).unwrap();
995 assert_eq!(fixed, content);
996 }
997
998 #[test]
1000 fn test_preserve_all_caps_acronyms() {
1001 let rule = create_rule();
1002 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1003
1004 let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
1006 assert_eq!(fixed, "# Using API in Production\n");
1007
1008 let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
1010 assert_eq!(fixed, "# API and GPU Integration\n");
1011
1012 let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
1014 assert_eq!(fixed, "# IO Performance Guide\n");
1015
1016 let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
1018 assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
1019 }
1020
1021 #[test]
1022 fn test_preserve_acronyms_in_hyphenated_words() {
1023 let rule = create_rule();
1024 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1025
1026 let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
1028 assert_eq!(fixed, "# API-Driven Architecture\n");
1029
1030 let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
1032 assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
1033 }
1034
1035 #[test]
1036 fn test_single_letters_not_treated_as_acronyms() {
1037 let rule = create_rule();
1038 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1039
1040 let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
1042 assert_eq!(fixed, "# I Am a Heading\n");
1043 }
1044
1045 #[test]
1046 fn test_lowercase_terms_need_ignore_words() {
1047 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1048
1049 let rule = create_rule();
1051 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1052 assert_eq!(fixed, "# Using Npm Packages\n");
1053
1054 let config = MD063Config {
1056 enabled: true,
1057 ignore_words: vec!["npm".to_string()],
1058 ..Default::default()
1059 };
1060 let rule = MD063HeadingCapitalization::from_config_struct(config);
1061 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1062 assert_eq!(fixed, "# Using npm Packages\n");
1063 }
1064
1065 #[test]
1066 fn test_acronyms_with_mixed_case_preserved() {
1067 let rule = create_rule();
1068 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1069
1070 let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
1072 assert_eq!(fixed, "# Using API with GitHub\n");
1073 }
1074
1075 #[test]
1076 fn test_real_world_acronyms() {
1077 let rule = create_rule();
1078 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1079
1080 let content = "# FFI bindings for CPU optimization\n";
1082 let fixed = rule.fix(&ctx(content)).unwrap();
1083 assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
1084
1085 let content = "# DOM manipulation and SSR rendering\n";
1086 let fixed = rule.fix(&ctx(content)).unwrap();
1087 assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
1088
1089 let content = "# CVE security and RNN models\n";
1090 let fixed = rule.fix(&ctx(content)).unwrap();
1091 assert_eq!(fixed, "# CVE Security and RNN Models\n");
1092 }
1093
1094 #[test]
1095 fn test_is_all_caps_acronym() {
1096 let rule = create_rule();
1097
1098 assert!(rule.is_all_caps_acronym("API"));
1100 assert!(rule.is_all_caps_acronym("IO"));
1101 assert!(rule.is_all_caps_acronym("GPU"));
1102 assert!(rule.is_all_caps_acronym("HTTP2")); assert!(!rule.is_all_caps_acronym("A"));
1106 assert!(!rule.is_all_caps_acronym("I"));
1107
1108 assert!(!rule.is_all_caps_acronym("Api"));
1110 assert!(!rule.is_all_caps_acronym("npm"));
1111 assert!(!rule.is_all_caps_acronym("iPhone"));
1112 }
1113}