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 first = chars[0];
82 let rest = &chars[1..];
83 let has_upper_in_rest = rest.iter().any(|c| c.is_uppercase());
84 let has_lower_in_rest = rest.iter().any(|c| c.is_lowercase());
85
86 if has_upper_in_rest && has_lower_in_rest {
88 return true;
89 }
90
91 if first.is_lowercase() && has_upper_in_rest {
93 return true;
94 }
95
96 false
97 }
98
99 fn is_all_caps_acronym(&self, word: &str) -> bool {
103 if word.len() < 2 {
105 return false;
106 }
107
108 let mut consecutive_upper = 0;
109 let mut max_consecutive = 0;
110
111 for c in word.chars() {
112 if c.is_uppercase() {
113 consecutive_upper += 1;
114 max_consecutive = max_consecutive.max(consecutive_upper);
115 } else if c.is_lowercase() {
116 return false;
118 } else {
119 consecutive_upper = 0;
121 }
122 }
123
124 max_consecutive >= 2
126 }
127
128 fn should_preserve_word(&self, word: &str) -> bool {
130 if self.config.ignore_words.iter().any(|w| w == word) {
132 return true;
133 }
134
135 if self.config.preserve_cased_words && self.has_internal_capitals(word) {
137 return true;
138 }
139
140 if self.config.preserve_cased_words && self.is_all_caps_acronym(word) {
142 return true;
143 }
144
145 false
146 }
147
148 fn is_lowercase_word(&self, word: &str) -> bool {
150 self.lowercase_set.contains(&word.to_lowercase())
151 }
152
153 fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
155 if word.is_empty() {
156 return word.to_string();
157 }
158
159 if self.should_preserve_word(word) {
161 return word.to_string();
162 }
163
164 if is_first || is_last {
166 return self.capitalize_first(word);
167 }
168
169 if self.is_lowercase_word(word) {
171 return word.to_lowercase();
172 }
173
174 self.capitalize_first(word)
176 }
177
178 fn capitalize_first(&self, word: &str) -> String {
180 let mut chars = word.chars();
181 match chars.next() {
182 None => String::new(),
183 Some(first) => {
184 let first_upper: String = first.to_uppercase().collect();
185 let rest: String = chars.collect();
186 format!("{}{}", first_upper, rest.to_lowercase())
187 }
188 }
189 }
190
191 fn apply_title_case(&self, text: &str) -> String {
193 let base_result = titlecase::titlecase(text);
195
196 let original_words: Vec<&str> = text.split_whitespace().collect();
198 let transformed_words: Vec<&str> = base_result.split_whitespace().collect();
199 let total_words = transformed_words.len();
200
201 let result_words: Vec<String> = transformed_words
202 .iter()
203 .enumerate()
204 .map(|(i, word)| {
205 let is_first = i == 0;
206 let is_last = i == total_words - 1;
207
208 if let Some(original_word) = original_words.get(i)
210 && self.should_preserve_word(original_word)
211 {
212 return (*original_word).to_string();
213 }
214
215 if word.contains('-') {
217 if let Some(original_word) = original_words.get(i) {
219 return self.handle_hyphenated_word_with_original(word, original_word, is_first, is_last);
220 }
221 return self.handle_hyphenated_word(word, is_first, is_last);
222 }
223
224 self.title_case_word(word, is_first, is_last)
225 })
226 .collect();
227
228 result_words.join(" ")
229 }
230
231 fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
233 let parts: Vec<&str> = word.split('-').collect();
234 let total_parts = parts.len();
235
236 let result_parts: Vec<String> = parts
237 .iter()
238 .enumerate()
239 .map(|(i, part)| {
240 let part_is_first = is_first && i == 0;
242 let part_is_last = is_last && i == total_parts - 1;
243 self.title_case_word(part, part_is_first, part_is_last)
244 })
245 .collect();
246
247 result_parts.join("-")
248 }
249
250 fn handle_hyphenated_word_with_original(
252 &self,
253 word: &str,
254 original: &str,
255 is_first: bool,
256 is_last: bool,
257 ) -> String {
258 let parts: Vec<&str> = word.split('-').collect();
259 let original_parts: Vec<&str> = original.split('-').collect();
260 let total_parts = parts.len();
261
262 let result_parts: Vec<String> = parts
263 .iter()
264 .enumerate()
265 .map(|(i, part)| {
266 if let Some(original_part) = original_parts.get(i)
268 && self.should_preserve_word(original_part)
269 {
270 return (*original_part).to_string();
271 }
272
273 let part_is_first = is_first && i == 0;
275 let part_is_last = is_last && i == total_parts - 1;
276 self.title_case_word(part, part_is_first, part_is_last)
277 })
278 .collect();
279
280 result_parts.join("-")
281 }
282
283 fn apply_sentence_case(&self, text: &str) -> String {
285 if text.is_empty() {
286 return text.to_string();
287 }
288
289 let mut result = String::new();
290 let mut current_pos = 0;
291 let mut is_first_word = true;
292
293 for word in text.split_whitespace() {
295 if let Some(pos) = text[current_pos..].find(word) {
296 let abs_pos = current_pos + pos;
297
298 result.push_str(&text[current_pos..abs_pos]);
300
301 if is_first_word {
303 if self.should_preserve_word(word) {
305 result.push_str(word);
307 } else {
308 let mut chars = word.chars();
310 if let Some(first) = chars.next() {
311 let first_upper: String = first.to_uppercase().collect();
312 result.push_str(&first_upper);
313 let rest: String = chars.collect();
314 result.push_str(&rest.to_lowercase());
315 }
316 }
317 is_first_word = false;
318 } else {
319 if self.should_preserve_word(word) {
321 result.push_str(word);
322 } else {
323 result.push_str(&word.to_lowercase());
324 }
325 }
326
327 current_pos = abs_pos + word.len();
328 }
329 }
330
331 if current_pos < text.len() {
333 result.push_str(&text[current_pos..]);
334 }
335
336 result
337 }
338
339 fn apply_all_caps(&self, text: &str) -> String {
341 if text.is_empty() {
342 return text.to_string();
343 }
344
345 let mut result = String::new();
346 let mut current_pos = 0;
347
348 for word in text.split_whitespace() {
350 if let Some(pos) = text[current_pos..].find(word) {
351 let abs_pos = current_pos + pos;
352
353 result.push_str(&text[current_pos..abs_pos]);
355
356 if self.should_preserve_word(word) {
358 result.push_str(word);
359 } else {
360 result.push_str(&word.to_uppercase());
361 }
362
363 current_pos = abs_pos + word.len();
364 }
365 }
366
367 if current_pos < text.len() {
369 result.push_str(&text[current_pos..]);
370 }
371
372 result
373 }
374
375 fn parse_segments(&self, text: &str) -> Vec<HeadingSegment> {
377 let mut segments = Vec::new();
378 let mut last_end = 0;
379
380 let mut special_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
382
383 for mat in INLINE_CODE_REGEX.find_iter(text) {
385 special_regions.push((mat.start(), mat.end(), HeadingSegment::Code(mat.as_str().to_string())));
386 }
387
388 for caps in LINK_REGEX.captures_iter(text) {
390 let full_match = caps.get(0).unwrap();
391 let text_match = caps.get(1).or_else(|| caps.get(2));
392
393 if let Some(text_m) = text_match {
394 special_regions.push((
395 full_match.start(),
396 full_match.end(),
397 HeadingSegment::Link {
398 full: full_match.as_str().to_string(),
399 text_start: text_m.start() - full_match.start(),
400 text_end: text_m.end() - full_match.start(),
401 },
402 ));
403 }
404 }
405
406 special_regions.sort_by_key(|(start, _, _)| *start);
408
409 let mut filtered_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
411 for region in special_regions {
412 let overlaps = filtered_regions.iter().any(|(s, e, _)| region.0 < *e && region.1 > *s);
413 if !overlaps {
414 filtered_regions.push(region);
415 }
416 }
417
418 for (start, end, segment) in filtered_regions {
420 if start > last_end {
422 let text_segment = &text[last_end..start];
423 if !text_segment.is_empty() {
424 segments.push(HeadingSegment::Text(text_segment.to_string()));
425 }
426 }
427 segments.push(segment);
428 last_end = end;
429 }
430
431 if last_end < text.len() {
433 let remaining = &text[last_end..];
434 if !remaining.is_empty() {
435 segments.push(HeadingSegment::Text(remaining.to_string()));
436 }
437 }
438
439 if segments.is_empty() && !text.is_empty() {
441 segments.push(HeadingSegment::Text(text.to_string()));
442 }
443
444 segments
445 }
446
447 fn apply_capitalization(&self, text: &str) -> String {
449 let (main_text, custom_id) = if let Some(mat) = CUSTOM_ID_REGEX.find(text) {
451 (&text[..mat.start()], Some(mat.as_str()))
452 } else {
453 (text, None)
454 };
455
456 let segments = self.parse_segments(main_text);
458
459 let text_segments: Vec<usize> = segments
461 .iter()
462 .enumerate()
463 .filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
464 .collect();
465
466 let last_segment_is_text = segments
470 .last()
471 .map(|s| matches!(s, HeadingSegment::Text(_)))
472 .unwrap_or(false);
473
474 let mut result_parts: Vec<String> = Vec::new();
476
477 for (i, segment) in segments.iter().enumerate() {
478 match segment {
479 HeadingSegment::Text(t) => {
480 let is_first_text = text_segments.first() == Some(&i);
481 let is_last_text = text_segments.last() == Some(&i) && last_segment_is_text;
485
486 let capitalized = match self.config.style {
487 HeadingCapStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
488 HeadingCapStyle::SentenceCase => {
489 if is_first_text {
490 self.apply_sentence_case(t)
491 } else {
492 self.apply_sentence_case_non_first(t)
494 }
495 }
496 HeadingCapStyle::AllCaps => self.apply_all_caps(t),
497 };
498 result_parts.push(capitalized);
499 }
500 HeadingSegment::Code(c) => {
501 result_parts.push(c.clone());
502 }
503 HeadingSegment::Link {
504 full,
505 text_start,
506 text_end,
507 } => {
508 let link_text = &full[*text_start..*text_end];
510 let capitalized_text = match self.config.style {
511 HeadingCapStyle::TitleCase => self.apply_title_case(link_text),
512 HeadingCapStyle::SentenceCase => link_text.to_lowercase(),
513 HeadingCapStyle::AllCaps => self.apply_all_caps(link_text),
514 };
515
516 let mut new_link = String::new();
517 new_link.push_str(&full[..*text_start]);
518 new_link.push_str(&capitalized_text);
519 new_link.push_str(&full[*text_end..]);
520 result_parts.push(new_link);
521 }
522 }
523 }
524
525 let mut result = result_parts.join("");
526
527 if let Some(id) = custom_id {
529 result.push_str(id);
530 }
531
532 result
533 }
534
535 fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
537 let words: Vec<&str> = text.split_whitespace().collect();
538 let total_words = words.len();
539
540 if total_words == 0 {
541 return text.to_string();
542 }
543
544 let result_words: Vec<String> = words
545 .iter()
546 .enumerate()
547 .map(|(i, word)| {
548 let is_first = is_first_segment && i == 0;
549 let is_last = is_last_segment && i == total_words - 1;
550
551 if word.contains('-') {
553 return self.handle_hyphenated_word(word, is_first, is_last);
554 }
555
556 self.title_case_word(word, is_first, is_last)
557 })
558 .collect();
559
560 let mut result = String::new();
562 let mut word_iter = result_words.iter();
563 let mut in_word = false;
564
565 for c in text.chars() {
566 if c.is_whitespace() {
567 if in_word {
568 in_word = false;
569 }
570 result.push(c);
571 } else if !in_word {
572 if let Some(word) = word_iter.next() {
573 result.push_str(word);
574 }
575 in_word = true;
576 }
577 }
578
579 result
580 }
581
582 fn apply_sentence_case_non_first(&self, text: &str) -> String {
584 if text.is_empty() {
585 return text.to_string();
586 }
587
588 let lower = text.to_lowercase();
589 let mut result = String::new();
590 let mut current_pos = 0;
591
592 for word in lower.split_whitespace() {
593 if let Some(pos) = lower[current_pos..].find(word) {
594 let abs_pos = current_pos + pos;
595
596 result.push_str(&lower[current_pos..abs_pos]);
598
599 let original_word = &text[abs_pos..abs_pos + word.len()];
601 if self.should_preserve_word(original_word) {
602 result.push_str(original_word);
603 } else {
604 result.push_str(word);
605 }
606
607 current_pos = abs_pos + word.len();
608 }
609 }
610
611 if current_pos < lower.len() {
613 result.push_str(&lower[current_pos..]);
614 }
615
616 result
617 }
618
619 fn get_line_byte_range(&self, content: &str, line_num: usize, line_index: &LineIndex) -> Range<usize> {
621 let start_pos = line_index.get_line_start_byte(line_num).unwrap_or(content.len());
622 let line = content.lines().nth(line_num - 1).unwrap_or("");
623 Range {
624 start: start_pos,
625 end: start_pos + line.len(),
626 }
627 }
628
629 fn fix_atx_heading(&self, _line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
631 let indent = " ".repeat(heading.marker_column);
633 let hashes = "#".repeat(heading.level as usize);
634
635 let fixed_text = self.apply_capitalization(&heading.raw_text);
637
638 let closing = &heading.closing_sequence;
640 if heading.has_closing_sequence {
641 format!("{indent}{hashes} {fixed_text} {closing}")
642 } else {
643 format!("{indent}{hashes} {fixed_text}")
644 }
645 }
646
647 fn fix_setext_heading(&self, line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
649 let fixed_text = self.apply_capitalization(&heading.raw_text);
651
652 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
654
655 format!("{leading_ws}{fixed_text}")
656 }
657}
658
659impl Rule for MD063HeadingCapitalization {
660 fn name(&self) -> &'static str {
661 "MD063"
662 }
663
664 fn description(&self) -> &'static str {
665 "Heading capitalization"
666 }
667
668 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
669 !self.config.enabled || !ctx.likely_has_headings() || !ctx.lines.iter().any(|line| line.heading.is_some())
671 }
672
673 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
674 if !self.config.enabled {
675 return Ok(Vec::new());
676 }
677
678 let content = ctx.content;
679
680 if content.is_empty() {
681 return Ok(Vec::new());
682 }
683
684 let mut warnings = Vec::new();
685 let line_index = &ctx.line_index;
686
687 for (line_num, line_info) in ctx.lines.iter().enumerate() {
688 if let Some(heading) = &line_info.heading {
689 if heading.level < self.config.min_level || heading.level > self.config.max_level {
691 continue;
692 }
693
694 if line_info.indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
696 continue;
697 }
698
699 let original_text = &heading.raw_text;
701 let fixed_text = self.apply_capitalization(original_text);
702
703 if original_text != &fixed_text {
704 let line = line_info.content(ctx.content);
705 let style_name = match self.config.style {
706 HeadingCapStyle::TitleCase => "title case",
707 HeadingCapStyle::SentenceCase => "sentence case",
708 HeadingCapStyle::AllCaps => "ALL CAPS",
709 };
710
711 warnings.push(LintWarning {
712 rule_name: Some(self.name().to_string()),
713 line: line_num + 1,
714 column: heading.content_column + 1,
715 end_line: line_num + 1,
716 end_column: heading.content_column + 1 + original_text.len(),
717 message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
718 severity: Severity::Warning,
719 fix: Some(Fix {
720 range: self.get_line_byte_range(content, line_num + 1, line_index),
721 replacement: match heading.style {
722 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
723 _ => self.fix_setext_heading(line, heading),
724 },
725 }),
726 });
727 }
728 }
729 }
730
731 Ok(warnings)
732 }
733
734 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
735 if !self.config.enabled {
736 return Ok(ctx.content.to_string());
737 }
738
739 let content = ctx.content;
740
741 if content.is_empty() {
742 return Ok(content.to_string());
743 }
744
745 let lines: Vec<&str> = content.lines().collect();
746 let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
747
748 for (line_num, line_info) in ctx.lines.iter().enumerate() {
749 if let Some(heading) = &line_info.heading {
750 if heading.level < self.config.min_level || heading.level > self.config.max_level {
752 continue;
753 }
754
755 if line_info.indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
757 continue;
758 }
759
760 let original_text = &heading.raw_text;
761 let fixed_text = self.apply_capitalization(original_text);
762
763 if original_text != &fixed_text {
764 let line = line_info.content(ctx.content);
765 fixed_lines[line_num] = match heading.style {
766 crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
767 _ => self.fix_setext_heading(line, heading),
768 };
769 }
770 }
771 }
772
773 let mut result = String::with_capacity(content.len());
775 for (i, line) in fixed_lines.iter().enumerate() {
776 result.push_str(line);
777 if i < fixed_lines.len() - 1 || content.ends_with('\n') {
778 result.push('\n');
779 }
780 }
781
782 Ok(result)
783 }
784
785 fn as_any(&self) -> &dyn std::any::Any {
786 self
787 }
788
789 fn default_config_section(&self) -> Option<(String, toml::Value)> {
790 let json_value = serde_json::to_value(&self.config).ok()?;
791 Some((
792 self.name().to_string(),
793 crate::rule_config_serde::json_to_toml_value(&json_value)?,
794 ))
795 }
796
797 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
798 where
799 Self: Sized,
800 {
801 let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
802 Box::new(Self::from_config_struct(rule_config))
803 }
804}
805
806#[cfg(test)]
807mod tests {
808 use super::*;
809 use crate::lint_context::LintContext;
810
811 fn create_rule() -> MD063HeadingCapitalization {
812 let config = MD063Config {
813 enabled: true,
814 ..Default::default()
815 };
816 MD063HeadingCapitalization::from_config_struct(config)
817 }
818
819 fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
820 let config = MD063Config {
821 enabled: true,
822 style,
823 ..Default::default()
824 };
825 MD063HeadingCapitalization::from_config_struct(config)
826 }
827
828 #[test]
830 fn test_title_case_basic() {
831 let rule = create_rule();
832 let content = "# hello world\n";
833 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
834 let result = rule.check(&ctx).unwrap();
835 assert_eq!(result.len(), 1);
836 assert!(result[0].message.contains("Hello World"));
837 }
838
839 #[test]
840 fn test_title_case_lowercase_words() {
841 let rule = create_rule();
842 let content = "# the quick brown fox\n";
843 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
844 let result = rule.check(&ctx).unwrap();
845 assert_eq!(result.len(), 1);
846 assert!(result[0].message.contains("The Quick Brown Fox"));
848 }
849
850 #[test]
851 fn test_title_case_already_correct() {
852 let rule = create_rule();
853 let content = "# The Quick Brown Fox\n";
854 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
855 let result = rule.check(&ctx).unwrap();
856 assert!(result.is_empty(), "Already correct heading should not be flagged");
857 }
858
859 #[test]
860 fn test_title_case_hyphenated() {
861 let rule = create_rule();
862 let content = "# self-documenting code\n";
863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864 let result = rule.check(&ctx).unwrap();
865 assert_eq!(result.len(), 1);
866 assert!(result[0].message.contains("Self-Documenting Code"));
867 }
868
869 #[test]
871 fn test_sentence_case_basic() {
872 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
873 let content = "# The Quick Brown Fox\n";
874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875 let result = rule.check(&ctx).unwrap();
876 assert_eq!(result.len(), 1);
877 assert!(result[0].message.contains("The quick brown fox"));
878 }
879
880 #[test]
881 fn test_sentence_case_already_correct() {
882 let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
883 let content = "# The quick brown fox\n";
884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
885 let result = rule.check(&ctx).unwrap();
886 assert!(result.is_empty());
887 }
888
889 #[test]
891 fn test_all_caps_basic() {
892 let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
893 let content = "# hello world\n";
894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
895 let result = rule.check(&ctx).unwrap();
896 assert_eq!(result.len(), 1);
897 assert!(result[0].message.contains("HELLO WORLD"));
898 }
899
900 #[test]
902 fn test_preserve_ignore_words() {
903 let config = MD063Config {
904 enabled: true,
905 ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
906 ..Default::default()
907 };
908 let rule = MD063HeadingCapitalization::from_config_struct(config);
909
910 let content = "# using iPhone on macOS\n";
911 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
912 let result = rule.check(&ctx).unwrap();
913 assert_eq!(result.len(), 1);
914 assert!(result[0].message.contains("iPhone"));
916 assert!(result[0].message.contains("macOS"));
917 }
918
919 #[test]
920 fn test_preserve_cased_words() {
921 let rule = create_rule();
922 let content = "# using GitHub actions\n";
923 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
924 let result = rule.check(&ctx).unwrap();
925 assert_eq!(result.len(), 1);
926 assert!(result[0].message.contains("GitHub"));
928 }
929
930 #[test]
932 fn test_inline_code_preserved() {
933 let rule = create_rule();
934 let content = "# using `const` in javascript\n";
935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
936 let result = rule.check(&ctx).unwrap();
937 assert_eq!(result.len(), 1);
938 assert!(result[0].message.contains("`const`"));
940 assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
941 }
942
943 #[test]
945 fn test_level_filter() {
946 let config = MD063Config {
947 enabled: true,
948 min_level: 2,
949 max_level: 4,
950 ..Default::default()
951 };
952 let rule = MD063HeadingCapitalization::from_config_struct(config);
953
954 let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956 let result = rule.check(&ctx).unwrap();
957
958 assert_eq!(result.len(), 2);
960 assert_eq!(result[0].line, 2); assert_eq!(result[1].line, 3); }
963
964 #[test]
966 fn test_fix_atx_heading() {
967 let rule = create_rule();
968 let content = "# hello world\n";
969 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
970 let fixed = rule.fix(&ctx).unwrap();
971 assert_eq!(fixed, "# Hello World\n");
972 }
973
974 #[test]
975 fn test_fix_multiple_headings() {
976 let rule = create_rule();
977 let content = "# first heading\n\n## second heading\n";
978 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
979 let fixed = rule.fix(&ctx).unwrap();
980 assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
981 }
982
983 #[test]
985 fn test_setext_heading() {
986 let rule = create_rule();
987 let content = "hello world\n============\n";
988 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
989 let result = rule.check(&ctx).unwrap();
990 assert_eq!(result.len(), 1);
991 assert!(result[0].message.contains("Hello World"));
992 }
993
994 #[test]
996 fn test_custom_id_preserved() {
997 let rule = create_rule();
998 let content = "# getting started {#intro}\n";
999 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1000 let result = rule.check(&ctx).unwrap();
1001 assert_eq!(result.len(), 1);
1002 assert!(result[0].message.contains("{#intro}"));
1004 }
1005
1006 #[test]
1007 fn test_md063_disabled_by_default() {
1008 let rule = MD063HeadingCapitalization::new();
1009 let content = "# hello world\n";
1010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1011
1012 let warnings = rule.check(&ctx).unwrap();
1014 assert_eq!(warnings.len(), 0);
1015
1016 let fixed = rule.fix(&ctx).unwrap();
1018 assert_eq!(fixed, content);
1019 }
1020
1021 #[test]
1023 fn test_preserve_all_caps_acronyms() {
1024 let rule = create_rule();
1025 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1026
1027 let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
1029 assert_eq!(fixed, "# Using API in Production\n");
1030
1031 let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
1033 assert_eq!(fixed, "# API and GPU Integration\n");
1034
1035 let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
1037 assert_eq!(fixed, "# IO Performance Guide\n");
1038
1039 let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
1041 assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
1042 }
1043
1044 #[test]
1045 fn test_preserve_acronyms_in_hyphenated_words() {
1046 let rule = create_rule();
1047 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1048
1049 let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
1051 assert_eq!(fixed, "# API-Driven Architecture\n");
1052
1053 let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
1055 assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
1056 }
1057
1058 #[test]
1059 fn test_single_letters_not_treated_as_acronyms() {
1060 let rule = create_rule();
1061 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1062
1063 let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
1065 assert_eq!(fixed, "# I Am a Heading\n");
1066 }
1067
1068 #[test]
1069 fn test_lowercase_terms_need_ignore_words() {
1070 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1071
1072 let rule = create_rule();
1074 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1075 assert_eq!(fixed, "# Using Npm Packages\n");
1076
1077 let config = MD063Config {
1079 enabled: true,
1080 ignore_words: vec!["npm".to_string()],
1081 ..Default::default()
1082 };
1083 let rule = MD063HeadingCapitalization::from_config_struct(config);
1084 let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
1085 assert_eq!(fixed, "# Using npm Packages\n");
1086 }
1087
1088 #[test]
1089 fn test_acronyms_with_mixed_case_preserved() {
1090 let rule = create_rule();
1091 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1092
1093 let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
1095 assert_eq!(fixed, "# Using API with GitHub\n");
1096 }
1097
1098 #[test]
1099 fn test_real_world_acronyms() {
1100 let rule = create_rule();
1101 let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
1102
1103 let content = "# FFI bindings for CPU optimization\n";
1105 let fixed = rule.fix(&ctx(content)).unwrap();
1106 assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
1107
1108 let content = "# DOM manipulation and SSR rendering\n";
1109 let fixed = rule.fix(&ctx(content)).unwrap();
1110 assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
1111
1112 let content = "# CVE security and RNN models\n";
1113 let fixed = rule.fix(&ctx(content)).unwrap();
1114 assert_eq!(fixed, "# CVE Security and RNN Models\n");
1115 }
1116
1117 #[test]
1118 fn test_is_all_caps_acronym() {
1119 let rule = create_rule();
1120
1121 assert!(rule.is_all_caps_acronym("API"));
1123 assert!(rule.is_all_caps_acronym("IO"));
1124 assert!(rule.is_all_caps_acronym("GPU"));
1125 assert!(rule.is_all_caps_acronym("HTTP2")); assert!(!rule.is_all_caps_acronym("A"));
1129 assert!(!rule.is_all_caps_acronym("I"));
1130
1131 assert!(!rule.is_all_caps_acronym("Api"));
1133 assert!(!rule.is_all_caps_acronym("npm"));
1134 assert!(!rule.is_all_caps_acronym("iPhone"));
1135 }
1136
1137 #[test]
1138 fn test_sentence_case_ignore_words_first_word() {
1139 let config = MD063Config {
1140 enabled: true,
1141 style: HeadingCapStyle::SentenceCase,
1142 ignore_words: vec!["nvim".to_string()],
1143 ..Default::default()
1144 };
1145 let rule = MD063HeadingCapitalization::from_config_struct(config);
1146
1147 let content = "# nvim config\n";
1149 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1150 let result = rule.check(&ctx).unwrap();
1151 assert!(
1152 result.is_empty(),
1153 "nvim in ignore-words should not be flagged. Got: {result:?}"
1154 );
1155
1156 let fixed = rule.fix(&ctx).unwrap();
1158 assert_eq!(fixed, "# nvim config\n");
1159 }
1160
1161 #[test]
1162 fn test_sentence_case_ignore_words_not_first() {
1163 let config = MD063Config {
1164 enabled: true,
1165 style: HeadingCapStyle::SentenceCase,
1166 ignore_words: vec!["nvim".to_string()],
1167 ..Default::default()
1168 };
1169 let rule = MD063HeadingCapitalization::from_config_struct(config);
1170
1171 let content = "# Using nvim editor\n";
1173 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1174 let result = rule.check(&ctx).unwrap();
1175 assert!(
1176 result.is_empty(),
1177 "nvim in ignore-words should be preserved. Got: {result:?}"
1178 );
1179 }
1180
1181 #[test]
1182 fn test_preserve_cased_words_ios() {
1183 let config = MD063Config {
1184 enabled: true,
1185 style: HeadingCapStyle::SentenceCase,
1186 preserve_cased_words: true,
1187 ..Default::default()
1188 };
1189 let rule = MD063HeadingCapitalization::from_config_struct(config);
1190
1191 let content = "## This is iOS\n";
1193 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1194 let result = rule.check(&ctx).unwrap();
1195 assert!(
1196 result.is_empty(),
1197 "iOS should be preserved with preserve-cased-words. Got: {result:?}"
1198 );
1199
1200 let fixed = rule.fix(&ctx).unwrap();
1202 assert_eq!(fixed, "## This is iOS\n");
1203 }
1204
1205 #[test]
1206 fn test_preserve_cased_words_ios_title_case() {
1207 let config = MD063Config {
1208 enabled: true,
1209 style: HeadingCapStyle::TitleCase,
1210 preserve_cased_words: true,
1211 ..Default::default()
1212 };
1213 let rule = MD063HeadingCapitalization::from_config_struct(config);
1214
1215 let content = "# developing for iOS\n";
1217 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1218 let fixed = rule.fix(&ctx).unwrap();
1219 assert_eq!(fixed, "# Developing for iOS\n");
1220 }
1221
1222 #[test]
1223 fn test_has_internal_capitals_ios() {
1224 let rule = create_rule();
1225
1226 assert!(
1228 rule.has_internal_capitals("iOS"),
1229 "iOS has mixed case (lowercase i, uppercase OS)"
1230 );
1231
1232 assert!(rule.has_internal_capitals("iPhone"));
1234 assert!(rule.has_internal_capitals("macOS"));
1235 assert!(rule.has_internal_capitals("GitHub"));
1236 assert!(rule.has_internal_capitals("JavaScript"));
1237 assert!(rule.has_internal_capitals("eBay"));
1238
1239 assert!(!rule.has_internal_capitals("API"));
1241 assert!(!rule.has_internal_capitals("GPU"));
1242
1243 assert!(!rule.has_internal_capitals("npm"));
1245 assert!(!rule.has_internal_capitals("config"));
1246
1247 assert!(!rule.has_internal_capitals("The"));
1249 assert!(!rule.has_internal_capitals("Hello"));
1250 }
1251
1252 #[test]
1253 fn test_lowercase_words_before_trailing_code() {
1254 let config = MD063Config {
1255 enabled: true,
1256 style: HeadingCapStyle::TitleCase,
1257 lowercase_words: vec![
1258 "a".to_string(),
1259 "an".to_string(),
1260 "and".to_string(),
1261 "at".to_string(),
1262 "but".to_string(),
1263 "by".to_string(),
1264 "for".to_string(),
1265 "from".to_string(),
1266 "into".to_string(),
1267 "nor".to_string(),
1268 "on".to_string(),
1269 "onto".to_string(),
1270 "or".to_string(),
1271 "the".to_string(),
1272 "to".to_string(),
1273 "upon".to_string(),
1274 "via".to_string(),
1275 "vs".to_string(),
1276 "with".to_string(),
1277 "without".to_string(),
1278 ],
1279 preserve_cased_words: true,
1280 ..Default::default()
1281 };
1282 let rule = MD063HeadingCapitalization::from_config_struct(config);
1283
1284 let content = "## subtitle with a `app`\n";
1289 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1290 let result = rule.check(&ctx).unwrap();
1291
1292 assert!(!result.is_empty(), "Should flag incorrect capitalization");
1294 let fixed = rule.fix(&ctx).unwrap();
1295 assert!(
1297 fixed.contains("with a `app`"),
1298 "Expected 'with a `app`' but got: {fixed:?}"
1299 );
1300 assert!(
1301 !fixed.contains("with A `app`"),
1302 "Should not capitalize 'a' to 'A'. Got: {fixed:?}"
1303 );
1304 assert!(
1306 fixed.contains("Subtitle with a `app`"),
1307 "Expected 'Subtitle with a `app`' but got: {fixed:?}"
1308 );
1309 }
1310
1311 #[test]
1312 fn test_lowercase_words_preserved_before_trailing_code_variant() {
1313 let config = MD063Config {
1314 enabled: true,
1315 style: HeadingCapStyle::TitleCase,
1316 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1317 ..Default::default()
1318 };
1319 let rule = MD063HeadingCapitalization::from_config_struct(config);
1320
1321 let content = "## Title with the `code`\n";
1323 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1324 let fixed = rule.fix(&ctx).unwrap();
1325 assert!(
1327 fixed.contains("with the `code`"),
1328 "Expected 'with the `code`' but got: {fixed:?}"
1329 );
1330 assert!(
1331 !fixed.contains("with The `code`"),
1332 "Should not capitalize 'the' to 'The'. Got: {fixed:?}"
1333 );
1334 }
1335
1336 #[test]
1337 fn test_last_word_capitalized_when_no_trailing_code() {
1338 let config = MD063Config {
1341 enabled: true,
1342 style: HeadingCapStyle::TitleCase,
1343 lowercase_words: vec!["a".to_string(), "the".to_string()],
1344 ..Default::default()
1345 };
1346 let rule = MD063HeadingCapitalization::from_config_struct(config);
1347
1348 let content = "## title with a word\n";
1351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1352 let fixed = rule.fix(&ctx).unwrap();
1353 assert!(
1355 fixed.contains("With a Word"),
1356 "Expected 'With a Word' but got: {fixed:?}"
1357 );
1358 }
1359
1360 #[test]
1361 fn test_multiple_lowercase_words_before_code() {
1362 let config = MD063Config {
1363 enabled: true,
1364 style: HeadingCapStyle::TitleCase,
1365 lowercase_words: vec![
1366 "a".to_string(),
1367 "the".to_string(),
1368 "with".to_string(),
1369 "for".to_string(),
1370 ],
1371 ..Default::default()
1372 };
1373 let rule = MD063HeadingCapitalization::from_config_struct(config);
1374
1375 let content = "## Guide for the `user`\n";
1377 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1378 let fixed = rule.fix(&ctx).unwrap();
1379 assert!(
1380 fixed.contains("for the `user`"),
1381 "Expected 'for the `user`' but got: {fixed:?}"
1382 );
1383 assert!(
1384 !fixed.contains("For The `user`"),
1385 "Should not capitalize lowercase words before code. Got: {fixed:?}"
1386 );
1387 }
1388
1389 #[test]
1390 fn test_code_in_middle_normal_rules_apply() {
1391 let config = MD063Config {
1392 enabled: true,
1393 style: HeadingCapStyle::TitleCase,
1394 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1395 ..Default::default()
1396 };
1397 let rule = MD063HeadingCapitalization::from_config_struct(config);
1398
1399 let content = "## Using `const` for the code\n";
1401 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1402 let fixed = rule.fix(&ctx).unwrap();
1403 assert!(
1405 fixed.contains("for the Code"),
1406 "Expected 'for the Code' but got: {fixed:?}"
1407 );
1408 }
1409
1410 #[test]
1411 fn test_link_at_end_same_as_code() {
1412 let config = MD063Config {
1413 enabled: true,
1414 style: HeadingCapStyle::TitleCase,
1415 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1416 ..Default::default()
1417 };
1418 let rule = MD063HeadingCapitalization::from_config_struct(config);
1419
1420 let content = "## Guide for the [link](./page.md)\n";
1422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1423 let fixed = rule.fix(&ctx).unwrap();
1424 assert!(
1426 fixed.contains("for the [Link]"),
1427 "Expected 'for the [Link]' but got: {fixed:?}"
1428 );
1429 assert!(
1430 !fixed.contains("for The [Link]"),
1431 "Should not capitalize 'the' before link. Got: {fixed:?}"
1432 );
1433 }
1434
1435 #[test]
1436 fn test_multiple_code_segments() {
1437 let config = MD063Config {
1438 enabled: true,
1439 style: HeadingCapStyle::TitleCase,
1440 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1441 ..Default::default()
1442 };
1443 let rule = MD063HeadingCapitalization::from_config_struct(config);
1444
1445 let content = "## Using `const` with a `variable`\n";
1447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1448 let fixed = rule.fix(&ctx).unwrap();
1449 assert!(
1451 fixed.contains("with a `variable`"),
1452 "Expected 'with a `variable`' but got: {fixed:?}"
1453 );
1454 assert!(
1455 !fixed.contains("with A `variable`"),
1456 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1457 );
1458 }
1459
1460 #[test]
1461 fn test_code_and_link_combination() {
1462 let config = MD063Config {
1463 enabled: true,
1464 style: HeadingCapStyle::TitleCase,
1465 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1466 ..Default::default()
1467 };
1468 let rule = MD063HeadingCapitalization::from_config_struct(config);
1469
1470 let content = "## Guide for the `code` [link](./page.md)\n";
1472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1473 let fixed = rule.fix(&ctx).unwrap();
1474 assert!(
1476 fixed.contains("for the `code`"),
1477 "Expected 'for the `code`' but got: {fixed:?}"
1478 );
1479 }
1480
1481 #[test]
1482 fn test_text_after_code_capitalizes_last() {
1483 let config = MD063Config {
1484 enabled: true,
1485 style: HeadingCapStyle::TitleCase,
1486 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1487 ..Default::default()
1488 };
1489 let rule = MD063HeadingCapitalization::from_config_struct(config);
1490
1491 let content = "## Using `const` for the code\n";
1493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1494 let fixed = rule.fix(&ctx).unwrap();
1495 assert!(
1497 fixed.contains("for the Code"),
1498 "Expected 'for the Code' but got: {fixed:?}"
1499 );
1500 }
1501
1502 #[test]
1503 fn test_preserve_cased_words_with_trailing_code() {
1504 let config = MD063Config {
1505 enabled: true,
1506 style: HeadingCapStyle::TitleCase,
1507 lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
1508 preserve_cased_words: true,
1509 ..Default::default()
1510 };
1511 let rule = MD063HeadingCapitalization::from_config_struct(config);
1512
1513 let content = "## Guide for iOS `app`\n";
1515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1516 let fixed = rule.fix(&ctx).unwrap();
1517 assert!(
1519 fixed.contains("for iOS `app`"),
1520 "Expected 'for iOS `app`' but got: {fixed:?}"
1521 );
1522 assert!(
1523 !fixed.contains("For iOS `app`"),
1524 "Should not capitalize 'for' before trailing code. Got: {fixed:?}"
1525 );
1526 }
1527
1528 #[test]
1529 fn test_ignore_words_with_trailing_code() {
1530 let config = MD063Config {
1531 enabled: true,
1532 style: HeadingCapStyle::TitleCase,
1533 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1534 ignore_words: vec!["npm".to_string()],
1535 ..Default::default()
1536 };
1537 let rule = MD063HeadingCapitalization::from_config_struct(config);
1538
1539 let content = "## Using npm with a `script`\n";
1541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1542 let fixed = rule.fix(&ctx).unwrap();
1543 assert!(
1545 fixed.contains("npm with a `script`"),
1546 "Expected 'npm with a `script`' but got: {fixed:?}"
1547 );
1548 assert!(
1549 !fixed.contains("with A `script`"),
1550 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1551 );
1552 }
1553
1554 #[test]
1555 fn test_empty_text_segment_edge_case() {
1556 let config = MD063Config {
1557 enabled: true,
1558 style: HeadingCapStyle::TitleCase,
1559 lowercase_words: vec!["a".to_string(), "with".to_string()],
1560 ..Default::default()
1561 };
1562 let rule = MD063HeadingCapitalization::from_config_struct(config);
1563
1564 let content = "## `start` with a `end`\n";
1566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1567 let fixed = rule.fix(&ctx).unwrap();
1568 assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
1571 assert!(
1572 !fixed.contains("A `end`"),
1573 "Should not capitalize 'a' before trailing code. Got: {fixed:?}"
1574 );
1575 }
1576
1577 #[test]
1578 fn test_sentence_case_with_trailing_code() {
1579 let config = MD063Config {
1580 enabled: true,
1581 style: HeadingCapStyle::SentenceCase,
1582 lowercase_words: vec!["a".to_string(), "the".to_string()],
1583 ..Default::default()
1584 };
1585 let rule = MD063HeadingCapitalization::from_config_struct(config);
1586
1587 let content = "## guide for the `user`\n";
1589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1590 let fixed = rule.fix(&ctx).unwrap();
1591 assert!(
1593 fixed.contains("Guide for the `user`"),
1594 "Expected 'Guide for the `user`' but got: {fixed:?}"
1595 );
1596 }
1597
1598 #[test]
1599 fn test_hyphenated_word_before_code() {
1600 let config = MD063Config {
1601 enabled: true,
1602 style: HeadingCapStyle::TitleCase,
1603 lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
1604 ..Default::default()
1605 };
1606 let rule = MD063HeadingCapitalization::from_config_struct(config);
1607
1608 let content = "## Self-contained with a `feature`\n";
1610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1611 let fixed = rule.fix(&ctx).unwrap();
1612 assert!(
1614 fixed.contains("with a `feature`"),
1615 "Expected 'with a `feature`' but got: {fixed:?}"
1616 );
1617 }
1618}