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); fn is_inline_config_comment(trimmed: &str) -> bool {
72 trimmed.starts_with("<!-- rumdl-")
73 || trimmed.starts_with("<!-- markdownlint-")
74 || trimmed.starts_with("<!-- vale off")
75 || trimmed.starts_with("<!-- vale on")
76 || (trimmed.starts_with("<!-- vale ") && trimmed.contains(" = "))
77 || trimmed.starts_with("<!-- vale style")
78 || trimmed.starts_with("<!-- lint disable ")
79 || trimmed.starts_with("<!-- lint enable ")
80 || trimmed.starts_with("<!-- lint ignore ")
81}
82
83#[derive(Clone)]
84pub struct MD044ProperNames {
85 config: MD044Config,
86 combined_pattern: Option<String>,
88 name_variants: Vec<String>,
90 ignore_fields: HashSet<String>,
92 content_cache: Arc<Mutex<HashMap<u64, Vec<WarningPosition>>>>,
101}
102
103impl MD044ProperNames {
104 pub fn new(names: Vec<String>, code_blocks: bool) -> Self {
105 let config = MD044Config {
106 names,
107 code_blocks,
108 ..Default::default()
109 };
110 let combined_pattern = Self::create_combined_pattern(&config);
111 let name_variants = Self::build_name_variants(&config);
112 let ignore_fields = config
113 .ignore_frontmatter_fields
114 .iter()
115 .flatten()
116 .map(|f| f.to_lowercase())
117 .collect();
118 Self {
119 config,
120 combined_pattern,
121 name_variants,
122 ignore_fields,
123 content_cache: Arc::new(Mutex::new(HashMap::new())),
124 }
125 }
126
127 fn ascii_normalize(s: &str) -> String {
129 s.replace(['é', 'è', 'ê', 'ë'], "e")
130 .replace(['à', 'á', 'â', 'ä', 'ã', 'å'], "a")
131 .replace(['ï', 'î', 'í', 'ì'], "i")
132 .replace(['ü', 'ú', 'ù', 'û'], "u")
133 .replace(['ö', 'ó', 'ò', 'ô', 'õ'], "o")
134 .replace('ñ', "n")
135 .replace('ç', "c")
136 }
137
138 pub fn from_config_struct(config: MD044Config) -> Self {
139 let combined_pattern = Self::create_combined_pattern(&config);
140 let name_variants = Self::build_name_variants(&config);
141 let ignore_fields = config
142 .ignore_frontmatter_fields
143 .iter()
144 .flatten()
145 .map(|f| f.to_lowercase())
146 .collect();
147 Self {
148 config,
149 combined_pattern,
150 name_variants,
151 ignore_fields,
152 content_cache: Arc::new(Mutex::new(HashMap::new())),
153 }
154 }
155
156 fn create_combined_pattern(config: &MD044Config) -> Option<String> {
158 if config.names.is_empty() {
159 return None;
160 }
161
162 let mut patterns: Vec<String> = config
164 .names
165 .iter()
166 .flat_map(|name| {
167 let mut variations = vec![];
168 let lower_name = name.to_lowercase();
169
170 variations.push(escape_regex(&lower_name));
172
173 let lower_name_no_dots = lower_name.replace('.', "");
175 if lower_name != lower_name_no_dots {
176 variations.push(escape_regex(&lower_name_no_dots));
177 }
178
179 let ascii_normalized = Self::ascii_normalize(&lower_name);
181
182 if ascii_normalized != lower_name {
183 variations.push(escape_regex(&ascii_normalized));
184
185 let ascii_no_dots = ascii_normalized.replace('.', "");
187 if ascii_normalized != ascii_no_dots {
188 variations.push(escape_regex(&ascii_no_dots));
189 }
190 }
191
192 variations
193 })
194 .collect();
195
196 patterns.sort_by_key(|b| std::cmp::Reverse(b.len()));
198
199 Some(format!(r"(?i)({})", patterns.join("|")))
202 }
203
204 fn build_name_variants(config: &MD044Config) -> Vec<String> {
205 let mut variants = HashSet::new();
206 for name in &config.names {
207 let lower_name = name.to_lowercase();
208 variants.insert(lower_name.clone());
209
210 let lower_no_dots = lower_name.replace('.', "");
211 if lower_name != lower_no_dots {
212 variants.insert(lower_no_dots);
213 }
214
215 let ascii_normalized = Self::ascii_normalize(&lower_name);
216 if ascii_normalized != lower_name {
217 variants.insert(ascii_normalized.clone());
218
219 let ascii_no_dots = ascii_normalized.replace('.', "");
220 if ascii_normalized != ascii_no_dots {
221 variants.insert(ascii_no_dots);
222 }
223 }
224 }
225
226 variants.into_iter().collect()
227 }
228
229 fn find_name_violations(
232 &self,
233 content: &str,
234 ctx: &crate::lint_context::LintContext,
235 content_lower: &str,
236 ) -> Vec<WarningPosition> {
237 if self.config.names.is_empty() || content.is_empty() || self.combined_pattern.is_none() {
239 return Vec::new();
240 }
241
242 let has_potential_matches = self.name_variants.iter().any(|name| content_lower.contains(name));
244
245 if !has_potential_matches {
246 return Vec::new();
247 }
248
249 let hash = fast_hash(content);
251 {
252 if let Ok(cache) = self.content_cache.lock()
254 && let Some(cached) = cache.get(&hash)
255 {
256 return cached.clone();
257 }
258 }
259
260 let mut violations = Vec::new();
261
262 let combined_regex = match &self.combined_pattern {
264 Some(pattern) => match get_cached_regex(pattern) {
265 Ok(regex) => regex,
266 Err(_) => return Vec::new(),
267 },
268 None => return Vec::new(),
269 };
270
271 let field_map = if self.ignore_fields.is_empty() {
273 Vec::new()
274 } else {
275 frontmatter_values::field_map(ctx)
276 };
277
278 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
280 let line_num = line_idx + 1;
281 let line = line_info.content(ctx.content);
282
283 let trimmed = line.trim_start();
285 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
286 continue;
287 }
288
289 if !self.config.code_blocks && line_info.in_code_block {
291 continue;
292 }
293
294 if !self.config.html_elements && line_info.in_html_block {
296 continue;
297 }
298
299 if !self.config.html_comments && line_info.in_html_comment {
301 continue;
302 }
303
304 if line_info.in_jsx_expression || line_info.in_mdx_comment {
306 continue;
307 }
308
309 if line_info.in_obsidian_comment {
311 continue;
312 }
313
314 let fm_value_offset = if line_info.in_front_matter {
317 frontmatter_values::value_offset(line)
318 } else {
319 0
320 };
321 if fm_value_offset == usize::MAX {
322 continue;
323 }
324 if line_info.in_front_matter
325 && let Some(Some(field)) = field_map.get(line_idx)
326 && self.ignore_fields.contains(field)
327 {
328 continue;
329 }
330 let fm_value_span = if line_info.in_front_matter {
331 frontmatter_values::value_span(line)
332 } else {
333 None
334 };
335
336 if is_inline_config_comment(trimmed) {
338 continue;
339 }
340
341 let line_lower = line.to_lowercase();
343 let has_line_matches = self.name_variants.iter().any(|name| line_lower.contains(name));
344
345 if !has_line_matches {
346 continue;
347 }
348
349 for cap in combined_regex.find_iter(line) {
351 let found_name = &line[cap.start()..cap.end()];
352
353 let start_pos = cap.start();
355 let end_pos = cap.end();
356
357 if start_pos < fm_value_offset {
359 continue;
360 }
361
362 let byte_pos = line_info.byte_offset + start_pos;
364 if ctx.is_in_html_tag(byte_pos) {
365 continue;
366 }
367
368 if ctx.is_in_shortcode(byte_pos) {
375 continue;
376 }
377
378 if !Self::is_at_word_boundary(line, start_pos, true) || !Self::is_at_word_boundary(line, end_pos, false)
379 {
380 continue; }
382
383 if !self.config.code_blocks {
385 if ctx.is_in_code_block_or_span(byte_pos) {
386 continue;
387 }
388 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
392 && Self::is_in_backtick_code_in_line(line, start_pos)
393 {
394 continue;
395 }
396 }
397
398 if Self::is_in_link(ctx, byte_pos) {
400 continue;
401 }
402
403 if Self::is_in_angle_bracket_url(line, start_pos) {
407 continue;
408 }
409
410 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
414 && Self::is_in_markdown_link_url(line, start_pos)
415 {
416 continue;
417 }
418
419 if Self::is_in_wikilink_url(ctx, byte_pos) {
424 continue;
425 }
426
427 if ctx.is_in_bare_url(byte_pos) {
433 continue;
434 }
435
436 if let Some(fm_value) = fm_value_span
443 && Self::is_in_path_like_token(line, start_pos, fm_value)
444 {
445 continue;
446 }
447
448 if let Some(proper_name) = self.get_proper_name_for(found_name) {
450 if found_name != proper_name {
452 violations.push((line_num, cap.start() + 1, found_name.to_string()));
453 }
454 }
455 }
456 }
457
458 if let Ok(mut cache) = self.content_cache.lock() {
460 cache.insert(hash, violations.clone());
461 }
462 violations
463 }
464
465 fn is_in_link(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
473 use pulldown_cmark::LinkType;
474
475 let link_idx = ctx.links.partition_point(|link| link.byte_offset <= byte_pos);
477 if link_idx > 0 {
478 let link = &ctx.links[link_idx - 1];
479 if byte_pos < link.byte_end {
480 let (text_start, text_end) = if matches!(link.link_type, LinkType::WikiLink { .. }) {
482 let span = &ctx.content[link.byte_offset..link.byte_end];
489 let start = match span.find('|') {
490 Some(pipe) => link.byte_offset + pipe + 1,
491 None => link.byte_offset + 2,
492 };
493 (start, link.byte_end.saturating_sub(2))
494 } else {
495 let start = link.byte_offset + 1;
496 (start, start + link.text.len())
497 };
498
499 if byte_pos >= text_start && byte_pos < text_end {
503 let is_wikilink = matches!(link.link_type, LinkType::WikiLink { .. });
504 if Self::link_text_is_url(&link.text)
505 || (!is_wikilink && Self::link_text_matches_link_url(&link.text, &link.url))
506 {
507 return true;
508 }
509 return Self::image_verdict(ctx, byte_pos).unwrap_or(false);
515 }
516 return true;
518 }
519 }
520
521 if let Some(verdict) = Self::image_verdict(ctx, byte_pos) {
522 return verdict;
523 }
524
525 ctx.is_in_reference_def(byte_pos)
527 }
528
529 fn image_verdict(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> Option<bool> {
534 let image_idx = ctx.images.partition_point(|img| img.byte_offset <= byte_pos);
536 let image = ctx.images.get(image_idx.checked_sub(1)?)?;
537 if byte_pos >= image.byte_end {
538 return None;
539 }
540
541 let alt_start = image.byte_offset + 2;
543 let alt_end = alt_start + image.alt_text.len();
544
545 Some(!(byte_pos >= alt_start && byte_pos < alt_end))
547 }
548
549 fn link_text_is_url(text: &str) -> bool {
551 let lower = text.trim().to_ascii_lowercase();
552 lower.starts_with("http://")
553 || lower.starts_with("https://")
554 || lower.starts_with("www.")
555 || lower.starts_with("//")
556 }
557
558 fn link_text_matches_link_url(text: &str, url: &str) -> bool {
570 let text = text.trim();
571 if !text.contains('.') {
573 return false;
574 }
575 let url_lower = url.to_ascii_lowercase();
576 let url_without_scheme = url_lower
577 .strip_prefix("https://")
578 .or_else(|| url_lower.strip_prefix("http://"))
579 .or_else(|| url_lower.strip_prefix("//"))
580 .unwrap_or(&url_lower);
581 let text_lower = text.to_ascii_lowercase();
582 if url_without_scheme == text_lower.as_str() {
584 return true;
585 }
586 url_without_scheme.len() > text_lower.len()
588 && url_without_scheme.starts_with(text_lower.as_str())
589 && matches!(
590 url_without_scheme.as_bytes().get(text_lower.len()),
591 Some(b'/') | Some(b'?') | Some(b'#')
592 )
593 }
594
595 fn is_in_angle_bracket_url(line: &str, pos: usize) -> bool {
601 let bytes = line.as_bytes();
602 let len = bytes.len();
603 let mut i = 0;
604 while i < len {
605 if bytes[i] == b'<' {
606 let after_open = i + 1;
607 if after_open < len && bytes[after_open].is_ascii_alphabetic() {
611 let mut s = after_open + 1;
612 let scheme_max = (after_open + 32).min(len);
613 while s < scheme_max
614 && (bytes[s].is_ascii_alphanumeric()
615 || bytes[s] == b'+'
616 || bytes[s] == b'-'
617 || bytes[s] == b'.')
618 {
619 s += 1;
620 }
621 if s < len && bytes[s] == b':' {
622 let mut j = s + 1;
624 let mut found_close = false;
625 while j < len {
626 match bytes[j] {
627 b'>' => {
628 found_close = true;
629 break;
630 }
631 b' ' | b'<' => break,
632 _ => j += 1,
633 }
634 }
635 if found_close && pos >= i && pos <= j {
636 return true;
637 }
638 if found_close {
639 i = j + 1;
640 continue;
641 }
642 }
643 }
644 }
645 i += 1;
646 }
647 false
648 }
649
650 fn is_in_wikilink_url(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
663 use pulldown_cmark::LinkType;
664 let content = ctx.content.as_bytes();
665
666 let end = ctx.links.partition_point(|l| l.byte_offset <= byte_pos);
669
670 for link in &ctx.links[..end] {
671 if !matches!(link.link_type, LinkType::WikiLink { .. }) {
672 continue;
673 }
674 let wiki_end = link.byte_end;
675 if wiki_end >= byte_pos || wiki_end >= content.len() || content[wiki_end] != b'(' {
677 continue;
678 }
679 let mut depth: u32 = 1;
684 let mut k = wiki_end + 1;
685 let mut valid_destination = true;
686 while k < content.len() && depth > 0 {
687 match content[k] {
688 b'\\' => {
689 k += 1; }
691 b'(' => depth += 1,
692 b')' => depth -= 1,
693 b' ' | b'\t' | b'\n' | b'\r' => {
694 valid_destination = false;
695 break;
696 }
697 _ => {}
698 }
699 k += 1;
700 }
701 if valid_destination && depth == 0 && byte_pos > wiki_end && byte_pos < k {
704 return true;
705 }
706 }
707 false
708 }
709
710 fn is_in_markdown_link_url(line: &str, pos: usize) -> bool {
720 let bytes = line.as_bytes();
721 let len = bytes.len();
722 let mut i = 0;
723
724 while i < len {
725 if bytes[i] == b'[' && (i == 0 || bytes[i - 1] != b'\\' || (i >= 2 && bytes[i - 2] == b'\\')) {
727 let mut depth: u32 = 1;
729 let mut j = i + 1;
730 while j < len && depth > 0 {
731 match bytes[j] {
732 b'\\' => {
733 j += 1; }
735 b'[' => depth += 1,
736 b']' => depth -= 1,
737 _ => {}
738 }
739 j += 1;
740 }
741
742 if depth == 0 && j < len {
744 if bytes[j] == b'(' {
745 let url_start = j;
747 let mut paren_depth: u32 = 1;
748 let mut k = j + 1;
749 while k < len && paren_depth > 0 {
750 match bytes[k] {
751 b'\\' => {
752 k += 1; }
754 b'(' => paren_depth += 1,
755 b')' => paren_depth -= 1,
756 _ => {}
757 }
758 k += 1;
759 }
760
761 if paren_depth == 0 {
762 if pos > url_start && pos < k {
763 return true;
764 }
765 i = k;
766 continue;
767 }
768 } else if bytes[j] == b'[' {
769 let ref_start = j;
771 let mut ref_depth: u32 = 1;
772 let mut k = j + 1;
773 while k < len && ref_depth > 0 {
774 match bytes[k] {
775 b'\\' => {
776 k += 1;
777 }
778 b'[' => ref_depth += 1,
779 b']' => ref_depth -= 1,
780 _ => {}
781 }
782 k += 1;
783 }
784
785 if ref_depth == 0 {
786 if pos > ref_start && pos < k {
787 return true;
788 }
789 i = k;
790 continue;
791 }
792 }
793 }
794 }
795 i += 1;
796 }
797 false
798 }
799
800 fn is_in_backtick_code_in_line(line: &str, pos: usize) -> bool {
808 let bytes = line.as_bytes();
809 let len = bytes.len();
810 let mut i = 0;
811 while i < len {
812 if bytes[i] == b'`' {
813 let open_start = i;
815 while i < len && bytes[i] == b'`' {
816 i += 1;
817 }
818 let tick_len = i - open_start;
819
820 while i < len {
822 if bytes[i] == b'`' {
823 let close_start = i;
824 while i < len && bytes[i] == b'`' {
825 i += 1;
826 }
827 if i - close_start == tick_len {
828 let content_start = open_start + tick_len;
832 let content_end = close_start;
833 if pos >= content_start && pos < content_end {
834 return true;
835 }
836 break;
838 }
839 } else {
841 i += 1;
842 }
843 }
844 } else {
845 i += 1;
846 }
847 }
848 false
849 }
850
851 fn is_word_boundary_char(c: char) -> bool {
853 !c.is_alphanumeric()
854 }
855
856 fn is_at_word_boundary(content: &str, pos: usize, is_start: bool) -> bool {
858 if is_start {
859 if pos == 0 {
860 return true;
861 }
862 match content[..pos].chars().next_back() {
863 None => true,
864 Some(c) => Self::is_word_boundary_char(c),
865 }
866 } else {
867 if pos >= content.len() {
868 return true;
869 }
870 match content[pos..].chars().next() {
871 None => true,
872 Some(c) => Self::is_word_boundary_char(c),
873 }
874 }
875 }
876
877 fn is_in_path_like_token(line: &str, match_start: usize, fm_value: (usize, usize)) -> bool {
905 let (value_start, value_end) = fm_value;
906 if match_start < value_start || match_start >= value_end {
907 return false;
908 }
909
910 let quoted_words: Vec<&str> = if frontmatter_values::value_is_quoted(line, value_start) {
918 line[value_start..value_end].split_whitespace().collect()
919 } else {
920 Vec::new()
921 };
922 let is_single_quoted_path = !quoted_words.is_empty() && quoted_words.iter().all(|word| word.contains('/'));
923 let is_multi_word_collapse = is_single_quoted_path && quoted_words.len() > 1;
931
932 let (raw_start, raw_end) = if is_single_quoted_path {
933 (value_start, value_end)
934 } else {
935 frontmatter_values::token_bounds(line, match_start, value_start, value_end)
936 };
937
938 let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
939 if match_start < start || match_start >= end {
940 return false;
941 }
942
943 let token = &line[start..end];
944 if !token.contains('/') {
945 return false;
946 }
947 if token.starts_with('/') || token.starts_with("./") || token.starts_with("../") || token.starts_with("~/") {
948 return true;
949 }
950 if token.rsplit('/').next().is_some_and(|seg| seg.contains('.')) {
951 return true;
952 }
953
954 if is_multi_word_collapse {
955 return false;
956 }
957
958 let sole_value = {
962 let (ts, te) = frontmatter_values::trim_token_bounds(line, value_start, value_end);
963 ts == start && te == end
964 };
965 sole_value && token.split('/').filter(|s| !s.is_empty()).count() >= 3
966 }
967
968 fn get_proper_name_for(&self, found_name: &str) -> Option<String> {
970 let found_lower = found_name.to_lowercase();
971
972 for name in &self.config.names {
974 let lower_name = name.to_lowercase();
975 let lower_name_no_dots = lower_name.replace('.', "");
976
977 if found_lower == lower_name || found_lower == lower_name_no_dots {
979 return Some(name.clone());
980 }
981
982 let ascii_normalized = Self::ascii_normalize(&lower_name);
984
985 let ascii_no_dots = ascii_normalized.replace('.', "");
986
987 if found_lower == ascii_normalized || found_lower == ascii_no_dots {
988 return Some(name.clone());
989 }
990 }
991 None
992 }
993}
994
995impl Rule for MD044ProperNames {
996 fn name(&self) -> &'static str {
997 "MD044"
998 }
999
1000 fn description(&self) -> &'static str {
1001 "Proper names should have the correct capitalization"
1002 }
1003
1004 fn category(&self) -> RuleCategory {
1005 RuleCategory::Other
1006 }
1007
1008 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1009 if self.config.names.is_empty() {
1010 return true;
1011 }
1012 let content_lower = if ctx.content.is_ascii() {
1014 ctx.content.to_ascii_lowercase()
1015 } else {
1016 ctx.content.to_lowercase()
1017 };
1018 !self.name_variants.iter().any(|name| content_lower.contains(name))
1019 }
1020
1021 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1022 let content = ctx.content;
1023 if content.is_empty() || self.config.names.is_empty() || self.combined_pattern.is_none() {
1024 return Ok(Vec::new());
1025 }
1026
1027 let content_lower = if content.is_ascii() {
1029 content.to_ascii_lowercase()
1030 } else {
1031 content.to_lowercase()
1032 };
1033
1034 let has_potential_matches = self.name_variants.iter().any(|name| content_lower.contains(name));
1036
1037 if !has_potential_matches {
1038 return Ok(Vec::new());
1039 }
1040
1041 let line_index = &ctx.line_index;
1042 let violations = self.find_name_violations(content, ctx, &content_lower);
1043
1044 let warnings = violations
1045 .into_iter()
1046 .filter_map(|(line, column, found_name)| {
1047 self.get_proper_name_for(&found_name).map(|proper_name| {
1048 let line_start = line_index.get_line_start_byte(line).unwrap_or(0);
1053 let byte_start = line_start + (column - 1);
1054 let byte_end = byte_start + found_name.len();
1055 let line_text = ctx.line_info(line).map_or("", |li| li.content(ctx.content));
1058 let char_col = byte_to_char_count(line_text, column - 1);
1059 LintWarning {
1060 rule_name: Some(self.name().to_string()),
1061 line,
1062 column: char_col,
1063 end_line: line,
1064 end_column: char_col + found_name.chars().count(),
1065 message: format!("Proper name '{found_name}' should be '{proper_name}'"),
1066 severity: Severity::Warning,
1067 fix: Some(Fix::new(byte_start..byte_end, proper_name)),
1068 }
1069 })
1070 })
1071 .collect();
1072
1073 Ok(warnings)
1074 }
1075
1076 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1077 if self.should_skip(ctx) {
1078 return Ok(ctx.content.to_string());
1079 }
1080 let warnings = self.check(ctx)?;
1081 if warnings.is_empty() {
1082 return Ok(ctx.content.to_string());
1083 }
1084 let warnings =
1085 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1086 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
1087 .map_err(crate::rule::LintError::InvalidInput)
1088 }
1089
1090 fn as_any(&self) -> &dyn std::any::Any {
1091 self
1092 }
1093
1094 crate::impl_rule_config_methods!(MD044Config);
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099 use super::*;
1100 use crate::lint_context::LintContext;
1101
1102 fn create_context(content: &str) -> LintContext<'_> {
1103 LintContext::new(content, crate::config::MarkdownFlavor::Standard, None)
1104 }
1105
1106 fn field_map_for(content: &str) -> Vec<Option<String>> {
1107 let ctx = create_context(content);
1108 frontmatter_values::field_map(&ctx)
1109 }
1110
1111 #[test]
1112 fn test_field_map_nested_lines_inherit_top_level_key() {
1113 let map = field_map_for("---\nseo:\n canonical: docs/a.md\n keywords:\n - myapp\ntitle: x\n---\n");
1114 assert_eq!(map[2].as_deref(), Some("seo"));
1115 assert_eq!(map[4].as_deref(), Some("seo"));
1116 assert_eq!(map[5].as_deref(), Some("title"));
1117 }
1118
1119 #[test]
1120 fn test_field_map_block_scalar_bracket_does_not_swallow_next_key() {
1121 let map = field_map_for("---\ndescription: |\n [myapp\ntitle: myapp\n---\n");
1122 assert_eq!(map[2].as_deref(), Some("description"));
1123 assert_eq!(
1124 map[3].as_deref(),
1125 Some("title"),
1126 "an indent-0 key always starts a new key"
1127 );
1128 }
1129
1130 #[test]
1131 fn test_field_map_quoted_key_with_colon() {
1132 let map = field_map_for("---\n\"og:title\": myapp\n---\n");
1133 assert_eq!(map[1].as_deref(), Some("og:title"));
1134 }
1135
1136 #[test]
1137 fn test_field_map_top_level_sequence_clears_attribution() {
1138 let map = field_map_for("---\n- myapp\n---\n");
1139 assert_eq!(map[1], None);
1140 }
1141
1142 #[test]
1143 fn test_field_map_toml_table_body_belongs_to_table_root() {
1144 let map = field_map_for("+++\n[seo]\ncanonical = \"docs/a.md\"\n\n[[authors]]\nname = \"myapp\"\n+++\n");
1145 assert_eq!(map[2].as_deref(), Some("seo"));
1146 assert_eq!(map[5].as_deref(), Some("authors"));
1147 }
1148
1149 #[test]
1150 fn test_field_map_toml_dotted_assignment_uses_root() {
1151 let map = field_map_for("+++\nseo.canonical = \"docs/a.md\"\n+++\n");
1152 assert_eq!(map[1].as_deref(), Some("seo"));
1153 }
1154
1155 #[test]
1156 fn test_field_map_toml_array_continuation_inherits() {
1157 let map = field_map_for("+++\nseo = [\n\"docs/guide/myapp\"\n]\n+++\n");
1158 assert_eq!(map[2].as_deref(), Some("seo"));
1159 }
1160
1161 #[test]
1162 fn test_field_map_indent_zero_flow_continuation_is_not_attributed_to_parent() {
1163 let map = field_map_for("---\nseo: [\n{name: myapp}\n]\n---\n");
1166 assert_eq!(map[2].as_deref(), Some("{name"));
1167 }
1168
1169 #[test]
1170 fn test_field_map_toml_nested_array_inherits_and_title_not_corrupted() {
1171 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\ntitle = \"x\"\n+++\n");
1172 assert_eq!(map[2].as_deref(), Some("matrix"), "nested array line inherits matrix");
1173 assert_eq!(map[3].as_deref(), Some("matrix"), "nested array line inherits matrix");
1174 assert_eq!(
1175 map[5].as_deref(),
1176 Some("title"),
1177 "title must not inherit stale attribution from a closed nested array"
1178 );
1179 }
1180
1181 #[test]
1182 fn test_field_map_toml_nested_array_last_element_without_trailing_comma() {
1183 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [2]\n]\ntitle = \"x\"\n+++\n");
1184 assert_eq!(
1185 map[3].as_deref(),
1186 Some("matrix"),
1187 "last element without a trailing comma still inherits matrix"
1188 );
1189 assert_eq!(
1190 map[5].as_deref(),
1191 Some("title"),
1192 "title must not inherit stale attribution from a closed nested array"
1193 );
1194 }
1195
1196 #[test]
1197 fn test_field_map_toml_nested_array_then_real_table_header_non_regression_guard() {
1198 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\n\n[seo]\ncanonical = \"docs/a.md\"\n+++\n");
1205 assert_eq!(map[6].as_deref(), Some("seo"), "real table header after a closed array");
1206 assert_eq!(
1207 map[7].as_deref(),
1208 Some("seo"),
1209 "table body still attributes to the table"
1210 );
1211 }
1212
1213 #[test]
1214 fn test_field_map_toml_unclosed_array_resyncs_on_next_assignment() {
1215 let map = field_map_for("+++\nmatrix = [\n [1, 2],\ntitle = \"x\"\n+++\n");
1221 assert_eq!(
1222 map[3].as_deref(),
1223 Some("title"),
1224 "title must resync even though the array was never closed"
1225 );
1226 }
1227
1228 #[test]
1229 fn test_field_map_toml_column_zero_array_elements_inherit_and_title_not_corrupted() {
1230 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[3, 4],\n]\ntitle = \"x\"\n+++\n");
1236 assert_eq!(
1237 map[2].as_deref(),
1238 Some("matrix"),
1239 "column-0 array element inherits matrix"
1240 );
1241 assert_eq!(
1242 map[3].as_deref(),
1243 Some("matrix"),
1244 "column-0 array element inherits matrix"
1245 );
1246 assert_eq!(
1247 map[5].as_deref(),
1248 Some("title"),
1249 "title must not inherit stale attribution from a misread array element"
1250 );
1251 }
1252
1253 #[test]
1254 fn test_field_map_toml_column_zero_array_last_element_without_trailing_comma() {
1255 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[2]\n]\ntitle = \"x\"\n+++\n");
1256 assert_eq!(
1257 map[3].as_deref(),
1258 Some("matrix"),
1259 "column-0 last element without a trailing comma still inherits matrix"
1260 );
1261 assert_eq!(
1262 map[5].as_deref(),
1263 Some("title"),
1264 "title must not inherit stale attribution from a misread array element"
1265 );
1266 }
1267
1268 #[test]
1269 fn test_correctly_capitalized_names() {
1270 let rule = MD044ProperNames::new(
1271 vec![
1272 "JavaScript".to_string(),
1273 "TypeScript".to_string(),
1274 "Node.js".to_string(),
1275 ],
1276 true,
1277 );
1278
1279 let content = "This document uses JavaScript, TypeScript, and Node.js correctly.";
1280 let ctx = create_context(content);
1281 let result = rule.check(&ctx).unwrap();
1282 assert!(result.is_empty(), "Should not flag correctly capitalized names");
1283 }
1284
1285 #[test]
1286 fn test_incorrectly_capitalized_names() {
1287 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1288
1289 let content = "This document uses javascript and typescript incorrectly.";
1290 let ctx = create_context(content);
1291 let result = rule.check(&ctx).unwrap();
1292
1293 assert_eq!(result.len(), 2, "Should flag two incorrect capitalizations");
1294 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1295 assert_eq!(result[0].line, 1);
1296 assert_eq!(result[0].column, 20);
1297 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1298 assert_eq!(result[1].line, 1);
1299 assert_eq!(result[1].column, 35);
1300 }
1301
1302 #[test]
1303 fn test_names_at_beginning_of_sentences() {
1304 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "Python".to_string()], true);
1305
1306 let content = "javascript is a great language. python is also popular.";
1307 let ctx = create_context(content);
1308 let result = rule.check(&ctx).unwrap();
1309
1310 assert_eq!(result.len(), 2, "Should flag names at beginning of sentences");
1311 assert_eq!(result[0].line, 1);
1312 assert_eq!(result[0].column, 1);
1313 assert_eq!(result[1].line, 1);
1314 assert_eq!(result[1].column, 33);
1315 }
1316
1317 #[test]
1318 fn test_names_in_code_blocks_checked_by_default() {
1319 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1320
1321 let content = r#"Here is some text with JavaScript.
1322
1323```javascript
1324// This javascript should be checked
1325const lang = "javascript";
1326```
1327
1328But this javascript should be flagged."#;
1329
1330 let ctx = create_context(content);
1331 let result = rule.check(&ctx).unwrap();
1332
1333 assert_eq!(result.len(), 3, "Should flag javascript inside and outside code blocks");
1334 assert_eq!(result[0].line, 4);
1335 assert_eq!(result[1].line, 5);
1336 assert_eq!(result[2].line, 8);
1337 }
1338
1339 #[test]
1340 fn test_names_in_code_blocks_ignored_when_disabled() {
1341 let rule = MD044ProperNames::new(
1342 vec!["JavaScript".to_string()],
1343 false, );
1345
1346 let content = r#"```
1347javascript in code block
1348```"#;
1349
1350 let ctx = create_context(content);
1351 let result = rule.check(&ctx).unwrap();
1352
1353 assert_eq!(
1354 result.len(),
1355 0,
1356 "Should not flag javascript in code blocks when code_blocks is false"
1357 );
1358 }
1359
1360 #[test]
1361 fn test_names_in_inline_code_checked_by_default() {
1362 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1363
1364 let content = "This is `javascript` in inline code and javascript outside.";
1365 let ctx = create_context(content);
1366 let result = rule.check(&ctx).unwrap();
1367
1368 assert_eq!(result.len(), 2, "Should flag javascript inside and outside inline code");
1370 assert_eq!(result[0].column, 10); assert_eq!(result[1].column, 41); }
1373
1374 #[test]
1375 fn test_multiple_names_in_same_line() {
1376 let rule = MD044ProperNames::new(
1377 vec!["JavaScript".to_string(), "TypeScript".to_string(), "React".to_string()],
1378 true,
1379 );
1380
1381 let content = "I use javascript, typescript, and react in my projects.";
1382 let ctx = create_context(content);
1383 let result = rule.check(&ctx).unwrap();
1384
1385 assert_eq!(result.len(), 3, "Should flag all three incorrect names");
1386 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1387 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1388 assert_eq!(result[2].message, "Proper name 'react' should be 'React'");
1389 }
1390
1391 #[test]
1392 fn test_case_sensitivity() {
1393 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1394
1395 let content = "JAVASCRIPT, Javascript, javascript, and JavaScript variations.";
1396 let ctx = create_context(content);
1397 let result = rule.check(&ctx).unwrap();
1398
1399 assert_eq!(result.len(), 3, "Should flag all incorrect case variations");
1400 assert!(result.iter().all(|w| w.message.contains("should be 'JavaScript'")));
1402 }
1403
1404 #[test]
1405 fn test_configuration_with_custom_name_list() {
1406 let config = MD044Config {
1407 names: vec!["GitHub".to_string(), "GitLab".to_string(), "DevOps".to_string()],
1408 code_blocks: true,
1409 ..Default::default()
1410 };
1411 let rule = MD044ProperNames::from_config_struct(config);
1412
1413 let content = "We use github, gitlab, and devops for our workflow.";
1414 let ctx = create_context(content);
1415 let result = rule.check(&ctx).unwrap();
1416
1417 assert_eq!(result.len(), 3, "Should flag all custom names");
1418 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
1419 assert_eq!(result[1].message, "Proper name 'gitlab' should be 'GitLab'");
1420 assert_eq!(result[2].message, "Proper name 'devops' should be 'DevOps'");
1421 }
1422
1423 #[test]
1424 fn test_empty_configuration() {
1425 let rule = MD044ProperNames::new(vec![], true);
1426
1427 let content = "This has javascript and typescript but no configured names.";
1428 let ctx = create_context(content);
1429 let result = rule.check(&ctx).unwrap();
1430
1431 assert!(result.is_empty(), "Should not flag anything with empty configuration");
1432 }
1433
1434 #[test]
1435 fn test_names_with_special_characters() {
1436 let rule = MD044ProperNames::new(
1437 vec!["Node.js".to_string(), "ASP.NET".to_string(), "C++".to_string()],
1438 true,
1439 );
1440
1441 let content = "We use nodejs, asp.net, ASP.NET, and c++ in our stack.";
1442 let ctx = create_context(content);
1443 let result = rule.check(&ctx).unwrap();
1444
1445 assert_eq!(result.len(), 3, "Should handle special characters correctly");
1450
1451 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
1452 assert!(messages.contains(&"Proper name 'nodejs' should be 'Node.js'"));
1453 assert!(messages.contains(&"Proper name 'asp.net' should be 'ASP.NET'"));
1454 assert!(messages.contains(&"Proper name 'c++' should be 'C++'"));
1455 }
1456
1457 #[test]
1458 fn test_word_boundaries() {
1459 let rule = MD044ProperNames::new(vec!["Java".to_string(), "Script".to_string()], true);
1460
1461 let content = "JavaScript is not java or script, but Java and Script are separate.";
1462 let ctx = create_context(content);
1463 let result = rule.check(&ctx).unwrap();
1464
1465 assert_eq!(result.len(), 2, "Should respect word boundaries");
1467 assert!(result.iter().any(|w| w.column == 19)); assert!(result.iter().any(|w| w.column == 27)); }
1470
1471 #[test]
1472 fn test_fix_method() {
1473 let rule = MD044ProperNames::new(
1474 vec![
1475 "JavaScript".to_string(),
1476 "TypeScript".to_string(),
1477 "Node.js".to_string(),
1478 ],
1479 true,
1480 );
1481
1482 let content = "I love javascript, typescript, and nodejs!";
1483 let ctx = create_context(content);
1484 let fixed = rule.fix(&ctx).unwrap();
1485
1486 assert_eq!(fixed, "I love JavaScript, TypeScript, and Node.js!");
1487 }
1488
1489 #[test]
1490 fn test_fix_multiple_occurrences() {
1491 let rule = MD044ProperNames::new(vec!["Python".to_string()], true);
1492
1493 let content = "python is great. I use python daily. PYTHON is powerful.";
1494 let ctx = create_context(content);
1495 let fixed = rule.fix(&ctx).unwrap();
1496
1497 assert_eq!(fixed, "Python is great. I use Python daily. Python is powerful.");
1498 }
1499
1500 #[test]
1501 fn test_fix_checks_code_blocks_by_default() {
1502 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1503
1504 let content = r#"I love javascript.
1505
1506```
1507const lang = "javascript";
1508```
1509
1510More javascript here."#;
1511
1512 let ctx = create_context(content);
1513 let fixed = rule.fix(&ctx).unwrap();
1514
1515 let expected = r#"I love JavaScript.
1516
1517```
1518const lang = "JavaScript";
1519```
1520
1521More JavaScript here."#;
1522
1523 assert_eq!(fixed, expected);
1524 }
1525
1526 #[test]
1527 fn test_multiline_content() {
1528 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
1529
1530 let content = r#"First line with rust.
1531Second line with python.
1532Third line with RUST and PYTHON."#;
1533
1534 let ctx = create_context(content);
1535 let result = rule.check(&ctx).unwrap();
1536
1537 assert_eq!(result.len(), 4, "Should flag all incorrect occurrences");
1538 assert_eq!(result[0].line, 1);
1539 assert_eq!(result[1].line, 2);
1540 assert_eq!(result[2].line, 3);
1541 assert_eq!(result[3].line, 3);
1542 }
1543
1544 #[test]
1545 fn test_default_config() {
1546 let config = MD044Config::default();
1547 assert!(config.names.is_empty());
1548 assert!(!config.code_blocks);
1549 assert!(config.html_elements);
1550 assert!(config.html_comments);
1551 }
1552
1553 #[test]
1554 fn test_default_config_checks_html_comments() {
1555 let config = MD044Config {
1556 names: vec!["JavaScript".to_string()],
1557 ..MD044Config::default()
1558 };
1559 let rule = MD044ProperNames::from_config_struct(config);
1560
1561 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1562 let ctx = create_context(content);
1563 let result = rule.check(&ctx).unwrap();
1564
1565 assert_eq!(result.len(), 1, "Default config should check HTML comments");
1566 assert_eq!(result[0].line, 3);
1567 }
1568
1569 #[test]
1570 fn test_default_config_skips_code_blocks() {
1571 let config = MD044Config {
1572 names: vec!["JavaScript".to_string()],
1573 ..MD044Config::default()
1574 };
1575 let rule = MD044ProperNames::from_config_struct(config);
1576
1577 let content = "# Guide\n\n```\njavascript in code\n```\n";
1578 let ctx = create_context(content);
1579 let result = rule.check(&ctx).unwrap();
1580
1581 assert_eq!(result.len(), 0, "Default config should skip code blocks");
1582 }
1583
1584 #[test]
1585 fn test_standalone_html_comment_checked() {
1586 let config = MD044Config {
1587 names: vec!["Test".to_string()],
1588 ..MD044Config::default()
1589 };
1590 let rule = MD044ProperNames::from_config_struct(config);
1591
1592 let content = "# Heading\n\n<!-- this is a test example -->\n";
1593 let ctx = create_context(content);
1594 let result = rule.check(&ctx).unwrap();
1595
1596 assert_eq!(result.len(), 1, "Should flag proper name in standalone HTML comment");
1597 assert_eq!(result[0].line, 3);
1598 }
1599
1600 #[test]
1601 fn test_inline_config_comments_not_flagged() {
1602 let config = MD044Config {
1603 names: vec!["RUMDL".to_string()],
1604 ..MD044Config::default()
1605 };
1606 let rule = MD044ProperNames::from_config_struct(config);
1607
1608 let content = "<!-- rumdl-disable MD044 -->\nSome rumdl text here.\n<!-- rumdl-enable MD044 -->\n<!-- markdownlint-disable -->\nMore rumdl text.\n<!-- markdownlint-enable -->\n";
1612 let ctx = create_context(content);
1613 let result = rule.check(&ctx).unwrap();
1614
1615 assert_eq!(result.len(), 2, "Should only flag body lines, not config comments");
1616 assert_eq!(result[0].line, 2);
1617 assert_eq!(result[1].line, 5);
1618 }
1619
1620 #[test]
1621 fn test_html_comment_skipped_when_disabled() {
1622 let config = MD044Config {
1623 names: vec!["Test".to_string()],
1624 code_blocks: true,
1625 html_comments: false,
1626 ..Default::default()
1627 };
1628 let rule = MD044ProperNames::from_config_struct(config);
1629
1630 let content = "# Heading\n\n<!-- this is a test example -->\n\nRegular test here.\n";
1631 let ctx = create_context(content);
1632 let result = rule.check(&ctx).unwrap();
1633
1634 assert_eq!(
1635 result.len(),
1636 1,
1637 "Should only flag 'test' outside HTML comment when html_comments=false"
1638 );
1639 assert_eq!(result[0].line, 5);
1640 }
1641
1642 #[test]
1643 fn test_fix_corrects_html_comment_content() {
1644 let config = MD044Config {
1645 names: vec!["JavaScript".to_string()],
1646 ..MD044Config::default()
1647 };
1648 let rule = MD044ProperNames::from_config_struct(config);
1649
1650 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1651 let ctx = create_context(content);
1652 let fixed = rule.fix(&ctx).unwrap();
1653
1654 assert_eq!(fixed, "# Guide\n\n<!-- JavaScript mentioned here -->\n");
1655 }
1656
1657 #[test]
1658 fn test_fix_does_not_modify_inline_config_comments() {
1659 let config = MD044Config {
1660 names: vec!["RUMDL".to_string()],
1661 ..MD044Config::default()
1662 };
1663 let rule = MD044ProperNames::from_config_struct(config);
1664
1665 let content = "<!-- rumdl-disable -->\nSome rumdl text.\n<!-- rumdl-enable -->\n";
1666 let ctx = create_context(content);
1667 let fixed = rule.fix(&ctx).unwrap();
1668
1669 assert!(fixed.contains("<!-- rumdl-disable -->"));
1671 assert!(fixed.contains("<!-- rumdl-enable -->"));
1672 assert!(
1674 fixed.contains("Some rumdl text."),
1675 "Line inside rumdl-disable block should not be modified by fix()"
1676 );
1677 }
1678
1679 #[test]
1680 fn test_fix_respects_inline_disable_partial() {
1681 let config = MD044Config {
1682 names: vec!["RUMDL".to_string()],
1683 ..MD044Config::default()
1684 };
1685 let rule = MD044ProperNames::from_config_struct(config);
1686
1687 let content =
1688 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
1689 let ctx = create_context(content);
1690 let fixed = rule.fix(&ctx).unwrap();
1691
1692 assert!(
1694 fixed.contains("Some rumdl text.\n<!-- rumdl-enable"),
1695 "Line inside disable block should not be modified"
1696 );
1697 assert!(
1699 fixed.contains("Some RUMDL text outside."),
1700 "Line outside disable block should be fixed"
1701 );
1702 }
1703
1704 #[test]
1705 fn test_performance_with_many_names() {
1706 let mut names = vec![];
1707 for i in 0..50 {
1708 names.push(format!("ProperName{i}"));
1709 }
1710
1711 let rule = MD044ProperNames::new(names, true);
1712
1713 let content = "This has propername0, propername25, and propername49 incorrectly.";
1714 let ctx = create_context(content);
1715 let result = rule.check(&ctx).unwrap();
1716
1717 assert_eq!(result.len(), 3, "Should handle many configured names efficiently");
1718 }
1719
1720 #[test]
1721 fn test_large_name_count_performance() {
1722 let names = (0..1000).map(|i| format!("ProperName{i}")).collect::<Vec<_>>();
1725
1726 let rule = MD044ProperNames::new(names, true);
1727
1728 assert!(rule.combined_pattern.is_some());
1730
1731 let content = "This has propername0 and propername999 in it.";
1733 let ctx = create_context(content);
1734 let result = rule.check(&ctx).unwrap();
1735
1736 assert_eq!(result.len(), 2, "Should handle 1000 names without issues");
1738 }
1739
1740 #[test]
1741 fn test_cache_behavior() {
1742 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1743
1744 let content = "Using javascript here.";
1745 let ctx = create_context(content);
1746
1747 let result1 = rule.check(&ctx).unwrap();
1749 assert_eq!(result1.len(), 1);
1750
1751 let result2 = rule.check(&ctx).unwrap();
1753 assert_eq!(result2.len(), 1);
1754
1755 assert_eq!(result1[0].line, result2[0].line);
1757 assert_eq!(result1[0].column, result2[0].column);
1758 }
1759
1760 #[test]
1761 fn test_html_comments_not_checked_when_disabled() {
1762 let config = MD044Config {
1763 names: vec!["JavaScript".to_string()],
1764 code_blocks: true, html_comments: false, ..Default::default()
1767 };
1768 let rule = MD044ProperNames::from_config_struct(config);
1769
1770 let content = r#"Regular javascript here.
1771<!-- This javascript in HTML comment should be ignored -->
1772More javascript outside."#;
1773
1774 let ctx = create_context(content);
1775 let result = rule.check(&ctx).unwrap();
1776
1777 assert_eq!(result.len(), 2, "Should only flag javascript outside HTML comments");
1778 assert_eq!(result[0].line, 1);
1779 assert_eq!(result[1].line, 3);
1780 }
1781
1782 #[test]
1783 fn test_html_comments_checked_when_enabled() {
1784 let config = MD044Config {
1785 names: vec!["JavaScript".to_string()],
1786 code_blocks: true, ..Default::default()
1788 };
1789 let rule = MD044ProperNames::from_config_struct(config);
1790
1791 let content = r#"Regular javascript here.
1792<!-- This javascript in HTML comment should be checked -->
1793More javascript outside."#;
1794
1795 let ctx = create_context(content);
1796 let result = rule.check(&ctx).unwrap();
1797
1798 assert_eq!(
1799 result.len(),
1800 3,
1801 "Should flag all javascript occurrences including in HTML comments"
1802 );
1803 }
1804
1805 #[test]
1806 fn test_indented_html_comment_escapes_via_link_and_backticks() {
1807 let config = MD044Config {
1812 names: vec!["Test".to_string()],
1813 ..Default::default()
1814 };
1815 let rule = MD044ProperNames::from_config_struct(config);
1816
1817 let content = "<!-- see the [relevant page](test.md). -->\n<!-- see `test.md` -->\n <!-- see the [relevant page](test.md). -->\n <!-- see `test.md` -->\n";
1818
1819 let ctx = create_context(content);
1820 let result = rule.check(&ctx).unwrap();
1821
1822 assert!(
1823 result.is_empty(),
1824 "'test' inside a link URL or backticks must be ignored in both column-0 and indented comments, got: {result:?}"
1825 );
1826 }
1827
1828 #[test]
1829 fn test_indented_html_comment_still_checks_bare_prose() {
1830 let config = MD044Config {
1833 names: vec!["Test".to_string()],
1834 ..Default::default()
1835 };
1836 let rule = MD044ProperNames::from_config_struct(config);
1837
1838 let content = " <!-- this is a test comment -->\n";
1839
1840 let ctx = create_context(content);
1841 let result = rule.check(&ctx).unwrap();
1842
1843 assert_eq!(
1844 result.len(),
1845 1,
1846 "bare 'test' in an indented comment is still a violation"
1847 );
1848 assert_eq!(result[0].line, 1);
1849 }
1850
1851 #[test]
1852 fn test_multiline_html_comments() {
1853 let config = MD044Config {
1854 names: vec!["Python".to_string(), "JavaScript".to_string()],
1855 code_blocks: true, html_comments: false, ..Default::default()
1858 };
1859 let rule = MD044ProperNames::from_config_struct(config);
1860
1861 let content = r#"Regular python here.
1862<!--
1863This is a multiline comment
1864with javascript and python
1865that should be ignored
1866-->
1867More javascript outside."#;
1868
1869 let ctx = create_context(content);
1870 let result = rule.check(&ctx).unwrap();
1871
1872 assert_eq!(result.len(), 2, "Should only flag names outside HTML comments");
1873 assert_eq!(result[0].line, 1); assert_eq!(result[1].line, 7); }
1876
1877 #[test]
1878 fn test_fix_preserves_html_comments_when_disabled() {
1879 let config = MD044Config {
1880 names: vec!["JavaScript".to_string()],
1881 code_blocks: true, html_comments: false, ..Default::default()
1884 };
1885 let rule = MD044ProperNames::from_config_struct(config);
1886
1887 let content = r#"javascript here.
1888<!-- javascript in comment -->
1889More javascript."#;
1890
1891 let ctx = create_context(content);
1892 let fixed = rule.fix(&ctx).unwrap();
1893
1894 let expected = r#"JavaScript here.
1895<!-- javascript in comment -->
1896More JavaScript."#;
1897
1898 assert_eq!(
1899 fixed, expected,
1900 "Should not fix names inside HTML comments when disabled"
1901 );
1902 }
1903
1904 #[test]
1905 fn test_proper_names_in_link_text_are_flagged() {
1906 let rule = MD044ProperNames::new(
1907 vec!["JavaScript".to_string(), "Node.js".to_string(), "Python".to_string()],
1908 true,
1909 );
1910
1911 let content = r#"Check this [javascript documentation](https://javascript.info) for info.
1912
1913Visit [node.js homepage](https://nodejs.org) and [python tutorial](https://python.org).
1914
1915Real javascript should be flagged.
1916
1917Also see the [typescript guide][ts-ref] for more.
1918
1919Real python should be flagged too.
1920
1921[ts-ref]: https://typescript.org/handbook"#;
1922
1923 let ctx = create_context(content);
1924 let result = rule.check(&ctx).unwrap();
1925
1926 assert_eq!(result.len(), 5, "Expected 5 warnings: 3 in link text + 2 standalone");
1933
1934 let line_1_warnings: Vec<_> = result.iter().filter(|w| w.line == 1).collect();
1936 assert_eq!(line_1_warnings.len(), 1);
1937 assert!(
1938 line_1_warnings[0]
1939 .message
1940 .contains("'javascript' should be 'JavaScript'")
1941 );
1942
1943 let line_3_warnings: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
1944 assert_eq!(line_3_warnings.len(), 2); assert!(result.iter().any(|w| w.line == 5 && w.message.contains("'javascript'")));
1948 assert!(result.iter().any(|w| w.line == 9 && w.message.contains("'python'")));
1949 }
1950
1951 #[test]
1952 fn test_link_urls_not_flagged() {
1953 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1954
1955 let content = r#"[Link Text](https://javascript.info/guide)"#;
1957
1958 let ctx = create_context(content);
1959 let result = rule.check(&ctx).unwrap();
1960
1961 assert!(result.is_empty(), "URLs should not be checked for proper names");
1963 }
1964
1965 #[test]
1966 fn test_bare_urls_not_flagged() {
1967 let rule = MD044ProperNames::new(vec!["Foo".to_string(), "JavaScript".to_string()], true);
1968
1969 let content =
1972 "https://foo.com\n\nSee https://javascript.info/foo/guide for details.\n\nMail foo@foo.com about it.\n";
1973
1974 let ctx = create_context(content);
1975 let result = rule.check(&ctx).unwrap();
1976
1977 assert!(
1978 result.is_empty(),
1979 "Bare URLs and emails should not be checked for proper names: {result:?}"
1980 );
1981 }
1982
1983 #[test]
1984 fn test_prose_around_bare_url_still_flagged() {
1985 let rule = MD044ProperNames::new(vec!["Foo".to_string()], true);
1986
1987 let content = "Use foo at https://foo.com because foo is great.\n";
1990
1991 let ctx = create_context(content);
1992 let result = rule.check(&ctx).unwrap();
1993
1994 assert_eq!(
1995 result.len(),
1996 2,
1997 "Prose occurrences around a bare URL must still be flagged: {result:?}"
1998 );
1999 assert!(result.iter().all(|w| w.message.contains("'foo' should be 'Foo'")));
2000 }
2001
2002 #[test]
2003 fn test_proper_names_in_image_alt_text_are_flagged() {
2004 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2005
2006 let content = r#"Here is a  image.
2007
2008Real javascript should be flagged."#;
2009
2010 let ctx = create_context(content);
2011 let result = rule.check(&ctx).unwrap();
2012
2013 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in alt text + 1 standalone");
2017 assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
2018 assert!(result[0].line == 1); assert!(result[1].message.contains("'javascript' should be 'JavaScript'"));
2020 assert!(result[1].line == 3); }
2022
2023 #[test]
2024 fn test_image_urls_not_flagged() {
2025 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2026
2027 let content = r#""#;
2029
2030 let ctx = create_context(content);
2031 let result = rule.check(&ctx).unwrap();
2032
2033 assert!(result.is_empty(), "Image URLs should not be checked for proper names");
2035 }
2036
2037 #[test]
2038 fn test_reference_link_text_flagged_but_definition_not() {
2039 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2040
2041 let content = r#"Check the [javascript guide][js-ref] for details.
2042
2043Real javascript should be flagged.
2044
2045[js-ref]: https://javascript.info/typescript/guide"#;
2046
2047 let ctx = create_context(content);
2048 let result = rule.check(&ctx).unwrap();
2049
2050 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in link text + 1 standalone");
2055 assert!(result.iter().any(|w| w.line == 1 && w.message.contains("'javascript'")));
2056 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2057 }
2058
2059 #[test]
2060 fn test_reference_definitions_not_flagged() {
2061 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2062
2063 let content = r#"[js-ref]: https://javascript.info/guide"#;
2065
2066 let ctx = create_context(content);
2067 let result = rule.check(&ctx).unwrap();
2068
2069 assert!(result.is_empty(), "Reference definitions should not be checked");
2071 }
2072
2073 #[test]
2074 fn test_wikilinks_text_is_flagged() {
2075 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2076
2077 let content = r#"[[javascript]]
2079
2080Regular javascript here.
2081
2082[[JavaScript|display text]]"#;
2083
2084 let ctx = create_context(content);
2085 let result = rule.check(&ctx).unwrap();
2086
2087 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in WikiLink + 1 standalone");
2091 assert!(
2092 result
2093 .iter()
2094 .any(|w| w.line == 1 && w.column == 3 && w.message.contains("'javascript'"))
2095 );
2096 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2097 }
2098
2099 #[test]
2100 fn test_url_link_text_not_flagged() {
2101 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2102
2103 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2105
2106[http://github.com/org/repo](http://github.com/org/repo)
2107
2108[www.github.com/org/repo](https://www.github.com/org/repo)"#;
2109
2110 let ctx = create_context(content);
2111 let result = rule.check(&ctx).unwrap();
2112
2113 assert!(
2114 result.is_empty(),
2115 "URL-like link text should not be flagged, got: {result:?}"
2116 );
2117 }
2118
2119 #[test]
2120 fn test_url_link_text_with_leading_space_not_flagged() {
2121 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2122
2123 let content = r#"[ https://github.com/org/repo](https://github.com/org/repo)"#;
2125
2126 let ctx = create_context(content);
2127 let result = rule.check(&ctx).unwrap();
2128
2129 assert!(
2130 result.is_empty(),
2131 "URL-like link text with leading space should not be flagged, got: {result:?}"
2132 );
2133 }
2134
2135 #[test]
2136 fn test_url_link_text_uppercase_scheme_not_flagged() {
2137 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2138
2139 let content = r#"[HTTPS://GITHUB.COM/org/repo](https://github.com/org/repo)"#;
2140
2141 let ctx = create_context(content);
2142 let result = rule.check(&ctx).unwrap();
2143
2144 assert!(
2145 result.is_empty(),
2146 "URL-like link text with uppercase scheme should not be flagged, got: {result:?}"
2147 );
2148 }
2149
2150 #[test]
2151 fn test_non_url_link_text_still_flagged() {
2152 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2153
2154 let content = r#"[github.com/org/repo](https://github.com/org/repo)
2158
2159[Visit github](https://github.com/org/repo)
2160
2161[//github.com/org/repo](//github.com/org/repo)
2162
2163[ftp://github.com/org/repo](ftp://github.com/org/repo)"#;
2164
2165 let ctx = create_context(content);
2166 let result = rule.check(&ctx).unwrap();
2167
2168 assert_eq!(
2173 result.len(),
2174 1,
2175 "Only prose link text should be flagged, got: {result:?}"
2176 );
2177 assert!(
2178 result.iter().any(|w| w.line == 3),
2179 "Expected 'Visit github' on line 3 to be flagged"
2180 );
2181 }
2182
2183 #[test]
2184 fn test_url_link_text_fix_not_applied() {
2185 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2186
2187 let content = "[https://github.com/org/repo](https://github.com/org/repo)\n";
2188
2189 let ctx = create_context(content);
2190 let result = rule.fix(&ctx).unwrap();
2191
2192 assert_eq!(result, content, "Fix should not modify URL-like link text");
2193 }
2194
2195 #[test]
2196 fn test_mixed_url_and_regular_link_text() {
2197 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2198
2199 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2201
2202Visit [github documentation](https://github.com/docs) for details.
2203
2204[www.github.com/pricing](https://www.github.com/pricing)"#;
2205
2206 let ctx = create_context(content);
2207 let result = rule.check(&ctx).unwrap();
2208
2209 assert_eq!(
2211 result.len(),
2212 1,
2213 "Only non-URL link text should be flagged, got: {result:?}"
2214 );
2215 assert_eq!(result[0].line, 3);
2216 }
2217
2218 #[test]
2219 fn test_html_attribute_values_not_flagged() {
2220 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2223 let content = "# Heading\n\ntest\n\n<img src=\"www.example.test/test_image.png\">\n";
2224 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2225 let result = rule.check(&ctx).unwrap();
2226
2227 let line5_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2229 assert!(
2230 line5_violations.is_empty(),
2231 "Should not flag anything inside HTML tag attributes: {line5_violations:?}"
2232 );
2233
2234 let line3_violations: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
2236 assert_eq!(line3_violations.len(), 1, "Plain 'test' on line 3 should be flagged");
2237 }
2238
2239 #[test]
2240 fn test_html_text_content_still_flagged() {
2241 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2243 let content = "# Heading\n\n<a href=\"https://example.test/page\">test link</a>\n";
2244 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2245 let result = rule.check(&ctx).unwrap();
2246
2247 assert_eq!(
2250 result.len(),
2251 1,
2252 "Should flag only 'test' in anchor text, not in href: {result:?}"
2253 );
2254 assert_eq!(result[0].column, 37, "Should flag col 37 ('test link' in anchor text)");
2255 }
2256
2257 #[test]
2258 fn test_html_attribute_various_not_flagged() {
2259 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2261 let content = concat!(
2262 "# Heading\n\n",
2263 "<img src=\"test.png\" alt=\"test image\">\n",
2264 "<span class=\"test-class\" data-test=\"value\">test content</span>\n",
2265 );
2266 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2267 let result = rule.check(&ctx).unwrap();
2268
2269 assert_eq!(
2271 result.len(),
2272 1,
2273 "Should flag only 'test content' between tags: {result:?}"
2274 );
2275 assert_eq!(result[0].line, 4);
2276 }
2277
2278 #[test]
2279 fn test_plain_text_underscore_boundary_unchanged() {
2280 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2283 let content = "# Heading\n\ntest_image is here and just_test ends here\n";
2284 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2285 let result = rule.check(&ctx).unwrap();
2286
2287 assert_eq!(
2290 result.len(),
2291 2,
2292 "Should flag 'test' in both 'test_image' and 'just_test': {result:?}"
2293 );
2294 let cols: Vec<usize> = result.iter().map(|w| w.column).collect();
2295 assert!(cols.contains(&1), "Should flag col 1 (test_image): {cols:?}");
2296 assert!(cols.contains(&29), "Should flag col 29 (just_test): {cols:?}");
2297 }
2298
2299 #[test]
2300 fn test_frontmatter_yaml_keys_not_flagged() {
2301 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2304
2305 let content = "---\ntitle: Heading\ntest: Some Test value\n---\n\nTest\n";
2306 let ctx = create_context(content);
2307 let result = rule.check(&ctx).unwrap();
2308
2309 assert!(
2313 result.is_empty(),
2314 "Should not flag YAML keys or correctly capitalized values: {result:?}"
2315 );
2316 }
2317
2318 #[test]
2319 fn test_frontmatter_yaml_values_flagged() {
2320 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2322
2323 let content = "---\ntitle: Heading\nkey: a test value\n---\n\nTest\n";
2324 let ctx = create_context(content);
2325 let result = rule.check(&ctx).unwrap();
2326
2327 assert_eq!(result.len(), 1, "Should flag 'test' in YAML value: {result:?}");
2329 assert_eq!(result[0].line, 3);
2330 assert_eq!(result[0].column, 8); }
2332
2333 #[test]
2334 fn test_frontmatter_key_matches_name_not_flagged() {
2335 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2337
2338 let content = "---\ntest: other value\n---\n\nBody text\n";
2339 let ctx = create_context(content);
2340 let result = rule.check(&ctx).unwrap();
2341
2342 assert!(
2343 result.is_empty(),
2344 "Should not flag YAML key that matches configured name: {result:?}"
2345 );
2346 }
2347
2348 #[test]
2349 fn test_frontmatter_empty_value_not_flagged() {
2350 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2352
2353 let content = "---\ntest:\ntest: \n---\n\nBody text\n";
2354 let ctx = create_context(content);
2355 let result = rule.check(&ctx).unwrap();
2356
2357 assert!(
2358 result.is_empty(),
2359 "Should not flag YAML keys with empty values: {result:?}"
2360 );
2361 }
2362
2363 #[test]
2364 fn test_frontmatter_nested_yaml_key_not_flagged() {
2365 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2367
2368 let content = "---\nparent:\n test: nested value\n---\n\nBody text\n";
2369 let ctx = create_context(content);
2370 let result = rule.check(&ctx).unwrap();
2371
2372 assert!(result.is_empty(), "Should not flag nested YAML keys: {result:?}");
2374 }
2375
2376 #[test]
2377 fn test_frontmatter_list_items_checked() {
2378 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2380
2381 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2382 let ctx = create_context(content);
2383 let result = rule.check(&ctx).unwrap();
2384
2385 assert_eq!(result.len(), 1, "Should flag 'test' in YAML list item: {result:?}");
2387 assert_eq!(result[0].line, 3);
2388 }
2389
2390 #[test]
2391 fn test_frontmatter_value_with_multiple_colons() {
2392 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2394
2395 let content = "---\ntest: description: a test thing\n---\n\nBody text\n";
2396 let ctx = create_context(content);
2397 let result = rule.check(&ctx).unwrap();
2398
2399 assert_eq!(
2402 result.len(),
2403 1,
2404 "Should flag 'test' in value after first colon: {result:?}"
2405 );
2406 assert_eq!(result[0].line, 2);
2407 assert!(result[0].column > 6, "Violation column should be in value portion");
2408 }
2409
2410 #[test]
2411 fn test_frontmatter_does_not_affect_body() {
2412 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2414
2415 let content = "---\ntitle: Heading\n---\n\ntest should be flagged here\n";
2416 let ctx = create_context(content);
2417 let result = rule.check(&ctx).unwrap();
2418
2419 assert_eq!(result.len(), 1, "Should flag 'test' in body text: {result:?}");
2420 assert_eq!(result[0].line, 5);
2421 }
2422
2423 #[test]
2424 fn test_frontmatter_fix_corrects_values_preserves_keys() {
2425 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2427
2428 let content = "---\ntest: a test value\n---\n\ntest here\n";
2429 let ctx = create_context(content);
2430 let fixed = rule.fix(&ctx).unwrap();
2431
2432 assert_eq!(fixed, "---\ntest: a Test value\n---\n\nTest here\n");
2434 }
2435
2436 #[test]
2437 fn test_frontmatter_multiword_value_flagged() {
2438 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2440
2441 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2442 let ctx = create_context(content);
2443 let result = rule.check(&ctx).unwrap();
2444
2445 assert_eq!(result.len(), 2, "Should flag both names in YAML value: {result:?}");
2446 assert!(result.iter().all(|w| w.line == 2));
2447 }
2448
2449 #[test]
2450 fn test_frontmatter_yaml_comments_not_checked() {
2451 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2453
2454 let content = "---\n# test comment\ntitle: Heading\n---\n\nBody text\n";
2455 let ctx = create_context(content);
2456 let result = rule.check(&ctx).unwrap();
2457
2458 assert!(result.is_empty(), "Should not flag names in YAML comments: {result:?}");
2459 }
2460
2461 #[test]
2462 fn test_frontmatter_delimiters_not_checked() {
2463 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2465
2466 let content = "---\ntitle: Heading\n---\n\ntest here\n";
2467 let ctx = create_context(content);
2468 let result = rule.check(&ctx).unwrap();
2469
2470 assert_eq!(result.len(), 1, "Should only flag body text: {result:?}");
2472 assert_eq!(result[0].line, 5);
2473 }
2474
2475 #[test]
2476 fn test_frontmatter_continuation_lines_checked() {
2477 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2479
2480 let content = "---\ndescription: >\n a test value\n continued here\n---\n\nBody\n";
2481 let ctx = create_context(content);
2482 let result = rule.check(&ctx).unwrap();
2483
2484 assert_eq!(result.len(), 1, "Should flag 'test' in continuation line: {result:?}");
2486 assert_eq!(result[0].line, 3);
2487 }
2488
2489 #[test]
2490 fn test_frontmatter_quoted_values_checked() {
2491 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2493
2494 let content = "---\ntitle: \"a test title\"\n---\n\nBody\n";
2495 let ctx = create_context(content);
2496 let result = rule.check(&ctx).unwrap();
2497
2498 assert_eq!(result.len(), 1, "Should flag 'test' in quoted YAML value: {result:?}");
2499 assert_eq!(result[0].line, 2);
2500 }
2501
2502 #[test]
2503 fn test_frontmatter_single_quoted_values_checked() {
2504 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2506
2507 let content = "---\ntitle: 'a test title'\n---\n\nBody\n";
2508 let ctx = create_context(content);
2509 let result = rule.check(&ctx).unwrap();
2510
2511 assert_eq!(
2512 result.len(),
2513 1,
2514 "Should flag 'test' in single-quoted YAML value: {result:?}"
2515 );
2516 assert_eq!(result[0].line, 2);
2517 }
2518
2519 #[test]
2520 fn test_frontmatter_fix_multiword_values() {
2521 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2523
2524 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2525 let ctx = create_context(content);
2526 let fixed = rule.fix(&ctx).unwrap();
2527
2528 assert_eq!(
2529 fixed,
2530 "---\ndescription: Learn JavaScript and TypeScript\n---\n\nBody\n"
2531 );
2532 }
2533
2534 #[test]
2535 fn test_frontmatter_fix_preserves_yaml_structure() {
2536 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2538
2539 let content = "---\ntags:\n - test\n - other\ntitle: a test doc\n---\n\ntest body\n";
2540 let ctx = create_context(content);
2541 let fixed = rule.fix(&ctx).unwrap();
2542
2543 assert_eq!(
2544 fixed,
2545 "---\ntags:\n - Test\n - other\ntitle: a Test doc\n---\n\nTest body\n"
2546 );
2547 }
2548
2549 #[test]
2550 fn test_frontmatter_toml_delimiters_not_checked() {
2551 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2553
2554 let content = "+++\ntitle = \"a test title\"\n+++\n\ntest body\n";
2555 let ctx = create_context(content);
2556 let result = rule.check(&ctx).unwrap();
2557
2558 assert_eq!(result.len(), 2, "Should flag TOML value and body: {result:?}");
2562 let fm_violations: Vec<_> = result.iter().filter(|w| w.line == 2).collect();
2563 assert_eq!(fm_violations.len(), 1, "Should flag 'test' in TOML value: {result:?}");
2564 let body_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2565 assert_eq!(body_violations.len(), 1, "Should flag body 'test': {result:?}");
2566 }
2567
2568 #[test]
2569 fn test_frontmatter_toml_key_not_flagged() {
2570 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2572
2573 let content = "+++\ntest = \"other value\"\n+++\n\nBody text\n";
2574 let ctx = create_context(content);
2575 let result = rule.check(&ctx).unwrap();
2576
2577 assert!(
2578 result.is_empty(),
2579 "Should not flag TOML key that matches configured name: {result:?}"
2580 );
2581 }
2582
2583 #[test]
2584 fn test_frontmatter_toml_fix_preserves_keys() {
2585 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2587
2588 let content = "+++\ntest = \"a test value\"\n+++\n\ntest here\n";
2589 let ctx = create_context(content);
2590 let fixed = rule.fix(&ctx).unwrap();
2591
2592 assert_eq!(fixed, "+++\ntest = \"a Test value\"\n+++\n\nTest here\n");
2594 }
2595
2596 #[test]
2597 fn test_frontmatter_list_item_mapping_key_not_flagged() {
2598 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2601
2602 let content = "---\nitems:\n - test: nested value\n---\n\nBody text\n";
2603 let ctx = create_context(content);
2604 let result = rule.check(&ctx).unwrap();
2605
2606 assert!(
2607 result.is_empty(),
2608 "Should not flag YAML key in list-item mapping: {result:?}"
2609 );
2610 }
2611
2612 #[test]
2613 fn test_frontmatter_list_item_mapping_value_flagged() {
2614 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2616
2617 let content = "---\nitems:\n - key: a test value\n---\n\nBody text\n";
2618 let ctx = create_context(content);
2619 let result = rule.check(&ctx).unwrap();
2620
2621 assert_eq!(
2622 result.len(),
2623 1,
2624 "Should flag 'test' in list-item mapping value: {result:?}"
2625 );
2626 assert_eq!(result[0].line, 3);
2627 }
2628
2629 #[test]
2630 fn test_frontmatter_bare_list_item_still_flagged() {
2631 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2633
2634 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2635 let ctx = create_context(content);
2636 let result = rule.check(&ctx).unwrap();
2637
2638 assert_eq!(result.len(), 1, "Should flag 'test' in bare list item: {result:?}");
2639 assert_eq!(result[0].line, 3);
2640 }
2641
2642 #[test]
2643 fn test_frontmatter_flow_mapping_not_flagged() {
2644 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2647
2648 let content = "---\nflow_map: {test: value, other: test}\n---\n\nBody text\n";
2649 let ctx = create_context(content);
2650 let result = rule.check(&ctx).unwrap();
2651
2652 assert!(
2653 result.is_empty(),
2654 "Should not flag names inside flow mappings: {result:?}"
2655 );
2656 }
2657
2658 #[test]
2659 fn test_frontmatter_flow_sequence_not_flagged() {
2660 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2662
2663 let content = "---\nitems: [test, other, test]\n---\n\nBody text\n";
2664 let ctx = create_context(content);
2665 let result = rule.check(&ctx).unwrap();
2666
2667 assert!(
2668 result.is_empty(),
2669 "Should not flag names inside flow sequences: {result:?}"
2670 );
2671 }
2672
2673 #[test]
2674 fn test_frontmatter_list_item_mapping_fix_preserves_key() {
2675 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2677
2678 let content = "---\nitems:\n - test: a test value\n---\n\ntest here\n";
2679 let ctx = create_context(content);
2680 let fixed = rule.fix(&ctx).unwrap();
2681
2682 assert_eq!(fixed, "---\nitems:\n - test: a Test value\n---\n\nTest here\n");
2685 }
2686
2687 #[test]
2688 fn test_frontmatter_backtick_code_not_flagged() {
2689 let config = MD044Config {
2691 names: vec!["GoodApplication".to_string()],
2692 code_blocks: false,
2693 ..MD044Config::default()
2694 };
2695 let rule = MD044ProperNames::from_config_struct(config);
2696
2697 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2698 let ctx = create_context(content);
2699 let result = rule.check(&ctx).unwrap();
2700
2701 assert!(
2703 result.is_empty(),
2704 "Should not flag names inside backticks in frontmatter or body: {result:?}"
2705 );
2706 }
2707
2708 #[test]
2709 fn test_frontmatter_unquoted_backtick_code_not_flagged() {
2710 let config = MD044Config {
2712 names: vec!["GoodApplication".to_string()],
2713 code_blocks: false,
2714 ..MD044Config::default()
2715 };
2716 let rule = MD044ProperNames::from_config_struct(config);
2717
2718 let content = "---\ntitle: `goodapplication` CLI\n---\n\nIntroductory `goodapplication` CLI text.\n";
2719 let ctx = create_context(content);
2720 let result = rule.check(&ctx).unwrap();
2721
2722 assert!(
2723 result.is_empty(),
2724 "Should not flag names inside backticks in unquoted YAML frontmatter: {result:?}"
2725 );
2726 }
2727
2728 #[test]
2729 fn test_frontmatter_bare_name_still_flagged_with_backtick_nearby() {
2730 let config = MD044Config {
2732 names: vec!["GoodApplication".to_string()],
2733 code_blocks: false,
2734 ..MD044Config::default()
2735 };
2736 let rule = MD044ProperNames::from_config_struct(config);
2737
2738 let content = "---\ntitle: goodapplication `goodapplication` CLI\n---\n\nBody\n";
2739 let ctx = create_context(content);
2740 let result = rule.check(&ctx).unwrap();
2741
2742 assert_eq!(
2744 result.len(),
2745 1,
2746 "Should flag bare name but not backtick-wrapped name: {result:?}"
2747 );
2748 assert_eq!(result[0].line, 2);
2749 assert_eq!(result[0].column, 8); }
2751
2752 #[test]
2753 fn test_frontmatter_backtick_code_with_code_blocks_true() {
2754 let config = MD044Config {
2756 names: vec!["GoodApplication".to_string()],
2757 code_blocks: true,
2758 ..MD044Config::default()
2759 };
2760 let rule = MD044ProperNames::from_config_struct(config);
2761
2762 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nBody\n";
2763 let ctx = create_context(content);
2764 let result = rule.check(&ctx).unwrap();
2765
2766 assert_eq!(
2768 result.len(),
2769 1,
2770 "Should flag backtick-wrapped name when code_blocks=true: {result:?}"
2771 );
2772 assert_eq!(result[0].line, 2);
2773 }
2774
2775 #[test]
2776 fn test_frontmatter_fix_preserves_backtick_code() {
2777 let config = MD044Config {
2779 names: vec!["GoodApplication".to_string()],
2780 code_blocks: false,
2781 ..MD044Config::default()
2782 };
2783 let rule = MD044ProperNames::from_config_struct(config);
2784
2785 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2786 let ctx = create_context(content);
2787 let fixed = rule.fix(&ctx).unwrap();
2788
2789 assert_eq!(
2791 fixed, content,
2792 "Fix should not modify names inside backticks in frontmatter"
2793 );
2794 }
2795
2796 fn rule_ignoring(names: &[&str], ignore: &[&str]) -> MD044ProperNames {
2797 MD044ProperNames::from_config_struct(MD044Config {
2798 names: names.iter().map(ToString::to_string).collect(),
2799 ignore_frontmatter_fields: Some(ignore.iter().map(ToString::to_string).collect()),
2800 ..Default::default()
2801 })
2802 }
2803
2804 #[test]
2805 fn test_ignore_frontmatter_field_suppresses_only_that_field() {
2806 let content = "---\ntitle: Heading for myapp\nslug: myapp-guide\n---\n";
2807 let rule = rule_ignoring(&["MyApp"], &["slug"]);
2808 let result = rule.check(&create_context(content)).unwrap();
2809 assert_eq!(result.len(), 1, "only title is flagged: {result:?}");
2810 assert_eq!(result[0].line, 2);
2811 }
2812
2813 #[test]
2814 fn test_ignore_frontmatter_field_is_case_insensitive() {
2815 let content = "---\nSlug: myapp-guide\n---\n";
2816 let rule = rule_ignoring(&["MyApp"], &["SLUG"]);
2817 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2818 }
2819
2820 #[test]
2821 fn test_ignore_frontmatter_field_covers_nested_subtree() {
2822 let content = "---\nseo:\n canonical: myapp\n keywords:\n - myapp\n---\n";
2823 let rule = rule_ignoring(&["MyApp"], &["seo"]);
2824 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2825 }
2826
2827 #[test]
2828 fn test_ignore_frontmatter_field_does_not_affect_body() {
2829 let content = "---\nslug: myapp\n---\n\nBody mentions myapp.\n";
2830 let rule = rule_ignoring(&["MyApp"], &["slug"]);
2831 let result = rule.check(&create_context(content)).unwrap();
2832 assert_eq!(result.len(), 1);
2833 assert_eq!(result[0].line, 5);
2834 }
2835
2836 #[test]
2837 fn test_ignore_frontmatter_field_toml_table() {
2838 let content = "+++\n[seo]\ncanonical = \"myapp\"\n+++\n";
2839 let rule = rule_ignoring(&["MyApp"], &["seo"]);
2840 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2841 }
2842
2843 #[test]
2846 fn test_angle_bracket_url_in_html_comment_not_flagged() {
2847 let config = MD044Config {
2849 names: vec!["Test".to_string()],
2850 ..MD044Config::default()
2851 };
2852 let rule = MD044ProperNames::from_config_struct(config);
2853
2854 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";
2855 let ctx = create_context(content);
2856 let result = rule.check(&ctx).unwrap();
2857
2858 let line8_warnings: Vec<_> = result.iter().filter(|w| w.line == 8).collect();
2866 assert!(
2867 line8_warnings.is_empty(),
2868 "Should not flag names inside angle-bracket URLs in HTML comments: {line8_warnings:?}"
2869 );
2870 }
2871
2872 #[test]
2873 fn test_bare_url_in_html_comment_still_flagged() {
2874 let config = MD044Config {
2876 names: vec!["Test".to_string()],
2877 ..MD044Config::default()
2878 };
2879 let rule = MD044ProperNames::from_config_struct(config);
2880
2881 let content = "<!-- This is a test https://www.example.test -->\n";
2882 let ctx = create_context(content);
2883 let result = rule.check(&ctx).unwrap();
2884
2885 assert!(
2888 !result.is_empty(),
2889 "Should flag 'test' in prose text of HTML comment with bare URL"
2890 );
2891 }
2892
2893 #[test]
2894 fn test_angle_bracket_url_in_regular_markdown_not_flagged() {
2895 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2898
2899 let content = "<https://www.example.test>\n";
2900 let ctx = create_context(content);
2901 let result = rule.check(&ctx).unwrap();
2902
2903 assert!(
2904 result.is_empty(),
2905 "Should not flag names inside angle-bracket URLs in regular markdown: {result:?}"
2906 );
2907 }
2908
2909 #[test]
2910 fn test_multiple_angle_bracket_urls_in_one_comment() {
2911 let config = MD044Config {
2912 names: vec!["Test".to_string()],
2913 ..MD044Config::default()
2914 };
2915 let rule = MD044ProperNames::from_config_struct(config);
2916
2917 let content = "<!-- See <https://test.example.com> and <https://www.example.test> for details -->\n";
2918 let ctx = create_context(content);
2919 let result = rule.check(&ctx).unwrap();
2920
2921 assert!(
2923 result.is_empty(),
2924 "Should not flag names inside multiple angle-bracket URLs: {result:?}"
2925 );
2926 }
2927
2928 #[test]
2929 fn test_angle_bracket_non_url_still_flagged() {
2930 assert!(
2933 !MD044ProperNames::is_in_angle_bracket_url("<test> which is not a URL.", 1),
2934 "is_in_angle_bracket_url should return false for non-URL angle brackets"
2935 );
2936 }
2937
2938 #[test]
2939 fn test_angle_bracket_mailto_url_not_flagged() {
2940 let config = MD044Config {
2941 names: vec!["Test".to_string()],
2942 ..MD044Config::default()
2943 };
2944 let rule = MD044ProperNames::from_config_struct(config);
2945
2946 let content = "<!-- Contact <mailto:test@example.com> for help -->\n";
2947 let ctx = create_context(content);
2948 let result = rule.check(&ctx).unwrap();
2949
2950 assert!(
2951 result.is_empty(),
2952 "Should not flag names inside angle-bracket mailto URLs: {result:?}"
2953 );
2954 }
2955
2956 #[test]
2957 fn test_angle_bracket_ftp_url_not_flagged() {
2958 let config = MD044Config {
2959 names: vec!["Test".to_string()],
2960 ..MD044Config::default()
2961 };
2962 let rule = MD044ProperNames::from_config_struct(config);
2963
2964 let content = "<!-- Download from <ftp://test.example.com/file> -->\n";
2965 let ctx = create_context(content);
2966 let result = rule.check(&ctx).unwrap();
2967
2968 assert!(
2969 result.is_empty(),
2970 "Should not flag names inside angle-bracket FTP URLs: {result:?}"
2971 );
2972 }
2973
2974 #[test]
2975 fn test_angle_bracket_url_fix_preserves_url() {
2976 let config = MD044Config {
2978 names: vec!["Test".to_string()],
2979 ..MD044Config::default()
2980 };
2981 let rule = MD044ProperNames::from_config_struct(config);
2982
2983 let content = "<!-- test text <https://www.example.test> -->\n";
2984 let ctx = create_context(content);
2985 let fixed = rule.fix(&ctx).unwrap();
2986
2987 assert!(
2989 fixed.contains("<https://www.example.test>"),
2990 "Fix should preserve angle-bracket URLs: {fixed}"
2991 );
2992 assert!(
2993 fixed.contains("Test text"),
2994 "Fix should correct prose 'test' to 'Test': {fixed}"
2995 );
2996 }
2997
2998 #[test]
2999 fn test_is_in_angle_bracket_url_helper() {
3000 let line = "text <https://example.test> more text";
3002
3003 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));
3016
3017 assert!(MD044ProperNames::is_in_angle_bracket_url(
3019 "<mailto:test@example.com>",
3020 10
3021 ));
3022
3023 assert!(MD044ProperNames::is_in_angle_bracket_url(
3025 "<ftp://test.example.com>",
3026 10
3027 ));
3028 }
3029
3030 #[test]
3031 fn test_is_in_angle_bracket_url_uppercase_scheme() {
3032 assert!(MD044ProperNames::is_in_angle_bracket_url(
3034 "<HTTPS://test.example.com>",
3035 10
3036 ));
3037 assert!(MD044ProperNames::is_in_angle_bracket_url(
3038 "<Http://test.example.com>",
3039 10
3040 ));
3041 }
3042
3043 #[test]
3044 fn test_is_in_angle_bracket_url_uncommon_schemes() {
3045 assert!(MD044ProperNames::is_in_angle_bracket_url(
3047 "<ssh://test@example.com>",
3048 10
3049 ));
3050 assert!(MD044ProperNames::is_in_angle_bracket_url("<file:///test/path>", 10));
3052 assert!(MD044ProperNames::is_in_angle_bracket_url("<data:text/plain;test>", 10));
3054 }
3055
3056 #[test]
3057 fn test_is_in_angle_bracket_url_unclosed() {
3058 assert!(!MD044ProperNames::is_in_angle_bracket_url(
3060 "<https://test.example.com",
3061 10
3062 ));
3063 }
3064
3065 #[test]
3066 fn test_vale_inline_config_comments_not_flagged() {
3067 let config = MD044Config {
3068 names: vec!["Vale".to_string(), "JavaScript".to_string()],
3069 ..MD044Config::default()
3070 };
3071 let rule = MD044ProperNames::from_config_struct(config);
3072
3073 let content = "\
3074<!-- vale off -->
3075Some javascript text here.
3076<!-- vale on -->
3077<!-- vale Style.Rule = NO -->
3078More javascript text.
3079<!-- vale Style.Rule = YES -->
3080<!-- vale JavaScript.Grammar = NO -->
3081";
3082 let ctx = create_context(content);
3083 let result = rule.check(&ctx).unwrap();
3084
3085 assert_eq!(result.len(), 2, "Should only flag body lines, not Vale config comments");
3087 assert_eq!(result[0].line, 2);
3088 assert_eq!(result[1].line, 5);
3089 }
3090
3091 #[test]
3092 fn test_remark_lint_inline_config_comments_not_flagged() {
3093 let config = MD044Config {
3094 names: vec!["JavaScript".to_string()],
3095 ..MD044Config::default()
3096 };
3097 let rule = MD044ProperNames::from_config_struct(config);
3098
3099 let content = "\
3100<!-- lint disable remark-lint-some-rule -->
3101Some javascript text here.
3102<!-- lint enable remark-lint-some-rule -->
3103<!-- lint ignore remark-lint-some-rule -->
3104More javascript text.
3105";
3106 let ctx = create_context(content);
3107 let result = rule.check(&ctx).unwrap();
3108
3109 assert_eq!(
3110 result.len(),
3111 2,
3112 "Should only flag body lines, not remark-lint config comments"
3113 );
3114 assert_eq!(result[0].line, 2);
3115 assert_eq!(result[1].line, 5);
3116 }
3117
3118 #[test]
3119 fn test_fix_does_not_modify_vale_remark_lint_comments() {
3120 let config = MD044Config {
3121 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3122 ..MD044Config::default()
3123 };
3124 let rule = MD044ProperNames::from_config_struct(config);
3125
3126 let content = "\
3127<!-- vale off -->
3128Some javascript text.
3129<!-- vale on -->
3130<!-- lint disable remark-lint-some-rule -->
3131More javascript text.
3132<!-- lint enable remark-lint-some-rule -->
3133";
3134 let ctx = create_context(content);
3135 let fixed = rule.fix(&ctx).unwrap();
3136
3137 assert!(fixed.contains("<!-- vale off -->"));
3139 assert!(fixed.contains("<!-- vale on -->"));
3140 assert!(fixed.contains("<!-- lint disable remark-lint-some-rule -->"));
3141 assert!(fixed.contains("<!-- lint enable remark-lint-some-rule -->"));
3142 assert!(fixed.contains("Some JavaScript text."));
3144 assert!(fixed.contains("More JavaScript text."));
3145 }
3146
3147 #[test]
3148 fn test_mixed_tool_directives_all_skipped() {
3149 let config = MD044Config {
3150 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3151 ..MD044Config::default()
3152 };
3153 let rule = MD044ProperNames::from_config_struct(config);
3154
3155 let content = "\
3156<!-- rumdl-disable MD044 -->
3157Some javascript text.
3158<!-- markdownlint-disable -->
3159More javascript text.
3160<!-- vale off -->
3161Even more javascript text.
3162<!-- lint disable some-rule -->
3163Final javascript text.
3164<!-- rumdl-enable MD044 -->
3165<!-- markdownlint-enable -->
3166<!-- vale on -->
3167<!-- lint enable some-rule -->
3168";
3169 let ctx = create_context(content);
3170 let result = rule.check(&ctx).unwrap();
3171
3172 assert_eq!(
3174 result.len(),
3175 4,
3176 "Should only flag body lines, not any tool directive comments"
3177 );
3178 assert_eq!(result[0].line, 2);
3179 assert_eq!(result[1].line, 4);
3180 assert_eq!(result[2].line, 6);
3181 assert_eq!(result[3].line, 8);
3182 }
3183
3184 #[test]
3185 fn test_vale_remark_lint_edge_cases_not_matched() {
3186 let config = MD044Config {
3187 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3188 ..MD044Config::default()
3189 };
3190 let rule = MD044ProperNames::from_config_struct(config);
3191
3192 let content = "\
3200<!-- vale -->
3201<!-- vale is a tool for writing -->
3202<!-- valedictorian javascript -->
3203<!-- linting javascript tips -->
3204<!-- vale javascript -->
3205<!-- lint your javascript code -->
3206";
3207 let ctx = create_context(content);
3208 let result = rule.check(&ctx).unwrap();
3209
3210 assert_eq!(
3217 result.len(),
3218 7,
3219 "Should flag proper names in non-directive HTML comments: got {result:?}"
3220 );
3221 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); }
3229
3230 #[test]
3231 fn test_vale_style_directives_skipped() {
3232 let config = MD044Config {
3233 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3234 ..MD044Config::default()
3235 };
3236 let rule = MD044ProperNames::from_config_struct(config);
3237
3238 let content = "\
3240<!-- vale style = MyStyle -->
3241<!-- vale styles = Style1, Style2 -->
3242<!-- vale MyRule.Name = YES -->
3243<!-- vale MyRule.Name = NO -->
3244Some javascript text.
3245";
3246 let ctx = create_context(content);
3247 let result = rule.check(&ctx).unwrap();
3248
3249 assert_eq!(
3251 result.len(),
3252 1,
3253 "Should only flag body lines, not Vale style/rule directives: got {result:?}"
3254 );
3255 assert_eq!(result[0].line, 5);
3256 }
3257
3258 #[test]
3261 fn test_backtick_code_single_backticks() {
3262 let line = "hello `world` bye";
3263 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 7));
3265 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3267 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 14));
3269 }
3270
3271 #[test]
3272 fn test_backtick_code_double_backticks() {
3273 let line = "a ``code`` b";
3274 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3276 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3278 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 11));
3280 }
3281
3282 #[test]
3283 fn test_backtick_code_unclosed() {
3284 let line = "a `code b";
3285 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3287 }
3288
3289 #[test]
3290 fn test_backtick_code_mismatched_count() {
3291 let line = "a `code`` b";
3293 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3296 }
3297
3298 #[test]
3299 fn test_backtick_code_multiple_spans() {
3300 let line = "`first` and `second`";
3301 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
3303 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 8));
3305 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 13));
3307 }
3308
3309 #[test]
3310 fn test_backtick_code_on_backtick_boundary() {
3311 let line = "`code`";
3312 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3314 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 5));
3316 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
3318 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3319 }
3320
3321 #[test]
3327 fn test_double_bracket_link_url_not_flagged() {
3328 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3329 let content = "[[rumdl]](https://github.com/rvben/rumdl)";
3331 let ctx = create_context(content);
3332 let result = rule.check(&ctx).unwrap();
3333 assert!(
3334 result.is_empty(),
3335 "URL inside [[text]](url) must not be flagged, got: {result:?}"
3336 );
3337 }
3338
3339 #[test]
3340 fn test_double_bracket_link_url_not_fixed() {
3341 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3342 let content = "[[rumdl]](https://github.com/rvben/rumdl)\n";
3343 let ctx = create_context(content);
3344 let fixed = rule.fix(&ctx).unwrap();
3345 assert_eq!(
3346 fixed, content,
3347 "fix() must leave the URL inside [[text]](url) unchanged"
3348 );
3349 }
3350
3351 #[test]
3352 fn test_double_bracket_link_text_still_flagged() {
3353 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3354 let content = "[[github]](https://example.com)";
3356 let ctx = create_context(content);
3357 let result = rule.check(&ctx).unwrap();
3358 assert_eq!(
3359 result.len(),
3360 1,
3361 "Incorrect name in [[text]] link text should still be flagged, got: {result:?}"
3362 );
3363 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
3364 }
3365
3366 #[test]
3367 fn test_double_bracket_link_mixed_line() {
3368 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3369 let content = "See [[rumdl]](https://github.com/rvben/rumdl) and github for more.";
3371 let ctx = create_context(content);
3372 let result = rule.check(&ctx).unwrap();
3373 assert_eq!(
3374 result.len(),
3375 1,
3376 "Only the standalone 'github' after the link should be flagged, got: {result:?}"
3377 );
3378 assert!(result[0].message.contains("'github'"));
3379 assert_eq!(
3381 result[0].column, 51,
3382 "Flagged column should be the trailing 'github', not the one in the URL"
3383 );
3384 }
3385
3386 #[test]
3387 fn test_regular_link_url_still_not_flagged() {
3388 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3390 let content = "[rumdl](https://github.com/rvben/rumdl)";
3391 let ctx = create_context(content);
3392 let result = rule.check(&ctx).unwrap();
3393 assert!(
3394 result.is_empty(),
3395 "URL inside regular [text](url) must still not be flagged, got: {result:?}"
3396 );
3397 }
3398
3399 #[test]
3400 fn test_link_like_text_in_code_span_still_flagged_when_code_blocks_enabled() {
3401 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
3406 let content = "`[foo](https://github.com/org/repo)`";
3407 let ctx = create_context(content);
3408 let result = rule.check(&ctx).unwrap();
3409 assert_eq!(
3410 result.len(),
3411 1,
3412 "Proper name inside a code span must be flagged when code-blocks=true, got: {result:?}"
3413 );
3414 assert!(result[0].message.contains("'github'"));
3415 }
3416
3417 #[test]
3418 fn test_malformed_link_not_treated_as_url() {
3419 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3422 let content = "See [rumdl](github repo) for details.";
3423 let ctx = create_context(content);
3424 let result = rule.check(&ctx).unwrap();
3425 assert_eq!(
3426 result.len(),
3427 1,
3428 "Name inside malformed [text](url with spaces) must still be flagged, got: {result:?}"
3429 );
3430 assert!(result[0].message.contains("'github'"));
3431 }
3432
3433 #[test]
3434 fn test_wikilink_followed_by_prose_parens_still_flagged() {
3435 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3439 let content = "[[note]](github repo)";
3440 let ctx = create_context(content);
3441 let result = rule.check(&ctx).unwrap();
3442 assert_eq!(
3443 result.len(),
3444 1,
3445 "Name inside [[wikilink]](prose with spaces) must still be flagged, got: {result:?}"
3446 );
3447 assert!(result[0].message.contains("'github'"));
3448 }
3449
3450 #[test]
3452 fn test_roundtrip_fix_then_check_basic() {
3453 let rule = MD044ProperNames::new(
3454 vec![
3455 "JavaScript".to_string(),
3456 "TypeScript".to_string(),
3457 "Node.js".to_string(),
3458 ],
3459 true,
3460 );
3461 let content = "I love javascript, typescript, and nodejs!";
3462 let ctx = create_context(content);
3463 let fixed = rule.fix(&ctx).unwrap();
3464 let ctx2 = create_context(&fixed);
3465 let warnings = rule.check(&ctx2).unwrap();
3466 assert!(
3467 warnings.is_empty(),
3468 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3469 );
3470 }
3471
3472 #[test]
3474 fn test_roundtrip_fix_then_check_multiline() {
3475 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
3476 let content = "First line with rust.\nSecond line with python.\nThird line with RUST and PYTHON.\n";
3477 let ctx = create_context(content);
3478 let fixed = rule.fix(&ctx).unwrap();
3479 let ctx2 = create_context(&fixed);
3480 let warnings = rule.check(&ctx2).unwrap();
3481 assert!(
3482 warnings.is_empty(),
3483 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3484 );
3485 }
3486
3487 #[test]
3489 fn test_roundtrip_fix_then_check_inline_config() {
3490 let config = MD044Config {
3491 names: vec!["RUMDL".to_string()],
3492 ..MD044Config::default()
3493 };
3494 let rule = MD044ProperNames::from_config_struct(config);
3495 let content =
3496 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
3497 let ctx = create_context(content);
3498 let fixed = rule.fix(&ctx).unwrap();
3499 assert!(
3501 fixed.contains("Some rumdl text.\n"),
3502 "Disabled block text should be preserved"
3503 );
3504 assert!(
3505 fixed.contains("Some RUMDL text outside."),
3506 "Outside text should be fixed"
3507 );
3508 }
3509
3510 #[test]
3512 fn test_roundtrip_fix_then_check_html_comments() {
3513 let config = MD044Config {
3514 names: vec!["JavaScript".to_string()],
3515 ..MD044Config::default()
3516 };
3517 let rule = MD044ProperNames::from_config_struct(config);
3518 let content = "# Guide\n\n<!-- javascript mentioned here -->\n\njavascript outside\n";
3519 let ctx = create_context(content);
3520 let fixed = rule.fix(&ctx).unwrap();
3521 let ctx2 = create_context(&fixed);
3522 let warnings = rule.check(&ctx2).unwrap();
3523 assert!(
3524 warnings.is_empty(),
3525 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3526 );
3527 }
3528
3529 #[test]
3531 fn test_roundtrip_no_op_when_correct() {
3532 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
3533 let content = "This uses JavaScript and TypeScript correctly.\n";
3534 let ctx = create_context(content);
3535 let fixed = rule.fix(&ctx).unwrap();
3536 assert_eq!(fixed, content, "Fix should be a no-op when content is already correct");
3537 }
3538
3539 #[test]
3542 fn test_bare_domain_link_text_not_flagged() {
3543 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3547 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3548 let ctx = create_context(content);
3549 let result = rule.check(&ctx).unwrap();
3550 assert!(
3551 result.is_empty(),
3552 "Should not flag 'github' in a bare-domain link text that matches the link URL: {result:?}"
3553 );
3554 }
3555
3556 #[test]
3557 fn test_bare_domain_link_text_not_fixed() {
3558 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3560 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3561 let ctx = create_context(content);
3562 let fixed = rule.fix(&ctx).unwrap();
3563 assert_eq!(
3564 fixed, content,
3565 "fix() must not alter bare-domain link text that matches the destination URL"
3566 );
3567 }
3568
3569 #[test]
3570 fn test_bare_domain_link_text_with_path_not_flagged() {
3571 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3573 let content = "Visit [ravencentric.github.io](https://ravencentric.github.io/projects).\n";
3574 let ctx = create_context(content);
3575 let result = rule.check(&ctx).unwrap();
3576 assert!(
3577 result.is_empty(),
3578 "Should not flag 'github' when bare-domain text is the hostname of its destination URL: {result:?}"
3579 );
3580 }
3581
3582 #[test]
3583 fn test_bare_domain_link_text_full_path_not_flagged() {
3584 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3586 let content = "See [ravencentric.github.io/blog](https://ravencentric.github.io/blog).\n";
3587 let ctx = create_context(content);
3588 let result = rule.check(&ctx).unwrap();
3589 assert!(
3590 result.is_empty(),
3591 "Should not flag 'github' when link text is the full URL path without scheme: {result:?}"
3592 );
3593 }
3594
3595 #[test]
3596 fn test_github_product_name_in_link_text_still_flagged() {
3597 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3600 let content = "Hosted on [github pages](https://pages.github.com).\n";
3601 let ctx = create_context(content);
3602 let result = rule.check(&ctx).unwrap();
3603 assert!(
3604 !result.is_empty(),
3605 "Should still flag 'github' in descriptive link text that does not match the destination URL"
3606 );
3607 }
3608
3609 #[test]
3610 fn test_protocol_relative_bare_domain_link_text_not_flagged() {
3611 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3613 let content = "See [github.io](//github.io).\n";
3614 let ctx = create_context(content);
3615 let result = rule.check(&ctx).unwrap();
3616 assert!(
3617 result.is_empty(),
3618 "Should not flag 'github' in bare-domain text matching a protocol-relative destination: {result:?}"
3619 );
3620 }
3621
3622 #[test]
3623 fn test_dotted_wikilink_target_still_flagged() {
3624 let rule = MD044ProperNames::new(vec!["Node.js".to_string()], false);
3629 let content = "See [[node.js]] for details.\n";
3630 let ctx = create_context(content);
3631 let result = rule.check(&ctx).unwrap();
3632 assert!(
3633 !result.is_empty(),
3634 "Should flag 'node.js' in a dotted WikiLink target: {result:?}"
3635 );
3636 }
3637
3638 #[test]
3639 fn test_bare_domain_link_text_case_insensitive_url() {
3640 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3643 let content = "See [github.io](HTTPS://github.io).\n";
3644 let ctx = create_context(content);
3645 let result = rule.check(&ctx).unwrap();
3646 assert!(
3647 result.is_empty(),
3648 "Should not flag bare-domain text when destination URL has an uppercase scheme: {result:?}"
3649 );
3650 }
3651
3652 #[test]
3653 fn test_frontmatter_value_span_strips_trailing_comment() {
3654 let line = "link: docs/guide/myapp # canonical path";
3655 let (s, e) = frontmatter_values::value_span(line).unwrap();
3656 assert_eq!(&line[s..e], "docs/guide/myapp");
3657 }
3658
3659 #[test]
3660 fn test_frontmatter_value_span_quoted_keeps_hash_and_spaces() {
3661 let line = "link: 'docs/My App/a#b'";
3662 let (s, e) = frontmatter_values::value_span(line).unwrap();
3663 assert_eq!(&line[s..e], "docs/My App/a#b");
3664 }
3665
3666 #[test]
3667 fn test_frontmatter_value_span_plain_value() {
3668 let line = "title: Heading for myapp";
3669 let (s, e) = frontmatter_values::value_span(line).unwrap();
3670 assert_eq!(&line[s..e], "Heading for myapp");
3671 }
3672
3673 #[test]
3674 fn test_frontmatter_value_span_none_for_key_only() {
3675 assert!(frontmatter_values::value_span("seo:").is_none());
3676 assert!(frontmatter_values::value_span("---").is_none());
3677 }
3678
3679 #[test]
3680 fn test_frontmatter_value_span_quoted_strips_trailing_comment() {
3681 let line = "link: 'docs/guide' # canonical path";
3682 let (s, e) = frontmatter_values::value_span(line).unwrap();
3683 assert_eq!(&line[s..e], "docs/guide");
3684 }
3685
3686 #[test]
3687 fn test_frontmatter_value_span_empty_quoted_value_is_none() {
3688 assert!(frontmatter_values::value_span("key: ''").is_none());
3689 }
3690
3691 #[test]
3692 fn test_frontmatter_value_span_unterminated_quote_strips_leading_quote() {
3693 let line = "link: 'docs/a";
3694 let (s, e) = frontmatter_values::value_span(line).unwrap();
3695 assert_eq!(&line[s..e], "docs/a");
3696 }
3697
3698 fn at(line: &str, needle: &str) -> usize {
3700 line.find(needle).expect("needle present")
3701 }
3702
3703 #[test]
3704 fn test_path_like_exempts_single_token_frontmatter_paths() {
3705 for line in [
3706 "link: this/is/a/link/to/myapp.md",
3707 "link: docs/myapp.md",
3708 "link: /abs/path/myapp.md",
3709 "link: ./myapp.md",
3710 "link: ../shared/myapp.md",
3711 ] {
3712 let span = frontmatter_values::value_span(line).unwrap();
3713 let pos = at(line, "myapp");
3714 assert!(
3715 MD044ProperNames::is_in_path_like_token(line, pos, span),
3716 "should treat as a path: {line}"
3717 );
3718 }
3719 }
3720
3721 #[test]
3722 fn test_path_like_does_not_exempt_slash_conjunction_prose() {
3723 let line = "description: We support github/gitlab/bitbucket imports.";
3726 let span = frontmatter_values::value_span(line).unwrap();
3727 assert!(
3728 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
3729 "slash-separated prose is not a path"
3730 );
3731
3732 let line = "description: The javascript/typescript ecosystem is large.";
3733 let span = frontmatter_values::value_span(line).unwrap();
3734 assert!(!MD044ProperNames::is_in_path_like_token(
3735 line,
3736 at(line, "javascript"),
3737 span
3738 ));
3739 }
3740
3741 #[test]
3742 fn test_path_like_requires_a_slash_so_dotted_names_survive() {
3743 let line = "title: Use nodejs and myapp.md today.";
3744 let span = frontmatter_values::value_span(line).unwrap();
3745 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3746 }
3747
3748 #[test]
3749 fn test_path_like_no_slash_frontmatter_value_still_flagged() {
3750 let line = "slug: myapp-guide";
3755 let span = frontmatter_values::value_span(line).unwrap();
3756 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3757 }
3758
3759 #[test]
3760 fn test_path_like_returns_false_outside_value_span() {
3761 let line = "myapp: docs/guide/myapp";
3764 let span = frontmatter_values::value_span(line).unwrap();
3765 let key_pos = 0;
3766 assert!(!MD044ProperNames::is_in_path_like_token(line, key_pos, span));
3767 }
3768
3769 #[test]
3770 fn test_path_like_three_segments_only_as_sole_frontmatter_value() {
3771 let line = "link: docs/guide/myapp";
3772 let span = frontmatter_values::value_span(line).unwrap();
3773 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3774
3775 let line = "description: We support github/gitlab/bitbucket now";
3776 let span = frontmatter_values::value_span(line).unwrap();
3777 assert!(
3778 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
3779 "multi-token value gets body treatment"
3780 );
3781 }
3782
3783 #[test]
3784 fn test_path_like_quoted_value_with_spaces() {
3785 let line = "link: 'docs/My App/myapp.md'";
3788 let span = frontmatter_values::value_span(line).unwrap();
3789 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3790 }
3791
3792 #[test]
3793 fn test_path_like_quoted_value_with_spaces_no_extension_not_exempt() {
3794 let line = "link: 'docs/My App/myapp'";
3801 let span = frontmatter_values::value_span(line).unwrap();
3802 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3803 }
3804
3805 #[test]
3806 fn test_path_like_trailing_comment_is_still_sole_value() {
3807 let line = "link: docs/guide/myapp # canonical path";
3808 let span = frontmatter_values::value_span(line).unwrap();
3809 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3810 }
3811
3812 #[test]
3813 fn test_path_like_trailing_punctuation_trimmed() {
3814 let line = "link: docs/myapp.md, then leave.";
3815 let span = frontmatter_values::value_span(line).unwrap();
3816 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3817 }
3818
3819 #[test]
3820 fn test_trim_token_bounds_reaches_fixpoint_after_punctuation_exposes_wrapper() {
3821 let line = r#"See "docs/myapp.md", then leave."#;
3822 let raw_start = at(line, "\"docs");
3823 let raw_end = raw_start + r#""docs/myapp.md","#.len();
3824 assert_eq!(&line[raw_start..raw_end], r#""docs/myapp.md","#);
3825 let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
3826 assert_eq!(&line[start..end], "docs/myapp.md");
3827 }
3828
3829 #[test]
3830 fn test_trim_token_bounds_reaches_fixpoint_with_multiple_trailing_wrappers() {
3831 let line = r#"("docs/myapp.md")."#;
3832 let (start, end) = frontmatter_values::trim_token_bounds(line, 0, line.len());
3833 assert_eq!(&line[start..end], "docs/myapp.md");
3834 }
3835
3836 #[test]
3837 fn test_frontmatter_link_path_not_flagged() {
3838 let content = "---\ntitle: Heading for MyApp\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
3839 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3840 let ctx = create_context(content);
3841 let result = rule.check(&ctx).unwrap();
3842 assert!(
3843 result.is_empty(),
3844 "path in a frontmatter value must not be flagged: {result:?}"
3845 );
3846 }
3847
3848 #[test]
3849 fn test_fix_does_not_corrupt_frontmatter_link_path() {
3850 let content = "---\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
3851 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3852 let ctx = create_context(content);
3853 assert_eq!(rule.fix(&ctx).unwrap(), content, "fix must not rewrite a path");
3854 }
3855
3856 #[test]
3867 fn test_body_prose_parenthesized_disambiguator_is_case_corrected() {
3868 let content = "See docs/myapp(1).md here.\n";
3869 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3870 let ctx = create_context(content);
3871 let result = rule.check(&ctx).unwrap();
3872 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3873 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp(1).md here.\n");
3874 }
3875
3876 #[test]
3877 fn test_body_prose_bracketed_dynamic_segment_is_case_corrected() {
3878 let content = "See docs/[myapp].md here.\n";
3879 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3880 let ctx = create_context(content);
3881 let result = rule.check(&ctx).unwrap();
3882 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3883 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/[MyApp].md here.\n");
3884 }
3885
3886 #[test]
3887 fn test_body_prose_nextjs_catch_all_segment_is_case_corrected() {
3888 let content = "pages/[[...myapp]].tsx are catch-all routes.\n";
3891 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3892 let ctx = create_context(content);
3893 let result = rule.check(&ctx).unwrap();
3894 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3895 assert_eq!(
3896 rule.fix(&ctx).unwrap(),
3897 "pages/[[...MyApp]].tsx are catch-all routes.\n"
3898 );
3899 }
3900
3901 #[test]
3902 fn test_two_adjacent_whitespace_free_links_both_flagged() {
3903 let content = "[myapp](https://a.com)[github](https://b.com)\n";
3907 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitHub".to_string()], false);
3908 let ctx = create_context(content);
3909 let result = rule.check(&ctx).unwrap();
3910 assert_eq!(result.len(), 2, "both link texts must be flagged: {result:?}");
3911 assert!(result.iter().any(|w| w.message.contains("'myapp'")));
3912 assert!(result.iter().any(|w| w.message.contains("'github'")));
3913 }
3914
3915 #[test]
3916 fn test_fix_does_not_corrupt_frontmatter_path_with_route_group_named_after_proper_name() {
3917 let content = "---\nlink: src/(myapp)/page.tsx\n---\n\nBody.\n";
3920 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3921 let ctx = create_context(content);
3922 assert_eq!(
3923 rule.fix(&ctx).unwrap(),
3924 content,
3925 "fix must not rewrite a frontmatter path whose route-group directory name is the proper name"
3926 );
3927 }
3928
3929 #[test]
3930 fn test_quoted_frontmatter_value_slash_conjunction_prose_still_flagged() {
3931 let content = "---\ndescription: \"We support github/gitlab/bitbucket now\"\n---\n\nBody.\n";
3936 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3937 let ctx = create_context(content);
3938 let result = rule.check(&ctx).unwrap();
3939 assert_eq!(
3940 result.len(),
3941 1,
3942 "quoted prose value must still flag 'github': {result:?}"
3943 );
3944 }
3945
3946 #[test]
3947 fn test_quoted_frontmatter_value_single_slash_word_with_unrelated_dot_still_flagged() {
3948 let content = "---\ndescription: \"We use myapp/gitlab and version 1.0 e.g. weekly\"\n---\n\nBody.\n";
3951 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3952 let ctx = create_context(content);
3953 let result = rule.check(&ctx).unwrap();
3954 assert_eq!(
3955 result.len(),
3956 1,
3957 "quoted prose value must still flag 'myapp': {result:?}"
3958 );
3959 }
3960
3961 #[test]
3962 fn test_quoted_toml_frontmatter_value_slash_conjunction_prose_still_flagged() {
3963 let content = "+++\ndescription = \"We support github/gitlab/bitbucket now\"\n+++\n\nBody.\n";
3966 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3967 let ctx = create_context(content);
3968 let result = rule.check(&ctx).unwrap();
3969 assert_eq!(
3970 result.len(),
3971 1,
3972 "TOML quoted prose value must still flag 'github': {result:?}"
3973 );
3974 }
3975
3976 #[test]
3977 fn test_path_like_collapsed_multiword_no_extension_not_exempt() {
3978 for line in [
3985 r#"description: "myapp/gitlab github/bitbucket""#,
3986 r#"description: "and/or this/that myapp/gitlab""#,
3987 r#"description: "he/him she/her myapp/gitlab""#,
3988 ] {
3989 let span = frontmatter_values::value_span(line).unwrap();
3990 for needle in ["myapp", "gitlab"] {
3991 if let Some(byte_pos) = line.find(needle) {
3992 assert!(
3993 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
3994 "collapsed multi-word value must not exempt '{needle}': {line}"
3995 );
3996 }
3997 }
3998 }
3999 }
4000
4001 #[test]
4002 fn test_path_like_collapsed_multiword_no_extension_not_exempt_toml() {
4003 let line = r#"description = "myapp/gitlab github/bitbucket""#;
4004 let span = frontmatter_values::value_span(line).unwrap();
4005 for needle in ["myapp", "gitlab", "github", "bitbucket"] {
4006 let byte_pos = at(line, needle);
4007 assert!(
4008 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
4009 "collapsed multi-word TOML value must not exempt '{needle}'"
4010 );
4011 }
4012 }
4013
4014 #[test]
4015 fn test_frontmatter_collapsed_multiword_names_all_flagged_yaml() {
4016 let content = "---\ndescription: \"myapp/gitlab github/bitbucket\"\n---\n\nBody.\n";
4017 let rule = MD044ProperNames::new(
4018 vec![
4019 "MyApp".to_string(),
4020 "GitLab".to_string(),
4021 "GitHub".to_string(),
4022 "Bitbucket".to_string(),
4023 ],
4024 false,
4025 );
4026 let ctx = create_context(content);
4027 let result = rule.check(&ctx).unwrap();
4028 assert_eq!(
4029 result.len(),
4030 4,
4031 "all four names in the collapsed multi-word value must be flagged: {result:?}"
4032 );
4033 }
4034
4035 #[test]
4036 fn test_frontmatter_collapsed_multiword_names_all_flagged_toml() {
4037 let content = "+++\ndescription = \"myapp/gitlab github/bitbucket\"\n+++\n\nBody.\n";
4038 let rule = MD044ProperNames::new(
4039 vec![
4040 "MyApp".to_string(),
4041 "GitLab".to_string(),
4042 "GitHub".to_string(),
4043 "Bitbucket".to_string(),
4044 ],
4045 false,
4046 );
4047 let ctx = create_context(content);
4048 let result = rule.check(&ctx).unwrap();
4049 assert_eq!(
4050 result.len(),
4051 4,
4052 "all four names in the collapsed multi-word TOML value must be flagged: {result:?}"
4053 );
4054 }
4055
4056 #[test]
4057 fn test_frontmatter_collapsed_multiword_conjunction_pairs_flagged() {
4058 let content = "---\ndescription: \"and/or this/that myapp/gitlab\"\n---\n\nBody.\n";
4059 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4060 let ctx = create_context(content);
4061 let result = rule.check(&ctx).unwrap();
4062 assert_eq!(
4063 result.len(),
4064 2,
4065 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4066 );
4067 }
4068
4069 #[test]
4070 fn test_frontmatter_collapsed_multiword_pronoun_pairs_flagged() {
4071 let content = "---\ndescription: \"he/him she/her myapp/gitlab\"\n---\n\nBody.\n";
4072 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4073 let ctx = create_context(content);
4074 let result = rule.check(&ctx).unwrap();
4075 assert_eq!(
4076 result.len(),
4077 2,
4078 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4079 );
4080 }
4081
4082 #[test]
4088 fn test_body_prose_path_is_flagged_frontmatter_only_scope() {
4089 let content = "See docs/myapp.md for details about myapp.\n";
4090 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4091 let ctx = create_context(content);
4092 let result = rule.check(&ctx).unwrap();
4093 assert_eq!(
4094 result.len(),
4095 2,
4096 "both the path occurrence and the prose occurrence are flagged in body text: {result:?}"
4097 );
4098 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp.md for details about MyApp.\n");
4099 }
4100
4101 #[test]
4102 fn test_slash_conjunction_prose_still_flagged() {
4103 let content = "We support github/gitlab imports.\n";
4104 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4105 let ctx = create_context(content);
4106 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
4107 }
4108}