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 !Self::is_at_word_boundary(line, start_pos, true) || !Self::is_at_word_boundary(line, end_pos, false)
369 {
370 continue; }
372
373 if !self.config.code_blocks {
375 if ctx.is_in_code_block_or_span(byte_pos) {
376 continue;
377 }
378 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
382 && Self::is_in_backtick_code_in_line(line, start_pos)
383 {
384 continue;
385 }
386 }
387
388 if Self::is_in_link(ctx, byte_pos) {
390 continue;
391 }
392
393 if Self::is_in_angle_bracket_url(line, start_pos) {
397 continue;
398 }
399
400 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
404 && Self::is_in_markdown_link_url(line, start_pos)
405 {
406 continue;
407 }
408
409 if Self::is_in_wikilink_url(ctx, byte_pos) {
414 continue;
415 }
416
417 if ctx.is_in_bare_url(byte_pos) {
423 continue;
424 }
425
426 if let Some(fm_value) = fm_value_span
433 && Self::is_in_path_like_token(line, start_pos, fm_value)
434 {
435 continue;
436 }
437
438 if let Some(proper_name) = self.get_proper_name_for(found_name) {
440 if found_name != proper_name {
442 violations.push((line_num, cap.start() + 1, found_name.to_string()));
443 }
444 }
445 }
446 }
447
448 if let Ok(mut cache) = self.content_cache.lock() {
450 cache.insert(hash, violations.clone());
451 }
452 violations
453 }
454
455 fn is_in_link(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
463 use pulldown_cmark::LinkType;
464
465 let link_idx = ctx.links.partition_point(|link| link.byte_offset <= byte_pos);
467 if link_idx > 0 {
468 let link = &ctx.links[link_idx - 1];
469 if byte_pos < link.byte_end {
470 let (text_start, text_end) = if matches!(link.link_type, LinkType::WikiLink { .. }) {
472 let span = &ctx.content[link.byte_offset..link.byte_end];
479 let start = match span.find('|') {
480 Some(pipe) => link.byte_offset + pipe + 1,
481 None => link.byte_offset + 2,
482 };
483 (start, link.byte_end.saturating_sub(2))
484 } else {
485 let start = link.byte_offset + 1;
486 (start, start + link.text.len())
487 };
488
489 if byte_pos >= text_start && byte_pos < text_end {
493 let is_wikilink = matches!(link.link_type, LinkType::WikiLink { .. });
494 if Self::link_text_is_url(&link.text)
495 || (!is_wikilink && Self::link_text_matches_link_url(&link.text, &link.url))
496 {
497 return true;
498 }
499 return Self::image_verdict(ctx, byte_pos).unwrap_or(false);
505 }
506 return true;
508 }
509 }
510
511 if let Some(verdict) = Self::image_verdict(ctx, byte_pos) {
512 return verdict;
513 }
514
515 ctx.is_in_reference_def(byte_pos)
517 }
518
519 fn image_verdict(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> Option<bool> {
524 let image_idx = ctx.images.partition_point(|img| img.byte_offset <= byte_pos);
526 let image = ctx.images.get(image_idx.checked_sub(1)?)?;
527 if byte_pos >= image.byte_end {
528 return None;
529 }
530
531 let alt_start = image.byte_offset + 2;
533 let alt_end = alt_start + image.alt_text.len();
534
535 Some(!(byte_pos >= alt_start && byte_pos < alt_end))
537 }
538
539 fn link_text_is_url(text: &str) -> bool {
541 let lower = text.trim().to_ascii_lowercase();
542 lower.starts_with("http://")
543 || lower.starts_with("https://")
544 || lower.starts_with("www.")
545 || lower.starts_with("//")
546 }
547
548 fn link_text_matches_link_url(text: &str, url: &str) -> bool {
560 let text = text.trim();
561 if !text.contains('.') {
563 return false;
564 }
565 let url_lower = url.to_ascii_lowercase();
566 let url_without_scheme = url_lower
567 .strip_prefix("https://")
568 .or_else(|| url_lower.strip_prefix("http://"))
569 .or_else(|| url_lower.strip_prefix("//"))
570 .unwrap_or(&url_lower);
571 let text_lower = text.to_ascii_lowercase();
572 if url_without_scheme == text_lower.as_str() {
574 return true;
575 }
576 url_without_scheme.len() > text_lower.len()
578 && url_without_scheme.starts_with(text_lower.as_str())
579 && matches!(
580 url_without_scheme.as_bytes().get(text_lower.len()),
581 Some(b'/') | Some(b'?') | Some(b'#')
582 )
583 }
584
585 fn is_in_angle_bracket_url(line: &str, pos: usize) -> bool {
591 let bytes = line.as_bytes();
592 let len = bytes.len();
593 let mut i = 0;
594 while i < len {
595 if bytes[i] == b'<' {
596 let after_open = i + 1;
597 if after_open < len && bytes[after_open].is_ascii_alphabetic() {
601 let mut s = after_open + 1;
602 let scheme_max = (after_open + 32).min(len);
603 while s < scheme_max
604 && (bytes[s].is_ascii_alphanumeric()
605 || bytes[s] == b'+'
606 || bytes[s] == b'-'
607 || bytes[s] == b'.')
608 {
609 s += 1;
610 }
611 if s < len && bytes[s] == b':' {
612 let mut j = s + 1;
614 let mut found_close = false;
615 while j < len {
616 match bytes[j] {
617 b'>' => {
618 found_close = true;
619 break;
620 }
621 b' ' | b'<' => break,
622 _ => j += 1,
623 }
624 }
625 if found_close && pos >= i && pos <= j {
626 return true;
627 }
628 if found_close {
629 i = j + 1;
630 continue;
631 }
632 }
633 }
634 }
635 i += 1;
636 }
637 false
638 }
639
640 fn is_in_wikilink_url(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
653 use pulldown_cmark::LinkType;
654 let content = ctx.content.as_bytes();
655
656 let end = ctx.links.partition_point(|l| l.byte_offset <= byte_pos);
659
660 for link in &ctx.links[..end] {
661 if !matches!(link.link_type, LinkType::WikiLink { .. }) {
662 continue;
663 }
664 let wiki_end = link.byte_end;
665 if wiki_end >= byte_pos || wiki_end >= content.len() || content[wiki_end] != b'(' {
667 continue;
668 }
669 let mut depth: u32 = 1;
674 let mut k = wiki_end + 1;
675 let mut valid_destination = true;
676 while k < content.len() && depth > 0 {
677 match content[k] {
678 b'\\' => {
679 k += 1; }
681 b'(' => depth += 1,
682 b')' => depth -= 1,
683 b' ' | b'\t' | b'\n' | b'\r' => {
684 valid_destination = false;
685 break;
686 }
687 _ => {}
688 }
689 k += 1;
690 }
691 if valid_destination && depth == 0 && byte_pos > wiki_end && byte_pos < k {
694 return true;
695 }
696 }
697 false
698 }
699
700 fn is_in_markdown_link_url(line: &str, pos: usize) -> bool {
710 let bytes = line.as_bytes();
711 let len = bytes.len();
712 let mut i = 0;
713
714 while i < len {
715 if bytes[i] == b'[' && (i == 0 || bytes[i - 1] != b'\\' || (i >= 2 && bytes[i - 2] == b'\\')) {
717 let mut depth: u32 = 1;
719 let mut j = i + 1;
720 while j < len && depth > 0 {
721 match bytes[j] {
722 b'\\' => {
723 j += 1; }
725 b'[' => depth += 1,
726 b']' => depth -= 1,
727 _ => {}
728 }
729 j += 1;
730 }
731
732 if depth == 0 && j < len {
734 if bytes[j] == b'(' {
735 let url_start = j;
737 let mut paren_depth: u32 = 1;
738 let mut k = j + 1;
739 while k < len && paren_depth > 0 {
740 match bytes[k] {
741 b'\\' => {
742 k += 1; }
744 b'(' => paren_depth += 1,
745 b')' => paren_depth -= 1,
746 _ => {}
747 }
748 k += 1;
749 }
750
751 if paren_depth == 0 {
752 if pos > url_start && pos < k {
753 return true;
754 }
755 i = k;
756 continue;
757 }
758 } else if bytes[j] == b'[' {
759 let ref_start = j;
761 let mut ref_depth: u32 = 1;
762 let mut k = j + 1;
763 while k < len && ref_depth > 0 {
764 match bytes[k] {
765 b'\\' => {
766 k += 1;
767 }
768 b'[' => ref_depth += 1,
769 b']' => ref_depth -= 1,
770 _ => {}
771 }
772 k += 1;
773 }
774
775 if ref_depth == 0 {
776 if pos > ref_start && pos < k {
777 return true;
778 }
779 i = k;
780 continue;
781 }
782 }
783 }
784 }
785 i += 1;
786 }
787 false
788 }
789
790 fn is_in_backtick_code_in_line(line: &str, pos: usize) -> bool {
798 let bytes = line.as_bytes();
799 let len = bytes.len();
800 let mut i = 0;
801 while i < len {
802 if bytes[i] == b'`' {
803 let open_start = i;
805 while i < len && bytes[i] == b'`' {
806 i += 1;
807 }
808 let tick_len = i - open_start;
809
810 while i < len {
812 if bytes[i] == b'`' {
813 let close_start = i;
814 while i < len && bytes[i] == b'`' {
815 i += 1;
816 }
817 if i - close_start == tick_len {
818 let content_start = open_start + tick_len;
822 let content_end = close_start;
823 if pos >= content_start && pos < content_end {
824 return true;
825 }
826 break;
828 }
829 } else {
831 i += 1;
832 }
833 }
834 } else {
835 i += 1;
836 }
837 }
838 false
839 }
840
841 fn is_word_boundary_char(c: char) -> bool {
843 !c.is_alphanumeric()
844 }
845
846 fn is_at_word_boundary(content: &str, pos: usize, is_start: bool) -> bool {
848 if is_start {
849 if pos == 0 {
850 return true;
851 }
852 match content[..pos].chars().next_back() {
853 None => true,
854 Some(c) => Self::is_word_boundary_char(c),
855 }
856 } else {
857 if pos >= content.len() {
858 return true;
859 }
860 match content[pos..].chars().next() {
861 None => true,
862 Some(c) => Self::is_word_boundary_char(c),
863 }
864 }
865 }
866
867 fn is_in_path_like_token(line: &str, match_start: usize, fm_value: (usize, usize)) -> bool {
895 let (value_start, value_end) = fm_value;
896 if match_start < value_start || match_start >= value_end {
897 return false;
898 }
899
900 let quoted_words: Vec<&str> = if frontmatter_values::value_is_quoted(line, value_start) {
908 line[value_start..value_end].split_whitespace().collect()
909 } else {
910 Vec::new()
911 };
912 let is_single_quoted_path = !quoted_words.is_empty() && quoted_words.iter().all(|word| word.contains('/'));
913 let is_multi_word_collapse = is_single_quoted_path && quoted_words.len() > 1;
921
922 let (raw_start, raw_end) = if is_single_quoted_path {
923 (value_start, value_end)
924 } else {
925 frontmatter_values::token_bounds(line, match_start, value_start, value_end)
926 };
927
928 let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
929 if match_start < start || match_start >= end {
930 return false;
931 }
932
933 let token = &line[start..end];
934 if !token.contains('/') {
935 return false;
936 }
937 if token.starts_with('/') || token.starts_with("./") || token.starts_with("../") || token.starts_with("~/") {
938 return true;
939 }
940 if token.rsplit('/').next().is_some_and(|seg| seg.contains('.')) {
941 return true;
942 }
943
944 if is_multi_word_collapse {
945 return false;
946 }
947
948 let sole_value = {
952 let (ts, te) = frontmatter_values::trim_token_bounds(line, value_start, value_end);
953 ts == start && te == end
954 };
955 sole_value && token.split('/').filter(|s| !s.is_empty()).count() >= 3
956 }
957
958 fn get_proper_name_for(&self, found_name: &str) -> Option<String> {
960 let found_lower = found_name.to_lowercase();
961
962 for name in &self.config.names {
964 let lower_name = name.to_lowercase();
965 let lower_name_no_dots = lower_name.replace('.', "");
966
967 if found_lower == lower_name || found_lower == lower_name_no_dots {
969 return Some(name.clone());
970 }
971
972 let ascii_normalized = Self::ascii_normalize(&lower_name);
974
975 let ascii_no_dots = ascii_normalized.replace('.', "");
976
977 if found_lower == ascii_normalized || found_lower == ascii_no_dots {
978 return Some(name.clone());
979 }
980 }
981 None
982 }
983}
984
985impl Rule for MD044ProperNames {
986 fn name(&self) -> &'static str {
987 "MD044"
988 }
989
990 fn description(&self) -> &'static str {
991 "Proper names should have the correct capitalization"
992 }
993
994 fn category(&self) -> RuleCategory {
995 RuleCategory::Other
996 }
997
998 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
999 if self.config.names.is_empty() {
1000 return true;
1001 }
1002 let content_lower = if ctx.content.is_ascii() {
1004 ctx.content.to_ascii_lowercase()
1005 } else {
1006 ctx.content.to_lowercase()
1007 };
1008 !self.name_variants.iter().any(|name| content_lower.contains(name))
1009 }
1010
1011 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1012 let content = ctx.content;
1013 if content.is_empty() || self.config.names.is_empty() || self.combined_pattern.is_none() {
1014 return Ok(Vec::new());
1015 }
1016
1017 let content_lower = if content.is_ascii() {
1019 content.to_ascii_lowercase()
1020 } else {
1021 content.to_lowercase()
1022 };
1023
1024 let has_potential_matches = self.name_variants.iter().any(|name| content_lower.contains(name));
1026
1027 if !has_potential_matches {
1028 return Ok(Vec::new());
1029 }
1030
1031 let line_index = &ctx.line_index;
1032 let violations = self.find_name_violations(content, ctx, &content_lower);
1033
1034 let warnings = violations
1035 .into_iter()
1036 .filter_map(|(line, column, found_name)| {
1037 self.get_proper_name_for(&found_name).map(|proper_name| {
1038 let line_start = line_index.get_line_start_byte(line).unwrap_or(0);
1043 let byte_start = line_start + (column - 1);
1044 let byte_end = byte_start + found_name.len();
1045 let line_text = ctx.line_info(line).map_or("", |li| li.content(ctx.content));
1048 let char_col = byte_to_char_count(line_text, column - 1);
1049 LintWarning {
1050 rule_name: Some(self.name().to_string()),
1051 line,
1052 column: char_col,
1053 end_line: line,
1054 end_column: char_col + found_name.chars().count(),
1055 message: format!("Proper name '{found_name}' should be '{proper_name}'"),
1056 severity: Severity::Warning,
1057 fix: Some(Fix::new(byte_start..byte_end, proper_name)),
1058 }
1059 })
1060 })
1061 .collect();
1062
1063 Ok(warnings)
1064 }
1065
1066 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1067 if self.should_skip(ctx) {
1068 return Ok(ctx.content.to_string());
1069 }
1070 let warnings = self.check(ctx)?;
1071 if warnings.is_empty() {
1072 return Ok(ctx.content.to_string());
1073 }
1074 let warnings =
1075 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1076 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
1077 .map_err(crate::rule::LintError::InvalidInput)
1078 }
1079
1080 fn as_any(&self) -> &dyn std::any::Any {
1081 self
1082 }
1083
1084 crate::impl_rule_config_methods!(MD044Config);
1085}
1086
1087#[cfg(test)]
1088mod tests {
1089 use super::*;
1090 use crate::lint_context::LintContext;
1091
1092 fn create_context(content: &str) -> LintContext<'_> {
1093 LintContext::new(content, crate::config::MarkdownFlavor::Standard, None)
1094 }
1095
1096 fn field_map_for(content: &str) -> Vec<Option<String>> {
1097 let ctx = create_context(content);
1098 frontmatter_values::field_map(&ctx)
1099 }
1100
1101 #[test]
1102 fn test_field_map_nested_lines_inherit_top_level_key() {
1103 let map = field_map_for("---\nseo:\n canonical: docs/a.md\n keywords:\n - myapp\ntitle: x\n---\n");
1104 assert_eq!(map[2].as_deref(), Some("seo"));
1105 assert_eq!(map[4].as_deref(), Some("seo"));
1106 assert_eq!(map[5].as_deref(), Some("title"));
1107 }
1108
1109 #[test]
1110 fn test_field_map_block_scalar_bracket_does_not_swallow_next_key() {
1111 let map = field_map_for("---\ndescription: |\n [myapp\ntitle: myapp\n---\n");
1112 assert_eq!(map[2].as_deref(), Some("description"));
1113 assert_eq!(
1114 map[3].as_deref(),
1115 Some("title"),
1116 "an indent-0 key always starts a new key"
1117 );
1118 }
1119
1120 #[test]
1121 fn test_field_map_quoted_key_with_colon() {
1122 let map = field_map_for("---\n\"og:title\": myapp\n---\n");
1123 assert_eq!(map[1].as_deref(), Some("og:title"));
1124 }
1125
1126 #[test]
1127 fn test_field_map_top_level_sequence_clears_attribution() {
1128 let map = field_map_for("---\n- myapp\n---\n");
1129 assert_eq!(map[1], None);
1130 }
1131
1132 #[test]
1133 fn test_field_map_toml_table_body_belongs_to_table_root() {
1134 let map = field_map_for("+++\n[seo]\ncanonical = \"docs/a.md\"\n\n[[authors]]\nname = \"myapp\"\n+++\n");
1135 assert_eq!(map[2].as_deref(), Some("seo"));
1136 assert_eq!(map[5].as_deref(), Some("authors"));
1137 }
1138
1139 #[test]
1140 fn test_field_map_toml_dotted_assignment_uses_root() {
1141 let map = field_map_for("+++\nseo.canonical = \"docs/a.md\"\n+++\n");
1142 assert_eq!(map[1].as_deref(), Some("seo"));
1143 }
1144
1145 #[test]
1146 fn test_field_map_toml_array_continuation_inherits() {
1147 let map = field_map_for("+++\nseo = [\n\"docs/guide/myapp\"\n]\n+++\n");
1148 assert_eq!(map[2].as_deref(), Some("seo"));
1149 }
1150
1151 #[test]
1152 fn test_field_map_indent_zero_flow_continuation_is_not_attributed_to_parent() {
1153 let map = field_map_for("---\nseo: [\n{name: myapp}\n]\n---\n");
1156 assert_eq!(map[2].as_deref(), Some("{name"));
1157 }
1158
1159 #[test]
1160 fn test_field_map_toml_nested_array_inherits_and_title_not_corrupted() {
1161 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\ntitle = \"x\"\n+++\n");
1162 assert_eq!(map[2].as_deref(), Some("matrix"), "nested array line inherits matrix");
1163 assert_eq!(map[3].as_deref(), Some("matrix"), "nested array line inherits matrix");
1164 assert_eq!(
1165 map[5].as_deref(),
1166 Some("title"),
1167 "title must not inherit stale attribution from a closed nested array"
1168 );
1169 }
1170
1171 #[test]
1172 fn test_field_map_toml_nested_array_last_element_without_trailing_comma() {
1173 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [2]\n]\ntitle = \"x\"\n+++\n");
1174 assert_eq!(
1175 map[3].as_deref(),
1176 Some("matrix"),
1177 "last element without a trailing comma still inherits matrix"
1178 );
1179 assert_eq!(
1180 map[5].as_deref(),
1181 Some("title"),
1182 "title must not inherit stale attribution from a closed nested array"
1183 );
1184 }
1185
1186 #[test]
1187 fn test_field_map_toml_nested_array_then_real_table_header_non_regression_guard() {
1188 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\n\n[seo]\ncanonical = \"docs/a.md\"\n+++\n");
1195 assert_eq!(map[6].as_deref(), Some("seo"), "real table header after a closed array");
1196 assert_eq!(
1197 map[7].as_deref(),
1198 Some("seo"),
1199 "table body still attributes to the table"
1200 );
1201 }
1202
1203 #[test]
1204 fn test_field_map_toml_unclosed_array_resyncs_on_next_assignment() {
1205 let map = field_map_for("+++\nmatrix = [\n [1, 2],\ntitle = \"x\"\n+++\n");
1211 assert_eq!(
1212 map[3].as_deref(),
1213 Some("title"),
1214 "title must resync even though the array was never closed"
1215 );
1216 }
1217
1218 #[test]
1219 fn test_field_map_toml_column_zero_array_elements_inherit_and_title_not_corrupted() {
1220 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[3, 4],\n]\ntitle = \"x\"\n+++\n");
1226 assert_eq!(
1227 map[2].as_deref(),
1228 Some("matrix"),
1229 "column-0 array element inherits matrix"
1230 );
1231 assert_eq!(
1232 map[3].as_deref(),
1233 Some("matrix"),
1234 "column-0 array element inherits matrix"
1235 );
1236 assert_eq!(
1237 map[5].as_deref(),
1238 Some("title"),
1239 "title must not inherit stale attribution from a misread array element"
1240 );
1241 }
1242
1243 #[test]
1244 fn test_field_map_toml_column_zero_array_last_element_without_trailing_comma() {
1245 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[2]\n]\ntitle = \"x\"\n+++\n");
1246 assert_eq!(
1247 map[3].as_deref(),
1248 Some("matrix"),
1249 "column-0 last element without a trailing comma still inherits matrix"
1250 );
1251 assert_eq!(
1252 map[5].as_deref(),
1253 Some("title"),
1254 "title must not inherit stale attribution from a misread array element"
1255 );
1256 }
1257
1258 #[test]
1259 fn test_correctly_capitalized_names() {
1260 let rule = MD044ProperNames::new(
1261 vec![
1262 "JavaScript".to_string(),
1263 "TypeScript".to_string(),
1264 "Node.js".to_string(),
1265 ],
1266 true,
1267 );
1268
1269 let content = "This document uses JavaScript, TypeScript, and Node.js correctly.";
1270 let ctx = create_context(content);
1271 let result = rule.check(&ctx).unwrap();
1272 assert!(result.is_empty(), "Should not flag correctly capitalized names");
1273 }
1274
1275 #[test]
1276 fn test_incorrectly_capitalized_names() {
1277 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1278
1279 let content = "This document uses javascript and typescript incorrectly.";
1280 let ctx = create_context(content);
1281 let result = rule.check(&ctx).unwrap();
1282
1283 assert_eq!(result.len(), 2, "Should flag two incorrect capitalizations");
1284 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1285 assert_eq!(result[0].line, 1);
1286 assert_eq!(result[0].column, 20);
1287 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1288 assert_eq!(result[1].line, 1);
1289 assert_eq!(result[1].column, 35);
1290 }
1291
1292 #[test]
1293 fn test_names_at_beginning_of_sentences() {
1294 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "Python".to_string()], true);
1295
1296 let content = "javascript is a great language. python is also popular.";
1297 let ctx = create_context(content);
1298 let result = rule.check(&ctx).unwrap();
1299
1300 assert_eq!(result.len(), 2, "Should flag names at beginning of sentences");
1301 assert_eq!(result[0].line, 1);
1302 assert_eq!(result[0].column, 1);
1303 assert_eq!(result[1].line, 1);
1304 assert_eq!(result[1].column, 33);
1305 }
1306
1307 #[test]
1308 fn test_names_in_code_blocks_checked_by_default() {
1309 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1310
1311 let content = r#"Here is some text with JavaScript.
1312
1313```javascript
1314// This javascript should be checked
1315const lang = "javascript";
1316```
1317
1318But this javascript should be flagged."#;
1319
1320 let ctx = create_context(content);
1321 let result = rule.check(&ctx).unwrap();
1322
1323 assert_eq!(result.len(), 3, "Should flag javascript inside and outside code blocks");
1324 assert_eq!(result[0].line, 4);
1325 assert_eq!(result[1].line, 5);
1326 assert_eq!(result[2].line, 8);
1327 }
1328
1329 #[test]
1330 fn test_names_in_code_blocks_ignored_when_disabled() {
1331 let rule = MD044ProperNames::new(
1332 vec!["JavaScript".to_string()],
1333 false, );
1335
1336 let content = r#"```
1337javascript in code block
1338```"#;
1339
1340 let ctx = create_context(content);
1341 let result = rule.check(&ctx).unwrap();
1342
1343 assert_eq!(
1344 result.len(),
1345 0,
1346 "Should not flag javascript in code blocks when code_blocks is false"
1347 );
1348 }
1349
1350 #[test]
1351 fn test_names_in_inline_code_checked_by_default() {
1352 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1353
1354 let content = "This is `javascript` in inline code and javascript outside.";
1355 let ctx = create_context(content);
1356 let result = rule.check(&ctx).unwrap();
1357
1358 assert_eq!(result.len(), 2, "Should flag javascript inside and outside inline code");
1360 assert_eq!(result[0].column, 10); assert_eq!(result[1].column, 41); }
1363
1364 #[test]
1365 fn test_multiple_names_in_same_line() {
1366 let rule = MD044ProperNames::new(
1367 vec!["JavaScript".to_string(), "TypeScript".to_string(), "React".to_string()],
1368 true,
1369 );
1370
1371 let content = "I use javascript, typescript, and react in my projects.";
1372 let ctx = create_context(content);
1373 let result = rule.check(&ctx).unwrap();
1374
1375 assert_eq!(result.len(), 3, "Should flag all three incorrect names");
1376 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1377 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1378 assert_eq!(result[2].message, "Proper name 'react' should be 'React'");
1379 }
1380
1381 #[test]
1382 fn test_case_sensitivity() {
1383 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1384
1385 let content = "JAVASCRIPT, Javascript, javascript, and JavaScript variations.";
1386 let ctx = create_context(content);
1387 let result = rule.check(&ctx).unwrap();
1388
1389 assert_eq!(result.len(), 3, "Should flag all incorrect case variations");
1390 assert!(result.iter().all(|w| w.message.contains("should be 'JavaScript'")));
1392 }
1393
1394 #[test]
1395 fn test_configuration_with_custom_name_list() {
1396 let config = MD044Config {
1397 names: vec!["GitHub".to_string(), "GitLab".to_string(), "DevOps".to_string()],
1398 code_blocks: true,
1399 ..Default::default()
1400 };
1401 let rule = MD044ProperNames::from_config_struct(config);
1402
1403 let content = "We use github, gitlab, and devops for our workflow.";
1404 let ctx = create_context(content);
1405 let result = rule.check(&ctx).unwrap();
1406
1407 assert_eq!(result.len(), 3, "Should flag all custom names");
1408 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
1409 assert_eq!(result[1].message, "Proper name 'gitlab' should be 'GitLab'");
1410 assert_eq!(result[2].message, "Proper name 'devops' should be 'DevOps'");
1411 }
1412
1413 #[test]
1414 fn test_empty_configuration() {
1415 let rule = MD044ProperNames::new(vec![], true);
1416
1417 let content = "This has javascript and typescript but no configured names.";
1418 let ctx = create_context(content);
1419 let result = rule.check(&ctx).unwrap();
1420
1421 assert!(result.is_empty(), "Should not flag anything with empty configuration");
1422 }
1423
1424 #[test]
1425 fn test_names_with_special_characters() {
1426 let rule = MD044ProperNames::new(
1427 vec!["Node.js".to_string(), "ASP.NET".to_string(), "C++".to_string()],
1428 true,
1429 );
1430
1431 let content = "We use nodejs, asp.net, ASP.NET, and c++ in our stack.";
1432 let ctx = create_context(content);
1433 let result = rule.check(&ctx).unwrap();
1434
1435 assert_eq!(result.len(), 3, "Should handle special characters correctly");
1440
1441 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
1442 assert!(messages.contains(&"Proper name 'nodejs' should be 'Node.js'"));
1443 assert!(messages.contains(&"Proper name 'asp.net' should be 'ASP.NET'"));
1444 assert!(messages.contains(&"Proper name 'c++' should be 'C++'"));
1445 }
1446
1447 #[test]
1448 fn test_word_boundaries() {
1449 let rule = MD044ProperNames::new(vec!["Java".to_string(), "Script".to_string()], true);
1450
1451 let content = "JavaScript is not java or script, but Java and Script are separate.";
1452 let ctx = create_context(content);
1453 let result = rule.check(&ctx).unwrap();
1454
1455 assert_eq!(result.len(), 2, "Should respect word boundaries");
1457 assert!(result.iter().any(|w| w.column == 19)); assert!(result.iter().any(|w| w.column == 27)); }
1460
1461 #[test]
1462 fn test_fix_method() {
1463 let rule = MD044ProperNames::new(
1464 vec![
1465 "JavaScript".to_string(),
1466 "TypeScript".to_string(),
1467 "Node.js".to_string(),
1468 ],
1469 true,
1470 );
1471
1472 let content = "I love javascript, typescript, and nodejs!";
1473 let ctx = create_context(content);
1474 let fixed = rule.fix(&ctx).unwrap();
1475
1476 assert_eq!(fixed, "I love JavaScript, TypeScript, and Node.js!");
1477 }
1478
1479 #[test]
1480 fn test_fix_multiple_occurrences() {
1481 let rule = MD044ProperNames::new(vec!["Python".to_string()], true);
1482
1483 let content = "python is great. I use python daily. PYTHON is powerful.";
1484 let ctx = create_context(content);
1485 let fixed = rule.fix(&ctx).unwrap();
1486
1487 assert_eq!(fixed, "Python is great. I use Python daily. Python is powerful.");
1488 }
1489
1490 #[test]
1491 fn test_fix_checks_code_blocks_by_default() {
1492 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1493
1494 let content = r#"I love javascript.
1495
1496```
1497const lang = "javascript";
1498```
1499
1500More javascript here."#;
1501
1502 let ctx = create_context(content);
1503 let fixed = rule.fix(&ctx).unwrap();
1504
1505 let expected = r#"I love JavaScript.
1506
1507```
1508const lang = "JavaScript";
1509```
1510
1511More JavaScript here."#;
1512
1513 assert_eq!(fixed, expected);
1514 }
1515
1516 #[test]
1517 fn test_multiline_content() {
1518 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
1519
1520 let content = r#"First line with rust.
1521Second line with python.
1522Third line with RUST and PYTHON."#;
1523
1524 let ctx = create_context(content);
1525 let result = rule.check(&ctx).unwrap();
1526
1527 assert_eq!(result.len(), 4, "Should flag all incorrect occurrences");
1528 assert_eq!(result[0].line, 1);
1529 assert_eq!(result[1].line, 2);
1530 assert_eq!(result[2].line, 3);
1531 assert_eq!(result[3].line, 3);
1532 }
1533
1534 #[test]
1535 fn test_default_config() {
1536 let config = MD044Config::default();
1537 assert!(config.names.is_empty());
1538 assert!(!config.code_blocks);
1539 assert!(config.html_elements);
1540 assert!(config.html_comments);
1541 }
1542
1543 #[test]
1544 fn test_default_config_checks_html_comments() {
1545 let config = MD044Config {
1546 names: vec!["JavaScript".to_string()],
1547 ..MD044Config::default()
1548 };
1549 let rule = MD044ProperNames::from_config_struct(config);
1550
1551 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1552 let ctx = create_context(content);
1553 let result = rule.check(&ctx).unwrap();
1554
1555 assert_eq!(result.len(), 1, "Default config should check HTML comments");
1556 assert_eq!(result[0].line, 3);
1557 }
1558
1559 #[test]
1560 fn test_default_config_skips_code_blocks() {
1561 let config = MD044Config {
1562 names: vec!["JavaScript".to_string()],
1563 ..MD044Config::default()
1564 };
1565 let rule = MD044ProperNames::from_config_struct(config);
1566
1567 let content = "# Guide\n\n```\njavascript in code\n```\n";
1568 let ctx = create_context(content);
1569 let result = rule.check(&ctx).unwrap();
1570
1571 assert_eq!(result.len(), 0, "Default config should skip code blocks");
1572 }
1573
1574 #[test]
1575 fn test_standalone_html_comment_checked() {
1576 let config = MD044Config {
1577 names: vec!["Test".to_string()],
1578 ..MD044Config::default()
1579 };
1580 let rule = MD044ProperNames::from_config_struct(config);
1581
1582 let content = "# Heading\n\n<!-- this is a test example -->\n";
1583 let ctx = create_context(content);
1584 let result = rule.check(&ctx).unwrap();
1585
1586 assert_eq!(result.len(), 1, "Should flag proper name in standalone HTML comment");
1587 assert_eq!(result[0].line, 3);
1588 }
1589
1590 #[test]
1591 fn test_inline_config_comments_not_flagged() {
1592 let config = MD044Config {
1593 names: vec!["RUMDL".to_string()],
1594 ..MD044Config::default()
1595 };
1596 let rule = MD044ProperNames::from_config_struct(config);
1597
1598 let content = "<!-- rumdl-disable MD044 -->\nSome rumdl text here.\n<!-- rumdl-enable MD044 -->\n<!-- markdownlint-disable -->\nMore rumdl text.\n<!-- markdownlint-enable -->\n";
1602 let ctx = create_context(content);
1603 let result = rule.check(&ctx).unwrap();
1604
1605 assert_eq!(result.len(), 2, "Should only flag body lines, not config comments");
1606 assert_eq!(result[0].line, 2);
1607 assert_eq!(result[1].line, 5);
1608 }
1609
1610 #[test]
1611 fn test_html_comment_skipped_when_disabled() {
1612 let config = MD044Config {
1613 names: vec!["Test".to_string()],
1614 code_blocks: true,
1615 html_comments: false,
1616 ..Default::default()
1617 };
1618 let rule = MD044ProperNames::from_config_struct(config);
1619
1620 let content = "# Heading\n\n<!-- this is a test example -->\n\nRegular test here.\n";
1621 let ctx = create_context(content);
1622 let result = rule.check(&ctx).unwrap();
1623
1624 assert_eq!(
1625 result.len(),
1626 1,
1627 "Should only flag 'test' outside HTML comment when html_comments=false"
1628 );
1629 assert_eq!(result[0].line, 5);
1630 }
1631
1632 #[test]
1633 fn test_fix_corrects_html_comment_content() {
1634 let config = MD044Config {
1635 names: vec!["JavaScript".to_string()],
1636 ..MD044Config::default()
1637 };
1638 let rule = MD044ProperNames::from_config_struct(config);
1639
1640 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1641 let ctx = create_context(content);
1642 let fixed = rule.fix(&ctx).unwrap();
1643
1644 assert_eq!(fixed, "# Guide\n\n<!-- JavaScript mentioned here -->\n");
1645 }
1646
1647 #[test]
1648 fn test_fix_does_not_modify_inline_config_comments() {
1649 let config = MD044Config {
1650 names: vec!["RUMDL".to_string()],
1651 ..MD044Config::default()
1652 };
1653 let rule = MD044ProperNames::from_config_struct(config);
1654
1655 let content = "<!-- rumdl-disable -->\nSome rumdl text.\n<!-- rumdl-enable -->\n";
1656 let ctx = create_context(content);
1657 let fixed = rule.fix(&ctx).unwrap();
1658
1659 assert!(fixed.contains("<!-- rumdl-disable -->"));
1661 assert!(fixed.contains("<!-- rumdl-enable -->"));
1662 assert!(
1664 fixed.contains("Some rumdl text."),
1665 "Line inside rumdl-disable block should not be modified by fix()"
1666 );
1667 }
1668
1669 #[test]
1670 fn test_fix_respects_inline_disable_partial() {
1671 let config = MD044Config {
1672 names: vec!["RUMDL".to_string()],
1673 ..MD044Config::default()
1674 };
1675 let rule = MD044ProperNames::from_config_struct(config);
1676
1677 let content =
1678 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
1679 let ctx = create_context(content);
1680 let fixed = rule.fix(&ctx).unwrap();
1681
1682 assert!(
1684 fixed.contains("Some rumdl text.\n<!-- rumdl-enable"),
1685 "Line inside disable block should not be modified"
1686 );
1687 assert!(
1689 fixed.contains("Some RUMDL text outside."),
1690 "Line outside disable block should be fixed"
1691 );
1692 }
1693
1694 #[test]
1695 fn test_performance_with_many_names() {
1696 let mut names = vec![];
1697 for i in 0..50 {
1698 names.push(format!("ProperName{i}"));
1699 }
1700
1701 let rule = MD044ProperNames::new(names, true);
1702
1703 let content = "This has propername0, propername25, and propername49 incorrectly.";
1704 let ctx = create_context(content);
1705 let result = rule.check(&ctx).unwrap();
1706
1707 assert_eq!(result.len(), 3, "Should handle many configured names efficiently");
1708 }
1709
1710 #[test]
1711 fn test_large_name_count_performance() {
1712 let names = (0..1000).map(|i| format!("ProperName{i}")).collect::<Vec<_>>();
1715
1716 let rule = MD044ProperNames::new(names, true);
1717
1718 assert!(rule.combined_pattern.is_some());
1720
1721 let content = "This has propername0 and propername999 in it.";
1723 let ctx = create_context(content);
1724 let result = rule.check(&ctx).unwrap();
1725
1726 assert_eq!(result.len(), 2, "Should handle 1000 names without issues");
1728 }
1729
1730 #[test]
1731 fn test_cache_behavior() {
1732 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1733
1734 let content = "Using javascript here.";
1735 let ctx = create_context(content);
1736
1737 let result1 = rule.check(&ctx).unwrap();
1739 assert_eq!(result1.len(), 1);
1740
1741 let result2 = rule.check(&ctx).unwrap();
1743 assert_eq!(result2.len(), 1);
1744
1745 assert_eq!(result1[0].line, result2[0].line);
1747 assert_eq!(result1[0].column, result2[0].column);
1748 }
1749
1750 #[test]
1751 fn test_html_comments_not_checked_when_disabled() {
1752 let config = MD044Config {
1753 names: vec!["JavaScript".to_string()],
1754 code_blocks: true, html_comments: false, ..Default::default()
1757 };
1758 let rule = MD044ProperNames::from_config_struct(config);
1759
1760 let content = r#"Regular javascript here.
1761<!-- This javascript in HTML comment should be ignored -->
1762More javascript outside."#;
1763
1764 let ctx = create_context(content);
1765 let result = rule.check(&ctx).unwrap();
1766
1767 assert_eq!(result.len(), 2, "Should only flag javascript outside HTML comments");
1768 assert_eq!(result[0].line, 1);
1769 assert_eq!(result[1].line, 3);
1770 }
1771
1772 #[test]
1773 fn test_html_comments_checked_when_enabled() {
1774 let config = MD044Config {
1775 names: vec!["JavaScript".to_string()],
1776 code_blocks: true, ..Default::default()
1778 };
1779 let rule = MD044ProperNames::from_config_struct(config);
1780
1781 let content = r#"Regular javascript here.
1782<!-- This javascript in HTML comment should be checked -->
1783More javascript outside."#;
1784
1785 let ctx = create_context(content);
1786 let result = rule.check(&ctx).unwrap();
1787
1788 assert_eq!(
1789 result.len(),
1790 3,
1791 "Should flag all javascript occurrences including in HTML comments"
1792 );
1793 }
1794
1795 #[test]
1796 fn test_indented_html_comment_escapes_via_link_and_backticks() {
1797 let config = MD044Config {
1802 names: vec!["Test".to_string()],
1803 ..Default::default()
1804 };
1805 let rule = MD044ProperNames::from_config_struct(config);
1806
1807 let content = "<!-- see the [relevant page](test.md). -->\n<!-- see `test.md` -->\n <!-- see the [relevant page](test.md). -->\n <!-- see `test.md` -->\n";
1808
1809 let ctx = create_context(content);
1810 let result = rule.check(&ctx).unwrap();
1811
1812 assert!(
1813 result.is_empty(),
1814 "'test' inside a link URL or backticks must be ignored in both column-0 and indented comments, got: {result:?}"
1815 );
1816 }
1817
1818 #[test]
1819 fn test_indented_html_comment_still_checks_bare_prose() {
1820 let config = MD044Config {
1823 names: vec!["Test".to_string()],
1824 ..Default::default()
1825 };
1826 let rule = MD044ProperNames::from_config_struct(config);
1827
1828 let content = " <!-- this is a test comment -->\n";
1829
1830 let ctx = create_context(content);
1831 let result = rule.check(&ctx).unwrap();
1832
1833 assert_eq!(
1834 result.len(),
1835 1,
1836 "bare 'test' in an indented comment is still a violation"
1837 );
1838 assert_eq!(result[0].line, 1);
1839 }
1840
1841 #[test]
1842 fn test_multiline_html_comments() {
1843 let config = MD044Config {
1844 names: vec!["Python".to_string(), "JavaScript".to_string()],
1845 code_blocks: true, html_comments: false, ..Default::default()
1848 };
1849 let rule = MD044ProperNames::from_config_struct(config);
1850
1851 let content = r#"Regular python here.
1852<!--
1853This is a multiline comment
1854with javascript and python
1855that should be ignored
1856-->
1857More javascript outside."#;
1858
1859 let ctx = create_context(content);
1860 let result = rule.check(&ctx).unwrap();
1861
1862 assert_eq!(result.len(), 2, "Should only flag names outside HTML comments");
1863 assert_eq!(result[0].line, 1); assert_eq!(result[1].line, 7); }
1866
1867 #[test]
1868 fn test_fix_preserves_html_comments_when_disabled() {
1869 let config = MD044Config {
1870 names: vec!["JavaScript".to_string()],
1871 code_blocks: true, html_comments: false, ..Default::default()
1874 };
1875 let rule = MD044ProperNames::from_config_struct(config);
1876
1877 let content = r#"javascript here.
1878<!-- javascript in comment -->
1879More javascript."#;
1880
1881 let ctx = create_context(content);
1882 let fixed = rule.fix(&ctx).unwrap();
1883
1884 let expected = r#"JavaScript here.
1885<!-- javascript in comment -->
1886More JavaScript."#;
1887
1888 assert_eq!(
1889 fixed, expected,
1890 "Should not fix names inside HTML comments when disabled"
1891 );
1892 }
1893
1894 #[test]
1895 fn test_proper_names_in_link_text_are_flagged() {
1896 let rule = MD044ProperNames::new(
1897 vec!["JavaScript".to_string(), "Node.js".to_string(), "Python".to_string()],
1898 true,
1899 );
1900
1901 let content = r#"Check this [javascript documentation](https://javascript.info) for info.
1902
1903Visit [node.js homepage](https://nodejs.org) and [python tutorial](https://python.org).
1904
1905Real javascript should be flagged.
1906
1907Also see the [typescript guide][ts-ref] for more.
1908
1909Real python should be flagged too.
1910
1911[ts-ref]: https://typescript.org/handbook"#;
1912
1913 let ctx = create_context(content);
1914 let result = rule.check(&ctx).unwrap();
1915
1916 assert_eq!(result.len(), 5, "Expected 5 warnings: 3 in link text + 2 standalone");
1923
1924 let line_1_warnings: Vec<_> = result.iter().filter(|w| w.line == 1).collect();
1926 assert_eq!(line_1_warnings.len(), 1);
1927 assert!(
1928 line_1_warnings[0]
1929 .message
1930 .contains("'javascript' should be 'JavaScript'")
1931 );
1932
1933 let line_3_warnings: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
1934 assert_eq!(line_3_warnings.len(), 2); assert!(result.iter().any(|w| w.line == 5 && w.message.contains("'javascript'")));
1938 assert!(result.iter().any(|w| w.line == 9 && w.message.contains("'python'")));
1939 }
1940
1941 #[test]
1942 fn test_link_urls_not_flagged() {
1943 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1944
1945 let content = r#"[Link Text](https://javascript.info/guide)"#;
1947
1948 let ctx = create_context(content);
1949 let result = rule.check(&ctx).unwrap();
1950
1951 assert!(result.is_empty(), "URLs should not be checked for proper names");
1953 }
1954
1955 #[test]
1956 fn test_bare_urls_not_flagged() {
1957 let rule = MD044ProperNames::new(vec!["Foo".to_string(), "JavaScript".to_string()], true);
1958
1959 let content =
1962 "https://foo.com\n\nSee https://javascript.info/foo/guide for details.\n\nMail foo@foo.com about it.\n";
1963
1964 let ctx = create_context(content);
1965 let result = rule.check(&ctx).unwrap();
1966
1967 assert!(
1968 result.is_empty(),
1969 "Bare URLs and emails should not be checked for proper names: {result:?}"
1970 );
1971 }
1972
1973 #[test]
1974 fn test_prose_around_bare_url_still_flagged() {
1975 let rule = MD044ProperNames::new(vec!["Foo".to_string()], true);
1976
1977 let content = "Use foo at https://foo.com because foo is great.\n";
1980
1981 let ctx = create_context(content);
1982 let result = rule.check(&ctx).unwrap();
1983
1984 assert_eq!(
1985 result.len(),
1986 2,
1987 "Prose occurrences around a bare URL must still be flagged: {result:?}"
1988 );
1989 assert!(result.iter().all(|w| w.message.contains("'foo' should be 'Foo'")));
1990 }
1991
1992 #[test]
1993 fn test_proper_names_in_image_alt_text_are_flagged() {
1994 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1995
1996 let content = r#"Here is a  image.
1997
1998Real javascript should be flagged."#;
1999
2000 let ctx = create_context(content);
2001 let result = rule.check(&ctx).unwrap();
2002
2003 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in alt text + 1 standalone");
2007 assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
2008 assert!(result[0].line == 1); assert!(result[1].message.contains("'javascript' should be 'JavaScript'"));
2010 assert!(result[1].line == 3); }
2012
2013 #[test]
2014 fn test_image_urls_not_flagged() {
2015 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2016
2017 let content = r#""#;
2019
2020 let ctx = create_context(content);
2021 let result = rule.check(&ctx).unwrap();
2022
2023 assert!(result.is_empty(), "Image URLs should not be checked for proper names");
2025 }
2026
2027 #[test]
2028 fn test_reference_link_text_flagged_but_definition_not() {
2029 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2030
2031 let content = r#"Check the [javascript guide][js-ref] for details.
2032
2033Real javascript should be flagged.
2034
2035[js-ref]: https://javascript.info/typescript/guide"#;
2036
2037 let ctx = create_context(content);
2038 let result = rule.check(&ctx).unwrap();
2039
2040 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in link text + 1 standalone");
2045 assert!(result.iter().any(|w| w.line == 1 && w.message.contains("'javascript'")));
2046 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2047 }
2048
2049 #[test]
2050 fn test_reference_definitions_not_flagged() {
2051 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2052
2053 let content = r#"[js-ref]: https://javascript.info/guide"#;
2055
2056 let ctx = create_context(content);
2057 let result = rule.check(&ctx).unwrap();
2058
2059 assert!(result.is_empty(), "Reference definitions should not be checked");
2061 }
2062
2063 #[test]
2064 fn test_wikilinks_text_is_flagged() {
2065 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2066
2067 let content = r#"[[javascript]]
2069
2070Regular javascript here.
2071
2072[[JavaScript|display text]]"#;
2073
2074 let ctx = create_context(content);
2075 let result = rule.check(&ctx).unwrap();
2076
2077 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in WikiLink + 1 standalone");
2081 assert!(
2082 result
2083 .iter()
2084 .any(|w| w.line == 1 && w.column == 3 && w.message.contains("'javascript'"))
2085 );
2086 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2087 }
2088
2089 #[test]
2090 fn test_url_link_text_not_flagged() {
2091 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2092
2093 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2095
2096[http://github.com/org/repo](http://github.com/org/repo)
2097
2098[www.github.com/org/repo](https://www.github.com/org/repo)"#;
2099
2100 let ctx = create_context(content);
2101 let result = rule.check(&ctx).unwrap();
2102
2103 assert!(
2104 result.is_empty(),
2105 "URL-like link text should not be flagged, got: {result:?}"
2106 );
2107 }
2108
2109 #[test]
2110 fn test_url_link_text_with_leading_space_not_flagged() {
2111 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2112
2113 let content = r#"[ https://github.com/org/repo](https://github.com/org/repo)"#;
2115
2116 let ctx = create_context(content);
2117 let result = rule.check(&ctx).unwrap();
2118
2119 assert!(
2120 result.is_empty(),
2121 "URL-like link text with leading space should not be flagged, got: {result:?}"
2122 );
2123 }
2124
2125 #[test]
2126 fn test_url_link_text_uppercase_scheme_not_flagged() {
2127 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2128
2129 let content = r#"[HTTPS://GITHUB.COM/org/repo](https://github.com/org/repo)"#;
2130
2131 let ctx = create_context(content);
2132 let result = rule.check(&ctx).unwrap();
2133
2134 assert!(
2135 result.is_empty(),
2136 "URL-like link text with uppercase scheme should not be flagged, got: {result:?}"
2137 );
2138 }
2139
2140 #[test]
2141 fn test_non_url_link_text_still_flagged() {
2142 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2143
2144 let content = r#"[github.com/org/repo](https://github.com/org/repo)
2148
2149[Visit github](https://github.com/org/repo)
2150
2151[//github.com/org/repo](//github.com/org/repo)
2152
2153[ftp://github.com/org/repo](ftp://github.com/org/repo)"#;
2154
2155 let ctx = create_context(content);
2156 let result = rule.check(&ctx).unwrap();
2157
2158 assert_eq!(
2163 result.len(),
2164 1,
2165 "Only prose link text should be flagged, got: {result:?}"
2166 );
2167 assert!(
2168 result.iter().any(|w| w.line == 3),
2169 "Expected 'Visit github' on line 3 to be flagged"
2170 );
2171 }
2172
2173 #[test]
2174 fn test_url_link_text_fix_not_applied() {
2175 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2176
2177 let content = "[https://github.com/org/repo](https://github.com/org/repo)\n";
2178
2179 let ctx = create_context(content);
2180 let result = rule.fix(&ctx).unwrap();
2181
2182 assert_eq!(result, content, "Fix should not modify URL-like link text");
2183 }
2184
2185 #[test]
2186 fn test_mixed_url_and_regular_link_text() {
2187 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2188
2189 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2191
2192Visit [github documentation](https://github.com/docs) for details.
2193
2194[www.github.com/pricing](https://www.github.com/pricing)"#;
2195
2196 let ctx = create_context(content);
2197 let result = rule.check(&ctx).unwrap();
2198
2199 assert_eq!(
2201 result.len(),
2202 1,
2203 "Only non-URL link text should be flagged, got: {result:?}"
2204 );
2205 assert_eq!(result[0].line, 3);
2206 }
2207
2208 #[test]
2209 fn test_html_attribute_values_not_flagged() {
2210 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2213 let content = "# Heading\n\ntest\n\n<img src=\"www.example.test/test_image.png\">\n";
2214 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2215 let result = rule.check(&ctx).unwrap();
2216
2217 let line5_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2219 assert!(
2220 line5_violations.is_empty(),
2221 "Should not flag anything inside HTML tag attributes: {line5_violations:?}"
2222 );
2223
2224 let line3_violations: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
2226 assert_eq!(line3_violations.len(), 1, "Plain 'test' on line 3 should be flagged");
2227 }
2228
2229 #[test]
2230 fn test_html_text_content_still_flagged() {
2231 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2233 let content = "# Heading\n\n<a href=\"https://example.test/page\">test link</a>\n";
2234 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2235 let result = rule.check(&ctx).unwrap();
2236
2237 assert_eq!(
2240 result.len(),
2241 1,
2242 "Should flag only 'test' in anchor text, not in href: {result:?}"
2243 );
2244 assert_eq!(result[0].column, 37, "Should flag col 37 ('test link' in anchor text)");
2245 }
2246
2247 #[test]
2248 fn test_html_attribute_various_not_flagged() {
2249 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2251 let content = concat!(
2252 "# Heading\n\n",
2253 "<img src=\"test.png\" alt=\"test image\">\n",
2254 "<span class=\"test-class\" data-test=\"value\">test content</span>\n",
2255 );
2256 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2257 let result = rule.check(&ctx).unwrap();
2258
2259 assert_eq!(
2261 result.len(),
2262 1,
2263 "Should flag only 'test content' between tags: {result:?}"
2264 );
2265 assert_eq!(result[0].line, 4);
2266 }
2267
2268 #[test]
2269 fn test_plain_text_underscore_boundary_unchanged() {
2270 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2273 let content = "# Heading\n\ntest_image is here and just_test ends here\n";
2274 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2275 let result = rule.check(&ctx).unwrap();
2276
2277 assert_eq!(
2280 result.len(),
2281 2,
2282 "Should flag 'test' in both 'test_image' and 'just_test': {result:?}"
2283 );
2284 let cols: Vec<usize> = result.iter().map(|w| w.column).collect();
2285 assert!(cols.contains(&1), "Should flag col 1 (test_image): {cols:?}");
2286 assert!(cols.contains(&29), "Should flag col 29 (just_test): {cols:?}");
2287 }
2288
2289 #[test]
2290 fn test_frontmatter_yaml_keys_not_flagged() {
2291 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2294
2295 let content = "---\ntitle: Heading\ntest: Some Test value\n---\n\nTest\n";
2296 let ctx = create_context(content);
2297 let result = rule.check(&ctx).unwrap();
2298
2299 assert!(
2303 result.is_empty(),
2304 "Should not flag YAML keys or correctly capitalized values: {result:?}"
2305 );
2306 }
2307
2308 #[test]
2309 fn test_frontmatter_yaml_values_flagged() {
2310 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2312
2313 let content = "---\ntitle: Heading\nkey: a test value\n---\n\nTest\n";
2314 let ctx = create_context(content);
2315 let result = rule.check(&ctx).unwrap();
2316
2317 assert_eq!(result.len(), 1, "Should flag 'test' in YAML value: {result:?}");
2319 assert_eq!(result[0].line, 3);
2320 assert_eq!(result[0].column, 8); }
2322
2323 #[test]
2324 fn test_frontmatter_key_matches_name_not_flagged() {
2325 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2327
2328 let content = "---\ntest: other value\n---\n\nBody text\n";
2329 let ctx = create_context(content);
2330 let result = rule.check(&ctx).unwrap();
2331
2332 assert!(
2333 result.is_empty(),
2334 "Should not flag YAML key that matches configured name: {result:?}"
2335 );
2336 }
2337
2338 #[test]
2339 fn test_frontmatter_empty_value_not_flagged() {
2340 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2342
2343 let content = "---\ntest:\ntest: \n---\n\nBody text\n";
2344 let ctx = create_context(content);
2345 let result = rule.check(&ctx).unwrap();
2346
2347 assert!(
2348 result.is_empty(),
2349 "Should not flag YAML keys with empty values: {result:?}"
2350 );
2351 }
2352
2353 #[test]
2354 fn test_frontmatter_nested_yaml_key_not_flagged() {
2355 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2357
2358 let content = "---\nparent:\n test: nested value\n---\n\nBody text\n";
2359 let ctx = create_context(content);
2360 let result = rule.check(&ctx).unwrap();
2361
2362 assert!(result.is_empty(), "Should not flag nested YAML keys: {result:?}");
2364 }
2365
2366 #[test]
2367 fn test_frontmatter_list_items_checked() {
2368 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2370
2371 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2372 let ctx = create_context(content);
2373 let result = rule.check(&ctx).unwrap();
2374
2375 assert_eq!(result.len(), 1, "Should flag 'test' in YAML list item: {result:?}");
2377 assert_eq!(result[0].line, 3);
2378 }
2379
2380 #[test]
2381 fn test_frontmatter_value_with_multiple_colons() {
2382 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2384
2385 let content = "---\ntest: description: a test thing\n---\n\nBody text\n";
2386 let ctx = create_context(content);
2387 let result = rule.check(&ctx).unwrap();
2388
2389 assert_eq!(
2392 result.len(),
2393 1,
2394 "Should flag 'test' in value after first colon: {result:?}"
2395 );
2396 assert_eq!(result[0].line, 2);
2397 assert!(result[0].column > 6, "Violation column should be in value portion");
2398 }
2399
2400 #[test]
2401 fn test_frontmatter_does_not_affect_body() {
2402 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2404
2405 let content = "---\ntitle: Heading\n---\n\ntest should be flagged here\n";
2406 let ctx = create_context(content);
2407 let result = rule.check(&ctx).unwrap();
2408
2409 assert_eq!(result.len(), 1, "Should flag 'test' in body text: {result:?}");
2410 assert_eq!(result[0].line, 5);
2411 }
2412
2413 #[test]
2414 fn test_frontmatter_fix_corrects_values_preserves_keys() {
2415 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2417
2418 let content = "---\ntest: a test value\n---\n\ntest here\n";
2419 let ctx = create_context(content);
2420 let fixed = rule.fix(&ctx).unwrap();
2421
2422 assert_eq!(fixed, "---\ntest: a Test value\n---\n\nTest here\n");
2424 }
2425
2426 #[test]
2427 fn test_frontmatter_multiword_value_flagged() {
2428 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2430
2431 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2432 let ctx = create_context(content);
2433 let result = rule.check(&ctx).unwrap();
2434
2435 assert_eq!(result.len(), 2, "Should flag both names in YAML value: {result:?}");
2436 assert!(result.iter().all(|w| w.line == 2));
2437 }
2438
2439 #[test]
2440 fn test_frontmatter_yaml_comments_not_checked() {
2441 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2443
2444 let content = "---\n# test comment\ntitle: Heading\n---\n\nBody text\n";
2445 let ctx = create_context(content);
2446 let result = rule.check(&ctx).unwrap();
2447
2448 assert!(result.is_empty(), "Should not flag names in YAML comments: {result:?}");
2449 }
2450
2451 #[test]
2452 fn test_frontmatter_delimiters_not_checked() {
2453 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2455
2456 let content = "---\ntitle: Heading\n---\n\ntest here\n";
2457 let ctx = create_context(content);
2458 let result = rule.check(&ctx).unwrap();
2459
2460 assert_eq!(result.len(), 1, "Should only flag body text: {result:?}");
2462 assert_eq!(result[0].line, 5);
2463 }
2464
2465 #[test]
2466 fn test_frontmatter_continuation_lines_checked() {
2467 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2469
2470 let content = "---\ndescription: >\n a test value\n continued here\n---\n\nBody\n";
2471 let ctx = create_context(content);
2472 let result = rule.check(&ctx).unwrap();
2473
2474 assert_eq!(result.len(), 1, "Should flag 'test' in continuation line: {result:?}");
2476 assert_eq!(result[0].line, 3);
2477 }
2478
2479 #[test]
2480 fn test_frontmatter_quoted_values_checked() {
2481 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2483
2484 let content = "---\ntitle: \"a test title\"\n---\n\nBody\n";
2485 let ctx = create_context(content);
2486 let result = rule.check(&ctx).unwrap();
2487
2488 assert_eq!(result.len(), 1, "Should flag 'test' in quoted YAML value: {result:?}");
2489 assert_eq!(result[0].line, 2);
2490 }
2491
2492 #[test]
2493 fn test_frontmatter_single_quoted_values_checked() {
2494 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2496
2497 let content = "---\ntitle: 'a test title'\n---\n\nBody\n";
2498 let ctx = create_context(content);
2499 let result = rule.check(&ctx).unwrap();
2500
2501 assert_eq!(
2502 result.len(),
2503 1,
2504 "Should flag 'test' in single-quoted YAML value: {result:?}"
2505 );
2506 assert_eq!(result[0].line, 2);
2507 }
2508
2509 #[test]
2510 fn test_frontmatter_fix_multiword_values() {
2511 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2513
2514 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2515 let ctx = create_context(content);
2516 let fixed = rule.fix(&ctx).unwrap();
2517
2518 assert_eq!(
2519 fixed,
2520 "---\ndescription: Learn JavaScript and TypeScript\n---\n\nBody\n"
2521 );
2522 }
2523
2524 #[test]
2525 fn test_frontmatter_fix_preserves_yaml_structure() {
2526 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2528
2529 let content = "---\ntags:\n - test\n - other\ntitle: a test doc\n---\n\ntest body\n";
2530 let ctx = create_context(content);
2531 let fixed = rule.fix(&ctx).unwrap();
2532
2533 assert_eq!(
2534 fixed,
2535 "---\ntags:\n - Test\n - other\ntitle: a Test doc\n---\n\nTest body\n"
2536 );
2537 }
2538
2539 #[test]
2540 fn test_frontmatter_toml_delimiters_not_checked() {
2541 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2543
2544 let content = "+++\ntitle = \"a test title\"\n+++\n\ntest body\n";
2545 let ctx = create_context(content);
2546 let result = rule.check(&ctx).unwrap();
2547
2548 assert_eq!(result.len(), 2, "Should flag TOML value and body: {result:?}");
2552 let fm_violations: Vec<_> = result.iter().filter(|w| w.line == 2).collect();
2553 assert_eq!(fm_violations.len(), 1, "Should flag 'test' in TOML value: {result:?}");
2554 let body_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2555 assert_eq!(body_violations.len(), 1, "Should flag body 'test': {result:?}");
2556 }
2557
2558 #[test]
2559 fn test_frontmatter_toml_key_not_flagged() {
2560 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2562
2563 let content = "+++\ntest = \"other value\"\n+++\n\nBody text\n";
2564 let ctx = create_context(content);
2565 let result = rule.check(&ctx).unwrap();
2566
2567 assert!(
2568 result.is_empty(),
2569 "Should not flag TOML key that matches configured name: {result:?}"
2570 );
2571 }
2572
2573 #[test]
2574 fn test_frontmatter_toml_fix_preserves_keys() {
2575 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2577
2578 let content = "+++\ntest = \"a test value\"\n+++\n\ntest here\n";
2579 let ctx = create_context(content);
2580 let fixed = rule.fix(&ctx).unwrap();
2581
2582 assert_eq!(fixed, "+++\ntest = \"a Test value\"\n+++\n\nTest here\n");
2584 }
2585
2586 #[test]
2587 fn test_frontmatter_list_item_mapping_key_not_flagged() {
2588 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2591
2592 let content = "---\nitems:\n - test: nested value\n---\n\nBody text\n";
2593 let ctx = create_context(content);
2594 let result = rule.check(&ctx).unwrap();
2595
2596 assert!(
2597 result.is_empty(),
2598 "Should not flag YAML key in list-item mapping: {result:?}"
2599 );
2600 }
2601
2602 #[test]
2603 fn test_frontmatter_list_item_mapping_value_flagged() {
2604 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2606
2607 let content = "---\nitems:\n - key: a test value\n---\n\nBody text\n";
2608 let ctx = create_context(content);
2609 let result = rule.check(&ctx).unwrap();
2610
2611 assert_eq!(
2612 result.len(),
2613 1,
2614 "Should flag 'test' in list-item mapping value: {result:?}"
2615 );
2616 assert_eq!(result[0].line, 3);
2617 }
2618
2619 #[test]
2620 fn test_frontmatter_bare_list_item_still_flagged() {
2621 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2623
2624 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2625 let ctx = create_context(content);
2626 let result = rule.check(&ctx).unwrap();
2627
2628 assert_eq!(result.len(), 1, "Should flag 'test' in bare list item: {result:?}");
2629 assert_eq!(result[0].line, 3);
2630 }
2631
2632 #[test]
2633 fn test_frontmatter_flow_mapping_not_flagged() {
2634 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2637
2638 let content = "---\nflow_map: {test: value, other: test}\n---\n\nBody text\n";
2639 let ctx = create_context(content);
2640 let result = rule.check(&ctx).unwrap();
2641
2642 assert!(
2643 result.is_empty(),
2644 "Should not flag names inside flow mappings: {result:?}"
2645 );
2646 }
2647
2648 #[test]
2649 fn test_frontmatter_flow_sequence_not_flagged() {
2650 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2652
2653 let content = "---\nitems: [test, other, test]\n---\n\nBody text\n";
2654 let ctx = create_context(content);
2655 let result = rule.check(&ctx).unwrap();
2656
2657 assert!(
2658 result.is_empty(),
2659 "Should not flag names inside flow sequences: {result:?}"
2660 );
2661 }
2662
2663 #[test]
2664 fn test_frontmatter_list_item_mapping_fix_preserves_key() {
2665 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2667
2668 let content = "---\nitems:\n - test: a test value\n---\n\ntest here\n";
2669 let ctx = create_context(content);
2670 let fixed = rule.fix(&ctx).unwrap();
2671
2672 assert_eq!(fixed, "---\nitems:\n - test: a Test value\n---\n\nTest here\n");
2675 }
2676
2677 #[test]
2678 fn test_frontmatter_backtick_code_not_flagged() {
2679 let config = MD044Config {
2681 names: vec!["GoodApplication".to_string()],
2682 code_blocks: false,
2683 ..MD044Config::default()
2684 };
2685 let rule = MD044ProperNames::from_config_struct(config);
2686
2687 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2688 let ctx = create_context(content);
2689 let result = rule.check(&ctx).unwrap();
2690
2691 assert!(
2693 result.is_empty(),
2694 "Should not flag names inside backticks in frontmatter or body: {result:?}"
2695 );
2696 }
2697
2698 #[test]
2699 fn test_frontmatter_unquoted_backtick_code_not_flagged() {
2700 let config = MD044Config {
2702 names: vec!["GoodApplication".to_string()],
2703 code_blocks: false,
2704 ..MD044Config::default()
2705 };
2706 let rule = MD044ProperNames::from_config_struct(config);
2707
2708 let content = "---\ntitle: `goodapplication` CLI\n---\n\nIntroductory `goodapplication` CLI text.\n";
2709 let ctx = create_context(content);
2710 let result = rule.check(&ctx).unwrap();
2711
2712 assert!(
2713 result.is_empty(),
2714 "Should not flag names inside backticks in unquoted YAML frontmatter: {result:?}"
2715 );
2716 }
2717
2718 #[test]
2719 fn test_frontmatter_bare_name_still_flagged_with_backtick_nearby() {
2720 let config = MD044Config {
2722 names: vec!["GoodApplication".to_string()],
2723 code_blocks: false,
2724 ..MD044Config::default()
2725 };
2726 let rule = MD044ProperNames::from_config_struct(config);
2727
2728 let content = "---\ntitle: goodapplication `goodapplication` CLI\n---\n\nBody\n";
2729 let ctx = create_context(content);
2730 let result = rule.check(&ctx).unwrap();
2731
2732 assert_eq!(
2734 result.len(),
2735 1,
2736 "Should flag bare name but not backtick-wrapped name: {result:?}"
2737 );
2738 assert_eq!(result[0].line, 2);
2739 assert_eq!(result[0].column, 8); }
2741
2742 #[test]
2743 fn test_frontmatter_backtick_code_with_code_blocks_true() {
2744 let config = MD044Config {
2746 names: vec!["GoodApplication".to_string()],
2747 code_blocks: true,
2748 ..MD044Config::default()
2749 };
2750 let rule = MD044ProperNames::from_config_struct(config);
2751
2752 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nBody\n";
2753 let ctx = create_context(content);
2754 let result = rule.check(&ctx).unwrap();
2755
2756 assert_eq!(
2758 result.len(),
2759 1,
2760 "Should flag backtick-wrapped name when code_blocks=true: {result:?}"
2761 );
2762 assert_eq!(result[0].line, 2);
2763 }
2764
2765 #[test]
2766 fn test_frontmatter_fix_preserves_backtick_code() {
2767 let config = MD044Config {
2769 names: vec!["GoodApplication".to_string()],
2770 code_blocks: false,
2771 ..MD044Config::default()
2772 };
2773 let rule = MD044ProperNames::from_config_struct(config);
2774
2775 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2776 let ctx = create_context(content);
2777 let fixed = rule.fix(&ctx).unwrap();
2778
2779 assert_eq!(
2781 fixed, content,
2782 "Fix should not modify names inside backticks in frontmatter"
2783 );
2784 }
2785
2786 fn rule_ignoring(names: &[&str], ignore: &[&str]) -> MD044ProperNames {
2787 MD044ProperNames::from_config_struct(MD044Config {
2788 names: names.iter().map(ToString::to_string).collect(),
2789 ignore_frontmatter_fields: Some(ignore.iter().map(ToString::to_string).collect()),
2790 ..Default::default()
2791 })
2792 }
2793
2794 #[test]
2795 fn test_ignore_frontmatter_field_suppresses_only_that_field() {
2796 let content = "---\ntitle: Heading for myapp\nslug: myapp-guide\n---\n";
2797 let rule = rule_ignoring(&["MyApp"], &["slug"]);
2798 let result = rule.check(&create_context(content)).unwrap();
2799 assert_eq!(result.len(), 1, "only title is flagged: {result:?}");
2800 assert_eq!(result[0].line, 2);
2801 }
2802
2803 #[test]
2804 fn test_ignore_frontmatter_field_is_case_insensitive() {
2805 let content = "---\nSlug: myapp-guide\n---\n";
2806 let rule = rule_ignoring(&["MyApp"], &["SLUG"]);
2807 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2808 }
2809
2810 #[test]
2811 fn test_ignore_frontmatter_field_covers_nested_subtree() {
2812 let content = "---\nseo:\n canonical: myapp\n keywords:\n - myapp\n---\n";
2813 let rule = rule_ignoring(&["MyApp"], &["seo"]);
2814 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2815 }
2816
2817 #[test]
2818 fn test_ignore_frontmatter_field_does_not_affect_body() {
2819 let content = "---\nslug: myapp\n---\n\nBody mentions myapp.\n";
2820 let rule = rule_ignoring(&["MyApp"], &["slug"]);
2821 let result = rule.check(&create_context(content)).unwrap();
2822 assert_eq!(result.len(), 1);
2823 assert_eq!(result[0].line, 5);
2824 }
2825
2826 #[test]
2827 fn test_ignore_frontmatter_field_toml_table() {
2828 let content = "+++\n[seo]\ncanonical = \"myapp\"\n+++\n";
2829 let rule = rule_ignoring(&["MyApp"], &["seo"]);
2830 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2831 }
2832
2833 #[test]
2836 fn test_angle_bracket_url_in_html_comment_not_flagged() {
2837 let config = MD044Config {
2839 names: vec!["Test".to_string()],
2840 ..MD044Config::default()
2841 };
2842 let rule = MD044ProperNames::from_config_struct(config);
2843
2844 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";
2845 let ctx = create_context(content);
2846 let result = rule.check(&ctx).unwrap();
2847
2848 let line8_warnings: Vec<_> = result.iter().filter(|w| w.line == 8).collect();
2856 assert!(
2857 line8_warnings.is_empty(),
2858 "Should not flag names inside angle-bracket URLs in HTML comments: {line8_warnings:?}"
2859 );
2860 }
2861
2862 #[test]
2863 fn test_bare_url_in_html_comment_still_flagged() {
2864 let config = MD044Config {
2866 names: vec!["Test".to_string()],
2867 ..MD044Config::default()
2868 };
2869 let rule = MD044ProperNames::from_config_struct(config);
2870
2871 let content = "<!-- This is a test https://www.example.test -->\n";
2872 let ctx = create_context(content);
2873 let result = rule.check(&ctx).unwrap();
2874
2875 assert!(
2878 !result.is_empty(),
2879 "Should flag 'test' in prose text of HTML comment with bare URL"
2880 );
2881 }
2882
2883 #[test]
2884 fn test_angle_bracket_url_in_regular_markdown_not_flagged() {
2885 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2888
2889 let content = "<https://www.example.test>\n";
2890 let ctx = create_context(content);
2891 let result = rule.check(&ctx).unwrap();
2892
2893 assert!(
2894 result.is_empty(),
2895 "Should not flag names inside angle-bracket URLs in regular markdown: {result:?}"
2896 );
2897 }
2898
2899 #[test]
2900 fn test_multiple_angle_bracket_urls_in_one_comment() {
2901 let config = MD044Config {
2902 names: vec!["Test".to_string()],
2903 ..MD044Config::default()
2904 };
2905 let rule = MD044ProperNames::from_config_struct(config);
2906
2907 let content = "<!-- See <https://test.example.com> and <https://www.example.test> for details -->\n";
2908 let ctx = create_context(content);
2909 let result = rule.check(&ctx).unwrap();
2910
2911 assert!(
2913 result.is_empty(),
2914 "Should not flag names inside multiple angle-bracket URLs: {result:?}"
2915 );
2916 }
2917
2918 #[test]
2919 fn test_angle_bracket_non_url_still_flagged() {
2920 assert!(
2923 !MD044ProperNames::is_in_angle_bracket_url("<test> which is not a URL.", 1),
2924 "is_in_angle_bracket_url should return false for non-URL angle brackets"
2925 );
2926 }
2927
2928 #[test]
2929 fn test_angle_bracket_mailto_url_not_flagged() {
2930 let config = MD044Config {
2931 names: vec!["Test".to_string()],
2932 ..MD044Config::default()
2933 };
2934 let rule = MD044ProperNames::from_config_struct(config);
2935
2936 let content = "<!-- Contact <mailto:test@example.com> for help -->\n";
2937 let ctx = create_context(content);
2938 let result = rule.check(&ctx).unwrap();
2939
2940 assert!(
2941 result.is_empty(),
2942 "Should not flag names inside angle-bracket mailto URLs: {result:?}"
2943 );
2944 }
2945
2946 #[test]
2947 fn test_angle_bracket_ftp_url_not_flagged() {
2948 let config = MD044Config {
2949 names: vec!["Test".to_string()],
2950 ..MD044Config::default()
2951 };
2952 let rule = MD044ProperNames::from_config_struct(config);
2953
2954 let content = "<!-- Download from <ftp://test.example.com/file> -->\n";
2955 let ctx = create_context(content);
2956 let result = rule.check(&ctx).unwrap();
2957
2958 assert!(
2959 result.is_empty(),
2960 "Should not flag names inside angle-bracket FTP URLs: {result:?}"
2961 );
2962 }
2963
2964 #[test]
2965 fn test_angle_bracket_url_fix_preserves_url() {
2966 let config = MD044Config {
2968 names: vec!["Test".to_string()],
2969 ..MD044Config::default()
2970 };
2971 let rule = MD044ProperNames::from_config_struct(config);
2972
2973 let content = "<!-- test text <https://www.example.test> -->\n";
2974 let ctx = create_context(content);
2975 let fixed = rule.fix(&ctx).unwrap();
2976
2977 assert!(
2979 fixed.contains("<https://www.example.test>"),
2980 "Fix should preserve angle-bracket URLs: {fixed}"
2981 );
2982 assert!(
2983 fixed.contains("Test text"),
2984 "Fix should correct prose 'test' to 'Test': {fixed}"
2985 );
2986 }
2987
2988 #[test]
2989 fn test_is_in_angle_bracket_url_helper() {
2990 let line = "text <https://example.test> more text";
2992
2993 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));
3006
3007 assert!(MD044ProperNames::is_in_angle_bracket_url(
3009 "<mailto:test@example.com>",
3010 10
3011 ));
3012
3013 assert!(MD044ProperNames::is_in_angle_bracket_url(
3015 "<ftp://test.example.com>",
3016 10
3017 ));
3018 }
3019
3020 #[test]
3021 fn test_is_in_angle_bracket_url_uppercase_scheme() {
3022 assert!(MD044ProperNames::is_in_angle_bracket_url(
3024 "<HTTPS://test.example.com>",
3025 10
3026 ));
3027 assert!(MD044ProperNames::is_in_angle_bracket_url(
3028 "<Http://test.example.com>",
3029 10
3030 ));
3031 }
3032
3033 #[test]
3034 fn test_is_in_angle_bracket_url_uncommon_schemes() {
3035 assert!(MD044ProperNames::is_in_angle_bracket_url(
3037 "<ssh://test@example.com>",
3038 10
3039 ));
3040 assert!(MD044ProperNames::is_in_angle_bracket_url("<file:///test/path>", 10));
3042 assert!(MD044ProperNames::is_in_angle_bracket_url("<data:text/plain;test>", 10));
3044 }
3045
3046 #[test]
3047 fn test_is_in_angle_bracket_url_unclosed() {
3048 assert!(!MD044ProperNames::is_in_angle_bracket_url(
3050 "<https://test.example.com",
3051 10
3052 ));
3053 }
3054
3055 #[test]
3056 fn test_vale_inline_config_comments_not_flagged() {
3057 let config = MD044Config {
3058 names: vec!["Vale".to_string(), "JavaScript".to_string()],
3059 ..MD044Config::default()
3060 };
3061 let rule = MD044ProperNames::from_config_struct(config);
3062
3063 let content = "\
3064<!-- vale off -->
3065Some javascript text here.
3066<!-- vale on -->
3067<!-- vale Style.Rule = NO -->
3068More javascript text.
3069<!-- vale Style.Rule = YES -->
3070<!-- vale JavaScript.Grammar = NO -->
3071";
3072 let ctx = create_context(content);
3073 let result = rule.check(&ctx).unwrap();
3074
3075 assert_eq!(result.len(), 2, "Should only flag body lines, not Vale config comments");
3077 assert_eq!(result[0].line, 2);
3078 assert_eq!(result[1].line, 5);
3079 }
3080
3081 #[test]
3082 fn test_remark_lint_inline_config_comments_not_flagged() {
3083 let config = MD044Config {
3084 names: vec!["JavaScript".to_string()],
3085 ..MD044Config::default()
3086 };
3087 let rule = MD044ProperNames::from_config_struct(config);
3088
3089 let content = "\
3090<!-- lint disable remark-lint-some-rule -->
3091Some javascript text here.
3092<!-- lint enable remark-lint-some-rule -->
3093<!-- lint ignore remark-lint-some-rule -->
3094More javascript text.
3095";
3096 let ctx = create_context(content);
3097 let result = rule.check(&ctx).unwrap();
3098
3099 assert_eq!(
3100 result.len(),
3101 2,
3102 "Should only flag body lines, not remark-lint config comments"
3103 );
3104 assert_eq!(result[0].line, 2);
3105 assert_eq!(result[1].line, 5);
3106 }
3107
3108 #[test]
3109 fn test_fix_does_not_modify_vale_remark_lint_comments() {
3110 let config = MD044Config {
3111 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3112 ..MD044Config::default()
3113 };
3114 let rule = MD044ProperNames::from_config_struct(config);
3115
3116 let content = "\
3117<!-- vale off -->
3118Some javascript text.
3119<!-- vale on -->
3120<!-- lint disable remark-lint-some-rule -->
3121More javascript text.
3122<!-- lint enable remark-lint-some-rule -->
3123";
3124 let ctx = create_context(content);
3125 let fixed = rule.fix(&ctx).unwrap();
3126
3127 assert!(fixed.contains("<!-- vale off -->"));
3129 assert!(fixed.contains("<!-- vale on -->"));
3130 assert!(fixed.contains("<!-- lint disable remark-lint-some-rule -->"));
3131 assert!(fixed.contains("<!-- lint enable remark-lint-some-rule -->"));
3132 assert!(fixed.contains("Some JavaScript text."));
3134 assert!(fixed.contains("More JavaScript text."));
3135 }
3136
3137 #[test]
3138 fn test_mixed_tool_directives_all_skipped() {
3139 let config = MD044Config {
3140 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3141 ..MD044Config::default()
3142 };
3143 let rule = MD044ProperNames::from_config_struct(config);
3144
3145 let content = "\
3146<!-- rumdl-disable MD044 -->
3147Some javascript text.
3148<!-- markdownlint-disable -->
3149More javascript text.
3150<!-- vale off -->
3151Even more javascript text.
3152<!-- lint disable some-rule -->
3153Final javascript text.
3154<!-- rumdl-enable MD044 -->
3155<!-- markdownlint-enable -->
3156<!-- vale on -->
3157<!-- lint enable some-rule -->
3158";
3159 let ctx = create_context(content);
3160 let result = rule.check(&ctx).unwrap();
3161
3162 assert_eq!(
3164 result.len(),
3165 4,
3166 "Should only flag body lines, not any tool directive comments"
3167 );
3168 assert_eq!(result[0].line, 2);
3169 assert_eq!(result[1].line, 4);
3170 assert_eq!(result[2].line, 6);
3171 assert_eq!(result[3].line, 8);
3172 }
3173
3174 #[test]
3175 fn test_vale_remark_lint_edge_cases_not_matched() {
3176 let config = MD044Config {
3177 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3178 ..MD044Config::default()
3179 };
3180 let rule = MD044ProperNames::from_config_struct(config);
3181
3182 let content = "\
3190<!-- vale -->
3191<!-- vale is a tool for writing -->
3192<!-- valedictorian javascript -->
3193<!-- linting javascript tips -->
3194<!-- vale javascript -->
3195<!-- lint your javascript code -->
3196";
3197 let ctx = create_context(content);
3198 let result = rule.check(&ctx).unwrap();
3199
3200 assert_eq!(
3207 result.len(),
3208 7,
3209 "Should flag proper names in non-directive HTML comments: got {result:?}"
3210 );
3211 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); }
3219
3220 #[test]
3221 fn test_vale_style_directives_skipped() {
3222 let config = MD044Config {
3223 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3224 ..MD044Config::default()
3225 };
3226 let rule = MD044ProperNames::from_config_struct(config);
3227
3228 let content = "\
3230<!-- vale style = MyStyle -->
3231<!-- vale styles = Style1, Style2 -->
3232<!-- vale MyRule.Name = YES -->
3233<!-- vale MyRule.Name = NO -->
3234Some javascript text.
3235";
3236 let ctx = create_context(content);
3237 let result = rule.check(&ctx).unwrap();
3238
3239 assert_eq!(
3241 result.len(),
3242 1,
3243 "Should only flag body lines, not Vale style/rule directives: got {result:?}"
3244 );
3245 assert_eq!(result[0].line, 5);
3246 }
3247
3248 #[test]
3251 fn test_backtick_code_single_backticks() {
3252 let line = "hello `world` bye";
3253 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 7));
3255 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3257 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 14));
3259 }
3260
3261 #[test]
3262 fn test_backtick_code_double_backticks() {
3263 let line = "a ``code`` b";
3264 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3266 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3268 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 11));
3270 }
3271
3272 #[test]
3273 fn test_backtick_code_unclosed() {
3274 let line = "a `code b";
3275 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3277 }
3278
3279 #[test]
3280 fn test_backtick_code_mismatched_count() {
3281 let line = "a `code`` b";
3283 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3286 }
3287
3288 #[test]
3289 fn test_backtick_code_multiple_spans() {
3290 let line = "`first` and `second`";
3291 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
3293 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 8));
3295 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 13));
3297 }
3298
3299 #[test]
3300 fn test_backtick_code_on_backtick_boundary() {
3301 let line = "`code`";
3302 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3304 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 5));
3306 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
3308 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3309 }
3310
3311 #[test]
3317 fn test_double_bracket_link_url_not_flagged() {
3318 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3319 let content = "[[rumdl]](https://github.com/rvben/rumdl)";
3321 let ctx = create_context(content);
3322 let result = rule.check(&ctx).unwrap();
3323 assert!(
3324 result.is_empty(),
3325 "URL inside [[text]](url) must not be flagged, got: {result:?}"
3326 );
3327 }
3328
3329 #[test]
3330 fn test_double_bracket_link_url_not_fixed() {
3331 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3332 let content = "[[rumdl]](https://github.com/rvben/rumdl)\n";
3333 let ctx = create_context(content);
3334 let fixed = rule.fix(&ctx).unwrap();
3335 assert_eq!(
3336 fixed, content,
3337 "fix() must leave the URL inside [[text]](url) unchanged"
3338 );
3339 }
3340
3341 #[test]
3342 fn test_double_bracket_link_text_still_flagged() {
3343 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3344 let content = "[[github]](https://example.com)";
3346 let ctx = create_context(content);
3347 let result = rule.check(&ctx).unwrap();
3348 assert_eq!(
3349 result.len(),
3350 1,
3351 "Incorrect name in [[text]] link text should still be flagged, got: {result:?}"
3352 );
3353 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
3354 }
3355
3356 #[test]
3357 fn test_double_bracket_link_mixed_line() {
3358 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3359 let content = "See [[rumdl]](https://github.com/rvben/rumdl) and github for more.";
3361 let ctx = create_context(content);
3362 let result = rule.check(&ctx).unwrap();
3363 assert_eq!(
3364 result.len(),
3365 1,
3366 "Only the standalone 'github' after the link should be flagged, got: {result:?}"
3367 );
3368 assert!(result[0].message.contains("'github'"));
3369 assert_eq!(
3371 result[0].column, 51,
3372 "Flagged column should be the trailing 'github', not the one in the URL"
3373 );
3374 }
3375
3376 #[test]
3377 fn test_regular_link_url_still_not_flagged() {
3378 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3380 let content = "[rumdl](https://github.com/rvben/rumdl)";
3381 let ctx = create_context(content);
3382 let result = rule.check(&ctx).unwrap();
3383 assert!(
3384 result.is_empty(),
3385 "URL inside regular [text](url) must still not be flagged, got: {result:?}"
3386 );
3387 }
3388
3389 #[test]
3390 fn test_link_like_text_in_code_span_still_flagged_when_code_blocks_enabled() {
3391 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
3396 let content = "`[foo](https://github.com/org/repo)`";
3397 let ctx = create_context(content);
3398 let result = rule.check(&ctx).unwrap();
3399 assert_eq!(
3400 result.len(),
3401 1,
3402 "Proper name inside a code span must be flagged when code-blocks=true, got: {result:?}"
3403 );
3404 assert!(result[0].message.contains("'github'"));
3405 }
3406
3407 #[test]
3408 fn test_malformed_link_not_treated_as_url() {
3409 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3412 let content = "See [rumdl](github repo) for details.";
3413 let ctx = create_context(content);
3414 let result = rule.check(&ctx).unwrap();
3415 assert_eq!(
3416 result.len(),
3417 1,
3418 "Name inside malformed [text](url with spaces) must still be flagged, got: {result:?}"
3419 );
3420 assert!(result[0].message.contains("'github'"));
3421 }
3422
3423 #[test]
3424 fn test_wikilink_followed_by_prose_parens_still_flagged() {
3425 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3429 let content = "[[note]](github repo)";
3430 let ctx = create_context(content);
3431 let result = rule.check(&ctx).unwrap();
3432 assert_eq!(
3433 result.len(),
3434 1,
3435 "Name inside [[wikilink]](prose with spaces) must still be flagged, got: {result:?}"
3436 );
3437 assert!(result[0].message.contains("'github'"));
3438 }
3439
3440 #[test]
3442 fn test_roundtrip_fix_then_check_basic() {
3443 let rule = MD044ProperNames::new(
3444 vec![
3445 "JavaScript".to_string(),
3446 "TypeScript".to_string(),
3447 "Node.js".to_string(),
3448 ],
3449 true,
3450 );
3451 let content = "I love javascript, typescript, and nodejs!";
3452 let ctx = create_context(content);
3453 let fixed = rule.fix(&ctx).unwrap();
3454 let ctx2 = create_context(&fixed);
3455 let warnings = rule.check(&ctx2).unwrap();
3456 assert!(
3457 warnings.is_empty(),
3458 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3459 );
3460 }
3461
3462 #[test]
3464 fn test_roundtrip_fix_then_check_multiline() {
3465 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
3466 let content = "First line with rust.\nSecond line with python.\nThird line with RUST and PYTHON.\n";
3467 let ctx = create_context(content);
3468 let fixed = rule.fix(&ctx).unwrap();
3469 let ctx2 = create_context(&fixed);
3470 let warnings = rule.check(&ctx2).unwrap();
3471 assert!(
3472 warnings.is_empty(),
3473 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3474 );
3475 }
3476
3477 #[test]
3479 fn test_roundtrip_fix_then_check_inline_config() {
3480 let config = MD044Config {
3481 names: vec!["RUMDL".to_string()],
3482 ..MD044Config::default()
3483 };
3484 let rule = MD044ProperNames::from_config_struct(config);
3485 let content =
3486 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
3487 let ctx = create_context(content);
3488 let fixed = rule.fix(&ctx).unwrap();
3489 assert!(
3491 fixed.contains("Some rumdl text.\n"),
3492 "Disabled block text should be preserved"
3493 );
3494 assert!(
3495 fixed.contains("Some RUMDL text outside."),
3496 "Outside text should be fixed"
3497 );
3498 }
3499
3500 #[test]
3502 fn test_roundtrip_fix_then_check_html_comments() {
3503 let config = MD044Config {
3504 names: vec!["JavaScript".to_string()],
3505 ..MD044Config::default()
3506 };
3507 let rule = MD044ProperNames::from_config_struct(config);
3508 let content = "# Guide\n\n<!-- javascript mentioned here -->\n\njavascript outside\n";
3509 let ctx = create_context(content);
3510 let fixed = rule.fix(&ctx).unwrap();
3511 let ctx2 = create_context(&fixed);
3512 let warnings = rule.check(&ctx2).unwrap();
3513 assert!(
3514 warnings.is_empty(),
3515 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3516 );
3517 }
3518
3519 #[test]
3521 fn test_roundtrip_no_op_when_correct() {
3522 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
3523 let content = "This uses JavaScript and TypeScript correctly.\n";
3524 let ctx = create_context(content);
3525 let fixed = rule.fix(&ctx).unwrap();
3526 assert_eq!(fixed, content, "Fix should be a no-op when content is already correct");
3527 }
3528
3529 #[test]
3532 fn test_bare_domain_link_text_not_flagged() {
3533 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3537 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3538 let ctx = create_context(content);
3539 let result = rule.check(&ctx).unwrap();
3540 assert!(
3541 result.is_empty(),
3542 "Should not flag 'github' in a bare-domain link text that matches the link URL: {result:?}"
3543 );
3544 }
3545
3546 #[test]
3547 fn test_bare_domain_link_text_not_fixed() {
3548 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3550 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3551 let ctx = create_context(content);
3552 let fixed = rule.fix(&ctx).unwrap();
3553 assert_eq!(
3554 fixed, content,
3555 "fix() must not alter bare-domain link text that matches the destination URL"
3556 );
3557 }
3558
3559 #[test]
3560 fn test_bare_domain_link_text_with_path_not_flagged() {
3561 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3563 let content = "Visit [ravencentric.github.io](https://ravencentric.github.io/projects).\n";
3564 let ctx = create_context(content);
3565 let result = rule.check(&ctx).unwrap();
3566 assert!(
3567 result.is_empty(),
3568 "Should not flag 'github' when bare-domain text is the hostname of its destination URL: {result:?}"
3569 );
3570 }
3571
3572 #[test]
3573 fn test_bare_domain_link_text_full_path_not_flagged() {
3574 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3576 let content = "See [ravencentric.github.io/blog](https://ravencentric.github.io/blog).\n";
3577 let ctx = create_context(content);
3578 let result = rule.check(&ctx).unwrap();
3579 assert!(
3580 result.is_empty(),
3581 "Should not flag 'github' when link text is the full URL path without scheme: {result:?}"
3582 );
3583 }
3584
3585 #[test]
3586 fn test_github_product_name_in_link_text_still_flagged() {
3587 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3590 let content = "Hosted on [github pages](https://pages.github.com).\n";
3591 let ctx = create_context(content);
3592 let result = rule.check(&ctx).unwrap();
3593 assert!(
3594 !result.is_empty(),
3595 "Should still flag 'github' in descriptive link text that does not match the destination URL"
3596 );
3597 }
3598
3599 #[test]
3600 fn test_protocol_relative_bare_domain_link_text_not_flagged() {
3601 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3603 let content = "See [github.io](//github.io).\n";
3604 let ctx = create_context(content);
3605 let result = rule.check(&ctx).unwrap();
3606 assert!(
3607 result.is_empty(),
3608 "Should not flag 'github' in bare-domain text matching a protocol-relative destination: {result:?}"
3609 );
3610 }
3611
3612 #[test]
3613 fn test_dotted_wikilink_target_still_flagged() {
3614 let rule = MD044ProperNames::new(vec!["Node.js".to_string()], false);
3619 let content = "See [[node.js]] for details.\n";
3620 let ctx = create_context(content);
3621 let result = rule.check(&ctx).unwrap();
3622 assert!(
3623 !result.is_empty(),
3624 "Should flag 'node.js' in a dotted WikiLink target: {result:?}"
3625 );
3626 }
3627
3628 #[test]
3629 fn test_bare_domain_link_text_case_insensitive_url() {
3630 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3633 let content = "See [github.io](HTTPS://github.io).\n";
3634 let ctx = create_context(content);
3635 let result = rule.check(&ctx).unwrap();
3636 assert!(
3637 result.is_empty(),
3638 "Should not flag bare-domain text when destination URL has an uppercase scheme: {result:?}"
3639 );
3640 }
3641
3642 #[test]
3643 fn test_frontmatter_value_span_strips_trailing_comment() {
3644 let line = "link: docs/guide/myapp # canonical path";
3645 let (s, e) = frontmatter_values::value_span(line).unwrap();
3646 assert_eq!(&line[s..e], "docs/guide/myapp");
3647 }
3648
3649 #[test]
3650 fn test_frontmatter_value_span_quoted_keeps_hash_and_spaces() {
3651 let line = "link: 'docs/My App/a#b'";
3652 let (s, e) = frontmatter_values::value_span(line).unwrap();
3653 assert_eq!(&line[s..e], "docs/My App/a#b");
3654 }
3655
3656 #[test]
3657 fn test_frontmatter_value_span_plain_value() {
3658 let line = "title: Heading for myapp";
3659 let (s, e) = frontmatter_values::value_span(line).unwrap();
3660 assert_eq!(&line[s..e], "Heading for myapp");
3661 }
3662
3663 #[test]
3664 fn test_frontmatter_value_span_none_for_key_only() {
3665 assert!(frontmatter_values::value_span("seo:").is_none());
3666 assert!(frontmatter_values::value_span("---").is_none());
3667 }
3668
3669 #[test]
3670 fn test_frontmatter_value_span_quoted_strips_trailing_comment() {
3671 let line = "link: 'docs/guide' # canonical path";
3672 let (s, e) = frontmatter_values::value_span(line).unwrap();
3673 assert_eq!(&line[s..e], "docs/guide");
3674 }
3675
3676 #[test]
3677 fn test_frontmatter_value_span_empty_quoted_value_is_none() {
3678 assert!(frontmatter_values::value_span("key: ''").is_none());
3679 }
3680
3681 #[test]
3682 fn test_frontmatter_value_span_unterminated_quote_strips_leading_quote() {
3683 let line = "link: 'docs/a";
3684 let (s, e) = frontmatter_values::value_span(line).unwrap();
3685 assert_eq!(&line[s..e], "docs/a");
3686 }
3687
3688 fn at(line: &str, needle: &str) -> usize {
3690 line.find(needle).expect("needle present")
3691 }
3692
3693 #[test]
3694 fn test_path_like_exempts_single_token_frontmatter_paths() {
3695 for line in [
3696 "link: this/is/a/link/to/myapp.md",
3697 "link: docs/myapp.md",
3698 "link: /abs/path/myapp.md",
3699 "link: ./myapp.md",
3700 "link: ../shared/myapp.md",
3701 ] {
3702 let span = frontmatter_values::value_span(line).unwrap();
3703 let pos = at(line, "myapp");
3704 assert!(
3705 MD044ProperNames::is_in_path_like_token(line, pos, span),
3706 "should treat as a path: {line}"
3707 );
3708 }
3709 }
3710
3711 #[test]
3712 fn test_path_like_does_not_exempt_slash_conjunction_prose() {
3713 let line = "description: We support github/gitlab/bitbucket imports.";
3716 let span = frontmatter_values::value_span(line).unwrap();
3717 assert!(
3718 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
3719 "slash-separated prose is not a path"
3720 );
3721
3722 let line = "description: The javascript/typescript ecosystem is large.";
3723 let span = frontmatter_values::value_span(line).unwrap();
3724 assert!(!MD044ProperNames::is_in_path_like_token(
3725 line,
3726 at(line, "javascript"),
3727 span
3728 ));
3729 }
3730
3731 #[test]
3732 fn test_path_like_requires_a_slash_so_dotted_names_survive() {
3733 let line = "title: Use nodejs and myapp.md today.";
3734 let span = frontmatter_values::value_span(line).unwrap();
3735 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3736 }
3737
3738 #[test]
3739 fn test_path_like_no_slash_frontmatter_value_still_flagged() {
3740 let line = "slug: myapp-guide";
3745 let span = frontmatter_values::value_span(line).unwrap();
3746 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3747 }
3748
3749 #[test]
3750 fn test_path_like_returns_false_outside_value_span() {
3751 let line = "myapp: docs/guide/myapp";
3754 let span = frontmatter_values::value_span(line).unwrap();
3755 let key_pos = 0;
3756 assert!(!MD044ProperNames::is_in_path_like_token(line, key_pos, span));
3757 }
3758
3759 #[test]
3760 fn test_path_like_three_segments_only_as_sole_frontmatter_value() {
3761 let line = "link: docs/guide/myapp";
3762 let span = frontmatter_values::value_span(line).unwrap();
3763 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3764
3765 let line = "description: We support github/gitlab/bitbucket now";
3766 let span = frontmatter_values::value_span(line).unwrap();
3767 assert!(
3768 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
3769 "multi-token value gets body treatment"
3770 );
3771 }
3772
3773 #[test]
3774 fn test_path_like_quoted_value_with_spaces() {
3775 let line = "link: 'docs/My App/myapp.md'";
3778 let span = frontmatter_values::value_span(line).unwrap();
3779 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3780 }
3781
3782 #[test]
3783 fn test_path_like_quoted_value_with_spaces_no_extension_not_exempt() {
3784 let line = "link: 'docs/My App/myapp'";
3791 let span = frontmatter_values::value_span(line).unwrap();
3792 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3793 }
3794
3795 #[test]
3796 fn test_path_like_trailing_comment_is_still_sole_value() {
3797 let line = "link: docs/guide/myapp # canonical path";
3798 let span = frontmatter_values::value_span(line).unwrap();
3799 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3800 }
3801
3802 #[test]
3803 fn test_path_like_trailing_punctuation_trimmed() {
3804 let line = "link: docs/myapp.md, then leave.";
3805 let span = frontmatter_values::value_span(line).unwrap();
3806 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3807 }
3808
3809 #[test]
3810 fn test_trim_token_bounds_reaches_fixpoint_after_punctuation_exposes_wrapper() {
3811 let line = r#"See "docs/myapp.md", then leave."#;
3812 let raw_start = at(line, "\"docs");
3813 let raw_end = raw_start + r#""docs/myapp.md","#.len();
3814 assert_eq!(&line[raw_start..raw_end], r#""docs/myapp.md","#);
3815 let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
3816 assert_eq!(&line[start..end], "docs/myapp.md");
3817 }
3818
3819 #[test]
3820 fn test_trim_token_bounds_reaches_fixpoint_with_multiple_trailing_wrappers() {
3821 let line = r#"("docs/myapp.md")."#;
3822 let (start, end) = frontmatter_values::trim_token_bounds(line, 0, line.len());
3823 assert_eq!(&line[start..end], "docs/myapp.md");
3824 }
3825
3826 #[test]
3827 fn test_frontmatter_link_path_not_flagged() {
3828 let content = "---\ntitle: Heading for MyApp\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
3829 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3830 let ctx = create_context(content);
3831 let result = rule.check(&ctx).unwrap();
3832 assert!(
3833 result.is_empty(),
3834 "path in a frontmatter value must not be flagged: {result:?}"
3835 );
3836 }
3837
3838 #[test]
3839 fn test_fix_does_not_corrupt_frontmatter_link_path() {
3840 let content = "---\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
3841 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3842 let ctx = create_context(content);
3843 assert_eq!(rule.fix(&ctx).unwrap(), content, "fix must not rewrite a path");
3844 }
3845
3846 #[test]
3857 fn test_body_prose_parenthesized_disambiguator_is_case_corrected() {
3858 let content = "See docs/myapp(1).md here.\n";
3859 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3860 let ctx = create_context(content);
3861 let result = rule.check(&ctx).unwrap();
3862 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3863 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp(1).md here.\n");
3864 }
3865
3866 #[test]
3867 fn test_body_prose_bracketed_dynamic_segment_is_case_corrected() {
3868 let content = "See docs/[myapp].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].md here.\n");
3874 }
3875
3876 #[test]
3877 fn test_body_prose_nextjs_catch_all_segment_is_case_corrected() {
3878 let content = "pages/[[...myapp]].tsx are catch-all routes.\n";
3881 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3882 let ctx = create_context(content);
3883 let result = rule.check(&ctx).unwrap();
3884 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3885 assert_eq!(
3886 rule.fix(&ctx).unwrap(),
3887 "pages/[[...MyApp]].tsx are catch-all routes.\n"
3888 );
3889 }
3890
3891 #[test]
3892 fn test_two_adjacent_whitespace_free_links_both_flagged() {
3893 let content = "[myapp](https://a.com)[github](https://b.com)\n";
3897 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitHub".to_string()], false);
3898 let ctx = create_context(content);
3899 let result = rule.check(&ctx).unwrap();
3900 assert_eq!(result.len(), 2, "both link texts must be flagged: {result:?}");
3901 assert!(result.iter().any(|w| w.message.contains("'myapp'")));
3902 assert!(result.iter().any(|w| w.message.contains("'github'")));
3903 }
3904
3905 #[test]
3906 fn test_fix_does_not_corrupt_frontmatter_path_with_route_group_named_after_proper_name() {
3907 let content = "---\nlink: src/(myapp)/page.tsx\n---\n\nBody.\n";
3910 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3911 let ctx = create_context(content);
3912 assert_eq!(
3913 rule.fix(&ctx).unwrap(),
3914 content,
3915 "fix must not rewrite a frontmatter path whose route-group directory name is the proper name"
3916 );
3917 }
3918
3919 #[test]
3920 fn test_quoted_frontmatter_value_slash_conjunction_prose_still_flagged() {
3921 let content = "---\ndescription: \"We support github/gitlab/bitbucket now\"\n---\n\nBody.\n";
3926 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3927 let ctx = create_context(content);
3928 let result = rule.check(&ctx).unwrap();
3929 assert_eq!(
3930 result.len(),
3931 1,
3932 "quoted prose value must still flag 'github': {result:?}"
3933 );
3934 }
3935
3936 #[test]
3937 fn test_quoted_frontmatter_value_single_slash_word_with_unrelated_dot_still_flagged() {
3938 let content = "---\ndescription: \"We use myapp/gitlab and version 1.0 e.g. weekly\"\n---\n\nBody.\n";
3941 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3942 let ctx = create_context(content);
3943 let result = rule.check(&ctx).unwrap();
3944 assert_eq!(
3945 result.len(),
3946 1,
3947 "quoted prose value must still flag 'myapp': {result:?}"
3948 );
3949 }
3950
3951 #[test]
3952 fn test_quoted_toml_frontmatter_value_slash_conjunction_prose_still_flagged() {
3953 let content = "+++\ndescription = \"We support github/gitlab/bitbucket now\"\n+++\n\nBody.\n";
3956 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3957 let ctx = create_context(content);
3958 let result = rule.check(&ctx).unwrap();
3959 assert_eq!(
3960 result.len(),
3961 1,
3962 "TOML quoted prose value must still flag 'github': {result:?}"
3963 );
3964 }
3965
3966 #[test]
3967 fn test_path_like_collapsed_multiword_no_extension_not_exempt() {
3968 for line in [
3975 r#"description: "myapp/gitlab github/bitbucket""#,
3976 r#"description: "and/or this/that myapp/gitlab""#,
3977 r#"description: "he/him she/her myapp/gitlab""#,
3978 ] {
3979 let span = frontmatter_values::value_span(line).unwrap();
3980 for needle in ["myapp", "gitlab"] {
3981 if let Some(byte_pos) = line.find(needle) {
3982 assert!(
3983 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
3984 "collapsed multi-word value must not exempt '{needle}': {line}"
3985 );
3986 }
3987 }
3988 }
3989 }
3990
3991 #[test]
3992 fn test_path_like_collapsed_multiword_no_extension_not_exempt_toml() {
3993 let line = r#"description = "myapp/gitlab github/bitbucket""#;
3994 let span = frontmatter_values::value_span(line).unwrap();
3995 for needle in ["myapp", "gitlab", "github", "bitbucket"] {
3996 let byte_pos = at(line, needle);
3997 assert!(
3998 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
3999 "collapsed multi-word TOML value must not exempt '{needle}'"
4000 );
4001 }
4002 }
4003
4004 #[test]
4005 fn test_frontmatter_collapsed_multiword_names_all_flagged_yaml() {
4006 let content = "---\ndescription: \"myapp/gitlab github/bitbucket\"\n---\n\nBody.\n";
4007 let rule = MD044ProperNames::new(
4008 vec![
4009 "MyApp".to_string(),
4010 "GitLab".to_string(),
4011 "GitHub".to_string(),
4012 "Bitbucket".to_string(),
4013 ],
4014 false,
4015 );
4016 let ctx = create_context(content);
4017 let result = rule.check(&ctx).unwrap();
4018 assert_eq!(
4019 result.len(),
4020 4,
4021 "all four names in the collapsed multi-word value must be flagged: {result:?}"
4022 );
4023 }
4024
4025 #[test]
4026 fn test_frontmatter_collapsed_multiword_names_all_flagged_toml() {
4027 let content = "+++\ndescription = \"myapp/gitlab github/bitbucket\"\n+++\n\nBody.\n";
4028 let rule = MD044ProperNames::new(
4029 vec![
4030 "MyApp".to_string(),
4031 "GitLab".to_string(),
4032 "GitHub".to_string(),
4033 "Bitbucket".to_string(),
4034 ],
4035 false,
4036 );
4037 let ctx = create_context(content);
4038 let result = rule.check(&ctx).unwrap();
4039 assert_eq!(
4040 result.len(),
4041 4,
4042 "all four names in the collapsed multi-word TOML value must be flagged: {result:?}"
4043 );
4044 }
4045
4046 #[test]
4047 fn test_frontmatter_collapsed_multiword_conjunction_pairs_flagged() {
4048 let content = "---\ndescription: \"and/or this/that myapp/gitlab\"\n---\n\nBody.\n";
4049 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4050 let ctx = create_context(content);
4051 let result = rule.check(&ctx).unwrap();
4052 assert_eq!(
4053 result.len(),
4054 2,
4055 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4056 );
4057 }
4058
4059 #[test]
4060 fn test_frontmatter_collapsed_multiword_pronoun_pairs_flagged() {
4061 let content = "---\ndescription: \"he/him she/her myapp/gitlab\"\n---\n\nBody.\n";
4062 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4063 let ctx = create_context(content);
4064 let result = rule.check(&ctx).unwrap();
4065 assert_eq!(
4066 result.len(),
4067 2,
4068 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4069 );
4070 }
4071
4072 #[test]
4078 fn test_body_prose_path_is_flagged_frontmatter_only_scope() {
4079 let content = "See docs/myapp.md for details about myapp.\n";
4080 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4081 let ctx = create_context(content);
4082 let result = rule.check(&ctx).unwrap();
4083 assert_eq!(
4084 result.len(),
4085 2,
4086 "both the path occurrence and the prose occurrence are flagged in body text: {result:?}"
4087 );
4088 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp.md for details about MyApp.\n");
4089 }
4090
4091 #[test]
4092 fn test_slash_conjunction_prose_still_flagged() {
4093 let content = "We support github/gitlab imports.\n";
4094 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4095 let ctx = create_context(content);
4096 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
4097 }
4098}