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::rules::front_matter_utils::FrontMatterUtils;
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 Self::frontmatter_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 Self::frontmatter_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 Self::frontmatter_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 Self::is_in_bare_url(ctx, 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_bare_url(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
458 let bare_urls = ctx.bare_urls();
459 let idx = bare_urls.partition_point(|url| url.byte_offset <= byte_pos);
461 idx > 0 && byte_pos < bare_urls[idx - 1].byte_end
462 }
463
464 fn is_in_link(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
471 use pulldown_cmark::LinkType;
472
473 let link_idx = ctx.links.partition_point(|link| link.byte_offset <= byte_pos);
475 if link_idx > 0 {
476 let link = &ctx.links[link_idx - 1];
477 if byte_pos < link.byte_end {
478 let text_start = if matches!(link.link_type, LinkType::WikiLink { .. }) {
480 link.byte_offset + 2
481 } else {
482 link.byte_offset + 1
483 };
484 let text_end = text_start + link.text.len();
485
486 if byte_pos >= text_start && byte_pos < text_end {
490 let is_wikilink = matches!(link.link_type, LinkType::WikiLink { .. });
491 return Self::link_text_is_url(&link.text)
492 || (!is_wikilink && Self::link_text_matches_link_url(&link.text, &link.url));
493 }
494 return true;
496 }
497 }
498
499 let image_idx = ctx.images.partition_point(|img| img.byte_offset <= byte_pos);
501 if image_idx > 0 {
502 let image = &ctx.images[image_idx - 1];
503 if byte_pos < image.byte_end {
504 let alt_start = image.byte_offset + 2;
506 let alt_end = alt_start + image.alt_text.len();
507
508 if byte_pos >= alt_start && byte_pos < alt_end {
510 return false;
511 }
512 return true;
514 }
515 }
516
517 ctx.is_in_reference_def(byte_pos)
519 }
520
521 fn link_text_is_url(text: &str) -> bool {
523 let lower = text.trim().to_ascii_lowercase();
524 lower.starts_with("http://")
525 || lower.starts_with("https://")
526 || lower.starts_with("www.")
527 || lower.starts_with("//")
528 }
529
530 fn link_text_matches_link_url(text: &str, url: &str) -> bool {
542 let text = text.trim();
543 if !text.contains('.') {
545 return false;
546 }
547 let url_lower = url.to_ascii_lowercase();
548 let url_without_scheme = url_lower
549 .strip_prefix("https://")
550 .or_else(|| url_lower.strip_prefix("http://"))
551 .or_else(|| url_lower.strip_prefix("//"))
552 .unwrap_or(&url_lower);
553 let text_lower = text.to_ascii_lowercase();
554 if url_without_scheme == text_lower.as_str() {
556 return true;
557 }
558 url_without_scheme.len() > text_lower.len()
560 && url_without_scheme.starts_with(text_lower.as_str())
561 && matches!(
562 url_without_scheme.as_bytes().get(text_lower.len()),
563 Some(b'/') | Some(b'?') | Some(b'#')
564 )
565 }
566
567 fn is_in_angle_bracket_url(line: &str, pos: usize) -> bool {
573 let bytes = line.as_bytes();
574 let len = bytes.len();
575 let mut i = 0;
576 while i < len {
577 if bytes[i] == b'<' {
578 let after_open = i + 1;
579 if after_open < len && bytes[after_open].is_ascii_alphabetic() {
583 let mut s = after_open + 1;
584 let scheme_max = (after_open + 32).min(len);
585 while s < scheme_max
586 && (bytes[s].is_ascii_alphanumeric()
587 || bytes[s] == b'+'
588 || bytes[s] == b'-'
589 || bytes[s] == b'.')
590 {
591 s += 1;
592 }
593 if s < len && bytes[s] == b':' {
594 let mut j = s + 1;
596 let mut found_close = false;
597 while j < len {
598 match bytes[j] {
599 b'>' => {
600 found_close = true;
601 break;
602 }
603 b' ' | b'<' => break,
604 _ => j += 1,
605 }
606 }
607 if found_close && pos >= i && pos <= j {
608 return true;
609 }
610 if found_close {
611 i = j + 1;
612 continue;
613 }
614 }
615 }
616 }
617 i += 1;
618 }
619 false
620 }
621
622 fn is_in_wikilink_url(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
635 use pulldown_cmark::LinkType;
636 let content = ctx.content.as_bytes();
637
638 let end = ctx.links.partition_point(|l| l.byte_offset <= byte_pos);
641
642 for link in &ctx.links[..end] {
643 if !matches!(link.link_type, LinkType::WikiLink { .. }) {
644 continue;
645 }
646 let wiki_end = link.byte_end;
647 if wiki_end >= byte_pos || wiki_end >= content.len() || content[wiki_end] != b'(' {
649 continue;
650 }
651 let mut depth: u32 = 1;
656 let mut k = wiki_end + 1;
657 let mut valid_destination = true;
658 while k < content.len() && depth > 0 {
659 match content[k] {
660 b'\\' => {
661 k += 1; }
663 b'(' => depth += 1,
664 b')' => depth -= 1,
665 b' ' | b'\t' | b'\n' | b'\r' => {
666 valid_destination = false;
667 break;
668 }
669 _ => {}
670 }
671 k += 1;
672 }
673 if valid_destination && depth == 0 && byte_pos > wiki_end && byte_pos < k {
676 return true;
677 }
678 }
679 false
680 }
681
682 fn is_in_markdown_link_url(line: &str, pos: usize) -> bool {
692 let bytes = line.as_bytes();
693 let len = bytes.len();
694 let mut i = 0;
695
696 while i < len {
697 if bytes[i] == b'[' && (i == 0 || bytes[i - 1] != b'\\' || (i >= 2 && bytes[i - 2] == b'\\')) {
699 let mut depth: u32 = 1;
701 let mut j = i + 1;
702 while j < len && depth > 0 {
703 match bytes[j] {
704 b'\\' => {
705 j += 1; }
707 b'[' => depth += 1,
708 b']' => depth -= 1,
709 _ => {}
710 }
711 j += 1;
712 }
713
714 if depth == 0 && j < len {
716 if bytes[j] == b'(' {
717 let url_start = j;
719 let mut paren_depth: u32 = 1;
720 let mut k = j + 1;
721 while k < len && paren_depth > 0 {
722 match bytes[k] {
723 b'\\' => {
724 k += 1; }
726 b'(' => paren_depth += 1,
727 b')' => paren_depth -= 1,
728 _ => {}
729 }
730 k += 1;
731 }
732
733 if paren_depth == 0 {
734 if pos > url_start && pos < k {
735 return true;
736 }
737 i = k;
738 continue;
739 }
740 } else if bytes[j] == b'[' {
741 let ref_start = j;
743 let mut ref_depth: u32 = 1;
744 let mut k = j + 1;
745 while k < len && ref_depth > 0 {
746 match bytes[k] {
747 b'\\' => {
748 k += 1;
749 }
750 b'[' => ref_depth += 1,
751 b']' => ref_depth -= 1,
752 _ => {}
753 }
754 k += 1;
755 }
756
757 if ref_depth == 0 {
758 if pos > ref_start && pos < k {
759 return true;
760 }
761 i = k;
762 continue;
763 }
764 }
765 }
766 }
767 i += 1;
768 }
769 false
770 }
771
772 fn is_in_backtick_code_in_line(line: &str, pos: usize) -> bool {
780 let bytes = line.as_bytes();
781 let len = bytes.len();
782 let mut i = 0;
783 while i < len {
784 if bytes[i] == b'`' {
785 let open_start = i;
787 while i < len && bytes[i] == b'`' {
788 i += 1;
789 }
790 let tick_len = i - open_start;
791
792 while i < len {
794 if bytes[i] == b'`' {
795 let close_start = i;
796 while i < len && bytes[i] == b'`' {
797 i += 1;
798 }
799 if i - close_start == tick_len {
800 let content_start = open_start + tick_len;
804 let content_end = close_start;
805 if pos >= content_start && pos < content_end {
806 return true;
807 }
808 break;
810 }
811 } else {
813 i += 1;
814 }
815 }
816 } else {
817 i += 1;
818 }
819 }
820 false
821 }
822
823 fn is_word_boundary_char(c: char) -> bool {
825 !c.is_alphanumeric()
826 }
827
828 fn is_at_word_boundary(content: &str, pos: usize, is_start: bool) -> bool {
830 if is_start {
831 if pos == 0 {
832 return true;
833 }
834 match content[..pos].chars().next_back() {
835 None => true,
836 Some(c) => Self::is_word_boundary_char(c),
837 }
838 } else {
839 if pos >= content.len() {
840 return true;
841 }
842 match content[pos..].chars().next() {
843 None => true,
844 Some(c) => Self::is_word_boundary_char(c),
845 }
846 }
847 }
848
849 fn value_is_quoted(line: &str, value_start: usize) -> bool {
859 matches!(line[..value_start].chars().next_back(), Some('\'') | Some('"'))
860 }
861
862 fn frontmatter_value_span(line: &str) -> Option<(usize, usize)> {
869 let start = Self::frontmatter_value_offset(line);
870 if start == usize::MAX || start >= line.len() {
871 return None;
872 }
873
874 let before = line[..start].chars().next_back();
879 let at = line[start..].chars().next();
880 let (content_start, quote) = match (before, at) {
881 (Some(q @ ('\'' | '"')), _) => (start, Some(q)),
882 (_, Some(q @ ('\'' | '"'))) => (start + q.len_utf8(), Some(q)),
883 _ => (start, None),
884 };
885
886 let end = if let Some(quote) = quote {
887 let rest = &line[content_start..];
888 match rest.find(quote) {
889 Some(i) => content_start + i,
890 None => content_start + rest.trim_end().len(),
891 }
892 } else {
893 let rest = &line[content_start..];
894 let raw_end = match rest.find(" #") {
895 Some(i) => content_start + i,
896 None => line.len(),
897 };
898 line[..raw_end].trim_end().len()
899 };
900
901 if end <= content_start {
902 None
903 } else {
904 Some((content_start, end))
905 }
906 }
907
908 const PATH_TOKEN_WRAPPERS: &'static [char] = &['\'', '"', '`', '(', ')', '[', ']', '<', '>'];
916
917 fn value_token_bounds(line: &str, pos: usize, value_start: usize, value_end: usize) -> (usize, usize) {
923 let before = &line[value_start..pos];
924 let start = before.rfind(char::is_whitespace).map_or(value_start, |i| {
925 value_start + i + before[i..].chars().next().unwrap().len_utf8()
926 });
927
928 let after = &line[pos..value_end];
929 let end = after.find(char::is_whitespace).map_or(value_end, |i| pos + i);
930
931 (start, end)
932 }
933
934 fn trim_token_bounds(line: &str, mut start: usize, mut end: usize) -> (usize, usize) {
940 const WRAPPERS: &[char] = MD044ProperNames::PATH_TOKEN_WRAPPERS;
941 const TRAILING: &[char] = &['.', ',', ';', ':', '!', '?'];
942 while start < end && line[start..end].starts_with(WRAPPERS) {
943 start += line[start..].chars().next().unwrap().len_utf8();
944 }
945 loop {
946 let before = (start, end);
947 while end > start && line[start..end].ends_with(WRAPPERS) {
948 end -= line[..end].chars().next_back().unwrap().len_utf8();
949 }
950 while end > start && line[start..end].ends_with(TRAILING) {
951 end -= line[..end].chars().next_back().unwrap().len_utf8();
952 }
953 if (start, end) == before {
954 break;
955 }
956 }
957 (start, end)
958 }
959
960 fn is_in_path_like_token(line: &str, match_start: usize, fm_value: (usize, usize)) -> bool {
988 let (value_start, value_end) = fm_value;
989 if match_start < value_start || match_start >= value_end {
990 return false;
991 }
992
993 let quoted_words: Vec<&str> = if Self::value_is_quoted(line, value_start) {
1001 line[value_start..value_end].split_whitespace().collect()
1002 } else {
1003 Vec::new()
1004 };
1005 let is_single_quoted_path = !quoted_words.is_empty() && quoted_words.iter().all(|word| word.contains('/'));
1006 let is_multi_word_collapse = is_single_quoted_path && quoted_words.len() > 1;
1014
1015 let (raw_start, raw_end) = if is_single_quoted_path {
1016 (value_start, value_end)
1017 } else {
1018 Self::value_token_bounds(line, match_start, value_start, value_end)
1019 };
1020
1021 let (start, end) = Self::trim_token_bounds(line, raw_start, raw_end);
1022 if match_start < start || match_start >= end {
1023 return false;
1024 }
1025
1026 let token = &line[start..end];
1027 if !token.contains('/') {
1028 return false;
1029 }
1030 if token.starts_with('/') || token.starts_with("./") || token.starts_with("../") || token.starts_with("~/") {
1031 return true;
1032 }
1033 if token.rsplit('/').next().is_some_and(|seg| seg.contains('.')) {
1034 return true;
1035 }
1036
1037 if is_multi_word_collapse {
1038 return false;
1039 }
1040
1041 let sole_value = {
1045 let (ts, te) = Self::trim_token_bounds(line, value_start, value_end);
1046 ts == start && te == end
1047 };
1048 sole_value && token.split('/').filter(|s| !s.is_empty()).count() >= 3
1049 }
1050
1051 fn frontmatter_value_offset(line: &str) -> usize {
1055 let trimmed = line.trim();
1056
1057 if trimmed == "---" || trimmed == "+++" || trimmed.is_empty() {
1059 return usize::MAX;
1060 }
1061
1062 if trimmed.starts_with('#') {
1064 return usize::MAX;
1065 }
1066
1067 let stripped = line.trim_start();
1069 if let Some(after_dash) = stripped.strip_prefix("- ") {
1070 let leading = line.len() - stripped.len();
1071 if let Some(result) = Self::kv_value_offset(line, after_dash, leading + 2) {
1073 return result;
1074 }
1075 return leading + 2;
1077 }
1078 if stripped == "-" {
1079 return usize::MAX;
1080 }
1081
1082 if let Some(result) = Self::kv_value_offset(line, stripped, line.len() - stripped.len()) {
1084 return result;
1085 }
1086
1087 if let Some(eq_pos) = line.find('=') {
1089 let after_eq = eq_pos + 1;
1090 if after_eq < line.len() && line.as_bytes()[after_eq] == b' ' {
1091 let value_start = after_eq + 1;
1092 let value_slice = &line[value_start..];
1093 let value_trimmed = value_slice.trim();
1094 if value_trimmed.is_empty() {
1095 return usize::MAX;
1096 }
1097 if (value_trimmed.starts_with('"') && value_trimmed.ends_with('"'))
1099 || (value_trimmed.starts_with('\'') && value_trimmed.ends_with('\''))
1100 {
1101 let quote_offset = value_slice.find(['"', '\'']).unwrap_or(0);
1102 return value_start + quote_offset + 1;
1103 }
1104 return value_start;
1105 }
1106 return usize::MAX;
1108 }
1109
1110 0
1112 }
1113
1114 fn kv_value_offset(line: &str, content: &str, base_offset: usize) -> Option<usize> {
1118 let colon_pos = content.find(':')?;
1119 let abs_colon = base_offset + colon_pos;
1120 let after_colon = abs_colon + 1;
1121 if after_colon < line.len() && line.as_bytes()[after_colon] == b' ' {
1122 let value_start = after_colon + 1;
1123 let value_slice = &line[value_start..];
1124 let value_trimmed = value_slice.trim();
1125 if value_trimmed.is_empty() {
1126 return Some(usize::MAX);
1127 }
1128 if value_trimmed.starts_with('{') || value_trimmed.starts_with('[') {
1130 return Some(usize::MAX);
1131 }
1132 if (value_trimmed.starts_with('"') && value_trimmed.ends_with('"'))
1134 || (value_trimmed.starts_with('\'') && value_trimmed.ends_with('\''))
1135 {
1136 let quote_offset = value_slice.find(['"', '\'']).unwrap_or(0);
1137 return Some(value_start + quote_offset + 1);
1138 }
1139 return Some(value_start);
1140 }
1141 Some(usize::MAX)
1143 }
1144
1145 fn find_unquoted(s: &str, target: char) -> Option<usize> {
1150 let mut in_double = false;
1151 let mut in_single = false;
1152 let mut chars = s.char_indices();
1153 while let Some((i, c)) = chars.next() {
1154 if in_double {
1155 if c == '\\' {
1156 chars.next();
1157 } else if c == '"' {
1158 in_double = false;
1159 }
1160 } else if in_single {
1161 if c == '\'' {
1162 in_single = false;
1163 }
1164 } else if c == target {
1165 return Some(i);
1166 } else if c == '"' {
1167 in_double = true;
1168 } else if c == '\'' {
1169 in_single = true;
1170 }
1171 }
1172 None
1173 }
1174
1175 fn toml_table_header(trimmed: &str) -> Option<&str> {
1191 let head = match Self::find_unquoted(trimmed, '#') {
1192 Some(i) => trimmed[..i].trim_end(),
1193 None => trimmed,
1194 };
1195
1196 let inner = if let Some(rest) = head.strip_prefix("[[") {
1197 rest.strip_suffix("]]")?
1198 } else {
1199 head.strip_prefix('[')?.strip_suffix(']')?
1200 };
1201
1202 if Self::find_unquoted(inner, ',').is_some() {
1203 return None;
1204 }
1205
1206 let inner = inner.trim();
1207 if inner.is_empty() { None } else { Some(inner) }
1208 }
1209
1210 fn toml_bracket_delta(trimmed: &str) -> i32 {
1215 let mut delta = 0i32;
1216 let mut chars = trimmed.chars();
1217 let mut in_double = false;
1218 let mut in_single = false;
1219 while let Some(c) = chars.next() {
1220 if in_double {
1221 if c == '\\' {
1222 chars.next();
1223 } else if c == '"' {
1224 in_double = false;
1225 }
1226 } else if in_single {
1227 if c == '\'' {
1228 in_single = false;
1229 }
1230 } else {
1231 match c {
1232 '"' => in_double = true,
1233 '\'' => in_single = true,
1234 '[' => delta += 1,
1235 ']' => delta -= 1,
1236 _ => {}
1237 }
1238 }
1239 }
1240 delta
1241 }
1242
1243 fn strip_key_quotes(raw: &str) -> &str {
1244 raw.strip_prefix('"')
1245 .and_then(|k| k.strip_suffix('"'))
1246 .or_else(|| raw.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')))
1247 .unwrap_or(raw)
1248 }
1249
1250 fn frontmatter_field_map(ctx: &crate::lint_context::LintContext) -> Vec<Option<String>> {
1260 let mut map = vec![None; ctx.lines.len()];
1261 let mut current: Option<String> = None;
1262 let mut toml = false;
1263 let mut in_toml_table = false;
1264 let mut toml_array_depth: i32 = 0;
1268
1269 for (idx, info) in ctx.lines.iter().enumerate() {
1270 if !info.in_front_matter {
1271 continue;
1272 }
1273 let line = info.content(ctx.content);
1274 let trimmed = line.trim();
1275
1276 if trimmed == "---" || trimmed == "+++" {
1277 toml = trimmed == "+++";
1278 current = None;
1279 in_toml_table = false;
1280 toml_array_depth = 0;
1281 continue;
1282 }
1283 if trimmed.is_empty() || trimmed.starts_with('#') {
1284 map[idx].clone_from(¤t);
1285 continue;
1286 }
1287
1288 if toml {
1289 let indent = line.len() - line.trim_start().len();
1305 let header = if indent == 0 && toml_array_depth == 0 {
1306 Self::toml_table_header(trimmed)
1307 } else {
1308 None
1309 };
1310 let assignment_eq = if indent == 0 {
1311 FrontMatterUtils::separator_pos_outside_quoted_key(trimmed, '=')
1312 } else {
1313 None
1314 };
1315 let resync = header.is_some() || assignment_eq.is_some();
1316
1317 if resync {
1318 if let Some(name) = header {
1319 current = Some(FrontMatterUtils::toml_root_key(name).to_lowercase());
1320 in_toml_table = true;
1321 } else if !in_toml_table && let Some(eq) = assignment_eq {
1322 let root = FrontMatterUtils::toml_root_key(trimmed[..eq].trim());
1323 current = Some(root.to_lowercase());
1324 }
1325 toml_array_depth = 0;
1328 }
1329 toml_array_depth = (toml_array_depth + Self::toml_bracket_delta(trimmed)).max(0);
1330 } else {
1331 let indent = line.len() - line.trim_start().len();
1341 if indent == 0 {
1342 if trimmed.starts_with("- ") || trimmed == "-" {
1343 current = None;
1344 } else if let Some(colon) = FrontMatterUtils::separator_pos_outside_quoted_key(trimmed, ':') {
1345 let raw = trimmed[..colon].trim();
1346 current = Some(Self::strip_key_quotes(raw).to_lowercase());
1347 }
1348 }
1349 }
1352 map[idx].clone_from(¤t);
1353 }
1354 map
1355 }
1356
1357 fn get_proper_name_for(&self, found_name: &str) -> Option<String> {
1359 let found_lower = found_name.to_lowercase();
1360
1361 for name in &self.config.names {
1363 let lower_name = name.to_lowercase();
1364 let lower_name_no_dots = lower_name.replace('.', "");
1365
1366 if found_lower == lower_name || found_lower == lower_name_no_dots {
1368 return Some(name.clone());
1369 }
1370
1371 let ascii_normalized = Self::ascii_normalize(&lower_name);
1373
1374 let ascii_no_dots = ascii_normalized.replace('.', "");
1375
1376 if found_lower == ascii_normalized || found_lower == ascii_no_dots {
1377 return Some(name.clone());
1378 }
1379 }
1380 None
1381 }
1382}
1383
1384impl Rule for MD044ProperNames {
1385 fn name(&self) -> &'static str {
1386 "MD044"
1387 }
1388
1389 fn description(&self) -> &'static str {
1390 "Proper names should have the correct capitalization"
1391 }
1392
1393 fn category(&self) -> RuleCategory {
1394 RuleCategory::Other
1395 }
1396
1397 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1398 if self.config.names.is_empty() {
1399 return true;
1400 }
1401 let content_lower = if ctx.content.is_ascii() {
1403 ctx.content.to_ascii_lowercase()
1404 } else {
1405 ctx.content.to_lowercase()
1406 };
1407 !self.name_variants.iter().any(|name| content_lower.contains(name))
1408 }
1409
1410 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1411 let content = ctx.content;
1412 if content.is_empty() || self.config.names.is_empty() || self.combined_pattern.is_none() {
1413 return Ok(Vec::new());
1414 }
1415
1416 let content_lower = if content.is_ascii() {
1418 content.to_ascii_lowercase()
1419 } else {
1420 content.to_lowercase()
1421 };
1422
1423 let has_potential_matches = self.name_variants.iter().any(|name| content_lower.contains(name));
1425
1426 if !has_potential_matches {
1427 return Ok(Vec::new());
1428 }
1429
1430 let line_index = &ctx.line_index;
1431 let violations = self.find_name_violations(content, ctx, &content_lower);
1432
1433 let warnings = violations
1434 .into_iter()
1435 .filter_map(|(line, column, found_name)| {
1436 self.get_proper_name_for(&found_name).map(|proper_name| {
1437 let line_start = line_index.get_line_start_byte(line).unwrap_or(0);
1442 let byte_start = line_start + (column - 1);
1443 let byte_end = byte_start + found_name.len();
1444 let line_text = ctx.line_info(line).map_or("", |li| li.content(ctx.content));
1447 let char_col = byte_to_char_count(line_text, column - 1);
1448 LintWarning {
1449 rule_name: Some(self.name().to_string()),
1450 line,
1451 column: char_col,
1452 end_line: line,
1453 end_column: char_col + found_name.chars().count(),
1454 message: format!("Proper name '{found_name}' should be '{proper_name}'"),
1455 severity: Severity::Warning,
1456 fix: Some(Fix::new(byte_start..byte_end, proper_name)),
1457 }
1458 })
1459 })
1460 .collect();
1461
1462 Ok(warnings)
1463 }
1464
1465 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1466 if self.should_skip(ctx) {
1467 return Ok(ctx.content.to_string());
1468 }
1469 let warnings = self.check(ctx)?;
1470 if warnings.is_empty() {
1471 return Ok(ctx.content.to_string());
1472 }
1473 let warnings =
1474 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1475 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
1476 .map_err(crate::rule::LintError::InvalidInput)
1477 }
1478
1479 fn as_any(&self) -> &dyn std::any::Any {
1480 self
1481 }
1482
1483 crate::impl_rule_config_methods!(MD044Config, nullable);
1484}
1485
1486#[cfg(test)]
1487mod tests {
1488 use super::*;
1489 use crate::lint_context::LintContext;
1490
1491 fn create_context(content: &str) -> LintContext<'_> {
1492 LintContext::new(content, crate::config::MarkdownFlavor::Standard, None)
1493 }
1494
1495 fn field_map_for(content: &str) -> Vec<Option<String>> {
1496 let ctx = create_context(content);
1497 MD044ProperNames::frontmatter_field_map(&ctx)
1498 }
1499
1500 #[test]
1501 fn test_field_map_nested_lines_inherit_top_level_key() {
1502 let map = field_map_for("---\nseo:\n canonical: docs/a.md\n keywords:\n - myapp\ntitle: x\n---\n");
1503 assert_eq!(map[2].as_deref(), Some("seo"));
1504 assert_eq!(map[4].as_deref(), Some("seo"));
1505 assert_eq!(map[5].as_deref(), Some("title"));
1506 }
1507
1508 #[test]
1509 fn test_field_map_block_scalar_bracket_does_not_swallow_next_key() {
1510 let map = field_map_for("---\ndescription: |\n [myapp\ntitle: myapp\n---\n");
1511 assert_eq!(map[2].as_deref(), Some("description"));
1512 assert_eq!(
1513 map[3].as_deref(),
1514 Some("title"),
1515 "an indent-0 key always starts a new key"
1516 );
1517 }
1518
1519 #[test]
1520 fn test_field_map_quoted_key_with_colon() {
1521 let map = field_map_for("---\n\"og:title\": myapp\n---\n");
1522 assert_eq!(map[1].as_deref(), Some("og:title"));
1523 }
1524
1525 #[test]
1526 fn test_field_map_top_level_sequence_clears_attribution() {
1527 let map = field_map_for("---\n- myapp\n---\n");
1528 assert_eq!(map[1], None);
1529 }
1530
1531 #[test]
1532 fn test_field_map_toml_table_body_belongs_to_table_root() {
1533 let map = field_map_for("+++\n[seo]\ncanonical = \"docs/a.md\"\n\n[[authors]]\nname = \"myapp\"\n+++\n");
1534 assert_eq!(map[2].as_deref(), Some("seo"));
1535 assert_eq!(map[5].as_deref(), Some("authors"));
1536 }
1537
1538 #[test]
1539 fn test_field_map_toml_dotted_assignment_uses_root() {
1540 let map = field_map_for("+++\nseo.canonical = \"docs/a.md\"\n+++\n");
1541 assert_eq!(map[1].as_deref(), Some("seo"));
1542 }
1543
1544 #[test]
1545 fn test_field_map_toml_array_continuation_inherits() {
1546 let map = field_map_for("+++\nseo = [\n\"docs/guide/myapp\"\n]\n+++\n");
1547 assert_eq!(map[2].as_deref(), Some("seo"));
1548 }
1549
1550 #[test]
1551 fn test_field_map_indent_zero_flow_continuation_is_not_attributed_to_parent() {
1552 let map = field_map_for("---\nseo: [\n{name: myapp}\n]\n---\n");
1555 assert_eq!(map[2].as_deref(), Some("{name"));
1556 }
1557
1558 #[test]
1559 fn test_field_map_toml_nested_array_inherits_and_title_not_corrupted() {
1560 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\ntitle = \"x\"\n+++\n");
1561 assert_eq!(map[2].as_deref(), Some("matrix"), "nested array line inherits matrix");
1562 assert_eq!(map[3].as_deref(), Some("matrix"), "nested array line inherits matrix");
1563 assert_eq!(
1564 map[5].as_deref(),
1565 Some("title"),
1566 "title must not inherit stale attribution from a closed nested array"
1567 );
1568 }
1569
1570 #[test]
1571 fn test_field_map_toml_nested_array_last_element_without_trailing_comma() {
1572 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [2]\n]\ntitle = \"x\"\n+++\n");
1573 assert_eq!(
1574 map[3].as_deref(),
1575 Some("matrix"),
1576 "last element without a trailing comma still inherits matrix"
1577 );
1578 assert_eq!(
1579 map[5].as_deref(),
1580 Some("title"),
1581 "title must not inherit stale attribution from a closed nested array"
1582 );
1583 }
1584
1585 #[test]
1586 fn test_field_map_toml_nested_array_then_real_table_header_non_regression_guard() {
1587 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\n\n[seo]\ncanonical = \"docs/a.md\"\n+++\n");
1594 assert_eq!(map[6].as_deref(), Some("seo"), "real table header after a closed array");
1595 assert_eq!(
1596 map[7].as_deref(),
1597 Some("seo"),
1598 "table body still attributes to the table"
1599 );
1600 }
1601
1602 #[test]
1603 fn test_field_map_toml_unclosed_array_resyncs_on_next_assignment() {
1604 let map = field_map_for("+++\nmatrix = [\n [1, 2],\ntitle = \"x\"\n+++\n");
1610 assert_eq!(
1611 map[3].as_deref(),
1612 Some("title"),
1613 "title must resync even though the array was never closed"
1614 );
1615 }
1616
1617 #[test]
1618 fn test_field_map_toml_column_zero_array_elements_inherit_and_title_not_corrupted() {
1619 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[3, 4],\n]\ntitle = \"x\"\n+++\n");
1625 assert_eq!(
1626 map[2].as_deref(),
1627 Some("matrix"),
1628 "column-0 array element inherits matrix"
1629 );
1630 assert_eq!(
1631 map[3].as_deref(),
1632 Some("matrix"),
1633 "column-0 array element inherits matrix"
1634 );
1635 assert_eq!(
1636 map[5].as_deref(),
1637 Some("title"),
1638 "title must not inherit stale attribution from a misread array element"
1639 );
1640 }
1641
1642 #[test]
1643 fn test_field_map_toml_column_zero_array_last_element_without_trailing_comma() {
1644 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[2]\n]\ntitle = \"x\"\n+++\n");
1645 assert_eq!(
1646 map[3].as_deref(),
1647 Some("matrix"),
1648 "column-0 last element without a trailing comma still inherits matrix"
1649 );
1650 assert_eq!(
1651 map[5].as_deref(),
1652 Some("title"),
1653 "title must not inherit stale attribution from a misread array element"
1654 );
1655 }
1656
1657 #[test]
1658 fn test_correctly_capitalized_names() {
1659 let rule = MD044ProperNames::new(
1660 vec![
1661 "JavaScript".to_string(),
1662 "TypeScript".to_string(),
1663 "Node.js".to_string(),
1664 ],
1665 true,
1666 );
1667
1668 let content = "This document uses JavaScript, TypeScript, and Node.js correctly.";
1669 let ctx = create_context(content);
1670 let result = rule.check(&ctx).unwrap();
1671 assert!(result.is_empty(), "Should not flag correctly capitalized names");
1672 }
1673
1674 #[test]
1675 fn test_incorrectly_capitalized_names() {
1676 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1677
1678 let content = "This document uses javascript and typescript incorrectly.";
1679 let ctx = create_context(content);
1680 let result = rule.check(&ctx).unwrap();
1681
1682 assert_eq!(result.len(), 2, "Should flag two incorrect capitalizations");
1683 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1684 assert_eq!(result[0].line, 1);
1685 assert_eq!(result[0].column, 20);
1686 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1687 assert_eq!(result[1].line, 1);
1688 assert_eq!(result[1].column, 35);
1689 }
1690
1691 #[test]
1692 fn test_names_at_beginning_of_sentences() {
1693 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "Python".to_string()], true);
1694
1695 let content = "javascript is a great language. python is also popular.";
1696 let ctx = create_context(content);
1697 let result = rule.check(&ctx).unwrap();
1698
1699 assert_eq!(result.len(), 2, "Should flag names at beginning of sentences");
1700 assert_eq!(result[0].line, 1);
1701 assert_eq!(result[0].column, 1);
1702 assert_eq!(result[1].line, 1);
1703 assert_eq!(result[1].column, 33);
1704 }
1705
1706 #[test]
1707 fn test_names_in_code_blocks_checked_by_default() {
1708 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1709
1710 let content = r#"Here is some text with JavaScript.
1711
1712```javascript
1713// This javascript should be checked
1714const lang = "javascript";
1715```
1716
1717But this javascript should be flagged."#;
1718
1719 let ctx = create_context(content);
1720 let result = rule.check(&ctx).unwrap();
1721
1722 assert_eq!(result.len(), 3, "Should flag javascript inside and outside code blocks");
1723 assert_eq!(result[0].line, 4);
1724 assert_eq!(result[1].line, 5);
1725 assert_eq!(result[2].line, 8);
1726 }
1727
1728 #[test]
1729 fn test_names_in_code_blocks_ignored_when_disabled() {
1730 let rule = MD044ProperNames::new(
1731 vec!["JavaScript".to_string()],
1732 false, );
1734
1735 let content = r#"```
1736javascript in code block
1737```"#;
1738
1739 let ctx = create_context(content);
1740 let result = rule.check(&ctx).unwrap();
1741
1742 assert_eq!(
1743 result.len(),
1744 0,
1745 "Should not flag javascript in code blocks when code_blocks is false"
1746 );
1747 }
1748
1749 #[test]
1750 fn test_names_in_inline_code_checked_by_default() {
1751 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1752
1753 let content = "This is `javascript` in inline code and javascript outside.";
1754 let ctx = create_context(content);
1755 let result = rule.check(&ctx).unwrap();
1756
1757 assert_eq!(result.len(), 2, "Should flag javascript inside and outside inline code");
1759 assert_eq!(result[0].column, 10); assert_eq!(result[1].column, 41); }
1762
1763 #[test]
1764 fn test_multiple_names_in_same_line() {
1765 let rule = MD044ProperNames::new(
1766 vec!["JavaScript".to_string(), "TypeScript".to_string(), "React".to_string()],
1767 true,
1768 );
1769
1770 let content = "I use javascript, typescript, and react in my projects.";
1771 let ctx = create_context(content);
1772 let result = rule.check(&ctx).unwrap();
1773
1774 assert_eq!(result.len(), 3, "Should flag all three incorrect names");
1775 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1776 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1777 assert_eq!(result[2].message, "Proper name 'react' should be 'React'");
1778 }
1779
1780 #[test]
1781 fn test_case_sensitivity() {
1782 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1783
1784 let content = "JAVASCRIPT, Javascript, javascript, and JavaScript variations.";
1785 let ctx = create_context(content);
1786 let result = rule.check(&ctx).unwrap();
1787
1788 assert_eq!(result.len(), 3, "Should flag all incorrect case variations");
1789 assert!(result.iter().all(|w| w.message.contains("should be 'JavaScript'")));
1791 }
1792
1793 #[test]
1794 fn test_configuration_with_custom_name_list() {
1795 let config = MD044Config {
1796 names: vec!["GitHub".to_string(), "GitLab".to_string(), "DevOps".to_string()],
1797 code_blocks: true,
1798 ..Default::default()
1799 };
1800 let rule = MD044ProperNames::from_config_struct(config);
1801
1802 let content = "We use github, gitlab, and devops for our workflow.";
1803 let ctx = create_context(content);
1804 let result = rule.check(&ctx).unwrap();
1805
1806 assert_eq!(result.len(), 3, "Should flag all custom names");
1807 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
1808 assert_eq!(result[1].message, "Proper name 'gitlab' should be 'GitLab'");
1809 assert_eq!(result[2].message, "Proper name 'devops' should be 'DevOps'");
1810 }
1811
1812 #[test]
1813 fn test_empty_configuration() {
1814 let rule = MD044ProperNames::new(vec![], true);
1815
1816 let content = "This has javascript and typescript but no configured names.";
1817 let ctx = create_context(content);
1818 let result = rule.check(&ctx).unwrap();
1819
1820 assert!(result.is_empty(), "Should not flag anything with empty configuration");
1821 }
1822
1823 #[test]
1824 fn test_names_with_special_characters() {
1825 let rule = MD044ProperNames::new(
1826 vec!["Node.js".to_string(), "ASP.NET".to_string(), "C++".to_string()],
1827 true,
1828 );
1829
1830 let content = "We use nodejs, asp.net, ASP.NET, and c++ in our stack.";
1831 let ctx = create_context(content);
1832 let result = rule.check(&ctx).unwrap();
1833
1834 assert_eq!(result.len(), 3, "Should handle special characters correctly");
1839
1840 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
1841 assert!(messages.contains(&"Proper name 'nodejs' should be 'Node.js'"));
1842 assert!(messages.contains(&"Proper name 'asp.net' should be 'ASP.NET'"));
1843 assert!(messages.contains(&"Proper name 'c++' should be 'C++'"));
1844 }
1845
1846 #[test]
1847 fn test_word_boundaries() {
1848 let rule = MD044ProperNames::new(vec!["Java".to_string(), "Script".to_string()], true);
1849
1850 let content = "JavaScript is not java or script, but Java and Script are separate.";
1851 let ctx = create_context(content);
1852 let result = rule.check(&ctx).unwrap();
1853
1854 assert_eq!(result.len(), 2, "Should respect word boundaries");
1856 assert!(result.iter().any(|w| w.column == 19)); assert!(result.iter().any(|w| w.column == 27)); }
1859
1860 #[test]
1861 fn test_fix_method() {
1862 let rule = MD044ProperNames::new(
1863 vec![
1864 "JavaScript".to_string(),
1865 "TypeScript".to_string(),
1866 "Node.js".to_string(),
1867 ],
1868 true,
1869 );
1870
1871 let content = "I love javascript, typescript, and nodejs!";
1872 let ctx = create_context(content);
1873 let fixed = rule.fix(&ctx).unwrap();
1874
1875 assert_eq!(fixed, "I love JavaScript, TypeScript, and Node.js!");
1876 }
1877
1878 #[test]
1879 fn test_fix_multiple_occurrences() {
1880 let rule = MD044ProperNames::new(vec!["Python".to_string()], true);
1881
1882 let content = "python is great. I use python daily. PYTHON is powerful.";
1883 let ctx = create_context(content);
1884 let fixed = rule.fix(&ctx).unwrap();
1885
1886 assert_eq!(fixed, "Python is great. I use Python daily. Python is powerful.");
1887 }
1888
1889 #[test]
1890 fn test_fix_checks_code_blocks_by_default() {
1891 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1892
1893 let content = r#"I love javascript.
1894
1895```
1896const lang = "javascript";
1897```
1898
1899More javascript here."#;
1900
1901 let ctx = create_context(content);
1902 let fixed = rule.fix(&ctx).unwrap();
1903
1904 let expected = r#"I love JavaScript.
1905
1906```
1907const lang = "JavaScript";
1908```
1909
1910More JavaScript here."#;
1911
1912 assert_eq!(fixed, expected);
1913 }
1914
1915 #[test]
1916 fn test_multiline_content() {
1917 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
1918
1919 let content = r#"First line with rust.
1920Second line with python.
1921Third line with RUST and PYTHON."#;
1922
1923 let ctx = create_context(content);
1924 let result = rule.check(&ctx).unwrap();
1925
1926 assert_eq!(result.len(), 4, "Should flag all incorrect occurrences");
1927 assert_eq!(result[0].line, 1);
1928 assert_eq!(result[1].line, 2);
1929 assert_eq!(result[2].line, 3);
1930 assert_eq!(result[3].line, 3);
1931 }
1932
1933 #[test]
1934 fn test_default_config() {
1935 let config = MD044Config::default();
1936 assert!(config.names.is_empty());
1937 assert!(!config.code_blocks);
1938 assert!(config.html_elements);
1939 assert!(config.html_comments);
1940 }
1941
1942 #[test]
1943 fn test_default_config_checks_html_comments() {
1944 let config = MD044Config {
1945 names: vec!["JavaScript".to_string()],
1946 ..MD044Config::default()
1947 };
1948 let rule = MD044ProperNames::from_config_struct(config);
1949
1950 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1951 let ctx = create_context(content);
1952 let result = rule.check(&ctx).unwrap();
1953
1954 assert_eq!(result.len(), 1, "Default config should check HTML comments");
1955 assert_eq!(result[0].line, 3);
1956 }
1957
1958 #[test]
1959 fn test_default_config_skips_code_blocks() {
1960 let config = MD044Config {
1961 names: vec!["JavaScript".to_string()],
1962 ..MD044Config::default()
1963 };
1964 let rule = MD044ProperNames::from_config_struct(config);
1965
1966 let content = "# Guide\n\n```\njavascript in code\n```\n";
1967 let ctx = create_context(content);
1968 let result = rule.check(&ctx).unwrap();
1969
1970 assert_eq!(result.len(), 0, "Default config should skip code blocks");
1971 }
1972
1973 #[test]
1974 fn test_standalone_html_comment_checked() {
1975 let config = MD044Config {
1976 names: vec!["Test".to_string()],
1977 ..MD044Config::default()
1978 };
1979 let rule = MD044ProperNames::from_config_struct(config);
1980
1981 let content = "# Heading\n\n<!-- this is a test example -->\n";
1982 let ctx = create_context(content);
1983 let result = rule.check(&ctx).unwrap();
1984
1985 assert_eq!(result.len(), 1, "Should flag proper name in standalone HTML comment");
1986 assert_eq!(result[0].line, 3);
1987 }
1988
1989 #[test]
1990 fn test_inline_config_comments_not_flagged() {
1991 let config = MD044Config {
1992 names: vec!["RUMDL".to_string()],
1993 ..MD044Config::default()
1994 };
1995 let rule = MD044ProperNames::from_config_struct(config);
1996
1997 let content = "<!-- rumdl-disable MD044 -->\nSome rumdl text here.\n<!-- rumdl-enable MD044 -->\n<!-- markdownlint-disable -->\nMore rumdl text.\n<!-- markdownlint-enable -->\n";
2001 let ctx = create_context(content);
2002 let result = rule.check(&ctx).unwrap();
2003
2004 assert_eq!(result.len(), 2, "Should only flag body lines, not config comments");
2005 assert_eq!(result[0].line, 2);
2006 assert_eq!(result[1].line, 5);
2007 }
2008
2009 #[test]
2010 fn test_html_comment_skipped_when_disabled() {
2011 let config = MD044Config {
2012 names: vec!["Test".to_string()],
2013 code_blocks: true,
2014 html_comments: false,
2015 ..Default::default()
2016 };
2017 let rule = MD044ProperNames::from_config_struct(config);
2018
2019 let content = "# Heading\n\n<!-- this is a test example -->\n\nRegular test here.\n";
2020 let ctx = create_context(content);
2021 let result = rule.check(&ctx).unwrap();
2022
2023 assert_eq!(
2024 result.len(),
2025 1,
2026 "Should only flag 'test' outside HTML comment when html_comments=false"
2027 );
2028 assert_eq!(result[0].line, 5);
2029 }
2030
2031 #[test]
2032 fn test_fix_corrects_html_comment_content() {
2033 let config = MD044Config {
2034 names: vec!["JavaScript".to_string()],
2035 ..MD044Config::default()
2036 };
2037 let rule = MD044ProperNames::from_config_struct(config);
2038
2039 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
2040 let ctx = create_context(content);
2041 let fixed = rule.fix(&ctx).unwrap();
2042
2043 assert_eq!(fixed, "# Guide\n\n<!-- JavaScript mentioned here -->\n");
2044 }
2045
2046 #[test]
2047 fn test_fix_does_not_modify_inline_config_comments() {
2048 let config = MD044Config {
2049 names: vec!["RUMDL".to_string()],
2050 ..MD044Config::default()
2051 };
2052 let rule = MD044ProperNames::from_config_struct(config);
2053
2054 let content = "<!-- rumdl-disable -->\nSome rumdl text.\n<!-- rumdl-enable -->\n";
2055 let ctx = create_context(content);
2056 let fixed = rule.fix(&ctx).unwrap();
2057
2058 assert!(fixed.contains("<!-- rumdl-disable -->"));
2060 assert!(fixed.contains("<!-- rumdl-enable -->"));
2061 assert!(
2063 fixed.contains("Some rumdl text."),
2064 "Line inside rumdl-disable block should not be modified by fix()"
2065 );
2066 }
2067
2068 #[test]
2069 fn test_fix_respects_inline_disable_partial() {
2070 let config = MD044Config {
2071 names: vec!["RUMDL".to_string()],
2072 ..MD044Config::default()
2073 };
2074 let rule = MD044ProperNames::from_config_struct(config);
2075
2076 let content =
2077 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
2078 let ctx = create_context(content);
2079 let fixed = rule.fix(&ctx).unwrap();
2080
2081 assert!(
2083 fixed.contains("Some rumdl text.\n<!-- rumdl-enable"),
2084 "Line inside disable block should not be modified"
2085 );
2086 assert!(
2088 fixed.contains("Some RUMDL text outside."),
2089 "Line outside disable block should be fixed"
2090 );
2091 }
2092
2093 #[test]
2094 fn test_performance_with_many_names() {
2095 let mut names = vec![];
2096 for i in 0..50 {
2097 names.push(format!("ProperName{i}"));
2098 }
2099
2100 let rule = MD044ProperNames::new(names, true);
2101
2102 let content = "This has propername0, propername25, and propername49 incorrectly.";
2103 let ctx = create_context(content);
2104 let result = rule.check(&ctx).unwrap();
2105
2106 assert_eq!(result.len(), 3, "Should handle many configured names efficiently");
2107 }
2108
2109 #[test]
2110 fn test_large_name_count_performance() {
2111 let names = (0..1000).map(|i| format!("ProperName{i}")).collect::<Vec<_>>();
2114
2115 let rule = MD044ProperNames::new(names, true);
2116
2117 assert!(rule.combined_pattern.is_some());
2119
2120 let content = "This has propername0 and propername999 in it.";
2122 let ctx = create_context(content);
2123 let result = rule.check(&ctx).unwrap();
2124
2125 assert_eq!(result.len(), 2, "Should handle 1000 names without issues");
2127 }
2128
2129 #[test]
2130 fn test_cache_behavior() {
2131 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2132
2133 let content = "Using javascript here.";
2134 let ctx = create_context(content);
2135
2136 let result1 = rule.check(&ctx).unwrap();
2138 assert_eq!(result1.len(), 1);
2139
2140 let result2 = rule.check(&ctx).unwrap();
2142 assert_eq!(result2.len(), 1);
2143
2144 assert_eq!(result1[0].line, result2[0].line);
2146 assert_eq!(result1[0].column, result2[0].column);
2147 }
2148
2149 #[test]
2150 fn test_html_comments_not_checked_when_disabled() {
2151 let config = MD044Config {
2152 names: vec!["JavaScript".to_string()],
2153 code_blocks: true, html_comments: false, ..Default::default()
2156 };
2157 let rule = MD044ProperNames::from_config_struct(config);
2158
2159 let content = r#"Regular javascript here.
2160<!-- This javascript in HTML comment should be ignored -->
2161More javascript outside."#;
2162
2163 let ctx = create_context(content);
2164 let result = rule.check(&ctx).unwrap();
2165
2166 assert_eq!(result.len(), 2, "Should only flag javascript outside HTML comments");
2167 assert_eq!(result[0].line, 1);
2168 assert_eq!(result[1].line, 3);
2169 }
2170
2171 #[test]
2172 fn test_html_comments_checked_when_enabled() {
2173 let config = MD044Config {
2174 names: vec!["JavaScript".to_string()],
2175 code_blocks: true, ..Default::default()
2177 };
2178 let rule = MD044ProperNames::from_config_struct(config);
2179
2180 let content = r#"Regular javascript here.
2181<!-- This javascript in HTML comment should be checked -->
2182More javascript outside."#;
2183
2184 let ctx = create_context(content);
2185 let result = rule.check(&ctx).unwrap();
2186
2187 assert_eq!(
2188 result.len(),
2189 3,
2190 "Should flag all javascript occurrences including in HTML comments"
2191 );
2192 }
2193
2194 #[test]
2195 fn test_multiline_html_comments() {
2196 let config = MD044Config {
2197 names: vec!["Python".to_string(), "JavaScript".to_string()],
2198 code_blocks: true, html_comments: false, ..Default::default()
2201 };
2202 let rule = MD044ProperNames::from_config_struct(config);
2203
2204 let content = r#"Regular python here.
2205<!--
2206This is a multiline comment
2207with javascript and python
2208that should be ignored
2209-->
2210More javascript outside."#;
2211
2212 let ctx = create_context(content);
2213 let result = rule.check(&ctx).unwrap();
2214
2215 assert_eq!(result.len(), 2, "Should only flag names outside HTML comments");
2216 assert_eq!(result[0].line, 1); assert_eq!(result[1].line, 7); }
2219
2220 #[test]
2221 fn test_fix_preserves_html_comments_when_disabled() {
2222 let config = MD044Config {
2223 names: vec!["JavaScript".to_string()],
2224 code_blocks: true, html_comments: false, ..Default::default()
2227 };
2228 let rule = MD044ProperNames::from_config_struct(config);
2229
2230 let content = r#"javascript here.
2231<!-- javascript in comment -->
2232More javascript."#;
2233
2234 let ctx = create_context(content);
2235 let fixed = rule.fix(&ctx).unwrap();
2236
2237 let expected = r#"JavaScript here.
2238<!-- javascript in comment -->
2239More JavaScript."#;
2240
2241 assert_eq!(
2242 fixed, expected,
2243 "Should not fix names inside HTML comments when disabled"
2244 );
2245 }
2246
2247 #[test]
2248 fn test_proper_names_in_link_text_are_flagged() {
2249 let rule = MD044ProperNames::new(
2250 vec!["JavaScript".to_string(), "Node.js".to_string(), "Python".to_string()],
2251 true,
2252 );
2253
2254 let content = r#"Check this [javascript documentation](https://javascript.info) for info.
2255
2256Visit [node.js homepage](https://nodejs.org) and [python tutorial](https://python.org).
2257
2258Real javascript should be flagged.
2259
2260Also see the [typescript guide][ts-ref] for more.
2261
2262Real python should be flagged too.
2263
2264[ts-ref]: https://typescript.org/handbook"#;
2265
2266 let ctx = create_context(content);
2267 let result = rule.check(&ctx).unwrap();
2268
2269 assert_eq!(result.len(), 5, "Expected 5 warnings: 3 in link text + 2 standalone");
2276
2277 let line_1_warnings: Vec<_> = result.iter().filter(|w| w.line == 1).collect();
2279 assert_eq!(line_1_warnings.len(), 1);
2280 assert!(
2281 line_1_warnings[0]
2282 .message
2283 .contains("'javascript' should be 'JavaScript'")
2284 );
2285
2286 let line_3_warnings: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
2287 assert_eq!(line_3_warnings.len(), 2); assert!(result.iter().any(|w| w.line == 5 && w.message.contains("'javascript'")));
2291 assert!(result.iter().any(|w| w.line == 9 && w.message.contains("'python'")));
2292 }
2293
2294 #[test]
2295 fn test_link_urls_not_flagged() {
2296 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2297
2298 let content = r#"[Link Text](https://javascript.info/guide)"#;
2300
2301 let ctx = create_context(content);
2302 let result = rule.check(&ctx).unwrap();
2303
2304 assert!(result.is_empty(), "URLs should not be checked for proper names");
2306 }
2307
2308 #[test]
2309 fn test_bare_urls_not_flagged() {
2310 let rule = MD044ProperNames::new(vec!["Foo".to_string(), "JavaScript".to_string()], true);
2311
2312 let content =
2315 "https://foo.com\n\nSee https://javascript.info/foo/guide for details.\n\nMail foo@foo.com about it.\n";
2316
2317 let ctx = create_context(content);
2318 let result = rule.check(&ctx).unwrap();
2319
2320 assert!(
2321 result.is_empty(),
2322 "Bare URLs and emails should not be checked for proper names: {result:?}"
2323 );
2324 }
2325
2326 #[test]
2327 fn test_prose_around_bare_url_still_flagged() {
2328 let rule = MD044ProperNames::new(vec!["Foo".to_string()], true);
2329
2330 let content = "Use foo at https://foo.com because foo is great.\n";
2333
2334 let ctx = create_context(content);
2335 let result = rule.check(&ctx).unwrap();
2336
2337 assert_eq!(
2338 result.len(),
2339 2,
2340 "Prose occurrences around a bare URL must still be flagged: {result:?}"
2341 );
2342 assert!(result.iter().all(|w| w.message.contains("'foo' should be 'Foo'")));
2343 }
2344
2345 #[test]
2346 fn test_proper_names_in_image_alt_text_are_flagged() {
2347 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2348
2349 let content = r#"Here is a  image.
2350
2351Real javascript should be flagged."#;
2352
2353 let ctx = create_context(content);
2354 let result = rule.check(&ctx).unwrap();
2355
2356 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in alt text + 1 standalone");
2360 assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
2361 assert!(result[0].line == 1); assert!(result[1].message.contains("'javascript' should be 'JavaScript'"));
2363 assert!(result[1].line == 3); }
2365
2366 #[test]
2367 fn test_image_urls_not_flagged() {
2368 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2369
2370 let content = r#""#;
2372
2373 let ctx = create_context(content);
2374 let result = rule.check(&ctx).unwrap();
2375
2376 assert!(result.is_empty(), "Image URLs should not be checked for proper names");
2378 }
2379
2380 #[test]
2381 fn test_reference_link_text_flagged_but_definition_not() {
2382 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2383
2384 let content = r#"Check the [javascript guide][js-ref] for details.
2385
2386Real javascript should be flagged.
2387
2388[js-ref]: https://javascript.info/typescript/guide"#;
2389
2390 let ctx = create_context(content);
2391 let result = rule.check(&ctx).unwrap();
2392
2393 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in link text + 1 standalone");
2398 assert!(result.iter().any(|w| w.line == 1 && w.message.contains("'javascript'")));
2399 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2400 }
2401
2402 #[test]
2403 fn test_reference_definitions_not_flagged() {
2404 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2405
2406 let content = r#"[js-ref]: https://javascript.info/guide"#;
2408
2409 let ctx = create_context(content);
2410 let result = rule.check(&ctx).unwrap();
2411
2412 assert!(result.is_empty(), "Reference definitions should not be checked");
2414 }
2415
2416 #[test]
2417 fn test_wikilinks_text_is_flagged() {
2418 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2419
2420 let content = r#"[[javascript]]
2422
2423Regular javascript here.
2424
2425[[JavaScript|display text]]"#;
2426
2427 let ctx = create_context(content);
2428 let result = rule.check(&ctx).unwrap();
2429
2430 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in WikiLink + 1 standalone");
2434 assert!(
2435 result
2436 .iter()
2437 .any(|w| w.line == 1 && w.column == 3 && w.message.contains("'javascript'"))
2438 );
2439 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2440 }
2441
2442 #[test]
2443 fn test_url_link_text_not_flagged() {
2444 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2445
2446 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2448
2449[http://github.com/org/repo](http://github.com/org/repo)
2450
2451[www.github.com/org/repo](https://www.github.com/org/repo)"#;
2452
2453 let ctx = create_context(content);
2454 let result = rule.check(&ctx).unwrap();
2455
2456 assert!(
2457 result.is_empty(),
2458 "URL-like link text should not be flagged, got: {result:?}"
2459 );
2460 }
2461
2462 #[test]
2463 fn test_url_link_text_with_leading_space_not_flagged() {
2464 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2465
2466 let content = r#"[ https://github.com/org/repo](https://github.com/org/repo)"#;
2468
2469 let ctx = create_context(content);
2470 let result = rule.check(&ctx).unwrap();
2471
2472 assert!(
2473 result.is_empty(),
2474 "URL-like link text with leading space should not be flagged, got: {result:?}"
2475 );
2476 }
2477
2478 #[test]
2479 fn test_url_link_text_uppercase_scheme_not_flagged() {
2480 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2481
2482 let content = r#"[HTTPS://GITHUB.COM/org/repo](https://github.com/org/repo)"#;
2483
2484 let ctx = create_context(content);
2485 let result = rule.check(&ctx).unwrap();
2486
2487 assert!(
2488 result.is_empty(),
2489 "URL-like link text with uppercase scheme should not be flagged, got: {result:?}"
2490 );
2491 }
2492
2493 #[test]
2494 fn test_non_url_link_text_still_flagged() {
2495 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2496
2497 let content = r#"[github.com/org/repo](https://github.com/org/repo)
2501
2502[Visit github](https://github.com/org/repo)
2503
2504[//github.com/org/repo](//github.com/org/repo)
2505
2506[ftp://github.com/org/repo](ftp://github.com/org/repo)"#;
2507
2508 let ctx = create_context(content);
2509 let result = rule.check(&ctx).unwrap();
2510
2511 assert_eq!(
2516 result.len(),
2517 1,
2518 "Only prose link text should be flagged, got: {result:?}"
2519 );
2520 assert!(
2521 result.iter().any(|w| w.line == 3),
2522 "Expected 'Visit github' on line 3 to be flagged"
2523 );
2524 }
2525
2526 #[test]
2527 fn test_url_link_text_fix_not_applied() {
2528 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2529
2530 let content = "[https://github.com/org/repo](https://github.com/org/repo)\n";
2531
2532 let ctx = create_context(content);
2533 let result = rule.fix(&ctx).unwrap();
2534
2535 assert_eq!(result, content, "Fix should not modify URL-like link text");
2536 }
2537
2538 #[test]
2539 fn test_mixed_url_and_regular_link_text() {
2540 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2541
2542 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2544
2545Visit [github documentation](https://github.com/docs) for details.
2546
2547[www.github.com/pricing](https://www.github.com/pricing)"#;
2548
2549 let ctx = create_context(content);
2550 let result = rule.check(&ctx).unwrap();
2551
2552 assert_eq!(
2554 result.len(),
2555 1,
2556 "Only non-URL link text should be flagged, got: {result:?}"
2557 );
2558 assert_eq!(result[0].line, 3);
2559 }
2560
2561 #[test]
2562 fn test_html_attribute_values_not_flagged() {
2563 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2566 let content = "# Heading\n\ntest\n\n<img src=\"www.example.test/test_image.png\">\n";
2567 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2568 let result = rule.check(&ctx).unwrap();
2569
2570 let line5_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2572 assert!(
2573 line5_violations.is_empty(),
2574 "Should not flag anything inside HTML tag attributes: {line5_violations:?}"
2575 );
2576
2577 let line3_violations: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
2579 assert_eq!(line3_violations.len(), 1, "Plain 'test' on line 3 should be flagged");
2580 }
2581
2582 #[test]
2583 fn test_html_text_content_still_flagged() {
2584 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2586 let content = "# Heading\n\n<a href=\"https://example.test/page\">test link</a>\n";
2587 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2588 let result = rule.check(&ctx).unwrap();
2589
2590 assert_eq!(
2593 result.len(),
2594 1,
2595 "Should flag only 'test' in anchor text, not in href: {result:?}"
2596 );
2597 assert_eq!(result[0].column, 37, "Should flag col 37 ('test link' in anchor text)");
2598 }
2599
2600 #[test]
2601 fn test_html_attribute_various_not_flagged() {
2602 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2604 let content = concat!(
2605 "# Heading\n\n",
2606 "<img src=\"test.png\" alt=\"test image\">\n",
2607 "<span class=\"test-class\" data-test=\"value\">test content</span>\n",
2608 );
2609 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2610 let result = rule.check(&ctx).unwrap();
2611
2612 assert_eq!(
2614 result.len(),
2615 1,
2616 "Should flag only 'test content' between tags: {result:?}"
2617 );
2618 assert_eq!(result[0].line, 4);
2619 }
2620
2621 #[test]
2622 fn test_plain_text_underscore_boundary_unchanged() {
2623 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2626 let content = "# Heading\n\ntest_image is here and just_test ends here\n";
2627 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2628 let result = rule.check(&ctx).unwrap();
2629
2630 assert_eq!(
2633 result.len(),
2634 2,
2635 "Should flag 'test' in both 'test_image' and 'just_test': {result:?}"
2636 );
2637 let cols: Vec<usize> = result.iter().map(|w| w.column).collect();
2638 assert!(cols.contains(&1), "Should flag col 1 (test_image): {cols:?}");
2639 assert!(cols.contains(&29), "Should flag col 29 (just_test): {cols:?}");
2640 }
2641
2642 #[test]
2643 fn test_frontmatter_yaml_keys_not_flagged() {
2644 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2647
2648 let content = "---\ntitle: Heading\ntest: Some Test value\n---\n\nTest\n";
2649 let ctx = create_context(content);
2650 let result = rule.check(&ctx).unwrap();
2651
2652 assert!(
2656 result.is_empty(),
2657 "Should not flag YAML keys or correctly capitalized values: {result:?}"
2658 );
2659 }
2660
2661 #[test]
2662 fn test_frontmatter_yaml_values_flagged() {
2663 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2665
2666 let content = "---\ntitle: Heading\nkey: a test value\n---\n\nTest\n";
2667 let ctx = create_context(content);
2668 let result = rule.check(&ctx).unwrap();
2669
2670 assert_eq!(result.len(), 1, "Should flag 'test' in YAML value: {result:?}");
2672 assert_eq!(result[0].line, 3);
2673 assert_eq!(result[0].column, 8); }
2675
2676 #[test]
2677 fn test_frontmatter_key_matches_name_not_flagged() {
2678 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2680
2681 let content = "---\ntest: other value\n---\n\nBody text\n";
2682 let ctx = create_context(content);
2683 let result = rule.check(&ctx).unwrap();
2684
2685 assert!(
2686 result.is_empty(),
2687 "Should not flag YAML key that matches configured name: {result:?}"
2688 );
2689 }
2690
2691 #[test]
2692 fn test_frontmatter_empty_value_not_flagged() {
2693 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2695
2696 let content = "---\ntest:\ntest: \n---\n\nBody text\n";
2697 let ctx = create_context(content);
2698 let result = rule.check(&ctx).unwrap();
2699
2700 assert!(
2701 result.is_empty(),
2702 "Should not flag YAML keys with empty values: {result:?}"
2703 );
2704 }
2705
2706 #[test]
2707 fn test_frontmatter_nested_yaml_key_not_flagged() {
2708 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2710
2711 let content = "---\nparent:\n test: nested value\n---\n\nBody text\n";
2712 let ctx = create_context(content);
2713 let result = rule.check(&ctx).unwrap();
2714
2715 assert!(result.is_empty(), "Should not flag nested YAML keys: {result:?}");
2717 }
2718
2719 #[test]
2720 fn test_frontmatter_list_items_checked() {
2721 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2723
2724 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2725 let ctx = create_context(content);
2726 let result = rule.check(&ctx).unwrap();
2727
2728 assert_eq!(result.len(), 1, "Should flag 'test' in YAML list item: {result:?}");
2730 assert_eq!(result[0].line, 3);
2731 }
2732
2733 #[test]
2734 fn test_frontmatter_value_with_multiple_colons() {
2735 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2737
2738 let content = "---\ntest: description: a test thing\n---\n\nBody text\n";
2739 let ctx = create_context(content);
2740 let result = rule.check(&ctx).unwrap();
2741
2742 assert_eq!(
2745 result.len(),
2746 1,
2747 "Should flag 'test' in value after first colon: {result:?}"
2748 );
2749 assert_eq!(result[0].line, 2);
2750 assert!(result[0].column > 6, "Violation column should be in value portion");
2751 }
2752
2753 #[test]
2754 fn test_frontmatter_does_not_affect_body() {
2755 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2757
2758 let content = "---\ntitle: Heading\n---\n\ntest should be flagged here\n";
2759 let ctx = create_context(content);
2760 let result = rule.check(&ctx).unwrap();
2761
2762 assert_eq!(result.len(), 1, "Should flag 'test' in body text: {result:?}");
2763 assert_eq!(result[0].line, 5);
2764 }
2765
2766 #[test]
2767 fn test_frontmatter_fix_corrects_values_preserves_keys() {
2768 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2770
2771 let content = "---\ntest: a test value\n---\n\ntest here\n";
2772 let ctx = create_context(content);
2773 let fixed = rule.fix(&ctx).unwrap();
2774
2775 assert_eq!(fixed, "---\ntest: a Test value\n---\n\nTest here\n");
2777 }
2778
2779 #[test]
2780 fn test_frontmatter_multiword_value_flagged() {
2781 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2783
2784 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2785 let ctx = create_context(content);
2786 let result = rule.check(&ctx).unwrap();
2787
2788 assert_eq!(result.len(), 2, "Should flag both names in YAML value: {result:?}");
2789 assert!(result.iter().all(|w| w.line == 2));
2790 }
2791
2792 #[test]
2793 fn test_frontmatter_yaml_comments_not_checked() {
2794 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2796
2797 let content = "---\n# test comment\ntitle: Heading\n---\n\nBody text\n";
2798 let ctx = create_context(content);
2799 let result = rule.check(&ctx).unwrap();
2800
2801 assert!(result.is_empty(), "Should not flag names in YAML comments: {result:?}");
2802 }
2803
2804 #[test]
2805 fn test_frontmatter_delimiters_not_checked() {
2806 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2808
2809 let content = "---\ntitle: Heading\n---\n\ntest here\n";
2810 let ctx = create_context(content);
2811 let result = rule.check(&ctx).unwrap();
2812
2813 assert_eq!(result.len(), 1, "Should only flag body text: {result:?}");
2815 assert_eq!(result[0].line, 5);
2816 }
2817
2818 #[test]
2819 fn test_frontmatter_continuation_lines_checked() {
2820 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2822
2823 let content = "---\ndescription: >\n a test value\n continued here\n---\n\nBody\n";
2824 let ctx = create_context(content);
2825 let result = rule.check(&ctx).unwrap();
2826
2827 assert_eq!(result.len(), 1, "Should flag 'test' in continuation line: {result:?}");
2829 assert_eq!(result[0].line, 3);
2830 }
2831
2832 #[test]
2833 fn test_frontmatter_quoted_values_checked() {
2834 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2836
2837 let content = "---\ntitle: \"a test title\"\n---\n\nBody\n";
2838 let ctx = create_context(content);
2839 let result = rule.check(&ctx).unwrap();
2840
2841 assert_eq!(result.len(), 1, "Should flag 'test' in quoted YAML value: {result:?}");
2842 assert_eq!(result[0].line, 2);
2843 }
2844
2845 #[test]
2846 fn test_frontmatter_single_quoted_values_checked() {
2847 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2849
2850 let content = "---\ntitle: 'a test title'\n---\n\nBody\n";
2851 let ctx = create_context(content);
2852 let result = rule.check(&ctx).unwrap();
2853
2854 assert_eq!(
2855 result.len(),
2856 1,
2857 "Should flag 'test' in single-quoted YAML value: {result:?}"
2858 );
2859 assert_eq!(result[0].line, 2);
2860 }
2861
2862 #[test]
2863 fn test_frontmatter_fix_multiword_values() {
2864 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2866
2867 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2868 let ctx = create_context(content);
2869 let fixed = rule.fix(&ctx).unwrap();
2870
2871 assert_eq!(
2872 fixed,
2873 "---\ndescription: Learn JavaScript and TypeScript\n---\n\nBody\n"
2874 );
2875 }
2876
2877 #[test]
2878 fn test_frontmatter_fix_preserves_yaml_structure() {
2879 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2881
2882 let content = "---\ntags:\n - test\n - other\ntitle: a test doc\n---\n\ntest body\n";
2883 let ctx = create_context(content);
2884 let fixed = rule.fix(&ctx).unwrap();
2885
2886 assert_eq!(
2887 fixed,
2888 "---\ntags:\n - Test\n - other\ntitle: a Test doc\n---\n\nTest body\n"
2889 );
2890 }
2891
2892 #[test]
2893 fn test_frontmatter_toml_delimiters_not_checked() {
2894 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2896
2897 let content = "+++\ntitle = \"a test title\"\n+++\n\ntest body\n";
2898 let ctx = create_context(content);
2899 let result = rule.check(&ctx).unwrap();
2900
2901 assert_eq!(result.len(), 2, "Should flag TOML value and body: {result:?}");
2905 let fm_violations: Vec<_> = result.iter().filter(|w| w.line == 2).collect();
2906 assert_eq!(fm_violations.len(), 1, "Should flag 'test' in TOML value: {result:?}");
2907 let body_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2908 assert_eq!(body_violations.len(), 1, "Should flag body 'test': {result:?}");
2909 }
2910
2911 #[test]
2912 fn test_frontmatter_toml_key_not_flagged() {
2913 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2915
2916 let content = "+++\ntest = \"other value\"\n+++\n\nBody text\n";
2917 let ctx = create_context(content);
2918 let result = rule.check(&ctx).unwrap();
2919
2920 assert!(
2921 result.is_empty(),
2922 "Should not flag TOML key that matches configured name: {result:?}"
2923 );
2924 }
2925
2926 #[test]
2927 fn test_frontmatter_toml_fix_preserves_keys() {
2928 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2930
2931 let content = "+++\ntest = \"a test value\"\n+++\n\ntest here\n";
2932 let ctx = create_context(content);
2933 let fixed = rule.fix(&ctx).unwrap();
2934
2935 assert_eq!(fixed, "+++\ntest = \"a Test value\"\n+++\n\nTest here\n");
2937 }
2938
2939 #[test]
2940 fn test_frontmatter_list_item_mapping_key_not_flagged() {
2941 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2944
2945 let content = "---\nitems:\n - test: nested value\n---\n\nBody text\n";
2946 let ctx = create_context(content);
2947 let result = rule.check(&ctx).unwrap();
2948
2949 assert!(
2950 result.is_empty(),
2951 "Should not flag YAML key in list-item mapping: {result:?}"
2952 );
2953 }
2954
2955 #[test]
2956 fn test_frontmatter_list_item_mapping_value_flagged() {
2957 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2959
2960 let content = "---\nitems:\n - key: a test value\n---\n\nBody text\n";
2961 let ctx = create_context(content);
2962 let result = rule.check(&ctx).unwrap();
2963
2964 assert_eq!(
2965 result.len(),
2966 1,
2967 "Should flag 'test' in list-item mapping value: {result:?}"
2968 );
2969 assert_eq!(result[0].line, 3);
2970 }
2971
2972 #[test]
2973 fn test_frontmatter_bare_list_item_still_flagged() {
2974 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2976
2977 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2978 let ctx = create_context(content);
2979 let result = rule.check(&ctx).unwrap();
2980
2981 assert_eq!(result.len(), 1, "Should flag 'test' in bare list item: {result:?}");
2982 assert_eq!(result[0].line, 3);
2983 }
2984
2985 #[test]
2986 fn test_frontmatter_flow_mapping_not_flagged() {
2987 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2990
2991 let content = "---\nflow_map: {test: value, other: test}\n---\n\nBody text\n";
2992 let ctx = create_context(content);
2993 let result = rule.check(&ctx).unwrap();
2994
2995 assert!(
2996 result.is_empty(),
2997 "Should not flag names inside flow mappings: {result:?}"
2998 );
2999 }
3000
3001 #[test]
3002 fn test_frontmatter_flow_sequence_not_flagged() {
3003 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
3005
3006 let content = "---\nitems: [test, other, test]\n---\n\nBody text\n";
3007 let ctx = create_context(content);
3008 let result = rule.check(&ctx).unwrap();
3009
3010 assert!(
3011 result.is_empty(),
3012 "Should not flag names inside flow sequences: {result:?}"
3013 );
3014 }
3015
3016 #[test]
3017 fn test_frontmatter_list_item_mapping_fix_preserves_key() {
3018 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
3020
3021 let content = "---\nitems:\n - test: a test value\n---\n\ntest here\n";
3022 let ctx = create_context(content);
3023 let fixed = rule.fix(&ctx).unwrap();
3024
3025 assert_eq!(fixed, "---\nitems:\n - test: a Test value\n---\n\nTest here\n");
3028 }
3029
3030 #[test]
3031 fn test_frontmatter_backtick_code_not_flagged() {
3032 let config = MD044Config {
3034 names: vec!["GoodApplication".to_string()],
3035 code_blocks: false,
3036 ..MD044Config::default()
3037 };
3038 let rule = MD044ProperNames::from_config_struct(config);
3039
3040 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
3041 let ctx = create_context(content);
3042 let result = rule.check(&ctx).unwrap();
3043
3044 assert!(
3046 result.is_empty(),
3047 "Should not flag names inside backticks in frontmatter or body: {result:?}"
3048 );
3049 }
3050
3051 #[test]
3052 fn test_frontmatter_unquoted_backtick_code_not_flagged() {
3053 let config = MD044Config {
3055 names: vec!["GoodApplication".to_string()],
3056 code_blocks: false,
3057 ..MD044Config::default()
3058 };
3059 let rule = MD044ProperNames::from_config_struct(config);
3060
3061 let content = "---\ntitle: `goodapplication` CLI\n---\n\nIntroductory `goodapplication` CLI text.\n";
3062 let ctx = create_context(content);
3063 let result = rule.check(&ctx).unwrap();
3064
3065 assert!(
3066 result.is_empty(),
3067 "Should not flag names inside backticks in unquoted YAML frontmatter: {result:?}"
3068 );
3069 }
3070
3071 #[test]
3072 fn test_frontmatter_bare_name_still_flagged_with_backtick_nearby() {
3073 let config = MD044Config {
3075 names: vec!["GoodApplication".to_string()],
3076 code_blocks: false,
3077 ..MD044Config::default()
3078 };
3079 let rule = MD044ProperNames::from_config_struct(config);
3080
3081 let content = "---\ntitle: goodapplication `goodapplication` CLI\n---\n\nBody\n";
3082 let ctx = create_context(content);
3083 let result = rule.check(&ctx).unwrap();
3084
3085 assert_eq!(
3087 result.len(),
3088 1,
3089 "Should flag bare name but not backtick-wrapped name: {result:?}"
3090 );
3091 assert_eq!(result[0].line, 2);
3092 assert_eq!(result[0].column, 8); }
3094
3095 #[test]
3096 fn test_frontmatter_backtick_code_with_code_blocks_true() {
3097 let config = MD044Config {
3099 names: vec!["GoodApplication".to_string()],
3100 code_blocks: true,
3101 ..MD044Config::default()
3102 };
3103 let rule = MD044ProperNames::from_config_struct(config);
3104
3105 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nBody\n";
3106 let ctx = create_context(content);
3107 let result = rule.check(&ctx).unwrap();
3108
3109 assert_eq!(
3111 result.len(),
3112 1,
3113 "Should flag backtick-wrapped name when code_blocks=true: {result:?}"
3114 );
3115 assert_eq!(result[0].line, 2);
3116 }
3117
3118 #[test]
3119 fn test_frontmatter_fix_preserves_backtick_code() {
3120 let config = MD044Config {
3122 names: vec!["GoodApplication".to_string()],
3123 code_blocks: false,
3124 ..MD044Config::default()
3125 };
3126 let rule = MD044ProperNames::from_config_struct(config);
3127
3128 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
3129 let ctx = create_context(content);
3130 let fixed = rule.fix(&ctx).unwrap();
3131
3132 assert_eq!(
3134 fixed, content,
3135 "Fix should not modify names inside backticks in frontmatter"
3136 );
3137 }
3138
3139 fn rule_ignoring(names: &[&str], ignore: &[&str]) -> MD044ProperNames {
3140 MD044ProperNames::from_config_struct(MD044Config {
3141 names: names.iter().map(ToString::to_string).collect(),
3142 ignore_frontmatter_fields: Some(ignore.iter().map(ToString::to_string).collect()),
3143 ..Default::default()
3144 })
3145 }
3146
3147 #[test]
3148 fn test_ignore_frontmatter_field_suppresses_only_that_field() {
3149 let content = "---\ntitle: Heading for myapp\nslug: myapp-guide\n---\n";
3150 let rule = rule_ignoring(&["MyApp"], &["slug"]);
3151 let result = rule.check(&create_context(content)).unwrap();
3152 assert_eq!(result.len(), 1, "only title is flagged: {result:?}");
3153 assert_eq!(result[0].line, 2);
3154 }
3155
3156 #[test]
3157 fn test_ignore_frontmatter_field_is_case_insensitive() {
3158 let content = "---\nSlug: myapp-guide\n---\n";
3159 let rule = rule_ignoring(&["MyApp"], &["SLUG"]);
3160 assert!(rule.check(&create_context(content)).unwrap().is_empty());
3161 }
3162
3163 #[test]
3164 fn test_ignore_frontmatter_field_covers_nested_subtree() {
3165 let content = "---\nseo:\n canonical: myapp\n keywords:\n - myapp\n---\n";
3166 let rule = rule_ignoring(&["MyApp"], &["seo"]);
3167 assert!(rule.check(&create_context(content)).unwrap().is_empty());
3168 }
3169
3170 #[test]
3171 fn test_ignore_frontmatter_field_does_not_affect_body() {
3172 let content = "---\nslug: myapp\n---\n\nBody mentions myapp.\n";
3173 let rule = rule_ignoring(&["MyApp"], &["slug"]);
3174 let result = rule.check(&create_context(content)).unwrap();
3175 assert_eq!(result.len(), 1);
3176 assert_eq!(result[0].line, 5);
3177 }
3178
3179 #[test]
3180 fn test_ignore_frontmatter_field_toml_table() {
3181 let content = "+++\n[seo]\ncanonical = \"myapp\"\n+++\n";
3182 let rule = rule_ignoring(&["MyApp"], &["seo"]);
3183 assert!(rule.check(&create_context(content)).unwrap().is_empty());
3184 }
3185
3186 #[test]
3189 fn test_angle_bracket_url_in_html_comment_not_flagged() {
3190 let config = MD044Config {
3192 names: vec!["Test".to_string()],
3193 ..MD044Config::default()
3194 };
3195 let rule = MD044ProperNames::from_config_struct(config);
3196
3197 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";
3198 let ctx = create_context(content);
3199 let result = rule.check(&ctx).unwrap();
3200
3201 let line8_warnings: Vec<_> = result.iter().filter(|w| w.line == 8).collect();
3209 assert!(
3210 line8_warnings.is_empty(),
3211 "Should not flag names inside angle-bracket URLs in HTML comments: {line8_warnings:?}"
3212 );
3213 }
3214
3215 #[test]
3216 fn test_bare_url_in_html_comment_still_flagged() {
3217 let config = MD044Config {
3219 names: vec!["Test".to_string()],
3220 ..MD044Config::default()
3221 };
3222 let rule = MD044ProperNames::from_config_struct(config);
3223
3224 let content = "<!-- This is a test https://www.example.test -->\n";
3225 let ctx = create_context(content);
3226 let result = rule.check(&ctx).unwrap();
3227
3228 assert!(
3231 !result.is_empty(),
3232 "Should flag 'test' in prose text of HTML comment with bare URL"
3233 );
3234 }
3235
3236 #[test]
3237 fn test_angle_bracket_url_in_regular_markdown_not_flagged() {
3238 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
3241
3242 let content = "<https://www.example.test>\n";
3243 let ctx = create_context(content);
3244 let result = rule.check(&ctx).unwrap();
3245
3246 assert!(
3247 result.is_empty(),
3248 "Should not flag names inside angle-bracket URLs in regular markdown: {result:?}"
3249 );
3250 }
3251
3252 #[test]
3253 fn test_multiple_angle_bracket_urls_in_one_comment() {
3254 let config = MD044Config {
3255 names: vec!["Test".to_string()],
3256 ..MD044Config::default()
3257 };
3258 let rule = MD044ProperNames::from_config_struct(config);
3259
3260 let content = "<!-- See <https://test.example.com> and <https://www.example.test> for details -->\n";
3261 let ctx = create_context(content);
3262 let result = rule.check(&ctx).unwrap();
3263
3264 assert!(
3266 result.is_empty(),
3267 "Should not flag names inside multiple angle-bracket URLs: {result:?}"
3268 );
3269 }
3270
3271 #[test]
3272 fn test_angle_bracket_non_url_still_flagged() {
3273 assert!(
3276 !MD044ProperNames::is_in_angle_bracket_url("<test> which is not a URL.", 1),
3277 "is_in_angle_bracket_url should return false for non-URL angle brackets"
3278 );
3279 }
3280
3281 #[test]
3282 fn test_angle_bracket_mailto_url_not_flagged() {
3283 let config = MD044Config {
3284 names: vec!["Test".to_string()],
3285 ..MD044Config::default()
3286 };
3287 let rule = MD044ProperNames::from_config_struct(config);
3288
3289 let content = "<!-- Contact <mailto:test@example.com> for help -->\n";
3290 let ctx = create_context(content);
3291 let result = rule.check(&ctx).unwrap();
3292
3293 assert!(
3294 result.is_empty(),
3295 "Should not flag names inside angle-bracket mailto URLs: {result:?}"
3296 );
3297 }
3298
3299 #[test]
3300 fn test_angle_bracket_ftp_url_not_flagged() {
3301 let config = MD044Config {
3302 names: vec!["Test".to_string()],
3303 ..MD044Config::default()
3304 };
3305 let rule = MD044ProperNames::from_config_struct(config);
3306
3307 let content = "<!-- Download from <ftp://test.example.com/file> -->\n";
3308 let ctx = create_context(content);
3309 let result = rule.check(&ctx).unwrap();
3310
3311 assert!(
3312 result.is_empty(),
3313 "Should not flag names inside angle-bracket FTP URLs: {result:?}"
3314 );
3315 }
3316
3317 #[test]
3318 fn test_angle_bracket_url_fix_preserves_url() {
3319 let config = MD044Config {
3321 names: vec!["Test".to_string()],
3322 ..MD044Config::default()
3323 };
3324 let rule = MD044ProperNames::from_config_struct(config);
3325
3326 let content = "<!-- test text <https://www.example.test> -->\n";
3327 let ctx = create_context(content);
3328 let fixed = rule.fix(&ctx).unwrap();
3329
3330 assert!(
3332 fixed.contains("<https://www.example.test>"),
3333 "Fix should preserve angle-bracket URLs: {fixed}"
3334 );
3335 assert!(
3336 fixed.contains("Test text"),
3337 "Fix should correct prose 'test' to 'Test': {fixed}"
3338 );
3339 }
3340
3341 #[test]
3342 fn test_is_in_angle_bracket_url_helper() {
3343 let line = "text <https://example.test> more text";
3345
3346 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));
3359
3360 assert!(MD044ProperNames::is_in_angle_bracket_url(
3362 "<mailto:test@example.com>",
3363 10
3364 ));
3365
3366 assert!(MD044ProperNames::is_in_angle_bracket_url(
3368 "<ftp://test.example.com>",
3369 10
3370 ));
3371 }
3372
3373 #[test]
3374 fn test_is_in_angle_bracket_url_uppercase_scheme() {
3375 assert!(MD044ProperNames::is_in_angle_bracket_url(
3377 "<HTTPS://test.example.com>",
3378 10
3379 ));
3380 assert!(MD044ProperNames::is_in_angle_bracket_url(
3381 "<Http://test.example.com>",
3382 10
3383 ));
3384 }
3385
3386 #[test]
3387 fn test_is_in_angle_bracket_url_uncommon_schemes() {
3388 assert!(MD044ProperNames::is_in_angle_bracket_url(
3390 "<ssh://test@example.com>",
3391 10
3392 ));
3393 assert!(MD044ProperNames::is_in_angle_bracket_url("<file:///test/path>", 10));
3395 assert!(MD044ProperNames::is_in_angle_bracket_url("<data:text/plain;test>", 10));
3397 }
3398
3399 #[test]
3400 fn test_is_in_angle_bracket_url_unclosed() {
3401 assert!(!MD044ProperNames::is_in_angle_bracket_url(
3403 "<https://test.example.com",
3404 10
3405 ));
3406 }
3407
3408 #[test]
3409 fn test_vale_inline_config_comments_not_flagged() {
3410 let config = MD044Config {
3411 names: vec!["Vale".to_string(), "JavaScript".to_string()],
3412 ..MD044Config::default()
3413 };
3414 let rule = MD044ProperNames::from_config_struct(config);
3415
3416 let content = "\
3417<!-- vale off -->
3418Some javascript text here.
3419<!-- vale on -->
3420<!-- vale Style.Rule = NO -->
3421More javascript text.
3422<!-- vale Style.Rule = YES -->
3423<!-- vale JavaScript.Grammar = NO -->
3424";
3425 let ctx = create_context(content);
3426 let result = rule.check(&ctx).unwrap();
3427
3428 assert_eq!(result.len(), 2, "Should only flag body lines, not Vale config comments");
3430 assert_eq!(result[0].line, 2);
3431 assert_eq!(result[1].line, 5);
3432 }
3433
3434 #[test]
3435 fn test_remark_lint_inline_config_comments_not_flagged() {
3436 let config = MD044Config {
3437 names: vec!["JavaScript".to_string()],
3438 ..MD044Config::default()
3439 };
3440 let rule = MD044ProperNames::from_config_struct(config);
3441
3442 let content = "\
3443<!-- lint disable remark-lint-some-rule -->
3444Some javascript text here.
3445<!-- lint enable remark-lint-some-rule -->
3446<!-- lint ignore remark-lint-some-rule -->
3447More javascript text.
3448";
3449 let ctx = create_context(content);
3450 let result = rule.check(&ctx).unwrap();
3451
3452 assert_eq!(
3453 result.len(),
3454 2,
3455 "Should only flag body lines, not remark-lint config comments"
3456 );
3457 assert_eq!(result[0].line, 2);
3458 assert_eq!(result[1].line, 5);
3459 }
3460
3461 #[test]
3462 fn test_fix_does_not_modify_vale_remark_lint_comments() {
3463 let config = MD044Config {
3464 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3465 ..MD044Config::default()
3466 };
3467 let rule = MD044ProperNames::from_config_struct(config);
3468
3469 let content = "\
3470<!-- vale off -->
3471Some javascript text.
3472<!-- vale on -->
3473<!-- lint disable remark-lint-some-rule -->
3474More javascript text.
3475<!-- lint enable remark-lint-some-rule -->
3476";
3477 let ctx = create_context(content);
3478 let fixed = rule.fix(&ctx).unwrap();
3479
3480 assert!(fixed.contains("<!-- vale off -->"));
3482 assert!(fixed.contains("<!-- vale on -->"));
3483 assert!(fixed.contains("<!-- lint disable remark-lint-some-rule -->"));
3484 assert!(fixed.contains("<!-- lint enable remark-lint-some-rule -->"));
3485 assert!(fixed.contains("Some JavaScript text."));
3487 assert!(fixed.contains("More JavaScript text."));
3488 }
3489
3490 #[test]
3491 fn test_mixed_tool_directives_all_skipped() {
3492 let config = MD044Config {
3493 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3494 ..MD044Config::default()
3495 };
3496 let rule = MD044ProperNames::from_config_struct(config);
3497
3498 let content = "\
3499<!-- rumdl-disable MD044 -->
3500Some javascript text.
3501<!-- markdownlint-disable -->
3502More javascript text.
3503<!-- vale off -->
3504Even more javascript text.
3505<!-- lint disable some-rule -->
3506Final javascript text.
3507<!-- rumdl-enable MD044 -->
3508<!-- markdownlint-enable -->
3509<!-- vale on -->
3510<!-- lint enable some-rule -->
3511";
3512 let ctx = create_context(content);
3513 let result = rule.check(&ctx).unwrap();
3514
3515 assert_eq!(
3517 result.len(),
3518 4,
3519 "Should only flag body lines, not any tool directive comments"
3520 );
3521 assert_eq!(result[0].line, 2);
3522 assert_eq!(result[1].line, 4);
3523 assert_eq!(result[2].line, 6);
3524 assert_eq!(result[3].line, 8);
3525 }
3526
3527 #[test]
3528 fn test_vale_remark_lint_edge_cases_not_matched() {
3529 let config = MD044Config {
3530 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3531 ..MD044Config::default()
3532 };
3533 let rule = MD044ProperNames::from_config_struct(config);
3534
3535 let content = "\
3543<!-- vale -->
3544<!-- vale is a tool for writing -->
3545<!-- valedictorian javascript -->
3546<!-- linting javascript tips -->
3547<!-- vale javascript -->
3548<!-- lint your javascript code -->
3549";
3550 let ctx = create_context(content);
3551 let result = rule.check(&ctx).unwrap();
3552
3553 assert_eq!(
3560 result.len(),
3561 7,
3562 "Should flag proper names in non-directive HTML comments: got {result:?}"
3563 );
3564 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); }
3572
3573 #[test]
3574 fn test_vale_style_directives_skipped() {
3575 let config = MD044Config {
3576 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3577 ..MD044Config::default()
3578 };
3579 let rule = MD044ProperNames::from_config_struct(config);
3580
3581 let content = "\
3583<!-- vale style = MyStyle -->
3584<!-- vale styles = Style1, Style2 -->
3585<!-- vale MyRule.Name = YES -->
3586<!-- vale MyRule.Name = NO -->
3587Some javascript text.
3588";
3589 let ctx = create_context(content);
3590 let result = rule.check(&ctx).unwrap();
3591
3592 assert_eq!(
3594 result.len(),
3595 1,
3596 "Should only flag body lines, not Vale style/rule directives: got {result:?}"
3597 );
3598 assert_eq!(result[0].line, 5);
3599 }
3600
3601 #[test]
3604 fn test_backtick_code_single_backticks() {
3605 let line = "hello `world` bye";
3606 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 7));
3608 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3610 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 14));
3612 }
3613
3614 #[test]
3615 fn test_backtick_code_double_backticks() {
3616 let line = "a ``code`` b";
3617 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3619 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3621 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 11));
3623 }
3624
3625 #[test]
3626 fn test_backtick_code_unclosed() {
3627 let line = "a `code b";
3628 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3630 }
3631
3632 #[test]
3633 fn test_backtick_code_mismatched_count() {
3634 let line = "a `code`` b";
3636 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3639 }
3640
3641 #[test]
3642 fn test_backtick_code_multiple_spans() {
3643 let line = "`first` and `second`";
3644 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
3646 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 8));
3648 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 13));
3650 }
3651
3652 #[test]
3653 fn test_backtick_code_on_backtick_boundary() {
3654 let line = "`code`";
3655 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3657 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 5));
3659 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
3661 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3662 }
3663
3664 #[test]
3670 fn test_double_bracket_link_url_not_flagged() {
3671 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3672 let content = "[[rumdl]](https://github.com/rvben/rumdl)";
3674 let ctx = create_context(content);
3675 let result = rule.check(&ctx).unwrap();
3676 assert!(
3677 result.is_empty(),
3678 "URL inside [[text]](url) must not be flagged, got: {result:?}"
3679 );
3680 }
3681
3682 #[test]
3683 fn test_double_bracket_link_url_not_fixed() {
3684 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3685 let content = "[[rumdl]](https://github.com/rvben/rumdl)\n";
3686 let ctx = create_context(content);
3687 let fixed = rule.fix(&ctx).unwrap();
3688 assert_eq!(
3689 fixed, content,
3690 "fix() must leave the URL inside [[text]](url) unchanged"
3691 );
3692 }
3693
3694 #[test]
3695 fn test_double_bracket_link_text_still_flagged() {
3696 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3697 let content = "[[github]](https://example.com)";
3699 let ctx = create_context(content);
3700 let result = rule.check(&ctx).unwrap();
3701 assert_eq!(
3702 result.len(),
3703 1,
3704 "Incorrect name in [[text]] link text should still be flagged, got: {result:?}"
3705 );
3706 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
3707 }
3708
3709 #[test]
3710 fn test_double_bracket_link_mixed_line() {
3711 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3712 let content = "See [[rumdl]](https://github.com/rvben/rumdl) and github for more.";
3714 let ctx = create_context(content);
3715 let result = rule.check(&ctx).unwrap();
3716 assert_eq!(
3717 result.len(),
3718 1,
3719 "Only the standalone 'github' after the link should be flagged, got: {result:?}"
3720 );
3721 assert!(result[0].message.contains("'github'"));
3722 assert_eq!(
3724 result[0].column, 51,
3725 "Flagged column should be the trailing 'github', not the one in the URL"
3726 );
3727 }
3728
3729 #[test]
3730 fn test_regular_link_url_still_not_flagged() {
3731 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3733 let content = "[rumdl](https://github.com/rvben/rumdl)";
3734 let ctx = create_context(content);
3735 let result = rule.check(&ctx).unwrap();
3736 assert!(
3737 result.is_empty(),
3738 "URL inside regular [text](url) must still not be flagged, got: {result:?}"
3739 );
3740 }
3741
3742 #[test]
3743 fn test_link_like_text_in_code_span_still_flagged_when_code_blocks_enabled() {
3744 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
3749 let content = "`[foo](https://github.com/org/repo)`";
3750 let ctx = create_context(content);
3751 let result = rule.check(&ctx).unwrap();
3752 assert_eq!(
3753 result.len(),
3754 1,
3755 "Proper name inside a code span must be flagged when code-blocks=true, got: {result:?}"
3756 );
3757 assert!(result[0].message.contains("'github'"));
3758 }
3759
3760 #[test]
3761 fn test_malformed_link_not_treated_as_url() {
3762 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3765 let content = "See [rumdl](github repo) for details.";
3766 let ctx = create_context(content);
3767 let result = rule.check(&ctx).unwrap();
3768 assert_eq!(
3769 result.len(),
3770 1,
3771 "Name inside malformed [text](url with spaces) must still be flagged, got: {result:?}"
3772 );
3773 assert!(result[0].message.contains("'github'"));
3774 }
3775
3776 #[test]
3777 fn test_wikilink_followed_by_prose_parens_still_flagged() {
3778 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3782 let content = "[[note]](github repo)";
3783 let ctx = create_context(content);
3784 let result = rule.check(&ctx).unwrap();
3785 assert_eq!(
3786 result.len(),
3787 1,
3788 "Name inside [[wikilink]](prose with spaces) must still be flagged, got: {result:?}"
3789 );
3790 assert!(result[0].message.contains("'github'"));
3791 }
3792
3793 #[test]
3795 fn test_roundtrip_fix_then_check_basic() {
3796 let rule = MD044ProperNames::new(
3797 vec![
3798 "JavaScript".to_string(),
3799 "TypeScript".to_string(),
3800 "Node.js".to_string(),
3801 ],
3802 true,
3803 );
3804 let content = "I love javascript, typescript, and nodejs!";
3805 let ctx = create_context(content);
3806 let fixed = rule.fix(&ctx).unwrap();
3807 let ctx2 = create_context(&fixed);
3808 let warnings = rule.check(&ctx2).unwrap();
3809 assert!(
3810 warnings.is_empty(),
3811 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3812 );
3813 }
3814
3815 #[test]
3817 fn test_roundtrip_fix_then_check_multiline() {
3818 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
3819 let content = "First line with rust.\nSecond line with python.\nThird line with RUST and PYTHON.\n";
3820 let ctx = create_context(content);
3821 let fixed = rule.fix(&ctx).unwrap();
3822 let ctx2 = create_context(&fixed);
3823 let warnings = rule.check(&ctx2).unwrap();
3824 assert!(
3825 warnings.is_empty(),
3826 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3827 );
3828 }
3829
3830 #[test]
3832 fn test_roundtrip_fix_then_check_inline_config() {
3833 let config = MD044Config {
3834 names: vec!["RUMDL".to_string()],
3835 ..MD044Config::default()
3836 };
3837 let rule = MD044ProperNames::from_config_struct(config);
3838 let content =
3839 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
3840 let ctx = create_context(content);
3841 let fixed = rule.fix(&ctx).unwrap();
3842 assert!(
3844 fixed.contains("Some rumdl text.\n"),
3845 "Disabled block text should be preserved"
3846 );
3847 assert!(
3848 fixed.contains("Some RUMDL text outside."),
3849 "Outside text should be fixed"
3850 );
3851 }
3852
3853 #[test]
3855 fn test_roundtrip_fix_then_check_html_comments() {
3856 let config = MD044Config {
3857 names: vec!["JavaScript".to_string()],
3858 ..MD044Config::default()
3859 };
3860 let rule = MD044ProperNames::from_config_struct(config);
3861 let content = "# Guide\n\n<!-- javascript mentioned here -->\n\njavascript outside\n";
3862 let ctx = create_context(content);
3863 let fixed = rule.fix(&ctx).unwrap();
3864 let ctx2 = create_context(&fixed);
3865 let warnings = rule.check(&ctx2).unwrap();
3866 assert!(
3867 warnings.is_empty(),
3868 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3869 );
3870 }
3871
3872 #[test]
3874 fn test_roundtrip_no_op_when_correct() {
3875 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
3876 let content = "This uses JavaScript and TypeScript correctly.\n";
3877 let ctx = create_context(content);
3878 let fixed = rule.fix(&ctx).unwrap();
3879 assert_eq!(fixed, content, "Fix should be a no-op when content is already correct");
3880 }
3881
3882 #[test]
3885 fn test_bare_domain_link_text_not_flagged() {
3886 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3890 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3891 let ctx = create_context(content);
3892 let result = rule.check(&ctx).unwrap();
3893 assert!(
3894 result.is_empty(),
3895 "Should not flag 'github' in a bare-domain link text that matches the link URL: {result:?}"
3896 );
3897 }
3898
3899 #[test]
3900 fn test_bare_domain_link_text_not_fixed() {
3901 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3903 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3904 let ctx = create_context(content);
3905 let fixed = rule.fix(&ctx).unwrap();
3906 assert_eq!(
3907 fixed, content,
3908 "fix() must not alter bare-domain link text that matches the destination URL"
3909 );
3910 }
3911
3912 #[test]
3913 fn test_bare_domain_link_text_with_path_not_flagged() {
3914 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3916 let content = "Visit [ravencentric.github.io](https://ravencentric.github.io/projects).\n";
3917 let ctx = create_context(content);
3918 let result = rule.check(&ctx).unwrap();
3919 assert!(
3920 result.is_empty(),
3921 "Should not flag 'github' when bare-domain text is the hostname of its destination URL: {result:?}"
3922 );
3923 }
3924
3925 #[test]
3926 fn test_bare_domain_link_text_full_path_not_flagged() {
3927 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3929 let content = "See [ravencentric.github.io/blog](https://ravencentric.github.io/blog).\n";
3930 let ctx = create_context(content);
3931 let result = rule.check(&ctx).unwrap();
3932 assert!(
3933 result.is_empty(),
3934 "Should not flag 'github' when link text is the full URL path without scheme: {result:?}"
3935 );
3936 }
3937
3938 #[test]
3939 fn test_github_product_name_in_link_text_still_flagged() {
3940 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3943 let content = "Hosted on [github pages](https://pages.github.com).\n";
3944 let ctx = create_context(content);
3945 let result = rule.check(&ctx).unwrap();
3946 assert!(
3947 !result.is_empty(),
3948 "Should still flag 'github' in descriptive link text that does not match the destination URL"
3949 );
3950 }
3951
3952 #[test]
3953 fn test_protocol_relative_bare_domain_link_text_not_flagged() {
3954 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3956 let content = "See [github.io](//github.io).\n";
3957 let ctx = create_context(content);
3958 let result = rule.check(&ctx).unwrap();
3959 assert!(
3960 result.is_empty(),
3961 "Should not flag 'github' in bare-domain text matching a protocol-relative destination: {result:?}"
3962 );
3963 }
3964
3965 #[test]
3966 fn test_dotted_wikilink_target_still_flagged() {
3967 let rule = MD044ProperNames::new(vec!["Node.js".to_string()], false);
3972 let content = "See [[node.js]] for details.\n";
3973 let ctx = create_context(content);
3974 let result = rule.check(&ctx).unwrap();
3975 assert!(
3976 !result.is_empty(),
3977 "Should flag 'node.js' in a dotted WikiLink target: {result:?}"
3978 );
3979 }
3980
3981 #[test]
3982 fn test_bare_domain_link_text_case_insensitive_url() {
3983 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3986 let content = "See [github.io](HTTPS://github.io).\n";
3987 let ctx = create_context(content);
3988 let result = rule.check(&ctx).unwrap();
3989 assert!(
3990 result.is_empty(),
3991 "Should not flag bare-domain text when destination URL has an uppercase scheme: {result:?}"
3992 );
3993 }
3994
3995 #[test]
3996 fn test_frontmatter_value_span_strips_trailing_comment() {
3997 let line = "link: docs/guide/myapp # canonical path";
3998 let (s, e) = MD044ProperNames::frontmatter_value_span(line).unwrap();
3999 assert_eq!(&line[s..e], "docs/guide/myapp");
4000 }
4001
4002 #[test]
4003 fn test_frontmatter_value_span_quoted_keeps_hash_and_spaces() {
4004 let line = "link: 'docs/My App/a#b'";
4005 let (s, e) = MD044ProperNames::frontmatter_value_span(line).unwrap();
4006 assert_eq!(&line[s..e], "docs/My App/a#b");
4007 }
4008
4009 #[test]
4010 fn test_frontmatter_value_span_plain_value() {
4011 let line = "title: Heading for myapp";
4012 let (s, e) = MD044ProperNames::frontmatter_value_span(line).unwrap();
4013 assert_eq!(&line[s..e], "Heading for myapp");
4014 }
4015
4016 #[test]
4017 fn test_frontmatter_value_span_none_for_key_only() {
4018 assert!(MD044ProperNames::frontmatter_value_span("seo:").is_none());
4019 assert!(MD044ProperNames::frontmatter_value_span("---").is_none());
4020 }
4021
4022 #[test]
4023 fn test_frontmatter_value_span_quoted_strips_trailing_comment() {
4024 let line = "link: 'docs/guide' # canonical path";
4025 let (s, e) = MD044ProperNames::frontmatter_value_span(line).unwrap();
4026 assert_eq!(&line[s..e], "docs/guide");
4027 }
4028
4029 #[test]
4030 fn test_frontmatter_value_span_empty_quoted_value_is_none() {
4031 assert!(MD044ProperNames::frontmatter_value_span("key: ''").is_none());
4032 }
4033
4034 #[test]
4035 fn test_frontmatter_value_span_unterminated_quote_strips_leading_quote() {
4036 let line = "link: 'docs/a";
4037 let (s, e) = MD044ProperNames::frontmatter_value_span(line).unwrap();
4038 assert_eq!(&line[s..e], "docs/a");
4039 }
4040
4041 fn at(line: &str, needle: &str) -> usize {
4043 line.find(needle).expect("needle present")
4044 }
4045
4046 #[test]
4047 fn test_path_like_exempts_single_token_frontmatter_paths() {
4048 for line in [
4049 "link: this/is/a/link/to/myapp.md",
4050 "link: docs/myapp.md",
4051 "link: /abs/path/myapp.md",
4052 "link: ./myapp.md",
4053 "link: ../shared/myapp.md",
4054 ] {
4055 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4056 let pos = at(line, "myapp");
4057 assert!(
4058 MD044ProperNames::is_in_path_like_token(line, pos, span),
4059 "should treat as a path: {line}"
4060 );
4061 }
4062 }
4063
4064 #[test]
4065 fn test_path_like_does_not_exempt_slash_conjunction_prose() {
4066 let line = "description: We support github/gitlab/bitbucket imports.";
4069 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4070 assert!(
4071 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
4072 "slash-separated prose is not a path"
4073 );
4074
4075 let line = "description: The javascript/typescript ecosystem is large.";
4076 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4077 assert!(!MD044ProperNames::is_in_path_like_token(
4078 line,
4079 at(line, "javascript"),
4080 span
4081 ));
4082 }
4083
4084 #[test]
4085 fn test_path_like_requires_a_slash_so_dotted_names_survive() {
4086 let line = "title: Use nodejs and myapp.md today.";
4087 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4088 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4089 }
4090
4091 #[test]
4092 fn test_path_like_no_slash_frontmatter_value_still_flagged() {
4093 let line = "slug: myapp-guide";
4098 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4099 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4100 }
4101
4102 #[test]
4103 fn test_path_like_returns_false_outside_value_span() {
4104 let line = "myapp: docs/guide/myapp";
4107 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4108 let key_pos = 0;
4109 assert!(!MD044ProperNames::is_in_path_like_token(line, key_pos, span));
4110 }
4111
4112 #[test]
4113 fn test_path_like_three_segments_only_as_sole_frontmatter_value() {
4114 let line = "link: docs/guide/myapp";
4115 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4116 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4117
4118 let line = "description: We support github/gitlab/bitbucket now";
4119 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4120 assert!(
4121 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
4122 "multi-token value gets body treatment"
4123 );
4124 }
4125
4126 #[test]
4127 fn test_path_like_quoted_value_with_spaces() {
4128 let line = "link: 'docs/My App/myapp.md'";
4131 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4132 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4133 }
4134
4135 #[test]
4136 fn test_path_like_quoted_value_with_spaces_no_extension_not_exempt() {
4137 let line = "link: 'docs/My App/myapp'";
4144 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4145 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4146 }
4147
4148 #[test]
4149 fn test_path_like_trailing_comment_is_still_sole_value() {
4150 let line = "link: docs/guide/myapp # canonical path";
4151 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4152 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4153 }
4154
4155 #[test]
4156 fn test_path_like_trailing_punctuation_trimmed() {
4157 let line = "link: docs/myapp.md, then leave.";
4158 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4159 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4160 }
4161
4162 #[test]
4163 fn test_trim_token_bounds_reaches_fixpoint_after_punctuation_exposes_wrapper() {
4164 let line = r#"See "docs/myapp.md", then leave."#;
4165 let raw_start = at(line, "\"docs");
4166 let raw_end = raw_start + r#""docs/myapp.md","#.len();
4167 assert_eq!(&line[raw_start..raw_end], r#""docs/myapp.md","#);
4168 let (start, end) = MD044ProperNames::trim_token_bounds(line, raw_start, raw_end);
4169 assert_eq!(&line[start..end], "docs/myapp.md");
4170 }
4171
4172 #[test]
4173 fn test_trim_token_bounds_reaches_fixpoint_with_multiple_trailing_wrappers() {
4174 let line = r#"("docs/myapp.md")."#;
4175 let (start, end) = MD044ProperNames::trim_token_bounds(line, 0, line.len());
4176 assert_eq!(&line[start..end], "docs/myapp.md");
4177 }
4178
4179 #[test]
4180 fn test_frontmatter_link_path_not_flagged() {
4181 let content = "---\ntitle: Heading for MyApp\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
4182 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4183 let ctx = create_context(content);
4184 let result = rule.check(&ctx).unwrap();
4185 assert!(
4186 result.is_empty(),
4187 "path in a frontmatter value must not be flagged: {result:?}"
4188 );
4189 }
4190
4191 #[test]
4192 fn test_fix_does_not_corrupt_frontmatter_link_path() {
4193 let content = "---\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
4194 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4195 let ctx = create_context(content);
4196 assert_eq!(rule.fix(&ctx).unwrap(), content, "fix must not rewrite a path");
4197 }
4198
4199 #[test]
4210 fn test_body_prose_parenthesized_disambiguator_is_case_corrected() {
4211 let content = "See docs/myapp(1).md here.\n";
4212 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4213 let ctx = create_context(content);
4214 let result = rule.check(&ctx).unwrap();
4215 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
4216 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp(1).md here.\n");
4217 }
4218
4219 #[test]
4220 fn test_body_prose_bracketed_dynamic_segment_is_case_corrected() {
4221 let content = "See docs/[myapp].md here.\n";
4222 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4223 let ctx = create_context(content);
4224 let result = rule.check(&ctx).unwrap();
4225 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
4226 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/[MyApp].md here.\n");
4227 }
4228
4229 #[test]
4230 fn test_body_prose_nextjs_catch_all_segment_is_case_corrected() {
4231 let content = "pages/[[...myapp]].tsx are catch-all routes.\n";
4234 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4235 let ctx = create_context(content);
4236 let result = rule.check(&ctx).unwrap();
4237 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
4238 assert_eq!(
4239 rule.fix(&ctx).unwrap(),
4240 "pages/[[...MyApp]].tsx are catch-all routes.\n"
4241 );
4242 }
4243
4244 #[test]
4245 fn test_two_adjacent_whitespace_free_links_both_flagged() {
4246 let content = "[myapp](https://a.com)[github](https://b.com)\n";
4250 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitHub".to_string()], false);
4251 let ctx = create_context(content);
4252 let result = rule.check(&ctx).unwrap();
4253 assert_eq!(result.len(), 2, "both link texts must be flagged: {result:?}");
4254 assert!(result.iter().any(|w| w.message.contains("'myapp'")));
4255 assert!(result.iter().any(|w| w.message.contains("'github'")));
4256 }
4257
4258 #[test]
4259 fn test_fix_does_not_corrupt_frontmatter_path_with_route_group_named_after_proper_name() {
4260 let content = "---\nlink: src/(myapp)/page.tsx\n---\n\nBody.\n";
4263 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4264 let ctx = create_context(content);
4265 assert_eq!(
4266 rule.fix(&ctx).unwrap(),
4267 content,
4268 "fix must not rewrite a frontmatter path whose route-group directory name is the proper name"
4269 );
4270 }
4271
4272 #[test]
4273 fn test_quoted_frontmatter_value_slash_conjunction_prose_still_flagged() {
4274 let content = "---\ndescription: \"We support github/gitlab/bitbucket now\"\n---\n\nBody.\n";
4279 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4280 let ctx = create_context(content);
4281 let result = rule.check(&ctx).unwrap();
4282 assert_eq!(
4283 result.len(),
4284 1,
4285 "quoted prose value must still flag 'github': {result:?}"
4286 );
4287 }
4288
4289 #[test]
4290 fn test_quoted_frontmatter_value_single_slash_word_with_unrelated_dot_still_flagged() {
4291 let content = "---\ndescription: \"We use myapp/gitlab and version 1.0 e.g. weekly\"\n---\n\nBody.\n";
4294 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4295 let ctx = create_context(content);
4296 let result = rule.check(&ctx).unwrap();
4297 assert_eq!(
4298 result.len(),
4299 1,
4300 "quoted prose value must still flag 'myapp': {result:?}"
4301 );
4302 }
4303
4304 #[test]
4305 fn test_quoted_toml_frontmatter_value_slash_conjunction_prose_still_flagged() {
4306 let content = "+++\ndescription = \"We support github/gitlab/bitbucket now\"\n+++\n\nBody.\n";
4309 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4310 let ctx = create_context(content);
4311 let result = rule.check(&ctx).unwrap();
4312 assert_eq!(
4313 result.len(),
4314 1,
4315 "TOML quoted prose value must still flag 'github': {result:?}"
4316 );
4317 }
4318
4319 #[test]
4320 fn test_path_like_collapsed_multiword_no_extension_not_exempt() {
4321 for line in [
4328 r#"description: "myapp/gitlab github/bitbucket""#,
4329 r#"description: "and/or this/that myapp/gitlab""#,
4330 r#"description: "he/him she/her myapp/gitlab""#,
4331 ] {
4332 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4333 for needle in ["myapp", "gitlab"] {
4334 if let Some(byte_pos) = line.find(needle) {
4335 assert!(
4336 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
4337 "collapsed multi-word value must not exempt '{needle}': {line}"
4338 );
4339 }
4340 }
4341 }
4342 }
4343
4344 #[test]
4345 fn test_path_like_collapsed_multiword_no_extension_not_exempt_toml() {
4346 let line = r#"description = "myapp/gitlab github/bitbucket""#;
4347 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4348 for needle in ["myapp", "gitlab", "github", "bitbucket"] {
4349 let byte_pos = at(line, needle);
4350 assert!(
4351 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
4352 "collapsed multi-word TOML value must not exempt '{needle}'"
4353 );
4354 }
4355 }
4356
4357 #[test]
4358 fn test_frontmatter_collapsed_multiword_names_all_flagged_yaml() {
4359 let content = "---\ndescription: \"myapp/gitlab github/bitbucket\"\n---\n\nBody.\n";
4360 let rule = MD044ProperNames::new(
4361 vec![
4362 "MyApp".to_string(),
4363 "GitLab".to_string(),
4364 "GitHub".to_string(),
4365 "Bitbucket".to_string(),
4366 ],
4367 false,
4368 );
4369 let ctx = create_context(content);
4370 let result = rule.check(&ctx).unwrap();
4371 assert_eq!(
4372 result.len(),
4373 4,
4374 "all four names in the collapsed multi-word value must be flagged: {result:?}"
4375 );
4376 }
4377
4378 #[test]
4379 fn test_frontmatter_collapsed_multiword_names_all_flagged_toml() {
4380 let content = "+++\ndescription = \"myapp/gitlab github/bitbucket\"\n+++\n\nBody.\n";
4381 let rule = MD044ProperNames::new(
4382 vec![
4383 "MyApp".to_string(),
4384 "GitLab".to_string(),
4385 "GitHub".to_string(),
4386 "Bitbucket".to_string(),
4387 ],
4388 false,
4389 );
4390 let ctx = create_context(content);
4391 let result = rule.check(&ctx).unwrap();
4392 assert_eq!(
4393 result.len(),
4394 4,
4395 "all four names in the collapsed multi-word TOML value must be flagged: {result:?}"
4396 );
4397 }
4398
4399 #[test]
4400 fn test_frontmatter_collapsed_multiword_conjunction_pairs_flagged() {
4401 let content = "---\ndescription: \"and/or this/that myapp/gitlab\"\n---\n\nBody.\n";
4402 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4403 let ctx = create_context(content);
4404 let result = rule.check(&ctx).unwrap();
4405 assert_eq!(
4406 result.len(),
4407 2,
4408 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4409 );
4410 }
4411
4412 #[test]
4413 fn test_frontmatter_collapsed_multiword_pronoun_pairs_flagged() {
4414 let content = "---\ndescription: \"he/him she/her myapp/gitlab\"\n---\n\nBody.\n";
4415 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4416 let ctx = create_context(content);
4417 let result = rule.check(&ctx).unwrap();
4418 assert_eq!(
4419 result.len(),
4420 2,
4421 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4422 );
4423 }
4424
4425 #[test]
4431 fn test_body_prose_path_is_flagged_frontmatter_only_scope() {
4432 let content = "See docs/myapp.md for details about myapp.\n";
4433 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4434 let ctx = create_context(content);
4435 let result = rule.check(&ctx).unwrap();
4436 assert_eq!(
4437 result.len(),
4438 2,
4439 "both the path occurrence and the prose occurrence are flagged in body text: {result:?}"
4440 );
4441 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp.md for details about MyApp.\n");
4442 }
4443
4444 #[test]
4445 fn test_slash_conjunction_prose_still_flagged() {
4446 let content = "We support github/gitlab imports.\n";
4447 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4448 let ctx = create_context(content);
4449 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
4450 }
4451}