1use crate::utils::fast_hash;
2use crate::utils::regex_cache::{escape_regex, get_cached_regex};
3
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::frontmatter_values;
6use crate::utils::range_utils::byte_to_char_count;
7use std::collections::{HashMap, HashSet};
8use std::sync::{Arc, Mutex};
9
10mod md044_config;
11pub(super) use md044_config::MD044Config;
12
13type WarningPosition = (usize, usize, String, usize); #[derive(Clone)]
71pub struct MD044ProperNames {
72 config: MD044Config,
73 combined_pattern: Option<String>,
75 name_variants: HashMap<String, usize>,
77 ignore_fields: HashSet<String>,
79 content_cache: Arc<Mutex<HashMap<u64, Vec<WarningPosition>>>>,
88}
89
90impl MD044ProperNames {
91 pub fn new(names: Vec<String>, code_blocks: bool) -> Self {
92 let config = MD044Config {
93 names,
94 code_blocks,
95 ..Default::default()
96 };
97 Self::from_config_struct(config)
98 }
99
100 fn ascii_normalize(s: &str) -> String {
102 s.replace(['é', 'è', 'ê', 'ë'], "e")
103 .replace(['à', 'á', 'â', 'ä', 'ã', 'å'], "a")
104 .replace(['ï', 'î', 'í', 'ì'], "i")
105 .replace(['ü', 'ú', 'ù', 'û'], "u")
106 .replace(['ö', 'ó', 'ò', 'ô', 'õ'], "o")
107 .replace('ñ', "n")
108 .replace('ç', "c")
109 }
110
111 pub fn from_config_struct(config: MD044Config) -> Self {
112 let combined_pattern = Self::create_combined_pattern(&config);
113 let name_variants = Self::build_name_variants(&config);
114 let ignore_fields = config
115 .ignore_frontmatter_fields
116 .iter()
117 .flatten()
118 .map(|f| f.to_lowercase())
119 .collect();
120 Self {
121 config,
122 combined_pattern,
123 name_variants,
124 ignore_fields,
125 content_cache: Arc::new(Mutex::new(HashMap::new())),
126 }
127 }
128
129 fn create_combined_pattern(config: &MD044Config) -> Option<String> {
131 if config.names.is_empty() {
132 return None;
133 }
134
135 let mut patterns: Vec<String> = config
137 .names
138 .iter()
139 .flat_map(|name| {
140 let mut variations = vec![];
141 let lower_name = name.to_lowercase();
142
143 variations.push(escape_regex(&lower_name));
145
146 let lower_name_no_dots = lower_name.replace('.', "");
148 if lower_name != lower_name_no_dots {
149 variations.push(escape_regex(&lower_name_no_dots));
150 }
151
152 let ascii_normalized = Self::ascii_normalize(&lower_name);
154
155 if ascii_normalized != lower_name {
156 variations.push(escape_regex(&ascii_normalized));
157
158 let ascii_no_dots = ascii_normalized.replace('.', "");
160 if ascii_normalized != ascii_no_dots {
161 variations.push(escape_regex(&ascii_no_dots));
162 }
163 }
164
165 variations
166 })
167 .collect();
168
169 patterns.sort_by_key(|b| std::cmp::Reverse(b.len()));
171
172 Some(format!(r"(?i)({})", patterns.join("|")))
175 }
176
177 fn build_name_variants(config: &MD044Config) -> HashMap<String, usize> {
178 let mut variants = HashMap::new();
179 for (index, name) in config.names.iter().enumerate() {
180 let lower_name = name.to_lowercase();
181 variants.entry(lower_name.clone()).or_insert(index);
182
183 let lower_no_dots = lower_name.replace('.', "");
184 if lower_name != lower_no_dots {
185 variants.entry(lower_no_dots).or_insert(index);
186 }
187
188 let ascii_normalized = Self::ascii_normalize(&lower_name);
189 if ascii_normalized != lower_name {
190 variants.entry(ascii_normalized.clone()).or_insert(index);
191
192 let ascii_no_dots = ascii_normalized.replace('.', "");
193 if ascii_normalized != ascii_no_dots {
194 variants.entry(ascii_no_dots).or_insert(index);
195 }
196 }
197 }
198
199 variants
200 }
201
202 fn find_name_violations(&self, ctx: &crate::lint_context::LintContext) -> Vec<WarningPosition> {
204 let content = ctx.content;
205 let hash = fast_hash(content);
207 {
208 if let Ok(cache) = self.content_cache.lock()
210 && let Some(cached) = cache.get(&hash)
211 {
212 return cached.clone();
213 }
214 }
215
216 let mut violations = Vec::new();
217
218 let combined_regex = match &self.combined_pattern {
220 Some(pattern) => match get_cached_regex(pattern) {
221 Ok(regex) => regex,
222 Err(_) => return Vec::new(),
223 },
224 None => return Vec::new(),
225 };
226
227 let field_map = if self.ignore_fields.is_empty() {
229 Vec::new()
230 } else {
231 frontmatter_values::field_map(ctx)
232 };
233
234 let directive_comments =
239 crate::inline_config::config_comment_ranges(content, |open| ctx.is_in_code_block_or_span(open));
240
241 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
243 let line_num = line_idx + 1;
244 let line = line_info.content(ctx.content);
245
246 let trimmed = line.trim_start();
248 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
249 continue;
250 }
251
252 if !self.config.code_blocks && line_info.in_code_block {
254 continue;
255 }
256
257 if !self.config.html_elements && line_info.in_html_block {
259 continue;
260 }
261
262 if !self.config.html_comments && line_info.in_html_comment {
264 continue;
265 }
266
267 if line_info.in_jsx_expression || line_info.in_mdx_comment {
269 continue;
270 }
271
272 if line_info.in_obsidian_comment {
274 continue;
275 }
276
277 let fm_value_offset = if line_info.in_front_matter {
280 frontmatter_values::value_offset(line)
281 } else {
282 0
283 };
284 if fm_value_offset == usize::MAX {
285 continue;
286 }
287 if line_info.in_front_matter
288 && let Some(Some(field)) = field_map.get(line_idx)
289 && self.ignore_fields.contains(field)
290 {
291 continue;
292 }
293 let fm_value_span = if line_info.in_front_matter {
294 frontmatter_values::value_span(line)
295 } else {
296 None
297 };
298
299 let line_lower = line.to_lowercase();
301 let has_line_matches = self.name_variants.keys().any(|name| line_lower.contains(name));
302
303 if !has_line_matches {
304 continue;
305 }
306
307 for cap in combined_regex.find_iter(line) {
309 let found_name = &line[cap.start()..cap.end()];
310
311 let start_pos = cap.start();
313 let end_pos = cap.end();
314
315 if start_pos < fm_value_offset {
317 continue;
318 }
319
320 let byte_pos = line_info.byte_offset + start_pos;
322 if crate::inline_config::offset_in_ranges(&directive_comments, byte_pos) {
323 continue;
324 }
325 if ctx.is_in_html_tag(byte_pos) {
326 continue;
327 }
328
329 if ctx.is_in_shortcode(byte_pos) {
336 continue;
337 }
338
339 if !Self::is_at_word_boundary(line, start_pos, true) || !Self::is_at_word_boundary(line, end_pos, false)
340 {
341 continue; }
343
344 if self.config.whole_words
345 && ((Self::is_joined_identifier_boundary(line, start_pos, true)
346 && !Self::is_underscore_emphasis_boundary(ctx, byte_pos, true))
347 || (Self::is_joined_identifier_boundary(line, end_pos, false)
348 && !Self::is_underscore_emphasis_boundary(ctx, byte_pos + found_name.len(), false)))
349 {
350 continue;
351 }
352
353 if !self.config.code_blocks {
355 if ctx.is_in_code_block_or_span(byte_pos) {
356 continue;
357 }
358 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
362 && Self::is_in_backtick_code_in_line(line, start_pos)
363 {
364 continue;
365 }
366 }
367
368 if Self::is_in_link(ctx, byte_pos) {
370 continue;
371 }
372
373 if Self::is_in_angle_bracket_url(line, start_pos) {
377 continue;
378 }
379
380 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
384 && Self::is_in_markdown_link_url(line, start_pos)
385 {
386 continue;
387 }
388
389 if Self::is_in_wikilink_url(ctx, byte_pos) {
394 continue;
395 }
396
397 if ctx.is_in_bare_url(byte_pos) {
403 continue;
404 }
405
406 if let Some(fm_value) = fm_value_span
413 && Self::is_in_path_like_token(line, start_pos, fm_value)
414 {
415 continue;
416 }
417
418 if let Some(&proper_name_index) = self.name_variants.get(&found_name.to_lowercase()) {
420 if found_name != self.config.names[proper_name_index] {
422 violations.push((line_num, cap.start() + 1, found_name.to_string(), proper_name_index));
423 }
424 }
425 }
426 }
427
428 if let Ok(mut cache) = self.content_cache.lock() {
430 cache.insert(hash, violations.clone());
431 }
432 violations
433 }
434
435 fn is_in_link(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
443 use pulldown_cmark::LinkType;
444
445 if let Some(link) = ctx.link_containing(byte_pos) {
446 let (text_start, text_end) = if matches!(link.link_type, LinkType::WikiLink { .. }) {
448 let span = &ctx.content[link.byte_offset..link.byte_end];
455 let start = match span.find('|') {
456 Some(pipe) => link.byte_offset + pipe + 1,
457 None => link.byte_offset + 2,
458 };
459 (start, link.byte_end.saturating_sub(2))
460 } else {
461 let start = link.byte_offset + 1;
462 (start, start + link.text.len())
463 };
464
465 if byte_pos >= text_start && byte_pos < text_end {
469 let is_wikilink = matches!(link.link_type, LinkType::WikiLink { .. });
470 if Self::link_text_is_url(&link.text)
471 || (!is_wikilink && Self::link_text_matches_link_url(&link.text, &link.url))
472 {
473 return true;
474 }
475 return Self::image_verdict(ctx, byte_pos).unwrap_or(false);
481 }
482 return true;
484 }
485
486 if let Some(verdict) = Self::image_verdict(ctx, byte_pos) {
487 return verdict;
488 }
489
490 ctx.is_in_reference_def(byte_pos)
492 }
493
494 fn image_verdict(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> Option<bool> {
499 let image = ctx.image_containing(byte_pos)?;
500
501 let alt_start = image.byte_offset + 2;
503 let alt_end = alt_start + image.alt_text.len();
504
505 Some(!(byte_pos >= alt_start && byte_pos < alt_end))
507 }
508
509 fn link_text_is_url(text: &str) -> bool {
511 let lower = text.trim().to_ascii_lowercase();
512 lower.starts_with("http://")
513 || lower.starts_with("https://")
514 || lower.starts_with("www.")
515 || lower.starts_with("//")
516 }
517
518 fn link_text_matches_link_url(text: &str, url: &str) -> bool {
530 let text = text.trim();
531 if !text.contains('.') {
533 return false;
534 }
535 let url_lower = url.to_ascii_lowercase();
536 let url_without_scheme = url_lower
537 .strip_prefix("https://")
538 .or_else(|| url_lower.strip_prefix("http://"))
539 .or_else(|| url_lower.strip_prefix("//"))
540 .unwrap_or(&url_lower);
541 let text_lower = text.to_ascii_lowercase();
542 if url_without_scheme == text_lower.as_str() {
544 return true;
545 }
546 url_without_scheme.len() > text_lower.len()
548 && url_without_scheme.starts_with(text_lower.as_str())
549 && matches!(
550 url_without_scheme.as_bytes().get(text_lower.len()),
551 Some(b'/') | Some(b'?') | Some(b'#')
552 )
553 }
554
555 fn is_in_angle_bracket_url(line: &str, pos: usize) -> bool {
561 let bytes = line.as_bytes();
562 let len = bytes.len();
563 let mut i = 0;
564 while i < len {
565 if bytes[i] == b'<' {
566 let after_open = i + 1;
567 if after_open < len && bytes[after_open].is_ascii_alphabetic() {
571 let mut s = after_open + 1;
572 let scheme_max = (after_open + 32).min(len);
573 while s < scheme_max
574 && (bytes[s].is_ascii_alphanumeric()
575 || bytes[s] == b'+'
576 || bytes[s] == b'-'
577 || bytes[s] == b'.')
578 {
579 s += 1;
580 }
581 if s < len && bytes[s] == b':' {
582 let mut j = s + 1;
584 let mut found_close = false;
585 while j < len {
586 match bytes[j] {
587 b'>' => {
588 found_close = true;
589 break;
590 }
591 b' ' | b'<' => break,
592 _ => j += 1,
593 }
594 }
595 if found_close && pos >= i && pos <= j {
596 return true;
597 }
598 if found_close {
599 i = j + 1;
600 continue;
601 }
602 }
603 }
604 }
605 i += 1;
606 }
607 false
608 }
609
610 fn is_in_wikilink_url(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
623 use pulldown_cmark::LinkType;
624 let content = ctx.content.as_bytes();
625
626 for link in ctx.links_starting_before_or_at(byte_pos) {
627 if !matches!(link.link_type, LinkType::WikiLink { .. }) {
628 continue;
629 }
630 let wiki_end = link.byte_end;
631 if wiki_end >= byte_pos || wiki_end >= content.len() || content[wiki_end] != b'(' {
633 continue;
634 }
635 let mut depth: u32 = 1;
640 let mut k = wiki_end + 1;
641 let mut valid_destination = true;
642 while k < content.len() && depth > 0 {
643 match content[k] {
644 b'\\' => {
645 k += 1; }
647 b'(' => depth += 1,
648 b')' => depth -= 1,
649 b' ' | b'\t' | b'\n' | b'\r' => {
650 valid_destination = false;
651 break;
652 }
653 _ => {}
654 }
655 k += 1;
656 }
657 if valid_destination && depth == 0 && byte_pos > wiki_end && byte_pos < k {
660 return true;
661 }
662 }
663 false
664 }
665
666 fn is_in_markdown_link_url(line: &str, pos: usize) -> bool {
676 let bytes = line.as_bytes();
677 let len = bytes.len();
678 let mut i = 0;
679
680 while i < len {
681 if bytes[i] == b'[' && (i == 0 || bytes[i - 1] != b'\\' || (i >= 2 && bytes[i - 2] == b'\\')) {
683 let mut depth: u32 = 1;
685 let mut j = i + 1;
686 while j < len && depth > 0 {
687 match bytes[j] {
688 b'\\' => {
689 j += 1; }
691 b'[' => depth += 1,
692 b']' => depth -= 1,
693 _ => {}
694 }
695 j += 1;
696 }
697
698 if depth == 0 && j < len {
700 if bytes[j] == b'(' {
701 let url_start = j;
703 let mut paren_depth: u32 = 1;
704 let mut k = j + 1;
705 while k < len && paren_depth > 0 {
706 match bytes[k] {
707 b'\\' => {
708 k += 1; }
710 b'(' => paren_depth += 1,
711 b')' => paren_depth -= 1,
712 _ => {}
713 }
714 k += 1;
715 }
716
717 if paren_depth == 0 {
718 if pos > url_start && pos < k {
719 return true;
720 }
721 i = k;
722 continue;
723 }
724 } else if bytes[j] == b'[' {
725 let ref_start = j;
727 let mut ref_depth: u32 = 1;
728 let mut k = j + 1;
729 while k < len && ref_depth > 0 {
730 match bytes[k] {
731 b'\\' => {
732 k += 1;
733 }
734 b'[' => ref_depth += 1,
735 b']' => ref_depth -= 1,
736 _ => {}
737 }
738 k += 1;
739 }
740
741 if ref_depth == 0 {
742 if pos > ref_start && pos < k {
743 return true;
744 }
745 i = k;
746 continue;
747 }
748 }
749 }
750 }
751 i += 1;
752 }
753 false
754 }
755
756 fn is_in_backtick_code_in_line(line: &str, pos: usize) -> bool {
764 let bytes = line.as_bytes();
765 let len = bytes.len();
766 let mut i = 0;
767 while i < len {
768 if bytes[i] == b'`' {
769 let open_start = i;
771 while i < len && bytes[i] == b'`' {
772 i += 1;
773 }
774 let tick_len = i - open_start;
775
776 while i < len {
778 if bytes[i] == b'`' {
779 let close_start = i;
780 while i < len && bytes[i] == b'`' {
781 i += 1;
782 }
783 if i - close_start == tick_len {
784 let content_start = open_start + tick_len;
788 let content_end = close_start;
789 if pos >= content_start && pos < content_end {
790 return true;
791 }
792 break;
794 }
795 } else {
797 i += 1;
798 }
799 }
800 } else {
801 i += 1;
802 }
803 }
804 false
805 }
806
807 fn is_word_boundary_char(c: char) -> bool {
809 !c.is_alphanumeric()
810 }
811
812 fn is_at_word_boundary(content: &str, pos: usize, is_start: bool) -> bool {
814 if is_start {
815 if pos == 0 {
816 return true;
817 }
818 match content[..pos].chars().next_back() {
819 None => true,
820 Some(c) => Self::is_word_boundary_char(c),
821 }
822 } else {
823 if pos >= content.len() {
824 return true;
825 }
826 match content[pos..].chars().next() {
827 None => true,
828 Some(c) => Self::is_word_boundary_char(c),
829 }
830 }
831 }
832
833 fn is_joined_identifier_boundary(content: &str, pos: usize, is_start: bool) -> bool {
841 if is_start {
842 let mut chars = content[..pos].char_indices().rev();
843 let mut saw_separator = false;
844 for (_, character) in &mut chars {
845 if character == '-' || character == '_' {
846 saw_separator = true;
847 } else {
848 return saw_separator && character.is_alphanumeric();
849 }
850 }
851 false
852 } else {
853 let mut saw_separator = false;
854 for character in content[pos..].chars() {
855 if character == '-' || character == '_' {
856 saw_separator = true;
857 } else {
858 return saw_separator && character.is_alphanumeric();
859 }
860 }
861 false
862 }
863 }
864
865 fn is_underscore_emphasis_boundary(ctx: &crate::lint_context::LintContext, pos: usize, is_start: bool) -> bool {
870 ctx.source_emphasis_spans().iter().any(|span| {
871 if span.marker != '_' {
872 return false;
873 }
874 let width = if span.is_strong { 2 } else { 1 };
875 if is_start {
876 span.byte_offset + width == pos
877 } else {
878 span.byte_end.checked_sub(width) == Some(pos)
879 }
880 })
881 }
882
883 fn is_in_path_like_token(line: &str, match_start: usize, fm_value: (usize, usize)) -> bool {
911 let (value_start, value_end) = fm_value;
912 if match_start < value_start || match_start >= value_end {
913 return false;
914 }
915
916 let quoted_words: Vec<&str> = if frontmatter_values::value_is_quoted(line, value_start) {
924 line[value_start..value_end].split_whitespace().collect()
925 } else {
926 Vec::new()
927 };
928 let is_single_quoted_path = !quoted_words.is_empty() && quoted_words.iter().all(|word| word.contains('/'));
929 let is_multi_word_collapse = is_single_quoted_path && quoted_words.len() > 1;
937
938 let (raw_start, raw_end) = if is_single_quoted_path {
939 (value_start, value_end)
940 } else {
941 frontmatter_values::token_bounds(line, match_start, value_start, value_end)
942 };
943
944 let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
945 if match_start < start || match_start >= end {
946 return false;
947 }
948
949 let token = &line[start..end];
950 if !token.contains('/') {
951 return false;
952 }
953 if token.starts_with('/') || token.starts_with("./") || token.starts_with("../") || token.starts_with("~/") {
954 return true;
955 }
956 if token.rsplit('/').next().is_some_and(|seg| seg.contains('.')) {
957 return true;
958 }
959
960 if is_multi_word_collapse {
961 return false;
962 }
963
964 let sole_value = {
968 let (ts, te) = frontmatter_values::trim_token_bounds(line, value_start, value_end);
969 ts == start && te == end
970 };
971 sole_value && token.split('/').filter(|s| !s.is_empty()).count() >= 3
972 }
973}
974
975impl Rule for MD044ProperNames {
976 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
977 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(44))
978 }
979
980 fn name(&self) -> &'static str {
981 "MD044"
982 }
983
984 fn description(&self) -> &'static str {
985 "Proper names should have the correct capitalization"
986 }
987
988 fn category(&self) -> RuleCategory {
989 RuleCategory::Other
990 }
991
992 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
993 if self.config.names.is_empty() {
994 return true;
995 }
996 let content_lower = if ctx.content.is_ascii() {
998 ctx.content.to_ascii_lowercase()
999 } else {
1000 ctx.content.to_lowercase()
1001 };
1002 !self.name_variants.keys().any(|name| content_lower.contains(name))
1003 }
1004
1005 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1006 let content = ctx.content;
1007 if content.is_empty() || self.config.names.is_empty() || self.combined_pattern.is_none() {
1008 return Ok(Vec::new());
1009 }
1010
1011 let content_lower = if content.is_ascii() {
1013 content.to_ascii_lowercase()
1014 } else {
1015 content.to_lowercase()
1016 };
1017
1018 let has_potential_matches = self.name_variants.keys().any(|name| content_lower.contains(name));
1020
1021 if !has_potential_matches {
1022 return Ok(Vec::new());
1023 }
1024 let violations = self.find_name_violations(ctx);
1025
1026 let warnings = violations
1027 .into_iter()
1028 .map(|(line, column, found_name, proper_name_index)| {
1029 let proper_name = &self.config.names[proper_name_index];
1030 let line_start = ctx.line_start_byte(line).unwrap_or(0);
1035 let byte_start = line_start + (column - 1);
1036 let byte_end = byte_start + found_name.len();
1037 let line_text = ctx.line_info(line).map_or("", |li| li.content(ctx.content));
1040 let char_col = byte_to_char_count(line_text, column - 1);
1041 LintWarning {
1042 rule_name: Some(self.name().to_string()),
1043 line,
1044 column: char_col,
1045 end_line: line,
1046 end_column: char_col + found_name.chars().count(),
1047 message: format!("Proper name '{found_name}' should be '{proper_name}'"),
1048 severity: Severity::Warning,
1049 fix: Some(Fix::new(byte_start..byte_end, proper_name.clone())),
1050 }
1051 })
1052 .collect();
1053
1054 Ok(warnings)
1055 }
1056
1057 fn fixes_from_diagnostics(&self) -> bool {
1058 true
1059 }
1060
1061 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1062 if self.should_skip(ctx) {
1063 return Ok(ctx.content.to_string());
1064 }
1065 let warnings = self.check(ctx)?;
1066 if warnings.is_empty() {
1067 return Ok(ctx.content.to_string());
1068 }
1069 let warnings =
1070 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1071 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
1072 .map_err(crate::rule::LintError::InvalidInput)
1073 }
1074
1075 fn as_any(&self) -> &dyn std::any::Any {
1076 self
1077 }
1078
1079 crate::impl_rule_config_methods!(MD044Config);
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084 use super::*;
1085 use crate::lint_context::LintContext;
1086
1087 fn create_context(content: &str) -> LintContext<'_> {
1088 LintContext::new(content, crate::config::MarkdownFlavor::Standard, None)
1089 }
1090
1091 fn field_map_for(content: &str) -> Vec<Option<String>> {
1092 let ctx = create_context(content);
1093 frontmatter_values::field_map(&ctx)
1094 }
1095
1096 fn whole_word_rule(names: &[&str]) -> MD044ProperNames {
1097 MD044ProperNames::from_config_struct(MD044Config {
1098 names: names.iter().map(|name| (*name).to_string()).collect(),
1099 whole_words: true,
1100 ..Default::default()
1101 })
1102 }
1103
1104 #[test]
1105 fn whole_words_preserves_compound_identifiers_but_checks_standalone_names() {
1106 let rule = whole_word_rule(&["ABC"]);
1107 let content = "123-abc-def\nfoo_abc_bar\nabc\n(abc) [abc]\n";
1108 let ctx = create_context(content);
1109
1110 let warnings = rule.check(&ctx).unwrap();
1111 assert_eq!(warnings.len(), 3);
1112 assert_eq!(
1113 warnings.iter().map(|warning| warning.line).collect::<Vec<_>>(),
1114 [3, 4, 4]
1115 );
1116 assert_eq!(rule.fix(&ctx).unwrap(), "123-abc-def\nfoo_abc_bar\nABC\n(ABC) [ABC]\n");
1117 }
1118
1119 #[test]
1120 fn whole_words_preserves_identifiers_inside_emphasis() {
1121 let rule = whole_word_rule(&["ABC"]);
1122 let content = "_123-abc-def_ _foo_abc_bar_ __foo__abc__bar__\n\
1123 _abc_def_ _foo_abc_\n\
1124 _abc_ __abc__ ___abc___ foo-__abc__-bar\n";
1125 let ctx = create_context(content);
1126 let warnings = rule.check(&ctx).unwrap();
1127 assert_eq!(warnings.len(), 4);
1128 assert!(warnings.iter().all(|warning| warning.line == 3));
1129 let fixed = rule.fix(&ctx).unwrap();
1130 assert_eq!(
1131 fixed,
1132 "_123-abc-def_ _foo_abc_bar_ __foo__abc__bar__\n\
1133 _abc_def_ _foo_abc_\n\
1134 _ABC_ __ABC__ ___ABC___ foo-__ABC__-bar\n"
1135 );
1136 assert!(rule.check(&create_context(&fixed)).unwrap().is_empty());
1137 assert_eq!(rule.fix(&create_context(&fixed)).unwrap(), fixed);
1138 }
1139
1140 #[test]
1141 fn whole_words_distinguishes_unicode_compounds_from_markdown_emphasis() {
1142 let rule = whole_word_rule(&["ABC"]);
1143 let content = "café_abc_menu 東京-abc-駅 foo__abc__bar _abc_ __abc__ foo-__abc__-bar\n";
1144 let ctx = create_context(content);
1145
1146 assert_eq!(rule.check(&ctx).unwrap().len(), 3);
1147 let fixed = rule.fix(&ctx).unwrap();
1148 assert_eq!(
1149 fixed,
1150 "café_abc_menu 東京-abc-駅 foo__abc__bar _ABC_ __ABC__ foo-__ABC__-bar\n"
1151 );
1152 assert_eq!(rule.fix(&create_context(&fixed)).unwrap(), fixed);
1153 }
1154
1155 #[test]
1156 fn repeated_intraword_underscores_keep_legacy_behavior_without_whole_words() {
1157 let rule = MD044ProperNames::new(vec!["ABC".to_string()], false);
1158 let content = "foo__abc__bar\n";
1159 let ctx = create_context(content);
1160
1161 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1162 assert_eq!(rule.fix(&ctx).unwrap(), "foo__ABC__bar\n");
1163 }
1164
1165 #[test]
1166 fn whole_words_respects_code_and_link_exclusions_and_multiword_names() {
1167 let rule = whole_word_rule(&["ABC", "My App"]);
1168 let content = "`abc` [label](https://example.test/abc) my app x-my app-y\n";
1169 let ctx = create_context(content);
1170
1171 let warnings = rule.check(&ctx).unwrap();
1172 assert_eq!(warnings.len(), 1);
1173 assert_eq!(warnings[0].message, "Proper name 'my app' should be 'My App'");
1174 let fixed = rule.fix(&ctx).unwrap();
1175 assert_eq!(fixed, "`abc` [label](https://example.test/abc) My App x-my app-y\n");
1176 assert_eq!(rule.fix(&create_context(&fixed)).unwrap(), fixed);
1177 }
1178
1179 #[test]
1180 fn first_configured_spelling_wins_for_colliding_variants() {
1181 for (names, content, expected) in [
1182 (["Node.js", "Nodejs"], "nodejs", "Node.js"),
1183 (["Nodejs", "Node.js"], "nodejs", "Nodejs"),
1184 (["Café", "Cafe"], "cafe", "Café"),
1185 (["Cafe", "Café"], "cafe", "Cafe"),
1186 (["Node.js", "NODE.JS"], "node.js", "Node.js"),
1187 ] {
1188 let rule = MD044ProperNames::new(names.map(str::to_string).to_vec(), false);
1189 let ctx = create_context(content);
1190 let warnings = rule.check(&ctx).unwrap();
1191 assert_eq!(warnings.len(), 1);
1192 assert_eq!(warnings[0].fix.as_ref().unwrap().replacement, expected);
1193 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1194 assert_eq!(rule.clone().check(&ctx).unwrap(), warnings);
1195 }
1196 }
1197
1198 #[test]
1199 fn test_field_map_nested_lines_inherit_top_level_key() {
1200 let map = field_map_for("---\nseo:\n canonical: docs/a.md\n keywords:\n - myapp\ntitle: x\n---\n");
1201 assert_eq!(map[2].as_deref(), Some("seo"));
1202 assert_eq!(map[4].as_deref(), Some("seo"));
1203 assert_eq!(map[5].as_deref(), Some("title"));
1204 }
1205
1206 #[test]
1207 fn test_field_map_block_scalar_bracket_does_not_swallow_next_key() {
1208 let map = field_map_for("---\ndescription: |\n [myapp\ntitle: myapp\n---\n");
1209 assert_eq!(map[2].as_deref(), Some("description"));
1210 assert_eq!(
1211 map[3].as_deref(),
1212 Some("title"),
1213 "an indent-0 key always starts a new key"
1214 );
1215 }
1216
1217 #[test]
1218 fn test_field_map_quoted_key_with_colon() {
1219 let map = field_map_for("---\n\"og:title\": myapp\n---\n");
1220 assert_eq!(map[1].as_deref(), Some("og:title"));
1221 }
1222
1223 #[test]
1224 fn test_field_map_top_level_sequence_clears_attribution() {
1225 let map = field_map_for("---\n- myapp\n---\n");
1226 assert_eq!(map[1], None);
1227 }
1228
1229 #[test]
1230 fn test_field_map_toml_table_body_belongs_to_table_root() {
1231 let map = field_map_for("+++\n[seo]\ncanonical = \"docs/a.md\"\n\n[[authors]]\nname = \"myapp\"\n+++\n");
1232 assert_eq!(map[2].as_deref(), Some("seo"));
1233 assert_eq!(map[5].as_deref(), Some("authors"));
1234 }
1235
1236 #[test]
1237 fn test_field_map_toml_dotted_assignment_uses_root() {
1238 let map = field_map_for("+++\nseo.canonical = \"docs/a.md\"\n+++\n");
1239 assert_eq!(map[1].as_deref(), Some("seo"));
1240 }
1241
1242 #[test]
1243 fn test_field_map_toml_array_continuation_inherits() {
1244 let map = field_map_for("+++\nseo = [\n\"docs/guide/myapp\"\n]\n+++\n");
1245 assert_eq!(map[2].as_deref(), Some("seo"));
1246 }
1247
1248 #[test]
1249 fn test_field_map_indent_zero_flow_continuation_is_not_attributed_to_parent() {
1250 let map = field_map_for("---\nseo: [\n{name: myapp}\n]\n---\n");
1253 assert_eq!(map[2].as_deref(), Some("{name"));
1254 }
1255
1256 #[test]
1257 fn test_field_map_toml_nested_array_inherits_and_title_not_corrupted() {
1258 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\ntitle = \"x\"\n+++\n");
1259 assert_eq!(map[2].as_deref(), Some("matrix"), "nested array line inherits matrix");
1260 assert_eq!(map[3].as_deref(), Some("matrix"), "nested array line inherits matrix");
1261 assert_eq!(
1262 map[5].as_deref(),
1263 Some("title"),
1264 "title must not inherit stale attribution from a closed nested array"
1265 );
1266 }
1267
1268 #[test]
1269 fn test_field_map_toml_nested_array_last_element_without_trailing_comma() {
1270 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [2]\n]\ntitle = \"x\"\n+++\n");
1271 assert_eq!(
1272 map[3].as_deref(),
1273 Some("matrix"),
1274 "last element without a trailing comma still inherits matrix"
1275 );
1276 assert_eq!(
1277 map[5].as_deref(),
1278 Some("title"),
1279 "title must not inherit stale attribution from a closed nested array"
1280 );
1281 }
1282
1283 #[test]
1284 fn test_field_map_toml_nested_array_then_real_table_header_non_regression_guard() {
1285 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\n\n[seo]\ncanonical = \"docs/a.md\"\n+++\n");
1292 assert_eq!(map[6].as_deref(), Some("seo"), "real table header after a closed array");
1293 assert_eq!(
1294 map[7].as_deref(),
1295 Some("seo"),
1296 "table body still attributes to the table"
1297 );
1298 }
1299
1300 #[test]
1301 fn test_field_map_toml_unclosed_array_resyncs_on_next_assignment() {
1302 let map = field_map_for("+++\nmatrix = [\n [1, 2],\ntitle = \"x\"\n+++\n");
1308 assert_eq!(
1309 map[3].as_deref(),
1310 Some("title"),
1311 "title must resync even though the array was never closed"
1312 );
1313 }
1314
1315 #[test]
1316 fn test_field_map_toml_column_zero_array_elements_inherit_and_title_not_corrupted() {
1317 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[3, 4],\n]\ntitle = \"x\"\n+++\n");
1323 assert_eq!(
1324 map[2].as_deref(),
1325 Some("matrix"),
1326 "column-0 array element inherits matrix"
1327 );
1328 assert_eq!(
1329 map[3].as_deref(),
1330 Some("matrix"),
1331 "column-0 array element inherits matrix"
1332 );
1333 assert_eq!(
1334 map[5].as_deref(),
1335 Some("title"),
1336 "title must not inherit stale attribution from a misread array element"
1337 );
1338 }
1339
1340 #[test]
1341 fn test_field_map_toml_column_zero_array_last_element_without_trailing_comma() {
1342 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[2]\n]\ntitle = \"x\"\n+++\n");
1343 assert_eq!(
1344 map[3].as_deref(),
1345 Some("matrix"),
1346 "column-0 last element without a trailing comma still inherits matrix"
1347 );
1348 assert_eq!(
1349 map[5].as_deref(),
1350 Some("title"),
1351 "title must not inherit stale attribution from a misread array element"
1352 );
1353 }
1354
1355 #[test]
1356 fn test_correctly_capitalized_names() {
1357 let rule = MD044ProperNames::new(
1358 vec![
1359 "JavaScript".to_string(),
1360 "TypeScript".to_string(),
1361 "Node.js".to_string(),
1362 ],
1363 true,
1364 );
1365
1366 let content = "This document uses JavaScript, TypeScript, and Node.js correctly.";
1367 let ctx = create_context(content);
1368 let result = rule.check(&ctx).unwrap();
1369 assert!(result.is_empty(), "Should not flag correctly capitalized names");
1370 }
1371
1372 #[test]
1373 fn test_incorrectly_capitalized_names() {
1374 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1375
1376 let content = "This document uses javascript and typescript incorrectly.";
1377 let ctx = create_context(content);
1378 let result = rule.check(&ctx).unwrap();
1379
1380 assert_eq!(result.len(), 2, "Should flag two incorrect capitalizations");
1381 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1382 assert_eq!(result[0].line, 1);
1383 assert_eq!(result[0].column, 20);
1384 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1385 assert_eq!(result[1].line, 1);
1386 assert_eq!(result[1].column, 35);
1387 }
1388
1389 #[test]
1390 fn test_names_at_beginning_of_sentences() {
1391 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "Python".to_string()], true);
1392
1393 let content = "javascript is a great language. python is also popular.";
1394 let ctx = create_context(content);
1395 let result = rule.check(&ctx).unwrap();
1396
1397 assert_eq!(result.len(), 2, "Should flag names at beginning of sentences");
1398 assert_eq!(result[0].line, 1);
1399 assert_eq!(result[0].column, 1);
1400 assert_eq!(result[1].line, 1);
1401 assert_eq!(result[1].column, 33);
1402 }
1403
1404 #[test]
1405 fn test_names_in_code_blocks_checked_by_default() {
1406 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1407
1408 let content = r#"Here is some text with JavaScript.
1409
1410```javascript
1411// This javascript should be checked
1412const lang = "javascript";
1413```
1414
1415But this javascript should be flagged."#;
1416
1417 let ctx = create_context(content);
1418 let result = rule.check(&ctx).unwrap();
1419
1420 assert_eq!(result.len(), 3, "Should flag javascript inside and outside code blocks");
1421 assert_eq!(result[0].line, 4);
1422 assert_eq!(result[1].line, 5);
1423 assert_eq!(result[2].line, 8);
1424 }
1425
1426 #[test]
1427 fn test_names_in_code_blocks_ignored_when_disabled() {
1428 let rule = MD044ProperNames::new(
1429 vec!["JavaScript".to_string()],
1430 false, );
1432
1433 let content = r#"```
1434javascript in code block
1435```"#;
1436
1437 let ctx = create_context(content);
1438 let result = rule.check(&ctx).unwrap();
1439
1440 assert_eq!(
1441 result.len(),
1442 0,
1443 "Should not flag javascript in code blocks when code_blocks is false"
1444 );
1445 }
1446
1447 #[test]
1448 fn test_names_in_inline_code_checked_by_default() {
1449 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1450
1451 let content = "This is `javascript` in inline code and javascript outside.";
1452 let ctx = create_context(content);
1453 let result = rule.check(&ctx).unwrap();
1454
1455 assert_eq!(result.len(), 2, "Should flag javascript inside and outside inline code");
1457 assert_eq!(result[0].column, 10); assert_eq!(result[1].column, 41); }
1460
1461 #[test]
1462 fn test_multiple_names_in_same_line() {
1463 let rule = MD044ProperNames::new(
1464 vec!["JavaScript".to_string(), "TypeScript".to_string(), "React".to_string()],
1465 true,
1466 );
1467
1468 let content = "I use javascript, typescript, and react in my projects.";
1469 let ctx = create_context(content);
1470 let result = rule.check(&ctx).unwrap();
1471
1472 assert_eq!(result.len(), 3, "Should flag all three incorrect names");
1473 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1474 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1475 assert_eq!(result[2].message, "Proper name 'react' should be 'React'");
1476 }
1477
1478 #[test]
1479 fn test_case_sensitivity() {
1480 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1481
1482 let content = "JAVASCRIPT, Javascript, javascript, and JavaScript variations.";
1483 let ctx = create_context(content);
1484 let result = rule.check(&ctx).unwrap();
1485
1486 assert_eq!(result.len(), 3, "Should flag all incorrect case variations");
1487 assert!(result.iter().all(|w| w.message.contains("should be 'JavaScript'")));
1489 }
1490
1491 #[test]
1492 fn test_configuration_with_custom_name_list() {
1493 let config = MD044Config {
1494 names: vec!["GitHub".to_string(), "GitLab".to_string(), "DevOps".to_string()],
1495 code_blocks: true,
1496 ..Default::default()
1497 };
1498 let rule = MD044ProperNames::from_config_struct(config);
1499
1500 let content = "We use github, gitlab, and devops for our workflow.";
1501 let ctx = create_context(content);
1502 let result = rule.check(&ctx).unwrap();
1503
1504 assert_eq!(result.len(), 3, "Should flag all custom names");
1505 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
1506 assert_eq!(result[1].message, "Proper name 'gitlab' should be 'GitLab'");
1507 assert_eq!(result[2].message, "Proper name 'devops' should be 'DevOps'");
1508 }
1509
1510 #[test]
1511 fn test_empty_configuration() {
1512 let rule = MD044ProperNames::new(vec![], true);
1513
1514 let content = "This has javascript and typescript but no configured names.";
1515 let ctx = create_context(content);
1516 let result = rule.check(&ctx).unwrap();
1517
1518 assert!(result.is_empty(), "Should not flag anything with empty configuration");
1519 }
1520
1521 #[test]
1522 fn test_names_with_special_characters() {
1523 let rule = MD044ProperNames::new(
1524 vec!["Node.js".to_string(), "ASP.NET".to_string(), "C++".to_string()],
1525 true,
1526 );
1527
1528 let content = "We use nodejs, asp.net, ASP.NET, and c++ in our stack.";
1529 let ctx = create_context(content);
1530 let result = rule.check(&ctx).unwrap();
1531
1532 assert_eq!(result.len(), 3, "Should handle special characters correctly");
1537
1538 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
1539 assert!(messages.contains(&"Proper name 'nodejs' should be 'Node.js'"));
1540 assert!(messages.contains(&"Proper name 'asp.net' should be 'ASP.NET'"));
1541 assert!(messages.contains(&"Proper name 'c++' should be 'C++'"));
1542 }
1543
1544 #[test]
1545 fn test_word_boundaries() {
1546 let rule = MD044ProperNames::new(vec!["Java".to_string(), "Script".to_string()], true);
1547
1548 let content = "JavaScript is not java or script, but Java and Script are separate.";
1549 let ctx = create_context(content);
1550 let result = rule.check(&ctx).unwrap();
1551
1552 assert_eq!(result.len(), 2, "Should respect word boundaries");
1554 assert!(result.iter().any(|w| w.column == 19)); assert!(result.iter().any(|w| w.column == 27)); }
1557
1558 #[test]
1559 fn test_fix_method() {
1560 let rule = MD044ProperNames::new(
1561 vec![
1562 "JavaScript".to_string(),
1563 "TypeScript".to_string(),
1564 "Node.js".to_string(),
1565 ],
1566 true,
1567 );
1568
1569 let content = "I love javascript, typescript, and nodejs!";
1570 let ctx = create_context(content);
1571 let fixed = rule.fix(&ctx).unwrap();
1572
1573 assert_eq!(fixed, "I love JavaScript, TypeScript, and Node.js!");
1574 }
1575
1576 #[test]
1577 fn test_fix_multiple_occurrences() {
1578 let rule = MD044ProperNames::new(vec!["Python".to_string()], true);
1579
1580 let content = "python is great. I use python daily. PYTHON is powerful.";
1581 let ctx = create_context(content);
1582 let fixed = rule.fix(&ctx).unwrap();
1583
1584 assert_eq!(fixed, "Python is great. I use Python daily. Python is powerful.");
1585 }
1586
1587 #[test]
1588 fn test_fix_checks_code_blocks_by_default() {
1589 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1590
1591 let content = r#"I love javascript.
1592
1593```
1594const lang = "javascript";
1595```
1596
1597More javascript here."#;
1598
1599 let ctx = create_context(content);
1600 let fixed = rule.fix(&ctx).unwrap();
1601
1602 let expected = r#"I love JavaScript.
1603
1604```
1605const lang = "JavaScript";
1606```
1607
1608More JavaScript here."#;
1609
1610 assert_eq!(fixed, expected);
1611 }
1612
1613 #[test]
1614 fn test_multiline_content() {
1615 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
1616
1617 let content = r#"First line with rust.
1618Second line with python.
1619Third line with RUST and PYTHON."#;
1620
1621 let ctx = create_context(content);
1622 let result = rule.check(&ctx).unwrap();
1623
1624 assert_eq!(result.len(), 4, "Should flag all incorrect occurrences");
1625 assert_eq!(result[0].line, 1);
1626 assert_eq!(result[1].line, 2);
1627 assert_eq!(result[2].line, 3);
1628 assert_eq!(result[3].line, 3);
1629 }
1630
1631 #[test]
1632 fn test_default_config() {
1633 let config = MD044Config::default();
1634 assert!(config.names.is_empty());
1635 assert!(!config.code_blocks);
1636 assert!(config.html_elements);
1637 assert!(config.html_comments);
1638 }
1639
1640 #[test]
1641 fn test_default_config_checks_html_comments() {
1642 let config = MD044Config {
1643 names: vec!["JavaScript".to_string()],
1644 ..MD044Config::default()
1645 };
1646 let rule = MD044ProperNames::from_config_struct(config);
1647
1648 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1649 let ctx = create_context(content);
1650 let result = rule.check(&ctx).unwrap();
1651
1652 assert_eq!(result.len(), 1, "Default config should check HTML comments");
1653 assert_eq!(result[0].line, 3);
1654 }
1655
1656 #[test]
1657 fn test_default_config_skips_code_blocks() {
1658 let config = MD044Config {
1659 names: vec!["JavaScript".to_string()],
1660 ..MD044Config::default()
1661 };
1662 let rule = MD044ProperNames::from_config_struct(config);
1663
1664 let content = "# Guide\n\n```\njavascript in code\n```\n";
1665 let ctx = create_context(content);
1666 let result = rule.check(&ctx).unwrap();
1667
1668 assert_eq!(result.len(), 0, "Default config should skip code blocks");
1669 }
1670
1671 #[test]
1672 fn test_standalone_html_comment_checked() {
1673 let config = MD044Config {
1674 names: vec!["Test".to_string()],
1675 ..MD044Config::default()
1676 };
1677 let rule = MD044ProperNames::from_config_struct(config);
1678
1679 let content = "# Heading\n\n<!-- this is a test example -->\n";
1680 let ctx = create_context(content);
1681 let result = rule.check(&ctx).unwrap();
1682
1683 assert_eq!(result.len(), 1, "Should flag proper name in standalone HTML comment");
1684 assert_eq!(result[0].line, 3);
1685 }
1686
1687 #[test]
1688 fn test_inline_config_comments_not_flagged() {
1689 let config = MD044Config {
1690 names: vec!["RUMDL".to_string()],
1691 ..MD044Config::default()
1692 };
1693 let rule = MD044ProperNames::from_config_struct(config);
1694
1695 let content = "<!-- rumdl-disable MD044 -->\nSome rumdl text here.\n<!-- rumdl-enable MD044 -->\n<!-- markdownlint-disable -->\nMore rumdl text.\n<!-- markdownlint-enable -->\n";
1699 let ctx = create_context(content);
1700 let result = rule.check(&ctx).unwrap();
1701
1702 assert_eq!(result.len(), 2, "Should only flag body lines, not config comments");
1703 assert_eq!(result[0].line, 2);
1704 assert_eq!(result[1].line, 5);
1705 }
1706
1707 #[test]
1708 fn test_compact_inline_config_comments_not_flagged() {
1709 let config = MD044Config {
1710 names: vec!["RUMDL".to_string(), "Markdownlint".to_string()],
1711 ..MD044Config::default()
1712 };
1713 let rule = MD044ProperNames::from_config_struct(config);
1714
1715 let content = "<!--rumdl-disable MD044-->\nSome rumdl text.\n<!--markdownlint-enable MD044 -->\n<!--\trumdl-capture-->\nMore rumdl text.\n<!-- note markdownlint -->\n";
1718 let ctx = create_context(content);
1719 let result = rule.check(&ctx).unwrap();
1720 let lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1721 assert_eq!(lines, vec![2, 5, 6], "{result:?}");
1722 }
1723
1724 #[test]
1725 fn test_html_comment_skipped_when_disabled() {
1726 let config = MD044Config {
1727 names: vec!["Test".to_string()],
1728 code_blocks: true,
1729 html_comments: false,
1730 ..Default::default()
1731 };
1732 let rule = MD044ProperNames::from_config_struct(config);
1733
1734 let content = "# Heading\n\n<!-- this is a test example -->\n\nRegular test here.\n";
1735 let ctx = create_context(content);
1736 let result = rule.check(&ctx).unwrap();
1737
1738 assert_eq!(
1739 result.len(),
1740 1,
1741 "Should only flag 'test' outside HTML comment when html_comments=false"
1742 );
1743 assert_eq!(result[0].line, 5);
1744 }
1745
1746 #[test]
1747 fn test_fix_corrects_html_comment_content() {
1748 let config = MD044Config {
1749 names: vec!["JavaScript".to_string()],
1750 ..MD044Config::default()
1751 };
1752 let rule = MD044ProperNames::from_config_struct(config);
1753
1754 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1755 let ctx = create_context(content);
1756 let fixed = rule.fix(&ctx).unwrap();
1757
1758 assert_eq!(fixed, "# Guide\n\n<!-- JavaScript mentioned here -->\n");
1759 }
1760
1761 #[test]
1762 fn test_fix_does_not_modify_inline_config_comments() {
1763 let config = MD044Config {
1764 names: vec!["RUMDL".to_string()],
1765 ..MD044Config::default()
1766 };
1767 let rule = MD044ProperNames::from_config_struct(config);
1768
1769 let content = "<!-- rumdl-disable -->\nSome rumdl text.\n<!-- rumdl-enable -->\n";
1770 let ctx = create_context(content);
1771 let fixed = rule.fix(&ctx).unwrap();
1772
1773 assert!(fixed.contains("<!-- rumdl-disable -->"));
1775 assert!(fixed.contains("<!-- rumdl-enable -->"));
1776 assert!(
1778 fixed.contains("Some rumdl text."),
1779 "Line inside rumdl-disable block should not be modified by fix()"
1780 );
1781 }
1782
1783 #[test]
1784 fn test_fix_respects_inline_disable_partial() {
1785 let config = MD044Config {
1786 names: vec!["RUMDL".to_string()],
1787 ..MD044Config::default()
1788 };
1789 let rule = MD044ProperNames::from_config_struct(config);
1790
1791 let content =
1792 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
1793 let ctx = create_context(content);
1794 let fixed = rule.fix(&ctx).unwrap();
1795
1796 assert!(
1798 fixed.contains("Some rumdl text.\n<!-- rumdl-enable"),
1799 "Line inside disable block should not be modified"
1800 );
1801 assert!(
1803 fixed.contains("Some RUMDL text outside."),
1804 "Line outside disable block should be fixed"
1805 );
1806 }
1807
1808 #[test]
1809 fn test_performance_with_many_names() {
1810 let mut names = vec![];
1811 for i in 0..50 {
1812 names.push(format!("ProperName{i}"));
1813 }
1814
1815 let rule = MD044ProperNames::new(names, true);
1816
1817 let content = "This has propername0, propername25, and propername49 incorrectly.";
1818 let ctx = create_context(content);
1819 let result = rule.check(&ctx).unwrap();
1820
1821 assert_eq!(result.len(), 3, "Should handle many configured names efficiently");
1822 }
1823
1824 #[test]
1825 fn test_large_name_count_performance() {
1826 let names = (0..1000).map(|i| format!("ProperName{i}")).collect::<Vec<_>>();
1829
1830 let rule = MD044ProperNames::new(names, true);
1831
1832 assert!(rule.combined_pattern.is_some());
1834
1835 let content = "This has propername0 and propername999 in it.";
1837 let ctx = create_context(content);
1838 let result = rule.check(&ctx).unwrap();
1839
1840 assert_eq!(result.len(), 2, "Should handle 1000 names without issues");
1842 }
1843
1844 #[test]
1845 fn test_cache_behavior() {
1846 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1847
1848 let content = "Using javascript here.";
1849 let ctx = create_context(content);
1850
1851 let result1 = rule.check(&ctx).unwrap();
1853 assert_eq!(result1.len(), 1);
1854
1855 let result2 = rule.check(&ctx).unwrap();
1857 assert_eq!(result2.len(), 1);
1858
1859 assert_eq!(result1[0].line, result2[0].line);
1861 assert_eq!(result1[0].column, result2[0].column);
1862 }
1863
1864 #[test]
1865 fn test_html_comments_not_checked_when_disabled() {
1866 let config = MD044Config {
1867 names: vec!["JavaScript".to_string()],
1868 code_blocks: true, html_comments: false, ..Default::default()
1871 };
1872 let rule = MD044ProperNames::from_config_struct(config);
1873
1874 let content = r#"Regular javascript here.
1875<!-- This javascript in HTML comment should be ignored -->
1876More javascript outside."#;
1877
1878 let ctx = create_context(content);
1879 let result = rule.check(&ctx).unwrap();
1880
1881 assert_eq!(result.len(), 2, "Should only flag javascript outside HTML comments");
1882 assert_eq!(result[0].line, 1);
1883 assert_eq!(result[1].line, 3);
1884 }
1885
1886 #[test]
1887 fn test_html_comments_checked_when_enabled() {
1888 let config = MD044Config {
1889 names: vec!["JavaScript".to_string()],
1890 code_blocks: true, ..Default::default()
1892 };
1893 let rule = MD044ProperNames::from_config_struct(config);
1894
1895 let content = r#"Regular javascript here.
1896<!-- This javascript in HTML comment should be checked -->
1897More javascript outside."#;
1898
1899 let ctx = create_context(content);
1900 let result = rule.check(&ctx).unwrap();
1901
1902 assert_eq!(
1903 result.len(),
1904 3,
1905 "Should flag all javascript occurrences including in HTML comments"
1906 );
1907 }
1908
1909 #[test]
1910 fn test_indented_html_comment_escapes_via_link_and_backticks() {
1911 let config = MD044Config {
1916 names: vec!["Test".to_string()],
1917 ..Default::default()
1918 };
1919 let rule = MD044ProperNames::from_config_struct(config);
1920
1921 let content = "<!-- see the [relevant page](test.md). -->\n<!-- see `test.md` -->\n <!-- see the [relevant page](test.md). -->\n <!-- see `test.md` -->\n";
1922
1923 let ctx = create_context(content);
1924 let result = rule.check(&ctx).unwrap();
1925
1926 assert!(
1927 result.is_empty(),
1928 "'test' inside a link URL or backticks must be ignored in both column-0 and indented comments, got: {result:?}"
1929 );
1930 }
1931
1932 #[test]
1933 fn test_indented_html_comment_still_checks_bare_prose() {
1934 let config = MD044Config {
1937 names: vec!["Test".to_string()],
1938 ..Default::default()
1939 };
1940 let rule = MD044ProperNames::from_config_struct(config);
1941
1942 let content = " <!-- this is a test comment -->\n";
1943
1944 let ctx = create_context(content);
1945 let result = rule.check(&ctx).unwrap();
1946
1947 assert_eq!(
1948 result.len(),
1949 1,
1950 "bare 'test' in an indented comment is still a violation"
1951 );
1952 assert_eq!(result[0].line, 1);
1953 }
1954
1955 #[test]
1956 fn test_multiline_html_comments() {
1957 let config = MD044Config {
1958 names: vec!["Python".to_string(), "JavaScript".to_string()],
1959 code_blocks: true, html_comments: false, ..Default::default()
1962 };
1963 let rule = MD044ProperNames::from_config_struct(config);
1964
1965 let content = r#"Regular python here.
1966<!--
1967This is a multiline comment
1968with javascript and python
1969that should be ignored
1970-->
1971More javascript outside."#;
1972
1973 let ctx = create_context(content);
1974 let result = rule.check(&ctx).unwrap();
1975
1976 assert_eq!(result.len(), 2, "Should only flag names outside HTML comments");
1977 assert_eq!(result[0].line, 1); assert_eq!(result[1].line, 7); }
1980
1981 #[test]
1982 fn test_fix_preserves_html_comments_when_disabled() {
1983 let config = MD044Config {
1984 names: vec!["JavaScript".to_string()],
1985 code_blocks: true, html_comments: false, ..Default::default()
1988 };
1989 let rule = MD044ProperNames::from_config_struct(config);
1990
1991 let content = r#"javascript here.
1992<!-- javascript in comment -->
1993More javascript."#;
1994
1995 let ctx = create_context(content);
1996 let fixed = rule.fix(&ctx).unwrap();
1997
1998 let expected = r#"JavaScript here.
1999<!-- javascript in comment -->
2000More JavaScript."#;
2001
2002 assert_eq!(
2003 fixed, expected,
2004 "Should not fix names inside HTML comments when disabled"
2005 );
2006 }
2007
2008 #[test]
2009 fn test_proper_names_in_link_text_are_flagged() {
2010 let rule = MD044ProperNames::new(
2011 vec!["JavaScript".to_string(), "Node.js".to_string(), "Python".to_string()],
2012 true,
2013 );
2014
2015 let content = r#"Check this [javascript documentation](https://javascript.info) for info.
2016
2017Visit [node.js homepage](https://nodejs.org) and [python tutorial](https://python.org).
2018
2019Real javascript should be flagged.
2020
2021Also see the [typescript guide][ts-ref] for more.
2022
2023Real python should be flagged too.
2024
2025[ts-ref]: https://typescript.org/handbook"#;
2026
2027 let ctx = create_context(content);
2028 let result = rule.check(&ctx).unwrap();
2029
2030 assert_eq!(result.len(), 5, "Expected 5 warnings: 3 in link text + 2 standalone");
2037
2038 let line_1_warnings: Vec<_> = result.iter().filter(|w| w.line == 1).collect();
2040 assert_eq!(line_1_warnings.len(), 1);
2041 assert!(
2042 line_1_warnings[0]
2043 .message
2044 .contains("'javascript' should be 'JavaScript'")
2045 );
2046
2047 let line_3_warnings: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
2048 assert_eq!(line_3_warnings.len(), 2); assert!(result.iter().any(|w| w.line == 5 && w.message.contains("'javascript'")));
2052 assert!(result.iter().any(|w| w.line == 9 && w.message.contains("'python'")));
2053 }
2054
2055 #[test]
2056 fn test_link_urls_not_flagged() {
2057 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2058
2059 let content = r#"[Link Text](https://javascript.info/guide)"#;
2061
2062 let ctx = create_context(content);
2063 let result = rule.check(&ctx).unwrap();
2064
2065 assert!(result.is_empty(), "URLs should not be checked for proper names");
2067 }
2068
2069 #[test]
2070 fn test_bare_urls_not_flagged() {
2071 let rule = MD044ProperNames::new(vec!["Foo".to_string(), "JavaScript".to_string()], true);
2072
2073 let content =
2076 "https://foo.com\n\nSee https://javascript.info/foo/guide for details.\n\nMail foo@foo.com about it.\n";
2077
2078 let ctx = create_context(content);
2079 let result = rule.check(&ctx).unwrap();
2080
2081 assert!(
2082 result.is_empty(),
2083 "Bare URLs and emails should not be checked for proper names: {result:?}"
2084 );
2085 }
2086
2087 #[test]
2088 fn test_prose_around_bare_url_still_flagged() {
2089 let rule = MD044ProperNames::new(vec!["Foo".to_string()], true);
2090
2091 let content = "Use foo at https://foo.com because foo is great.\n";
2094
2095 let ctx = create_context(content);
2096 let result = rule.check(&ctx).unwrap();
2097
2098 assert_eq!(
2099 result.len(),
2100 2,
2101 "Prose occurrences around a bare URL must still be flagged: {result:?}"
2102 );
2103 assert!(result.iter().all(|w| w.message.contains("'foo' should be 'Foo'")));
2104 }
2105
2106 #[test]
2107 fn test_proper_names_in_image_alt_text_are_flagged() {
2108 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2109
2110 let content = r#"Here is a  image.
2111
2112Real javascript should be flagged."#;
2113
2114 let ctx = create_context(content);
2115 let result = rule.check(&ctx).unwrap();
2116
2117 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in alt text + 1 standalone");
2121 assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
2122 assert!(result[0].line == 1); assert!(result[1].message.contains("'javascript' should be 'JavaScript'"));
2124 assert!(result[1].line == 3); }
2126
2127 #[test]
2128 fn test_image_urls_not_flagged() {
2129 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2130
2131 let content = r#""#;
2133
2134 let ctx = create_context(content);
2135 let result = rule.check(&ctx).unwrap();
2136
2137 assert!(result.is_empty(), "Image URLs should not be checked for proper names");
2139 }
2140
2141 #[test]
2142 fn test_reference_link_text_flagged_but_definition_not() {
2143 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2144
2145 let content = r#"Check the [javascript guide][js-ref] for details.
2146
2147Real javascript should be flagged.
2148
2149[js-ref]: https://javascript.info/typescript/guide"#;
2150
2151 let ctx = create_context(content);
2152 let result = rule.check(&ctx).unwrap();
2153
2154 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in link text + 1 standalone");
2159 assert!(result.iter().any(|w| w.line == 1 && w.message.contains("'javascript'")));
2160 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2161 }
2162
2163 #[test]
2164 fn test_reference_definitions_not_flagged() {
2165 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2166
2167 let content = r#"[js-ref]: https://javascript.info/guide"#;
2169
2170 let ctx = create_context(content);
2171 let result = rule.check(&ctx).unwrap();
2172
2173 assert!(result.is_empty(), "Reference definitions should not be checked");
2175 }
2176
2177 #[test]
2178 fn test_wikilinks_text_is_flagged() {
2179 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2180
2181 let content = r#"[[javascript]]
2183
2184Regular javascript here.
2185
2186[[JavaScript|display text]]"#;
2187
2188 let ctx = create_context(content);
2189 let result = rule.check(&ctx).unwrap();
2190
2191 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in WikiLink + 1 standalone");
2195 assert!(
2196 result
2197 .iter()
2198 .any(|w| w.line == 1 && w.column == 3 && w.message.contains("'javascript'"))
2199 );
2200 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2201 }
2202
2203 #[test]
2204 fn test_url_link_text_not_flagged() {
2205 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2206
2207 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2209
2210[http://github.com/org/repo](http://github.com/org/repo)
2211
2212[www.github.com/org/repo](https://www.github.com/org/repo)"#;
2213
2214 let ctx = create_context(content);
2215 let result = rule.check(&ctx).unwrap();
2216
2217 assert!(
2218 result.is_empty(),
2219 "URL-like link text should not be flagged, got: {result:?}"
2220 );
2221 }
2222
2223 #[test]
2224 fn test_url_link_text_with_leading_space_not_flagged() {
2225 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2226
2227 let content = r#"[ https://github.com/org/repo](https://github.com/org/repo)"#;
2229
2230 let ctx = create_context(content);
2231 let result = rule.check(&ctx).unwrap();
2232
2233 assert!(
2234 result.is_empty(),
2235 "URL-like link text with leading space should not be flagged, got: {result:?}"
2236 );
2237 }
2238
2239 #[test]
2240 fn test_url_link_text_uppercase_scheme_not_flagged() {
2241 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2242
2243 let content = r#"[HTTPS://GITHUB.COM/org/repo](https://github.com/org/repo)"#;
2244
2245 let ctx = create_context(content);
2246 let result = rule.check(&ctx).unwrap();
2247
2248 assert!(
2249 result.is_empty(),
2250 "URL-like link text with uppercase scheme should not be flagged, got: {result:?}"
2251 );
2252 }
2253
2254 #[test]
2255 fn test_non_url_link_text_still_flagged() {
2256 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2257
2258 let content = r#"[github.com/org/repo](https://github.com/org/repo)
2262
2263[Visit github](https://github.com/org/repo)
2264
2265[//github.com/org/repo](//github.com/org/repo)
2266
2267[ftp://github.com/org/repo](ftp://github.com/org/repo)"#;
2268
2269 let ctx = create_context(content);
2270 let result = rule.check(&ctx).unwrap();
2271
2272 assert_eq!(
2277 result.len(),
2278 1,
2279 "Only prose link text should be flagged, got: {result:?}"
2280 );
2281 assert!(
2282 result.iter().any(|w| w.line == 3),
2283 "Expected 'Visit github' on line 3 to be flagged"
2284 );
2285 }
2286
2287 #[test]
2288 fn test_url_link_text_fix_not_applied() {
2289 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2290
2291 let content = "[https://github.com/org/repo](https://github.com/org/repo)\n";
2292
2293 let ctx = create_context(content);
2294 let result = rule.fix(&ctx).unwrap();
2295
2296 assert_eq!(result, content, "Fix should not modify URL-like link text");
2297 }
2298
2299 #[test]
2300 fn test_mixed_url_and_regular_link_text() {
2301 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2302
2303 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2305
2306Visit [github documentation](https://github.com/docs) for details.
2307
2308[www.github.com/pricing](https://www.github.com/pricing)"#;
2309
2310 let ctx = create_context(content);
2311 let result = rule.check(&ctx).unwrap();
2312
2313 assert_eq!(
2315 result.len(),
2316 1,
2317 "Only non-URL link text should be flagged, got: {result:?}"
2318 );
2319 assert_eq!(result[0].line, 3);
2320 }
2321
2322 #[test]
2323 fn test_html_attribute_values_not_flagged() {
2324 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2327 let content = "# Heading\n\ntest\n\n<img src=\"www.example.test/test_image.png\">\n";
2328 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2329 let result = rule.check(&ctx).unwrap();
2330
2331 let line5_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2333 assert!(
2334 line5_violations.is_empty(),
2335 "Should not flag anything inside HTML tag attributes: {line5_violations:?}"
2336 );
2337
2338 let line3_violations: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
2340 assert_eq!(line3_violations.len(), 1, "Plain 'test' on line 3 should be flagged");
2341 }
2342
2343 #[test]
2344 fn test_html_text_content_still_flagged() {
2345 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2347 let content = "# Heading\n\n<a href=\"https://example.test/page\">test link</a>\n";
2348 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2349 let result = rule.check(&ctx).unwrap();
2350
2351 assert_eq!(
2354 result.len(),
2355 1,
2356 "Should flag only 'test' in anchor text, not in href: {result:?}"
2357 );
2358 assert_eq!(result[0].column, 37, "Should flag col 37 ('test link' in anchor text)");
2359 }
2360
2361 #[test]
2362 fn test_html_attribute_various_not_flagged() {
2363 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2365 let content = concat!(
2366 "# Heading\n\n",
2367 "<img src=\"test.png\" alt=\"test image\">\n",
2368 "<span class=\"test-class\" data-test=\"value\">test content</span>\n",
2369 );
2370 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2371 let result = rule.check(&ctx).unwrap();
2372
2373 assert_eq!(
2375 result.len(),
2376 1,
2377 "Should flag only 'test content' between tags: {result:?}"
2378 );
2379 assert_eq!(result[0].line, 4);
2380 }
2381
2382 #[test]
2383 fn test_plain_text_underscore_boundary_unchanged() {
2384 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2387 let content = "# Heading\n\ntest_image is here and just_test ends here\n";
2388 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2389 let result = rule.check(&ctx).unwrap();
2390
2391 assert_eq!(
2394 result.len(),
2395 2,
2396 "Should flag 'test' in both 'test_image' and 'just_test': {result:?}"
2397 );
2398 let cols: Vec<usize> = result.iter().map(|w| w.column).collect();
2399 assert!(cols.contains(&1), "Should flag col 1 (test_image): {cols:?}");
2400 assert!(cols.contains(&29), "Should flag col 29 (just_test): {cols:?}");
2401 }
2402
2403 #[test]
2404 fn test_frontmatter_yaml_keys_not_flagged() {
2405 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2408
2409 let content = "---\ntitle: Heading\ntest: Some Test value\n---\n\nTest\n";
2410 let ctx = create_context(content);
2411 let result = rule.check(&ctx).unwrap();
2412
2413 assert!(
2417 result.is_empty(),
2418 "Should not flag YAML keys or correctly capitalized values: {result:?}"
2419 );
2420 }
2421
2422 #[test]
2423 fn test_frontmatter_yaml_values_flagged() {
2424 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2426
2427 let content = "---\ntitle: Heading\nkey: a test value\n---\n\nTest\n";
2428 let ctx = create_context(content);
2429 let result = rule.check(&ctx).unwrap();
2430
2431 assert_eq!(result.len(), 1, "Should flag 'test' in YAML value: {result:?}");
2433 assert_eq!(result[0].line, 3);
2434 assert_eq!(result[0].column, 8); }
2436
2437 #[test]
2438 fn test_frontmatter_key_matches_name_not_flagged() {
2439 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2441
2442 let content = "---\ntest: other value\n---\n\nBody text\n";
2443 let ctx = create_context(content);
2444 let result = rule.check(&ctx).unwrap();
2445
2446 assert!(
2447 result.is_empty(),
2448 "Should not flag YAML key that matches configured name: {result:?}"
2449 );
2450 }
2451
2452 #[test]
2453 fn test_frontmatter_empty_value_not_flagged() {
2454 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2456
2457 let content = "---\ntest:\ntest: \n---\n\nBody text\n";
2458 let ctx = create_context(content);
2459 let result = rule.check(&ctx).unwrap();
2460
2461 assert!(
2462 result.is_empty(),
2463 "Should not flag YAML keys with empty values: {result:?}"
2464 );
2465 }
2466
2467 #[test]
2468 fn test_frontmatter_nested_yaml_key_not_flagged() {
2469 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2471
2472 let content = "---\nparent:\n test: nested value\n---\n\nBody text\n";
2473 let ctx = create_context(content);
2474 let result = rule.check(&ctx).unwrap();
2475
2476 assert!(result.is_empty(), "Should not flag nested YAML keys: {result:?}");
2478 }
2479
2480 #[test]
2481 fn test_frontmatter_list_items_checked() {
2482 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2484
2485 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2486 let ctx = create_context(content);
2487 let result = rule.check(&ctx).unwrap();
2488
2489 assert_eq!(result.len(), 1, "Should flag 'test' in YAML list item: {result:?}");
2491 assert_eq!(result[0].line, 3);
2492 }
2493
2494 #[test]
2495 fn test_frontmatter_value_with_multiple_colons() {
2496 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2498
2499 let content = "---\ntest: description: a test thing\n---\n\nBody text\n";
2500 let ctx = create_context(content);
2501 let result = rule.check(&ctx).unwrap();
2502
2503 assert_eq!(
2506 result.len(),
2507 1,
2508 "Should flag 'test' in value after first colon: {result:?}"
2509 );
2510 assert_eq!(result[0].line, 2);
2511 assert!(result[0].column > 6, "Violation column should be in value portion");
2512 }
2513
2514 #[test]
2515 fn test_frontmatter_does_not_affect_body() {
2516 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2518
2519 let content = "---\ntitle: Heading\n---\n\ntest should be flagged here\n";
2520 let ctx = create_context(content);
2521 let result = rule.check(&ctx).unwrap();
2522
2523 assert_eq!(result.len(), 1, "Should flag 'test' in body text: {result:?}");
2524 assert_eq!(result[0].line, 5);
2525 }
2526
2527 #[test]
2528 fn test_frontmatter_fix_corrects_values_preserves_keys() {
2529 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2531
2532 let content = "---\ntest: a test value\n---\n\ntest here\n";
2533 let ctx = create_context(content);
2534 let fixed = rule.fix(&ctx).unwrap();
2535
2536 assert_eq!(fixed, "---\ntest: a Test value\n---\n\nTest here\n");
2538 }
2539
2540 #[test]
2541 fn test_frontmatter_multiword_value_flagged() {
2542 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2544
2545 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2546 let ctx = create_context(content);
2547 let result = rule.check(&ctx).unwrap();
2548
2549 assert_eq!(result.len(), 2, "Should flag both names in YAML value: {result:?}");
2550 assert!(result.iter().all(|w| w.line == 2));
2551 }
2552
2553 #[test]
2554 fn test_frontmatter_yaml_comments_not_checked() {
2555 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2557
2558 let content = "---\n# test comment\ntitle: Heading\n---\n\nBody text\n";
2559 let ctx = create_context(content);
2560 let result = rule.check(&ctx).unwrap();
2561
2562 assert!(result.is_empty(), "Should not flag names in YAML comments: {result:?}");
2563 }
2564
2565 #[test]
2566 fn test_frontmatter_delimiters_not_checked() {
2567 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2569
2570 let content = "---\ntitle: Heading\n---\n\ntest here\n";
2571 let ctx = create_context(content);
2572 let result = rule.check(&ctx).unwrap();
2573
2574 assert_eq!(result.len(), 1, "Should only flag body text: {result:?}");
2576 assert_eq!(result[0].line, 5);
2577 }
2578
2579 #[test]
2580 fn test_frontmatter_continuation_lines_checked() {
2581 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2583
2584 let content = "---\ndescription: >\n a test value\n continued here\n---\n\nBody\n";
2585 let ctx = create_context(content);
2586 let result = rule.check(&ctx).unwrap();
2587
2588 assert_eq!(result.len(), 1, "Should flag 'test' in continuation line: {result:?}");
2590 assert_eq!(result[0].line, 3);
2591 }
2592
2593 #[test]
2594 fn test_frontmatter_quoted_values_checked() {
2595 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2597
2598 let content = "---\ntitle: \"a test title\"\n---\n\nBody\n";
2599 let ctx = create_context(content);
2600 let result = rule.check(&ctx).unwrap();
2601
2602 assert_eq!(result.len(), 1, "Should flag 'test' in quoted YAML value: {result:?}");
2603 assert_eq!(result[0].line, 2);
2604 }
2605
2606 #[test]
2607 fn test_frontmatter_single_quoted_values_checked() {
2608 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2610
2611 let content = "---\ntitle: 'a test title'\n---\n\nBody\n";
2612 let ctx = create_context(content);
2613 let result = rule.check(&ctx).unwrap();
2614
2615 assert_eq!(
2616 result.len(),
2617 1,
2618 "Should flag 'test' in single-quoted YAML value: {result:?}"
2619 );
2620 assert_eq!(result[0].line, 2);
2621 }
2622
2623 #[test]
2624 fn test_frontmatter_fix_multiword_values() {
2625 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2627
2628 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2629 let ctx = create_context(content);
2630 let fixed = rule.fix(&ctx).unwrap();
2631
2632 assert_eq!(
2633 fixed,
2634 "---\ndescription: Learn JavaScript and TypeScript\n---\n\nBody\n"
2635 );
2636 }
2637
2638 #[test]
2639 fn test_frontmatter_fix_preserves_yaml_structure() {
2640 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2642
2643 let content = "---\ntags:\n - test\n - other\ntitle: a test doc\n---\n\ntest body\n";
2644 let ctx = create_context(content);
2645 let fixed = rule.fix(&ctx).unwrap();
2646
2647 assert_eq!(
2648 fixed,
2649 "---\ntags:\n - Test\n - other\ntitle: a Test doc\n---\n\nTest body\n"
2650 );
2651 }
2652
2653 #[test]
2654 fn test_frontmatter_toml_delimiters_not_checked() {
2655 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2657
2658 let content = "+++\ntitle = \"a test title\"\n+++\n\ntest body\n";
2659 let ctx = create_context(content);
2660 let result = rule.check(&ctx).unwrap();
2661
2662 assert_eq!(result.len(), 2, "Should flag TOML value and body: {result:?}");
2666 let fm_violations: Vec<_> = result.iter().filter(|w| w.line == 2).collect();
2667 assert_eq!(fm_violations.len(), 1, "Should flag 'test' in TOML value: {result:?}");
2668 let body_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2669 assert_eq!(body_violations.len(), 1, "Should flag body 'test': {result:?}");
2670 }
2671
2672 #[test]
2673 fn test_frontmatter_toml_key_not_flagged() {
2674 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2676
2677 let content = "+++\ntest = \"other value\"\n+++\n\nBody text\n";
2678 let ctx = create_context(content);
2679 let result = rule.check(&ctx).unwrap();
2680
2681 assert!(
2682 result.is_empty(),
2683 "Should not flag TOML key that matches configured name: {result:?}"
2684 );
2685 }
2686
2687 #[test]
2688 fn test_frontmatter_toml_fix_preserves_keys() {
2689 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2691
2692 let content = "+++\ntest = \"a test value\"\n+++\n\ntest here\n";
2693 let ctx = create_context(content);
2694 let fixed = rule.fix(&ctx).unwrap();
2695
2696 assert_eq!(fixed, "+++\ntest = \"a Test value\"\n+++\n\nTest here\n");
2698 }
2699
2700 #[test]
2701 fn test_frontmatter_list_item_mapping_key_not_flagged() {
2702 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2705
2706 let content = "---\nitems:\n - test: nested value\n---\n\nBody text\n";
2707 let ctx = create_context(content);
2708 let result = rule.check(&ctx).unwrap();
2709
2710 assert!(
2711 result.is_empty(),
2712 "Should not flag YAML key in list-item mapping: {result:?}"
2713 );
2714 }
2715
2716 #[test]
2717 fn test_frontmatter_list_item_mapping_value_flagged() {
2718 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2720
2721 let content = "---\nitems:\n - key: a test value\n---\n\nBody text\n";
2722 let ctx = create_context(content);
2723 let result = rule.check(&ctx).unwrap();
2724
2725 assert_eq!(
2726 result.len(),
2727 1,
2728 "Should flag 'test' in list-item mapping value: {result:?}"
2729 );
2730 assert_eq!(result[0].line, 3);
2731 }
2732
2733 #[test]
2734 fn test_frontmatter_bare_list_item_still_flagged() {
2735 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2737
2738 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2739 let ctx = create_context(content);
2740 let result = rule.check(&ctx).unwrap();
2741
2742 assert_eq!(result.len(), 1, "Should flag 'test' in bare list item: {result:?}");
2743 assert_eq!(result[0].line, 3);
2744 }
2745
2746 #[test]
2747 fn test_frontmatter_flow_mapping_not_flagged() {
2748 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2751
2752 let content = "---\nflow_map: {test: value, other: test}\n---\n\nBody text\n";
2753 let ctx = create_context(content);
2754 let result = rule.check(&ctx).unwrap();
2755
2756 assert!(
2757 result.is_empty(),
2758 "Should not flag names inside flow mappings: {result:?}"
2759 );
2760 }
2761
2762 #[test]
2763 fn test_frontmatter_flow_sequence_not_flagged() {
2764 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2766
2767 let content = "---\nitems: [test, other, test]\n---\n\nBody text\n";
2768 let ctx = create_context(content);
2769 let result = rule.check(&ctx).unwrap();
2770
2771 assert!(
2772 result.is_empty(),
2773 "Should not flag names inside flow sequences: {result:?}"
2774 );
2775 }
2776
2777 #[test]
2778 fn test_frontmatter_list_item_mapping_fix_preserves_key() {
2779 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2781
2782 let content = "---\nitems:\n - test: a test value\n---\n\ntest here\n";
2783 let ctx = create_context(content);
2784 let fixed = rule.fix(&ctx).unwrap();
2785
2786 assert_eq!(fixed, "---\nitems:\n - test: a Test value\n---\n\nTest here\n");
2789 }
2790
2791 #[test]
2792 fn test_frontmatter_backtick_code_not_flagged() {
2793 let config = MD044Config {
2795 names: vec!["GoodApplication".to_string()],
2796 code_blocks: false,
2797 ..MD044Config::default()
2798 };
2799 let rule = MD044ProperNames::from_config_struct(config);
2800
2801 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2802 let ctx = create_context(content);
2803 let result = rule.check(&ctx).unwrap();
2804
2805 assert!(
2807 result.is_empty(),
2808 "Should not flag names inside backticks in frontmatter or body: {result:?}"
2809 );
2810 }
2811
2812 #[test]
2813 fn test_frontmatter_unquoted_backtick_code_not_flagged() {
2814 let config = MD044Config {
2816 names: vec!["GoodApplication".to_string()],
2817 code_blocks: false,
2818 ..MD044Config::default()
2819 };
2820 let rule = MD044ProperNames::from_config_struct(config);
2821
2822 let content = "---\ntitle: `goodapplication` CLI\n---\n\nIntroductory `goodapplication` CLI text.\n";
2823 let ctx = create_context(content);
2824 let result = rule.check(&ctx).unwrap();
2825
2826 assert!(
2827 result.is_empty(),
2828 "Should not flag names inside backticks in unquoted YAML frontmatter: {result:?}"
2829 );
2830 }
2831
2832 #[test]
2833 fn test_frontmatter_bare_name_still_flagged_with_backtick_nearby() {
2834 let config = MD044Config {
2836 names: vec!["GoodApplication".to_string()],
2837 code_blocks: false,
2838 ..MD044Config::default()
2839 };
2840 let rule = MD044ProperNames::from_config_struct(config);
2841
2842 let content = "---\ntitle: goodapplication `goodapplication` CLI\n---\n\nBody\n";
2843 let ctx = create_context(content);
2844 let result = rule.check(&ctx).unwrap();
2845
2846 assert_eq!(
2848 result.len(),
2849 1,
2850 "Should flag bare name but not backtick-wrapped name: {result:?}"
2851 );
2852 assert_eq!(result[0].line, 2);
2853 assert_eq!(result[0].column, 8); }
2855
2856 #[test]
2857 fn test_frontmatter_backtick_code_with_code_blocks_true() {
2858 let config = MD044Config {
2860 names: vec!["GoodApplication".to_string()],
2861 code_blocks: true,
2862 ..MD044Config::default()
2863 };
2864 let rule = MD044ProperNames::from_config_struct(config);
2865
2866 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nBody\n";
2867 let ctx = create_context(content);
2868 let result = rule.check(&ctx).unwrap();
2869
2870 assert_eq!(
2872 result.len(),
2873 1,
2874 "Should flag backtick-wrapped name when code_blocks=true: {result:?}"
2875 );
2876 assert_eq!(result[0].line, 2);
2877 }
2878
2879 #[test]
2880 fn test_frontmatter_fix_preserves_backtick_code() {
2881 let config = MD044Config {
2883 names: vec!["GoodApplication".to_string()],
2884 code_blocks: false,
2885 ..MD044Config::default()
2886 };
2887 let rule = MD044ProperNames::from_config_struct(config);
2888
2889 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2890 let ctx = create_context(content);
2891 let fixed = rule.fix(&ctx).unwrap();
2892
2893 assert_eq!(
2895 fixed, content,
2896 "Fix should not modify names inside backticks in frontmatter"
2897 );
2898 }
2899
2900 fn rule_ignoring(names: &[&str], ignore: &[&str]) -> MD044ProperNames {
2901 MD044ProperNames::from_config_struct(MD044Config {
2902 names: names.iter().map(ToString::to_string).collect(),
2903 ignore_frontmatter_fields: Some(ignore.iter().map(ToString::to_string).collect()),
2904 ..Default::default()
2905 })
2906 }
2907
2908 #[test]
2909 fn test_ignore_frontmatter_field_suppresses_only_that_field() {
2910 let content = "---\ntitle: Heading for myapp\nslug: myapp-guide\n---\n";
2911 let rule = rule_ignoring(&["MyApp"], &["slug"]);
2912 let result = rule.check(&create_context(content)).unwrap();
2913 assert_eq!(result.len(), 1, "only title is flagged: {result:?}");
2914 assert_eq!(result[0].line, 2);
2915 }
2916
2917 #[test]
2918 fn test_ignore_frontmatter_field_is_case_insensitive() {
2919 let content = "---\nSlug: myapp-guide\n---\n";
2920 let rule = rule_ignoring(&["MyApp"], &["SLUG"]);
2921 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2922 }
2923
2924 #[test]
2925 fn test_ignore_frontmatter_field_covers_nested_subtree() {
2926 let content = "---\nseo:\n canonical: myapp\n keywords:\n - myapp\n---\n";
2927 let rule = rule_ignoring(&["MyApp"], &["seo"]);
2928 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2929 }
2930
2931 #[test]
2932 fn test_ignore_frontmatter_field_does_not_affect_body() {
2933 let content = "---\nslug: myapp\n---\n\nBody mentions myapp.\n";
2934 let rule = rule_ignoring(&["MyApp"], &["slug"]);
2935 let result = rule.check(&create_context(content)).unwrap();
2936 assert_eq!(result.len(), 1);
2937 assert_eq!(result[0].line, 5);
2938 }
2939
2940 #[test]
2941 fn test_ignore_frontmatter_field_toml_table() {
2942 let content = "+++\n[seo]\ncanonical = \"myapp\"\n+++\n";
2943 let rule = rule_ignoring(&["MyApp"], &["seo"]);
2944 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2945 }
2946
2947 #[test]
2950 fn test_angle_bracket_url_in_html_comment_not_flagged() {
2951 let config = MD044Config {
2953 names: vec!["Test".to_string()],
2954 ..MD044Config::default()
2955 };
2956 let rule = MD044ProperNames::from_config_struct(config);
2957
2958 let content = "---\ntitle: Level 1 heading\n---\n\n<https://www.example.test>\n\n<!-- This is a Test https://www.example.test -->\n<!-- This is a Test <https://www.example.test> -->\n";
2959 let ctx = create_context(content);
2960 let result = rule.check(&ctx).unwrap();
2961
2962 let line8_warnings: Vec<_> = result.iter().filter(|w| w.line == 8).collect();
2970 assert!(
2971 line8_warnings.is_empty(),
2972 "Should not flag names inside angle-bracket URLs in HTML comments: {line8_warnings:?}"
2973 );
2974 }
2975
2976 #[test]
2977 fn test_bare_url_in_html_comment_still_flagged() {
2978 let config = MD044Config {
2980 names: vec!["Test".to_string()],
2981 ..MD044Config::default()
2982 };
2983 let rule = MD044ProperNames::from_config_struct(config);
2984
2985 let content = "<!-- This is a test https://www.example.test -->\n";
2986 let ctx = create_context(content);
2987 let result = rule.check(&ctx).unwrap();
2988
2989 assert!(
2992 !result.is_empty(),
2993 "Should flag 'test' in prose text of HTML comment with bare URL"
2994 );
2995 }
2996
2997 #[test]
2998 fn test_angle_bracket_url_in_regular_markdown_not_flagged() {
2999 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
3002
3003 let content = "<https://www.example.test>\n";
3004 let ctx = create_context(content);
3005 let result = rule.check(&ctx).unwrap();
3006
3007 assert!(
3008 result.is_empty(),
3009 "Should not flag names inside angle-bracket URLs in regular markdown: {result:?}"
3010 );
3011 }
3012
3013 #[test]
3014 fn test_multiple_angle_bracket_urls_in_one_comment() {
3015 let config = MD044Config {
3016 names: vec!["Test".to_string()],
3017 ..MD044Config::default()
3018 };
3019 let rule = MD044ProperNames::from_config_struct(config);
3020
3021 let content = "<!-- See <https://test.example.com> and <https://www.example.test> for details -->\n";
3022 let ctx = create_context(content);
3023 let result = rule.check(&ctx).unwrap();
3024
3025 assert!(
3027 result.is_empty(),
3028 "Should not flag names inside multiple angle-bracket URLs: {result:?}"
3029 );
3030 }
3031
3032 #[test]
3033 fn test_angle_bracket_non_url_still_flagged() {
3034 assert!(
3037 !MD044ProperNames::is_in_angle_bracket_url("<test> which is not a URL.", 1),
3038 "is_in_angle_bracket_url should return false for non-URL angle brackets"
3039 );
3040 }
3041
3042 #[test]
3043 fn test_angle_bracket_mailto_url_not_flagged() {
3044 let config = MD044Config {
3045 names: vec!["Test".to_string()],
3046 ..MD044Config::default()
3047 };
3048 let rule = MD044ProperNames::from_config_struct(config);
3049
3050 let content = "<!-- Contact <mailto:test@example.com> for help -->\n";
3051 let ctx = create_context(content);
3052 let result = rule.check(&ctx).unwrap();
3053
3054 assert!(
3055 result.is_empty(),
3056 "Should not flag names inside angle-bracket mailto URLs: {result:?}"
3057 );
3058 }
3059
3060 #[test]
3061 fn test_angle_bracket_ftp_url_not_flagged() {
3062 let config = MD044Config {
3063 names: vec!["Test".to_string()],
3064 ..MD044Config::default()
3065 };
3066 let rule = MD044ProperNames::from_config_struct(config);
3067
3068 let content = "<!-- Download from <ftp://test.example.com/file> -->\n";
3069 let ctx = create_context(content);
3070 let result = rule.check(&ctx).unwrap();
3071
3072 assert!(
3073 result.is_empty(),
3074 "Should not flag names inside angle-bracket FTP URLs: {result:?}"
3075 );
3076 }
3077
3078 #[test]
3079 fn test_angle_bracket_url_fix_preserves_url() {
3080 let config = MD044Config {
3082 names: vec!["Test".to_string()],
3083 ..MD044Config::default()
3084 };
3085 let rule = MD044ProperNames::from_config_struct(config);
3086
3087 let content = "<!-- test text <https://www.example.test> -->\n";
3088 let ctx = create_context(content);
3089 let fixed = rule.fix(&ctx).unwrap();
3090
3091 assert!(
3093 fixed.contains("<https://www.example.test>"),
3094 "Fix should preserve angle-bracket URLs: {fixed}"
3095 );
3096 assert!(
3097 fixed.contains("Test text"),
3098 "Fix should correct prose 'test' to 'Test': {fixed}"
3099 );
3100 }
3101
3102 #[test]
3103 fn test_is_in_angle_bracket_url_helper() {
3104 let line = "text <https://example.test> more text";
3106
3107 assert!(MD044ProperNames::is_in_angle_bracket_url(line, 5)); assert!(MD044ProperNames::is_in_angle_bracket_url(line, 6)); assert!(MD044ProperNames::is_in_angle_bracket_url(line, 15)); assert!(MD044ProperNames::is_in_angle_bracket_url(line, 26)); assert!(!MD044ProperNames::is_in_angle_bracket_url(line, 0)); assert!(!MD044ProperNames::is_in_angle_bracket_url(line, 4)); assert!(!MD044ProperNames::is_in_angle_bracket_url(line, 27)); assert!(!MD044ProperNames::is_in_angle_bracket_url("<notaurl>", 1));
3120
3121 assert!(MD044ProperNames::is_in_angle_bracket_url(
3123 "<mailto:test@example.com>",
3124 10
3125 ));
3126
3127 assert!(MD044ProperNames::is_in_angle_bracket_url(
3129 "<ftp://test.example.com>",
3130 10
3131 ));
3132 }
3133
3134 #[test]
3135 fn test_is_in_angle_bracket_url_uppercase_scheme() {
3136 assert!(MD044ProperNames::is_in_angle_bracket_url(
3138 "<HTTPS://test.example.com>",
3139 10
3140 ));
3141 assert!(MD044ProperNames::is_in_angle_bracket_url(
3142 "<Http://test.example.com>",
3143 10
3144 ));
3145 }
3146
3147 #[test]
3148 fn test_is_in_angle_bracket_url_uncommon_schemes() {
3149 assert!(MD044ProperNames::is_in_angle_bracket_url(
3151 "<ssh://test@example.com>",
3152 10
3153 ));
3154 assert!(MD044ProperNames::is_in_angle_bracket_url("<file:///test/path>", 10));
3156 assert!(MD044ProperNames::is_in_angle_bracket_url("<data:text/plain;test>", 10));
3158 }
3159
3160 #[test]
3161 fn test_is_in_angle_bracket_url_unclosed() {
3162 assert!(!MD044ProperNames::is_in_angle_bracket_url(
3164 "<https://test.example.com",
3165 10
3166 ));
3167 }
3168
3169 #[test]
3170 fn test_vale_inline_config_comments_not_flagged() {
3171 let config = MD044Config {
3172 names: vec!["Vale".to_string(), "JavaScript".to_string()],
3173 ..MD044Config::default()
3174 };
3175 let rule = MD044ProperNames::from_config_struct(config);
3176
3177 let content = "\
3178<!-- vale off -->
3179Some javascript text here.
3180<!-- vale on -->
3181<!-- vale Style.Rule = NO -->
3182More javascript text.
3183<!-- vale Style.Rule = YES -->
3184<!-- vale JavaScript.Grammar = NO -->
3185";
3186 let ctx = create_context(content);
3187 let result = rule.check(&ctx).unwrap();
3188
3189 assert_eq!(result.len(), 2, "Should only flag body lines, not Vale config comments");
3191 assert_eq!(result[0].line, 2);
3192 assert_eq!(result[1].line, 5);
3193 }
3194
3195 #[test]
3196 fn test_remark_lint_inline_config_comments_not_flagged() {
3197 let config = MD044Config {
3198 names: vec!["JavaScript".to_string()],
3199 ..MD044Config::default()
3200 };
3201 let rule = MD044ProperNames::from_config_struct(config);
3202
3203 let content = "\
3204<!-- lint disable remark-lint-some-rule -->
3205Some javascript text here.
3206<!-- lint enable remark-lint-some-rule -->
3207<!-- lint ignore remark-lint-some-rule -->
3208More javascript text.
3209";
3210 let ctx = create_context(content);
3211 let result = rule.check(&ctx).unwrap();
3212
3213 assert_eq!(
3214 result.len(),
3215 2,
3216 "Should only flag body lines, not remark-lint config comments"
3217 );
3218 assert_eq!(result[0].line, 2);
3219 assert_eq!(result[1].line, 5);
3220 }
3221
3222 #[test]
3223 fn test_fix_does_not_modify_vale_remark_lint_comments() {
3224 let config = MD044Config {
3225 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3226 ..MD044Config::default()
3227 };
3228 let rule = MD044ProperNames::from_config_struct(config);
3229
3230 let content = "\
3231<!-- vale off -->
3232Some javascript text.
3233<!-- vale on -->
3234<!-- lint disable remark-lint-some-rule -->
3235More javascript text.
3236<!-- lint enable remark-lint-some-rule -->
3237";
3238 let ctx = create_context(content);
3239 let fixed = rule.fix(&ctx).unwrap();
3240
3241 assert!(fixed.contains("<!-- vale off -->"));
3243 assert!(fixed.contains("<!-- vale on -->"));
3244 assert!(fixed.contains("<!-- lint disable remark-lint-some-rule -->"));
3245 assert!(fixed.contains("<!-- lint enable remark-lint-some-rule -->"));
3246 assert!(fixed.contains("Some JavaScript text."));
3248 assert!(fixed.contains("More JavaScript text."));
3249 }
3250
3251 #[test]
3252 fn test_mixed_tool_directives_all_skipped() {
3253 let config = MD044Config {
3254 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3255 ..MD044Config::default()
3256 };
3257 let rule = MD044ProperNames::from_config_struct(config);
3258
3259 let content = "\
3260<!-- rumdl-disable MD044 -->
3261Some javascript text.
3262<!-- markdownlint-disable -->
3263More javascript text.
3264<!-- vale off -->
3265Even more javascript text.
3266<!-- lint disable some-rule -->
3267Final javascript text.
3268<!-- rumdl-enable MD044 -->
3269<!-- markdownlint-enable -->
3270<!-- vale on -->
3271<!-- lint enable some-rule -->
3272";
3273 let ctx = create_context(content);
3274 let result = rule.check(&ctx).unwrap();
3275
3276 assert_eq!(
3278 result.len(),
3279 4,
3280 "Should only flag body lines, not any tool directive comments"
3281 );
3282 assert_eq!(result[0].line, 2);
3283 assert_eq!(result[1].line, 4);
3284 assert_eq!(result[2].line, 6);
3285 assert_eq!(result[3].line, 8);
3286 }
3287
3288 #[test]
3289 fn test_vale_remark_lint_edge_cases_not_matched() {
3290 let config = MD044Config {
3291 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3292 ..MD044Config::default()
3293 };
3294 let rule = MD044ProperNames::from_config_struct(config);
3295
3296 let content = "\
3304<!-- vale -->
3305<!-- vale is a tool for writing -->
3306<!-- valedictorian javascript -->
3307<!-- linting javascript tips -->
3308<!-- vale javascript -->
3309<!-- lint your javascript code -->
3310";
3311 let ctx = create_context(content);
3312 let result = rule.check(&ctx).unwrap();
3313
3314 assert_eq!(
3321 result.len(),
3322 7,
3323 "Should flag proper names in non-directive HTML comments: got {result:?}"
3324 );
3325 assert_eq!(result[0].line, 1); assert_eq!(result[1].line, 2); assert_eq!(result[2].line, 3); assert_eq!(result[3].line, 4); assert_eq!(result[4].line, 5); assert_eq!(result[5].line, 5); assert_eq!(result[6].line, 6); }
3333
3334 #[test]
3335 fn test_vale_style_directives_skipped() {
3336 let config = MD044Config {
3337 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3338 ..MD044Config::default()
3339 };
3340 let rule = MD044ProperNames::from_config_struct(config);
3341
3342 let content = "\
3344<!-- vale style = MyStyle -->
3345<!-- vale styles = Style1, Style2 -->
3346<!-- vale MyRule.Name = YES -->
3347<!-- vale MyRule.Name = NO -->
3348Some javascript text.
3349";
3350 let ctx = create_context(content);
3351 let result = rule.check(&ctx).unwrap();
3352
3353 assert_eq!(
3355 result.len(),
3356 1,
3357 "Should only flag body lines, not Vale style/rule directives: got {result:?}"
3358 );
3359 assert_eq!(result[0].line, 5);
3360 }
3361
3362 #[test]
3365 fn test_backtick_code_single_backticks() {
3366 let line = "hello `world` bye";
3367 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 7));
3369 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3371 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 14));
3373 }
3374
3375 #[test]
3376 fn test_backtick_code_double_backticks() {
3377 let line = "a ``code`` b";
3378 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3380 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3382 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 11));
3384 }
3385
3386 #[test]
3387 fn test_backtick_code_unclosed() {
3388 let line = "a `code b";
3389 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3391 }
3392
3393 #[test]
3394 fn test_backtick_code_mismatched_count() {
3395 let line = "a `code`` b";
3397 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3400 }
3401
3402 #[test]
3403 fn test_backtick_code_multiple_spans() {
3404 let line = "`first` and `second`";
3405 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
3407 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 8));
3409 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 13));
3411 }
3412
3413 #[test]
3414 fn test_backtick_code_on_backtick_boundary() {
3415 let line = "`code`";
3416 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3418 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 5));
3420 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
3422 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3423 }
3424
3425 #[test]
3431 fn test_double_bracket_link_url_not_flagged() {
3432 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3433 let content = "[[rumdl]](https://github.com/rvben/rumdl)";
3435 let ctx = create_context(content);
3436 let result = rule.check(&ctx).unwrap();
3437 assert!(
3438 result.is_empty(),
3439 "URL inside [[text]](url) must not be flagged, got: {result:?}"
3440 );
3441 }
3442
3443 #[test]
3444 fn test_double_bracket_link_url_not_fixed() {
3445 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3446 let content = "[[rumdl]](https://github.com/rvben/rumdl)\n";
3447 let ctx = create_context(content);
3448 let fixed = rule.fix(&ctx).unwrap();
3449 assert_eq!(
3450 fixed, content,
3451 "fix() must leave the URL inside [[text]](url) unchanged"
3452 );
3453 }
3454
3455 #[test]
3456 fn test_double_bracket_link_text_still_flagged() {
3457 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3458 let content = "[[github]](https://example.com)";
3460 let ctx = create_context(content);
3461 let result = rule.check(&ctx).unwrap();
3462 assert_eq!(
3463 result.len(),
3464 1,
3465 "Incorrect name in [[text]] link text should still be flagged, got: {result:?}"
3466 );
3467 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
3468 }
3469
3470 #[test]
3471 fn test_double_bracket_link_mixed_line() {
3472 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3473 let content = "See [[rumdl]](https://github.com/rvben/rumdl) and github for more.";
3475 let ctx = create_context(content);
3476 let result = rule.check(&ctx).unwrap();
3477 assert_eq!(
3478 result.len(),
3479 1,
3480 "Only the standalone 'github' after the link should be flagged, got: {result:?}"
3481 );
3482 assert!(result[0].message.contains("'github'"));
3483 assert_eq!(
3485 result[0].column, 51,
3486 "Flagged column should be the trailing 'github', not the one in the URL"
3487 );
3488 }
3489
3490 #[test]
3491 fn test_regular_link_url_still_not_flagged() {
3492 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3494 let content = "[rumdl](https://github.com/rvben/rumdl)";
3495 let ctx = create_context(content);
3496 let result = rule.check(&ctx).unwrap();
3497 assert!(
3498 result.is_empty(),
3499 "URL inside regular [text](url) must still not be flagged, got: {result:?}"
3500 );
3501 }
3502
3503 #[test]
3504 fn test_link_like_text_in_code_span_still_flagged_when_code_blocks_enabled() {
3505 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
3510 let content = "`[foo](https://github.com/org/repo)`";
3511 let ctx = create_context(content);
3512 let result = rule.check(&ctx).unwrap();
3513 assert_eq!(
3514 result.len(),
3515 1,
3516 "Proper name inside a code span must be flagged when code-blocks=true, got: {result:?}"
3517 );
3518 assert!(result[0].message.contains("'github'"));
3519 }
3520
3521 #[test]
3522 fn test_malformed_link_not_treated_as_url() {
3523 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3526 let content = "See [rumdl](github repo) for details.";
3527 let ctx = create_context(content);
3528 let result = rule.check(&ctx).unwrap();
3529 assert_eq!(
3530 result.len(),
3531 1,
3532 "Name inside malformed [text](url with spaces) must still be flagged, got: {result:?}"
3533 );
3534 assert!(result[0].message.contains("'github'"));
3535 }
3536
3537 #[test]
3538 fn test_wikilink_followed_by_prose_parens_still_flagged() {
3539 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3543 let content = "[[note]](github repo)";
3544 let ctx = create_context(content);
3545 let result = rule.check(&ctx).unwrap();
3546 assert_eq!(
3547 result.len(),
3548 1,
3549 "Name inside [[wikilink]](prose with spaces) must still be flagged, got: {result:?}"
3550 );
3551 assert!(result[0].message.contains("'github'"));
3552 }
3553
3554 #[test]
3556 fn test_roundtrip_fix_then_check_basic() {
3557 let rule = MD044ProperNames::new(
3558 vec![
3559 "JavaScript".to_string(),
3560 "TypeScript".to_string(),
3561 "Node.js".to_string(),
3562 ],
3563 true,
3564 );
3565 let content = "I love javascript, typescript, and nodejs!";
3566 let ctx = create_context(content);
3567 let fixed = rule.fix(&ctx).unwrap();
3568 let ctx2 = create_context(&fixed);
3569 let warnings = rule.check(&ctx2).unwrap();
3570 assert!(
3571 warnings.is_empty(),
3572 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3573 );
3574 }
3575
3576 #[test]
3578 fn test_roundtrip_fix_then_check_multiline() {
3579 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
3580 let content = "First line with rust.\nSecond line with python.\nThird line with RUST and PYTHON.\n";
3581 let ctx = create_context(content);
3582 let fixed = rule.fix(&ctx).unwrap();
3583 let ctx2 = create_context(&fixed);
3584 let warnings = rule.check(&ctx2).unwrap();
3585 assert!(
3586 warnings.is_empty(),
3587 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3588 );
3589 }
3590
3591 #[test]
3593 fn test_roundtrip_fix_then_check_inline_config() {
3594 let config = MD044Config {
3595 names: vec!["RUMDL".to_string()],
3596 ..MD044Config::default()
3597 };
3598 let rule = MD044ProperNames::from_config_struct(config);
3599 let content =
3600 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
3601 let ctx = create_context(content);
3602 let fixed = rule.fix(&ctx).unwrap();
3603 assert!(
3605 fixed.contains("Some rumdl text.\n"),
3606 "Disabled block text should be preserved"
3607 );
3608 assert!(
3609 fixed.contains("Some RUMDL text outside."),
3610 "Outside text should be fixed"
3611 );
3612 }
3613
3614 #[test]
3616 fn test_roundtrip_fix_then_check_html_comments() {
3617 let config = MD044Config {
3618 names: vec!["JavaScript".to_string()],
3619 ..MD044Config::default()
3620 };
3621 let rule = MD044ProperNames::from_config_struct(config);
3622 let content = "# Guide\n\n<!-- javascript mentioned here -->\n\njavascript outside\n";
3623 let ctx = create_context(content);
3624 let fixed = rule.fix(&ctx).unwrap();
3625 let ctx2 = create_context(&fixed);
3626 let warnings = rule.check(&ctx2).unwrap();
3627 assert!(
3628 warnings.is_empty(),
3629 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3630 );
3631 }
3632
3633 #[test]
3635 fn test_roundtrip_no_op_when_correct() {
3636 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
3637 let content = "This uses JavaScript and TypeScript correctly.\n";
3638 let ctx = create_context(content);
3639 let fixed = rule.fix(&ctx).unwrap();
3640 assert_eq!(fixed, content, "Fix should be a no-op when content is already correct");
3641 }
3642
3643 #[test]
3646 fn test_bare_domain_link_text_not_flagged() {
3647 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3651 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3652 let ctx = create_context(content);
3653 let result = rule.check(&ctx).unwrap();
3654 assert!(
3655 result.is_empty(),
3656 "Should not flag 'github' in a bare-domain link text that matches the link URL: {result:?}"
3657 );
3658 }
3659
3660 #[test]
3661 fn test_bare_domain_link_text_not_fixed() {
3662 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3664 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3665 let ctx = create_context(content);
3666 let fixed = rule.fix(&ctx).unwrap();
3667 assert_eq!(
3668 fixed, content,
3669 "fix() must not alter bare-domain link text that matches the destination URL"
3670 );
3671 }
3672
3673 #[test]
3674 fn test_bare_domain_link_text_with_path_not_flagged() {
3675 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3677 let content = "Visit [ravencentric.github.io](https://ravencentric.github.io/projects).\n";
3678 let ctx = create_context(content);
3679 let result = rule.check(&ctx).unwrap();
3680 assert!(
3681 result.is_empty(),
3682 "Should not flag 'github' when bare-domain text is the hostname of its destination URL: {result:?}"
3683 );
3684 }
3685
3686 #[test]
3687 fn test_bare_domain_link_text_full_path_not_flagged() {
3688 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3690 let content = "See [ravencentric.github.io/blog](https://ravencentric.github.io/blog).\n";
3691 let ctx = create_context(content);
3692 let result = rule.check(&ctx).unwrap();
3693 assert!(
3694 result.is_empty(),
3695 "Should not flag 'github' when link text is the full URL path without scheme: {result:?}"
3696 );
3697 }
3698
3699 #[test]
3700 fn test_github_product_name_in_link_text_still_flagged() {
3701 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3704 let content = "Hosted on [github pages](https://pages.github.com).\n";
3705 let ctx = create_context(content);
3706 let result = rule.check(&ctx).unwrap();
3707 assert!(
3708 !result.is_empty(),
3709 "Should still flag 'github' in descriptive link text that does not match the destination URL"
3710 );
3711 }
3712
3713 #[test]
3714 fn test_protocol_relative_bare_domain_link_text_not_flagged() {
3715 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3717 let content = "See [github.io](//github.io).\n";
3718 let ctx = create_context(content);
3719 let result = rule.check(&ctx).unwrap();
3720 assert!(
3721 result.is_empty(),
3722 "Should not flag 'github' in bare-domain text matching a protocol-relative destination: {result:?}"
3723 );
3724 }
3725
3726 #[test]
3727 fn test_dotted_wikilink_target_still_flagged() {
3728 let rule = MD044ProperNames::new(vec!["Node.js".to_string()], false);
3733 let content = "See [[node.js]] for details.\n";
3734 let ctx = create_context(content);
3735 let result = rule.check(&ctx).unwrap();
3736 assert!(
3737 !result.is_empty(),
3738 "Should flag 'node.js' in a dotted WikiLink target: {result:?}"
3739 );
3740 }
3741
3742 #[test]
3743 fn test_bare_domain_link_text_case_insensitive_url() {
3744 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3747 let content = "See [github.io](HTTPS://github.io).\n";
3748 let ctx = create_context(content);
3749 let result = rule.check(&ctx).unwrap();
3750 assert!(
3751 result.is_empty(),
3752 "Should not flag bare-domain text when destination URL has an uppercase scheme: {result:?}"
3753 );
3754 }
3755
3756 #[test]
3757 fn test_frontmatter_value_span_strips_trailing_comment() {
3758 let line = "link: docs/guide/myapp # canonical path";
3759 let (s, e) = frontmatter_values::value_span(line).unwrap();
3760 assert_eq!(&line[s..e], "docs/guide/myapp");
3761 }
3762
3763 #[test]
3764 fn test_frontmatter_value_span_quoted_keeps_hash_and_spaces() {
3765 let line = "link: 'docs/My App/a#b'";
3766 let (s, e) = frontmatter_values::value_span(line).unwrap();
3767 assert_eq!(&line[s..e], "docs/My App/a#b");
3768 }
3769
3770 #[test]
3771 fn test_frontmatter_value_span_plain_value() {
3772 let line = "title: Heading for myapp";
3773 let (s, e) = frontmatter_values::value_span(line).unwrap();
3774 assert_eq!(&line[s..e], "Heading for myapp");
3775 }
3776
3777 #[test]
3778 fn test_frontmatter_value_span_none_for_key_only() {
3779 assert!(frontmatter_values::value_span("seo:").is_none());
3780 assert!(frontmatter_values::value_span("---").is_none());
3781 }
3782
3783 #[test]
3784 fn test_frontmatter_value_span_quoted_strips_trailing_comment() {
3785 let line = "link: 'docs/guide' # canonical path";
3786 let (s, e) = frontmatter_values::value_span(line).unwrap();
3787 assert_eq!(&line[s..e], "docs/guide");
3788 }
3789
3790 #[test]
3791 fn test_frontmatter_value_span_empty_quoted_value_is_none() {
3792 assert!(frontmatter_values::value_span("key: ''").is_none());
3793 }
3794
3795 #[test]
3796 fn test_frontmatter_value_span_unterminated_quote_strips_leading_quote() {
3797 let line = "link: 'docs/a";
3798 let (s, e) = frontmatter_values::value_span(line).unwrap();
3799 assert_eq!(&line[s..e], "docs/a");
3800 }
3801
3802 fn at(line: &str, needle: &str) -> usize {
3804 line.find(needle).expect("needle present")
3805 }
3806
3807 #[test]
3808 fn test_path_like_exempts_single_token_frontmatter_paths() {
3809 for line in [
3810 "link: this/is/a/link/to/myapp.md",
3811 "link: docs/myapp.md",
3812 "link: /abs/path/myapp.md",
3813 "link: ./myapp.md",
3814 "link: ../shared/myapp.md",
3815 ] {
3816 let span = frontmatter_values::value_span(line).unwrap();
3817 let pos = at(line, "myapp");
3818 assert!(
3819 MD044ProperNames::is_in_path_like_token(line, pos, span),
3820 "should treat as a path: {line}"
3821 );
3822 }
3823 }
3824
3825 #[test]
3826 fn test_path_like_does_not_exempt_slash_conjunction_prose() {
3827 let line = "description: We support github/gitlab/bitbucket imports.";
3830 let span = frontmatter_values::value_span(line).unwrap();
3831 assert!(
3832 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
3833 "slash-separated prose is not a path"
3834 );
3835
3836 let line = "description: The javascript/typescript ecosystem is large.";
3837 let span = frontmatter_values::value_span(line).unwrap();
3838 assert!(!MD044ProperNames::is_in_path_like_token(
3839 line,
3840 at(line, "javascript"),
3841 span
3842 ));
3843 }
3844
3845 #[test]
3846 fn test_path_like_requires_a_slash_so_dotted_names_survive() {
3847 let line = "title: Use nodejs and myapp.md today.";
3848 let span = frontmatter_values::value_span(line).unwrap();
3849 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3850 }
3851
3852 #[test]
3853 fn test_path_like_no_slash_frontmatter_value_still_flagged() {
3854 let line = "slug: myapp-guide";
3859 let span = frontmatter_values::value_span(line).unwrap();
3860 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3861 }
3862
3863 #[test]
3864 fn test_path_like_returns_false_outside_value_span() {
3865 let line = "myapp: docs/guide/myapp";
3868 let span = frontmatter_values::value_span(line).unwrap();
3869 let key_pos = 0;
3870 assert!(!MD044ProperNames::is_in_path_like_token(line, key_pos, span));
3871 }
3872
3873 #[test]
3874 fn test_path_like_three_segments_only_as_sole_frontmatter_value() {
3875 let line = "link: docs/guide/myapp";
3876 let span = frontmatter_values::value_span(line).unwrap();
3877 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3878
3879 let line = "description: We support github/gitlab/bitbucket now";
3880 let span = frontmatter_values::value_span(line).unwrap();
3881 assert!(
3882 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
3883 "multi-token value gets body treatment"
3884 );
3885 }
3886
3887 #[test]
3888 fn test_path_like_quoted_value_with_spaces() {
3889 let line = "link: 'docs/My App/myapp.md'";
3892 let span = frontmatter_values::value_span(line).unwrap();
3893 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3894 }
3895
3896 #[test]
3897 fn test_path_like_quoted_value_with_spaces_no_extension_not_exempt() {
3898 let line = "link: 'docs/My App/myapp'";
3905 let span = frontmatter_values::value_span(line).unwrap();
3906 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3907 }
3908
3909 #[test]
3910 fn test_path_like_trailing_comment_is_still_sole_value() {
3911 let line = "link: docs/guide/myapp # canonical path";
3912 let span = frontmatter_values::value_span(line).unwrap();
3913 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3914 }
3915
3916 #[test]
3917 fn test_path_like_trailing_punctuation_trimmed() {
3918 let line = "link: docs/myapp.md, then leave.";
3919 let span = frontmatter_values::value_span(line).unwrap();
3920 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3921 }
3922
3923 #[test]
3924 fn test_trim_token_bounds_reaches_fixpoint_after_punctuation_exposes_wrapper() {
3925 let line = r#"See "docs/myapp.md", then leave."#;
3926 let raw_start = at(line, "\"docs");
3927 let raw_end = raw_start + r#""docs/myapp.md","#.len();
3928 assert_eq!(&line[raw_start..raw_end], r#""docs/myapp.md","#);
3929 let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
3930 assert_eq!(&line[start..end], "docs/myapp.md");
3931 }
3932
3933 #[test]
3934 fn test_trim_token_bounds_reaches_fixpoint_with_multiple_trailing_wrappers() {
3935 let line = r#"("docs/myapp.md")."#;
3936 let (start, end) = frontmatter_values::trim_token_bounds(line, 0, line.len());
3937 assert_eq!(&line[start..end], "docs/myapp.md");
3938 }
3939
3940 #[test]
3941 fn test_frontmatter_link_path_not_flagged() {
3942 let content = "---\ntitle: Heading for MyApp\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
3943 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3944 let ctx = create_context(content);
3945 let result = rule.check(&ctx).unwrap();
3946 assert!(
3947 result.is_empty(),
3948 "path in a frontmatter value must not be flagged: {result:?}"
3949 );
3950 }
3951
3952 #[test]
3953 fn test_fix_does_not_corrupt_frontmatter_link_path() {
3954 let content = "---\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
3955 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3956 let ctx = create_context(content);
3957 assert_eq!(rule.fix(&ctx).unwrap(), content, "fix must not rewrite a path");
3958 }
3959
3960 #[test]
3971 fn test_body_prose_parenthesized_disambiguator_is_case_corrected() {
3972 let content = "See docs/myapp(1).md here.\n";
3973 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3974 let ctx = create_context(content);
3975 let result = rule.check(&ctx).unwrap();
3976 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3977 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp(1).md here.\n");
3978 }
3979
3980 #[test]
3981 fn test_body_prose_bracketed_dynamic_segment_is_case_corrected() {
3982 let content = "See docs/[myapp].md here.\n";
3983 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3984 let ctx = create_context(content);
3985 let result = rule.check(&ctx).unwrap();
3986 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3987 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/[MyApp].md here.\n");
3988 }
3989
3990 #[test]
3991 fn test_body_prose_nextjs_catch_all_segment_is_case_corrected() {
3992 let content = "pages/[[...myapp]].tsx are catch-all routes.\n";
3995 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3996 let ctx = create_context(content);
3997 let result = rule.check(&ctx).unwrap();
3998 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3999 assert_eq!(
4000 rule.fix(&ctx).unwrap(),
4001 "pages/[[...MyApp]].tsx are catch-all routes.\n"
4002 );
4003 }
4004
4005 #[test]
4006 fn test_two_adjacent_whitespace_free_links_both_flagged() {
4007 let content = "[myapp](https://a.com)[github](https://b.com)\n";
4011 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitHub".to_string()], false);
4012 let ctx = create_context(content);
4013 let result = rule.check(&ctx).unwrap();
4014 assert_eq!(result.len(), 2, "both link texts must be flagged: {result:?}");
4015 assert!(result.iter().any(|w| w.message.contains("'myapp'")));
4016 assert!(result.iter().any(|w| w.message.contains("'github'")));
4017 }
4018
4019 #[test]
4020 fn test_fix_does_not_corrupt_frontmatter_path_with_route_group_named_after_proper_name() {
4021 let content = "---\nlink: src/(myapp)/page.tsx\n---\n\nBody.\n";
4024 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4025 let ctx = create_context(content);
4026 assert_eq!(
4027 rule.fix(&ctx).unwrap(),
4028 content,
4029 "fix must not rewrite a frontmatter path whose route-group directory name is the proper name"
4030 );
4031 }
4032
4033 #[test]
4034 fn test_quoted_frontmatter_value_slash_conjunction_prose_still_flagged() {
4035 let content = "---\ndescription: \"We support github/gitlab/bitbucket now\"\n---\n\nBody.\n";
4040 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4041 let ctx = create_context(content);
4042 let result = rule.check(&ctx).unwrap();
4043 assert_eq!(
4044 result.len(),
4045 1,
4046 "quoted prose value must still flag 'github': {result:?}"
4047 );
4048 }
4049
4050 #[test]
4051 fn test_quoted_frontmatter_value_single_slash_word_with_unrelated_dot_still_flagged() {
4052 let content = "---\ndescription: \"We use myapp/gitlab and version 1.0 e.g. weekly\"\n---\n\nBody.\n";
4055 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4056 let ctx = create_context(content);
4057 let result = rule.check(&ctx).unwrap();
4058 assert_eq!(
4059 result.len(),
4060 1,
4061 "quoted prose value must still flag 'myapp': {result:?}"
4062 );
4063 }
4064
4065 #[test]
4066 fn test_quoted_toml_frontmatter_value_slash_conjunction_prose_still_flagged() {
4067 let content = "+++\ndescription = \"We support github/gitlab/bitbucket now\"\n+++\n\nBody.\n";
4070 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4071 let ctx = create_context(content);
4072 let result = rule.check(&ctx).unwrap();
4073 assert_eq!(
4074 result.len(),
4075 1,
4076 "TOML quoted prose value must still flag 'github': {result:?}"
4077 );
4078 }
4079
4080 #[test]
4081 fn test_path_like_collapsed_multiword_no_extension_not_exempt() {
4082 for line in [
4089 r#"description: "myapp/gitlab github/bitbucket""#,
4090 r#"description: "and/or this/that myapp/gitlab""#,
4091 r#"description: "he/him she/her myapp/gitlab""#,
4092 ] {
4093 let span = frontmatter_values::value_span(line).unwrap();
4094 for needle in ["myapp", "gitlab"] {
4095 if let Some(byte_pos) = line.find(needle) {
4096 assert!(
4097 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
4098 "collapsed multi-word value must not exempt '{needle}': {line}"
4099 );
4100 }
4101 }
4102 }
4103 }
4104
4105 #[test]
4106 fn test_path_like_collapsed_multiword_no_extension_not_exempt_toml() {
4107 let line = r#"description = "myapp/gitlab github/bitbucket""#;
4108 let span = frontmatter_values::value_span(line).unwrap();
4109 for needle in ["myapp", "gitlab", "github", "bitbucket"] {
4110 let byte_pos = at(line, needle);
4111 assert!(
4112 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
4113 "collapsed multi-word TOML value must not exempt '{needle}'"
4114 );
4115 }
4116 }
4117
4118 #[test]
4119 fn test_frontmatter_collapsed_multiword_names_all_flagged_yaml() {
4120 let content = "---\ndescription: \"myapp/gitlab github/bitbucket\"\n---\n\nBody.\n";
4121 let rule = MD044ProperNames::new(
4122 vec![
4123 "MyApp".to_string(),
4124 "GitLab".to_string(),
4125 "GitHub".to_string(),
4126 "Bitbucket".to_string(),
4127 ],
4128 false,
4129 );
4130 let ctx = create_context(content);
4131 let result = rule.check(&ctx).unwrap();
4132 assert_eq!(
4133 result.len(),
4134 4,
4135 "all four names in the collapsed multi-word value must be flagged: {result:?}"
4136 );
4137 }
4138
4139 #[test]
4140 fn test_frontmatter_collapsed_multiword_names_all_flagged_toml() {
4141 let content = "+++\ndescription = \"myapp/gitlab github/bitbucket\"\n+++\n\nBody.\n";
4142 let rule = MD044ProperNames::new(
4143 vec![
4144 "MyApp".to_string(),
4145 "GitLab".to_string(),
4146 "GitHub".to_string(),
4147 "Bitbucket".to_string(),
4148 ],
4149 false,
4150 );
4151 let ctx = create_context(content);
4152 let result = rule.check(&ctx).unwrap();
4153 assert_eq!(
4154 result.len(),
4155 4,
4156 "all four names in the collapsed multi-word TOML value must be flagged: {result:?}"
4157 );
4158 }
4159
4160 #[test]
4161 fn test_frontmatter_collapsed_multiword_conjunction_pairs_flagged() {
4162 let content = "---\ndescription: \"and/or this/that myapp/gitlab\"\n---\n\nBody.\n";
4163 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4164 let ctx = create_context(content);
4165 let result = rule.check(&ctx).unwrap();
4166 assert_eq!(
4167 result.len(),
4168 2,
4169 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4170 );
4171 }
4172
4173 #[test]
4174 fn test_frontmatter_collapsed_multiword_pronoun_pairs_flagged() {
4175 let content = "---\ndescription: \"he/him she/her myapp/gitlab\"\n---\n\nBody.\n";
4176 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4177 let ctx = create_context(content);
4178 let result = rule.check(&ctx).unwrap();
4179 assert_eq!(
4180 result.len(),
4181 2,
4182 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4183 );
4184 }
4185
4186 #[test]
4192 fn test_body_prose_path_is_flagged_frontmatter_only_scope() {
4193 let content = "See docs/myapp.md for details about myapp.\n";
4194 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4195 let ctx = create_context(content);
4196 let result = rule.check(&ctx).unwrap();
4197 assert_eq!(
4198 result.len(),
4199 2,
4200 "both the path occurrence and the prose occurrence are flagged in body text: {result:?}"
4201 );
4202 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp.md for details about MyApp.\n");
4203 }
4204
4205 #[test]
4206 fn test_slash_conjunction_prose_still_flagged() {
4207 let content = "We support github/gitlab imports.\n";
4208 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4209 let ctx = create_context(content);
4210 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
4211 }
4212}