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_indented_html_comment_escapes_via_link_and_backticks() {
2196 let config = MD044Config {
2201 names: vec!["Test".to_string()],
2202 ..Default::default()
2203 };
2204 let rule = MD044ProperNames::from_config_struct(config);
2205
2206 let content = "<!-- see the [relevant page](test.md). -->\n<!-- see `test.md` -->\n <!-- see the [relevant page](test.md). -->\n <!-- see `test.md` -->\n";
2207
2208 let ctx = create_context(content);
2209 let result = rule.check(&ctx).unwrap();
2210
2211 assert!(
2212 result.is_empty(),
2213 "'test' inside a link URL or backticks must be ignored in both column-0 and indented comments, got: {result:?}"
2214 );
2215 }
2216
2217 #[test]
2218 fn test_indented_html_comment_still_checks_bare_prose() {
2219 let config = MD044Config {
2222 names: vec!["Test".to_string()],
2223 ..Default::default()
2224 };
2225 let rule = MD044ProperNames::from_config_struct(config);
2226
2227 let content = " <!-- this is a test comment -->\n";
2228
2229 let ctx = create_context(content);
2230 let result = rule.check(&ctx).unwrap();
2231
2232 assert_eq!(
2233 result.len(),
2234 1,
2235 "bare 'test' in an indented comment is still a violation"
2236 );
2237 assert_eq!(result[0].line, 1);
2238 }
2239
2240 #[test]
2241 fn test_multiline_html_comments() {
2242 let config = MD044Config {
2243 names: vec!["Python".to_string(), "JavaScript".to_string()],
2244 code_blocks: true, html_comments: false, ..Default::default()
2247 };
2248 let rule = MD044ProperNames::from_config_struct(config);
2249
2250 let content = r#"Regular python here.
2251<!--
2252This is a multiline comment
2253with javascript and python
2254that should be ignored
2255-->
2256More javascript outside."#;
2257
2258 let ctx = create_context(content);
2259 let result = rule.check(&ctx).unwrap();
2260
2261 assert_eq!(result.len(), 2, "Should only flag names outside HTML comments");
2262 assert_eq!(result[0].line, 1); assert_eq!(result[1].line, 7); }
2265
2266 #[test]
2267 fn test_fix_preserves_html_comments_when_disabled() {
2268 let config = MD044Config {
2269 names: vec!["JavaScript".to_string()],
2270 code_blocks: true, html_comments: false, ..Default::default()
2273 };
2274 let rule = MD044ProperNames::from_config_struct(config);
2275
2276 let content = r#"javascript here.
2277<!-- javascript in comment -->
2278More javascript."#;
2279
2280 let ctx = create_context(content);
2281 let fixed = rule.fix(&ctx).unwrap();
2282
2283 let expected = r#"JavaScript here.
2284<!-- javascript in comment -->
2285More JavaScript."#;
2286
2287 assert_eq!(
2288 fixed, expected,
2289 "Should not fix names inside HTML comments when disabled"
2290 );
2291 }
2292
2293 #[test]
2294 fn test_proper_names_in_link_text_are_flagged() {
2295 let rule = MD044ProperNames::new(
2296 vec!["JavaScript".to_string(), "Node.js".to_string(), "Python".to_string()],
2297 true,
2298 );
2299
2300 let content = r#"Check this [javascript documentation](https://javascript.info) for info.
2301
2302Visit [node.js homepage](https://nodejs.org) and [python tutorial](https://python.org).
2303
2304Real javascript should be flagged.
2305
2306Also see the [typescript guide][ts-ref] for more.
2307
2308Real python should be flagged too.
2309
2310[ts-ref]: https://typescript.org/handbook"#;
2311
2312 let ctx = create_context(content);
2313 let result = rule.check(&ctx).unwrap();
2314
2315 assert_eq!(result.len(), 5, "Expected 5 warnings: 3 in link text + 2 standalone");
2322
2323 let line_1_warnings: Vec<_> = result.iter().filter(|w| w.line == 1).collect();
2325 assert_eq!(line_1_warnings.len(), 1);
2326 assert!(
2327 line_1_warnings[0]
2328 .message
2329 .contains("'javascript' should be 'JavaScript'")
2330 );
2331
2332 let line_3_warnings: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
2333 assert_eq!(line_3_warnings.len(), 2); assert!(result.iter().any(|w| w.line == 5 && w.message.contains("'javascript'")));
2337 assert!(result.iter().any(|w| w.line == 9 && w.message.contains("'python'")));
2338 }
2339
2340 #[test]
2341 fn test_link_urls_not_flagged() {
2342 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2343
2344 let content = r#"[Link Text](https://javascript.info/guide)"#;
2346
2347 let ctx = create_context(content);
2348 let result = rule.check(&ctx).unwrap();
2349
2350 assert!(result.is_empty(), "URLs should not be checked for proper names");
2352 }
2353
2354 #[test]
2355 fn test_bare_urls_not_flagged() {
2356 let rule = MD044ProperNames::new(vec!["Foo".to_string(), "JavaScript".to_string()], true);
2357
2358 let content =
2361 "https://foo.com\n\nSee https://javascript.info/foo/guide for details.\n\nMail foo@foo.com about it.\n";
2362
2363 let ctx = create_context(content);
2364 let result = rule.check(&ctx).unwrap();
2365
2366 assert!(
2367 result.is_empty(),
2368 "Bare URLs and emails should not be checked for proper names: {result:?}"
2369 );
2370 }
2371
2372 #[test]
2373 fn test_prose_around_bare_url_still_flagged() {
2374 let rule = MD044ProperNames::new(vec!["Foo".to_string()], true);
2375
2376 let content = "Use foo at https://foo.com because foo is great.\n";
2379
2380 let ctx = create_context(content);
2381 let result = rule.check(&ctx).unwrap();
2382
2383 assert_eq!(
2384 result.len(),
2385 2,
2386 "Prose occurrences around a bare URL must still be flagged: {result:?}"
2387 );
2388 assert!(result.iter().all(|w| w.message.contains("'foo' should be 'Foo'")));
2389 }
2390
2391 #[test]
2392 fn test_proper_names_in_image_alt_text_are_flagged() {
2393 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2394
2395 let content = r#"Here is a  image.
2396
2397Real javascript should be flagged."#;
2398
2399 let ctx = create_context(content);
2400 let result = rule.check(&ctx).unwrap();
2401
2402 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in alt text + 1 standalone");
2406 assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
2407 assert!(result[0].line == 1); assert!(result[1].message.contains("'javascript' should be 'JavaScript'"));
2409 assert!(result[1].line == 3); }
2411
2412 #[test]
2413 fn test_image_urls_not_flagged() {
2414 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2415
2416 let content = r#""#;
2418
2419 let ctx = create_context(content);
2420 let result = rule.check(&ctx).unwrap();
2421
2422 assert!(result.is_empty(), "Image URLs should not be checked for proper names");
2424 }
2425
2426 #[test]
2427 fn test_reference_link_text_flagged_but_definition_not() {
2428 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2429
2430 let content = r#"Check the [javascript guide][js-ref] for details.
2431
2432Real javascript should be flagged.
2433
2434[js-ref]: https://javascript.info/typescript/guide"#;
2435
2436 let ctx = create_context(content);
2437 let result = rule.check(&ctx).unwrap();
2438
2439 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in link text + 1 standalone");
2444 assert!(result.iter().any(|w| w.line == 1 && w.message.contains("'javascript'")));
2445 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2446 }
2447
2448 #[test]
2449 fn test_reference_definitions_not_flagged() {
2450 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2451
2452 let content = r#"[js-ref]: https://javascript.info/guide"#;
2454
2455 let ctx = create_context(content);
2456 let result = rule.check(&ctx).unwrap();
2457
2458 assert!(result.is_empty(), "Reference definitions should not be checked");
2460 }
2461
2462 #[test]
2463 fn test_wikilinks_text_is_flagged() {
2464 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2465
2466 let content = r#"[[javascript]]
2468
2469Regular javascript here.
2470
2471[[JavaScript|display text]]"#;
2472
2473 let ctx = create_context(content);
2474 let result = rule.check(&ctx).unwrap();
2475
2476 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in WikiLink + 1 standalone");
2480 assert!(
2481 result
2482 .iter()
2483 .any(|w| w.line == 1 && w.column == 3 && w.message.contains("'javascript'"))
2484 );
2485 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2486 }
2487
2488 #[test]
2489 fn test_url_link_text_not_flagged() {
2490 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2491
2492 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2494
2495[http://github.com/org/repo](http://github.com/org/repo)
2496
2497[www.github.com/org/repo](https://www.github.com/org/repo)"#;
2498
2499 let ctx = create_context(content);
2500 let result = rule.check(&ctx).unwrap();
2501
2502 assert!(
2503 result.is_empty(),
2504 "URL-like link text should not be flagged, got: {result:?}"
2505 );
2506 }
2507
2508 #[test]
2509 fn test_url_link_text_with_leading_space_not_flagged() {
2510 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2511
2512 let content = r#"[ https://github.com/org/repo](https://github.com/org/repo)"#;
2514
2515 let ctx = create_context(content);
2516 let result = rule.check(&ctx).unwrap();
2517
2518 assert!(
2519 result.is_empty(),
2520 "URL-like link text with leading space should not be flagged, got: {result:?}"
2521 );
2522 }
2523
2524 #[test]
2525 fn test_url_link_text_uppercase_scheme_not_flagged() {
2526 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2527
2528 let content = r#"[HTTPS://GITHUB.COM/org/repo](https://github.com/org/repo)"#;
2529
2530 let ctx = create_context(content);
2531 let result = rule.check(&ctx).unwrap();
2532
2533 assert!(
2534 result.is_empty(),
2535 "URL-like link text with uppercase scheme should not be flagged, got: {result:?}"
2536 );
2537 }
2538
2539 #[test]
2540 fn test_non_url_link_text_still_flagged() {
2541 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2542
2543 let content = r#"[github.com/org/repo](https://github.com/org/repo)
2547
2548[Visit github](https://github.com/org/repo)
2549
2550[//github.com/org/repo](//github.com/org/repo)
2551
2552[ftp://github.com/org/repo](ftp://github.com/org/repo)"#;
2553
2554 let ctx = create_context(content);
2555 let result = rule.check(&ctx).unwrap();
2556
2557 assert_eq!(
2562 result.len(),
2563 1,
2564 "Only prose link text should be flagged, got: {result:?}"
2565 );
2566 assert!(
2567 result.iter().any(|w| w.line == 3),
2568 "Expected 'Visit github' on line 3 to be flagged"
2569 );
2570 }
2571
2572 #[test]
2573 fn test_url_link_text_fix_not_applied() {
2574 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2575
2576 let content = "[https://github.com/org/repo](https://github.com/org/repo)\n";
2577
2578 let ctx = create_context(content);
2579 let result = rule.fix(&ctx).unwrap();
2580
2581 assert_eq!(result, content, "Fix should not modify URL-like link text");
2582 }
2583
2584 #[test]
2585 fn test_mixed_url_and_regular_link_text() {
2586 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2587
2588 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2590
2591Visit [github documentation](https://github.com/docs) for details.
2592
2593[www.github.com/pricing](https://www.github.com/pricing)"#;
2594
2595 let ctx = create_context(content);
2596 let result = rule.check(&ctx).unwrap();
2597
2598 assert_eq!(
2600 result.len(),
2601 1,
2602 "Only non-URL link text should be flagged, got: {result:?}"
2603 );
2604 assert_eq!(result[0].line, 3);
2605 }
2606
2607 #[test]
2608 fn test_html_attribute_values_not_flagged() {
2609 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2612 let content = "# Heading\n\ntest\n\n<img src=\"www.example.test/test_image.png\">\n";
2613 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2614 let result = rule.check(&ctx).unwrap();
2615
2616 let line5_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2618 assert!(
2619 line5_violations.is_empty(),
2620 "Should not flag anything inside HTML tag attributes: {line5_violations:?}"
2621 );
2622
2623 let line3_violations: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
2625 assert_eq!(line3_violations.len(), 1, "Plain 'test' on line 3 should be flagged");
2626 }
2627
2628 #[test]
2629 fn test_html_text_content_still_flagged() {
2630 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2632 let content = "# Heading\n\n<a href=\"https://example.test/page\">test link</a>\n";
2633 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2634 let result = rule.check(&ctx).unwrap();
2635
2636 assert_eq!(
2639 result.len(),
2640 1,
2641 "Should flag only 'test' in anchor text, not in href: {result:?}"
2642 );
2643 assert_eq!(result[0].column, 37, "Should flag col 37 ('test link' in anchor text)");
2644 }
2645
2646 #[test]
2647 fn test_html_attribute_various_not_flagged() {
2648 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2650 let content = concat!(
2651 "# Heading\n\n",
2652 "<img src=\"test.png\" alt=\"test image\">\n",
2653 "<span class=\"test-class\" data-test=\"value\">test content</span>\n",
2654 );
2655 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2656 let result = rule.check(&ctx).unwrap();
2657
2658 assert_eq!(
2660 result.len(),
2661 1,
2662 "Should flag only 'test content' between tags: {result:?}"
2663 );
2664 assert_eq!(result[0].line, 4);
2665 }
2666
2667 #[test]
2668 fn test_plain_text_underscore_boundary_unchanged() {
2669 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2672 let content = "# Heading\n\ntest_image is here and just_test ends here\n";
2673 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2674 let result = rule.check(&ctx).unwrap();
2675
2676 assert_eq!(
2679 result.len(),
2680 2,
2681 "Should flag 'test' in both 'test_image' and 'just_test': {result:?}"
2682 );
2683 let cols: Vec<usize> = result.iter().map(|w| w.column).collect();
2684 assert!(cols.contains(&1), "Should flag col 1 (test_image): {cols:?}");
2685 assert!(cols.contains(&29), "Should flag col 29 (just_test): {cols:?}");
2686 }
2687
2688 #[test]
2689 fn test_frontmatter_yaml_keys_not_flagged() {
2690 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2693
2694 let content = "---\ntitle: Heading\ntest: Some Test value\n---\n\nTest\n";
2695 let ctx = create_context(content);
2696 let result = rule.check(&ctx).unwrap();
2697
2698 assert!(
2702 result.is_empty(),
2703 "Should not flag YAML keys or correctly capitalized values: {result:?}"
2704 );
2705 }
2706
2707 #[test]
2708 fn test_frontmatter_yaml_values_flagged() {
2709 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2711
2712 let content = "---\ntitle: Heading\nkey: a test value\n---\n\nTest\n";
2713 let ctx = create_context(content);
2714 let result = rule.check(&ctx).unwrap();
2715
2716 assert_eq!(result.len(), 1, "Should flag 'test' in YAML value: {result:?}");
2718 assert_eq!(result[0].line, 3);
2719 assert_eq!(result[0].column, 8); }
2721
2722 #[test]
2723 fn test_frontmatter_key_matches_name_not_flagged() {
2724 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2726
2727 let content = "---\ntest: other value\n---\n\nBody text\n";
2728 let ctx = create_context(content);
2729 let result = rule.check(&ctx).unwrap();
2730
2731 assert!(
2732 result.is_empty(),
2733 "Should not flag YAML key that matches configured name: {result:?}"
2734 );
2735 }
2736
2737 #[test]
2738 fn test_frontmatter_empty_value_not_flagged() {
2739 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2741
2742 let content = "---\ntest:\ntest: \n---\n\nBody text\n";
2743 let ctx = create_context(content);
2744 let result = rule.check(&ctx).unwrap();
2745
2746 assert!(
2747 result.is_empty(),
2748 "Should not flag YAML keys with empty values: {result:?}"
2749 );
2750 }
2751
2752 #[test]
2753 fn test_frontmatter_nested_yaml_key_not_flagged() {
2754 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2756
2757 let content = "---\nparent:\n test: nested value\n---\n\nBody text\n";
2758 let ctx = create_context(content);
2759 let result = rule.check(&ctx).unwrap();
2760
2761 assert!(result.is_empty(), "Should not flag nested YAML keys: {result:?}");
2763 }
2764
2765 #[test]
2766 fn test_frontmatter_list_items_checked() {
2767 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2769
2770 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2771 let ctx = create_context(content);
2772 let result = rule.check(&ctx).unwrap();
2773
2774 assert_eq!(result.len(), 1, "Should flag 'test' in YAML list item: {result:?}");
2776 assert_eq!(result[0].line, 3);
2777 }
2778
2779 #[test]
2780 fn test_frontmatter_value_with_multiple_colons() {
2781 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2783
2784 let content = "---\ntest: description: a test thing\n---\n\nBody text\n";
2785 let ctx = create_context(content);
2786 let result = rule.check(&ctx).unwrap();
2787
2788 assert_eq!(
2791 result.len(),
2792 1,
2793 "Should flag 'test' in value after first colon: {result:?}"
2794 );
2795 assert_eq!(result[0].line, 2);
2796 assert!(result[0].column > 6, "Violation column should be in value portion");
2797 }
2798
2799 #[test]
2800 fn test_frontmatter_does_not_affect_body() {
2801 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2803
2804 let content = "---\ntitle: Heading\n---\n\ntest should be flagged here\n";
2805 let ctx = create_context(content);
2806 let result = rule.check(&ctx).unwrap();
2807
2808 assert_eq!(result.len(), 1, "Should flag 'test' in body text: {result:?}");
2809 assert_eq!(result[0].line, 5);
2810 }
2811
2812 #[test]
2813 fn test_frontmatter_fix_corrects_values_preserves_keys() {
2814 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2816
2817 let content = "---\ntest: a test value\n---\n\ntest here\n";
2818 let ctx = create_context(content);
2819 let fixed = rule.fix(&ctx).unwrap();
2820
2821 assert_eq!(fixed, "---\ntest: a Test value\n---\n\nTest here\n");
2823 }
2824
2825 #[test]
2826 fn test_frontmatter_multiword_value_flagged() {
2827 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2829
2830 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2831 let ctx = create_context(content);
2832 let result = rule.check(&ctx).unwrap();
2833
2834 assert_eq!(result.len(), 2, "Should flag both names in YAML value: {result:?}");
2835 assert!(result.iter().all(|w| w.line == 2));
2836 }
2837
2838 #[test]
2839 fn test_frontmatter_yaml_comments_not_checked() {
2840 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2842
2843 let content = "---\n# test comment\ntitle: Heading\n---\n\nBody text\n";
2844 let ctx = create_context(content);
2845 let result = rule.check(&ctx).unwrap();
2846
2847 assert!(result.is_empty(), "Should not flag names in YAML comments: {result:?}");
2848 }
2849
2850 #[test]
2851 fn test_frontmatter_delimiters_not_checked() {
2852 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2854
2855 let content = "---\ntitle: Heading\n---\n\ntest here\n";
2856 let ctx = create_context(content);
2857 let result = rule.check(&ctx).unwrap();
2858
2859 assert_eq!(result.len(), 1, "Should only flag body text: {result:?}");
2861 assert_eq!(result[0].line, 5);
2862 }
2863
2864 #[test]
2865 fn test_frontmatter_continuation_lines_checked() {
2866 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2868
2869 let content = "---\ndescription: >\n a test value\n continued here\n---\n\nBody\n";
2870 let ctx = create_context(content);
2871 let result = rule.check(&ctx).unwrap();
2872
2873 assert_eq!(result.len(), 1, "Should flag 'test' in continuation line: {result:?}");
2875 assert_eq!(result[0].line, 3);
2876 }
2877
2878 #[test]
2879 fn test_frontmatter_quoted_values_checked() {
2880 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2882
2883 let content = "---\ntitle: \"a test title\"\n---\n\nBody\n";
2884 let ctx = create_context(content);
2885 let result = rule.check(&ctx).unwrap();
2886
2887 assert_eq!(result.len(), 1, "Should flag 'test' in quoted YAML value: {result:?}");
2888 assert_eq!(result[0].line, 2);
2889 }
2890
2891 #[test]
2892 fn test_frontmatter_single_quoted_values_checked() {
2893 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2895
2896 let content = "---\ntitle: 'a test title'\n---\n\nBody\n";
2897 let ctx = create_context(content);
2898 let result = rule.check(&ctx).unwrap();
2899
2900 assert_eq!(
2901 result.len(),
2902 1,
2903 "Should flag 'test' in single-quoted YAML value: {result:?}"
2904 );
2905 assert_eq!(result[0].line, 2);
2906 }
2907
2908 #[test]
2909 fn test_frontmatter_fix_multiword_values() {
2910 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2912
2913 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2914 let ctx = create_context(content);
2915 let fixed = rule.fix(&ctx).unwrap();
2916
2917 assert_eq!(
2918 fixed,
2919 "---\ndescription: Learn JavaScript and TypeScript\n---\n\nBody\n"
2920 );
2921 }
2922
2923 #[test]
2924 fn test_frontmatter_fix_preserves_yaml_structure() {
2925 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2927
2928 let content = "---\ntags:\n - test\n - other\ntitle: a test doc\n---\n\ntest body\n";
2929 let ctx = create_context(content);
2930 let fixed = rule.fix(&ctx).unwrap();
2931
2932 assert_eq!(
2933 fixed,
2934 "---\ntags:\n - Test\n - other\ntitle: a Test doc\n---\n\nTest body\n"
2935 );
2936 }
2937
2938 #[test]
2939 fn test_frontmatter_toml_delimiters_not_checked() {
2940 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2942
2943 let content = "+++\ntitle = \"a test title\"\n+++\n\ntest body\n";
2944 let ctx = create_context(content);
2945 let result = rule.check(&ctx).unwrap();
2946
2947 assert_eq!(result.len(), 2, "Should flag TOML value and body: {result:?}");
2951 let fm_violations: Vec<_> = result.iter().filter(|w| w.line == 2).collect();
2952 assert_eq!(fm_violations.len(), 1, "Should flag 'test' in TOML value: {result:?}");
2953 let body_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2954 assert_eq!(body_violations.len(), 1, "Should flag body 'test': {result:?}");
2955 }
2956
2957 #[test]
2958 fn test_frontmatter_toml_key_not_flagged() {
2959 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2961
2962 let content = "+++\ntest = \"other value\"\n+++\n\nBody text\n";
2963 let ctx = create_context(content);
2964 let result = rule.check(&ctx).unwrap();
2965
2966 assert!(
2967 result.is_empty(),
2968 "Should not flag TOML key that matches configured name: {result:?}"
2969 );
2970 }
2971
2972 #[test]
2973 fn test_frontmatter_toml_fix_preserves_keys() {
2974 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2976
2977 let content = "+++\ntest = \"a test value\"\n+++\n\ntest here\n";
2978 let ctx = create_context(content);
2979 let fixed = rule.fix(&ctx).unwrap();
2980
2981 assert_eq!(fixed, "+++\ntest = \"a Test value\"\n+++\n\nTest here\n");
2983 }
2984
2985 #[test]
2986 fn test_frontmatter_list_item_mapping_key_not_flagged() {
2987 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2990
2991 let content = "---\nitems:\n - test: nested value\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 YAML key in list-item mapping: {result:?}"
2998 );
2999 }
3000
3001 #[test]
3002 fn test_frontmatter_list_item_mapping_value_flagged() {
3003 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
3005
3006 let content = "---\nitems:\n - key: a test value\n---\n\nBody text\n";
3007 let ctx = create_context(content);
3008 let result = rule.check(&ctx).unwrap();
3009
3010 assert_eq!(
3011 result.len(),
3012 1,
3013 "Should flag 'test' in list-item mapping value: {result:?}"
3014 );
3015 assert_eq!(result[0].line, 3);
3016 }
3017
3018 #[test]
3019 fn test_frontmatter_bare_list_item_still_flagged() {
3020 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
3022
3023 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
3024 let ctx = create_context(content);
3025 let result = rule.check(&ctx).unwrap();
3026
3027 assert_eq!(result.len(), 1, "Should flag 'test' in bare list item: {result:?}");
3028 assert_eq!(result[0].line, 3);
3029 }
3030
3031 #[test]
3032 fn test_frontmatter_flow_mapping_not_flagged() {
3033 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
3036
3037 let content = "---\nflow_map: {test: value, other: test}\n---\n\nBody text\n";
3038 let ctx = create_context(content);
3039 let result = rule.check(&ctx).unwrap();
3040
3041 assert!(
3042 result.is_empty(),
3043 "Should not flag names inside flow mappings: {result:?}"
3044 );
3045 }
3046
3047 #[test]
3048 fn test_frontmatter_flow_sequence_not_flagged() {
3049 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
3051
3052 let content = "---\nitems: [test, other, test]\n---\n\nBody text\n";
3053 let ctx = create_context(content);
3054 let result = rule.check(&ctx).unwrap();
3055
3056 assert!(
3057 result.is_empty(),
3058 "Should not flag names inside flow sequences: {result:?}"
3059 );
3060 }
3061
3062 #[test]
3063 fn test_frontmatter_list_item_mapping_fix_preserves_key() {
3064 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
3066
3067 let content = "---\nitems:\n - test: a test value\n---\n\ntest here\n";
3068 let ctx = create_context(content);
3069 let fixed = rule.fix(&ctx).unwrap();
3070
3071 assert_eq!(fixed, "---\nitems:\n - test: a Test value\n---\n\nTest here\n");
3074 }
3075
3076 #[test]
3077 fn test_frontmatter_backtick_code_not_flagged() {
3078 let config = MD044Config {
3080 names: vec!["GoodApplication".to_string()],
3081 code_blocks: false,
3082 ..MD044Config::default()
3083 };
3084 let rule = MD044ProperNames::from_config_struct(config);
3085
3086 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
3087 let ctx = create_context(content);
3088 let result = rule.check(&ctx).unwrap();
3089
3090 assert!(
3092 result.is_empty(),
3093 "Should not flag names inside backticks in frontmatter or body: {result:?}"
3094 );
3095 }
3096
3097 #[test]
3098 fn test_frontmatter_unquoted_backtick_code_not_flagged() {
3099 let config = MD044Config {
3101 names: vec!["GoodApplication".to_string()],
3102 code_blocks: false,
3103 ..MD044Config::default()
3104 };
3105 let rule = MD044ProperNames::from_config_struct(config);
3106
3107 let content = "---\ntitle: `goodapplication` CLI\n---\n\nIntroductory `goodapplication` CLI text.\n";
3108 let ctx = create_context(content);
3109 let result = rule.check(&ctx).unwrap();
3110
3111 assert!(
3112 result.is_empty(),
3113 "Should not flag names inside backticks in unquoted YAML frontmatter: {result:?}"
3114 );
3115 }
3116
3117 #[test]
3118 fn test_frontmatter_bare_name_still_flagged_with_backtick_nearby() {
3119 let config = MD044Config {
3121 names: vec!["GoodApplication".to_string()],
3122 code_blocks: false,
3123 ..MD044Config::default()
3124 };
3125 let rule = MD044ProperNames::from_config_struct(config);
3126
3127 let content = "---\ntitle: goodapplication `goodapplication` CLI\n---\n\nBody\n";
3128 let ctx = create_context(content);
3129 let result = rule.check(&ctx).unwrap();
3130
3131 assert_eq!(
3133 result.len(),
3134 1,
3135 "Should flag bare name but not backtick-wrapped name: {result:?}"
3136 );
3137 assert_eq!(result[0].line, 2);
3138 assert_eq!(result[0].column, 8); }
3140
3141 #[test]
3142 fn test_frontmatter_backtick_code_with_code_blocks_true() {
3143 let config = MD044Config {
3145 names: vec!["GoodApplication".to_string()],
3146 code_blocks: true,
3147 ..MD044Config::default()
3148 };
3149 let rule = MD044ProperNames::from_config_struct(config);
3150
3151 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nBody\n";
3152 let ctx = create_context(content);
3153 let result = rule.check(&ctx).unwrap();
3154
3155 assert_eq!(
3157 result.len(),
3158 1,
3159 "Should flag backtick-wrapped name when code_blocks=true: {result:?}"
3160 );
3161 assert_eq!(result[0].line, 2);
3162 }
3163
3164 #[test]
3165 fn test_frontmatter_fix_preserves_backtick_code() {
3166 let config = MD044Config {
3168 names: vec!["GoodApplication".to_string()],
3169 code_blocks: false,
3170 ..MD044Config::default()
3171 };
3172 let rule = MD044ProperNames::from_config_struct(config);
3173
3174 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
3175 let ctx = create_context(content);
3176 let fixed = rule.fix(&ctx).unwrap();
3177
3178 assert_eq!(
3180 fixed, content,
3181 "Fix should not modify names inside backticks in frontmatter"
3182 );
3183 }
3184
3185 fn rule_ignoring(names: &[&str], ignore: &[&str]) -> MD044ProperNames {
3186 MD044ProperNames::from_config_struct(MD044Config {
3187 names: names.iter().map(ToString::to_string).collect(),
3188 ignore_frontmatter_fields: Some(ignore.iter().map(ToString::to_string).collect()),
3189 ..Default::default()
3190 })
3191 }
3192
3193 #[test]
3194 fn test_ignore_frontmatter_field_suppresses_only_that_field() {
3195 let content = "---\ntitle: Heading for myapp\nslug: myapp-guide\n---\n";
3196 let rule = rule_ignoring(&["MyApp"], &["slug"]);
3197 let result = rule.check(&create_context(content)).unwrap();
3198 assert_eq!(result.len(), 1, "only title is flagged: {result:?}");
3199 assert_eq!(result[0].line, 2);
3200 }
3201
3202 #[test]
3203 fn test_ignore_frontmatter_field_is_case_insensitive() {
3204 let content = "---\nSlug: myapp-guide\n---\n";
3205 let rule = rule_ignoring(&["MyApp"], &["SLUG"]);
3206 assert!(rule.check(&create_context(content)).unwrap().is_empty());
3207 }
3208
3209 #[test]
3210 fn test_ignore_frontmatter_field_covers_nested_subtree() {
3211 let content = "---\nseo:\n canonical: myapp\n keywords:\n - myapp\n---\n";
3212 let rule = rule_ignoring(&["MyApp"], &["seo"]);
3213 assert!(rule.check(&create_context(content)).unwrap().is_empty());
3214 }
3215
3216 #[test]
3217 fn test_ignore_frontmatter_field_does_not_affect_body() {
3218 let content = "---\nslug: myapp\n---\n\nBody mentions myapp.\n";
3219 let rule = rule_ignoring(&["MyApp"], &["slug"]);
3220 let result = rule.check(&create_context(content)).unwrap();
3221 assert_eq!(result.len(), 1);
3222 assert_eq!(result[0].line, 5);
3223 }
3224
3225 #[test]
3226 fn test_ignore_frontmatter_field_toml_table() {
3227 let content = "+++\n[seo]\ncanonical = \"myapp\"\n+++\n";
3228 let rule = rule_ignoring(&["MyApp"], &["seo"]);
3229 assert!(rule.check(&create_context(content)).unwrap().is_empty());
3230 }
3231
3232 #[test]
3235 fn test_angle_bracket_url_in_html_comment_not_flagged() {
3236 let config = MD044Config {
3238 names: vec!["Test".to_string()],
3239 ..MD044Config::default()
3240 };
3241 let rule = MD044ProperNames::from_config_struct(config);
3242
3243 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";
3244 let ctx = create_context(content);
3245 let result = rule.check(&ctx).unwrap();
3246
3247 let line8_warnings: Vec<_> = result.iter().filter(|w| w.line == 8).collect();
3255 assert!(
3256 line8_warnings.is_empty(),
3257 "Should not flag names inside angle-bracket URLs in HTML comments: {line8_warnings:?}"
3258 );
3259 }
3260
3261 #[test]
3262 fn test_bare_url_in_html_comment_still_flagged() {
3263 let config = MD044Config {
3265 names: vec!["Test".to_string()],
3266 ..MD044Config::default()
3267 };
3268 let rule = MD044ProperNames::from_config_struct(config);
3269
3270 let content = "<!-- This is a test https://www.example.test -->\n";
3271 let ctx = create_context(content);
3272 let result = rule.check(&ctx).unwrap();
3273
3274 assert!(
3277 !result.is_empty(),
3278 "Should flag 'test' in prose text of HTML comment with bare URL"
3279 );
3280 }
3281
3282 #[test]
3283 fn test_angle_bracket_url_in_regular_markdown_not_flagged() {
3284 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
3287
3288 let content = "<https://www.example.test>\n";
3289 let ctx = create_context(content);
3290 let result = rule.check(&ctx).unwrap();
3291
3292 assert!(
3293 result.is_empty(),
3294 "Should not flag names inside angle-bracket URLs in regular markdown: {result:?}"
3295 );
3296 }
3297
3298 #[test]
3299 fn test_multiple_angle_bracket_urls_in_one_comment() {
3300 let config = MD044Config {
3301 names: vec!["Test".to_string()],
3302 ..MD044Config::default()
3303 };
3304 let rule = MD044ProperNames::from_config_struct(config);
3305
3306 let content = "<!-- See <https://test.example.com> and <https://www.example.test> for details -->\n";
3307 let ctx = create_context(content);
3308 let result = rule.check(&ctx).unwrap();
3309
3310 assert!(
3312 result.is_empty(),
3313 "Should not flag names inside multiple angle-bracket URLs: {result:?}"
3314 );
3315 }
3316
3317 #[test]
3318 fn test_angle_bracket_non_url_still_flagged() {
3319 assert!(
3322 !MD044ProperNames::is_in_angle_bracket_url("<test> which is not a URL.", 1),
3323 "is_in_angle_bracket_url should return false for non-URL angle brackets"
3324 );
3325 }
3326
3327 #[test]
3328 fn test_angle_bracket_mailto_url_not_flagged() {
3329 let config = MD044Config {
3330 names: vec!["Test".to_string()],
3331 ..MD044Config::default()
3332 };
3333 let rule = MD044ProperNames::from_config_struct(config);
3334
3335 let content = "<!-- Contact <mailto:test@example.com> for help -->\n";
3336 let ctx = create_context(content);
3337 let result = rule.check(&ctx).unwrap();
3338
3339 assert!(
3340 result.is_empty(),
3341 "Should not flag names inside angle-bracket mailto URLs: {result:?}"
3342 );
3343 }
3344
3345 #[test]
3346 fn test_angle_bracket_ftp_url_not_flagged() {
3347 let config = MD044Config {
3348 names: vec!["Test".to_string()],
3349 ..MD044Config::default()
3350 };
3351 let rule = MD044ProperNames::from_config_struct(config);
3352
3353 let content = "<!-- Download from <ftp://test.example.com/file> -->\n";
3354 let ctx = create_context(content);
3355 let result = rule.check(&ctx).unwrap();
3356
3357 assert!(
3358 result.is_empty(),
3359 "Should not flag names inside angle-bracket FTP URLs: {result:?}"
3360 );
3361 }
3362
3363 #[test]
3364 fn test_angle_bracket_url_fix_preserves_url() {
3365 let config = MD044Config {
3367 names: vec!["Test".to_string()],
3368 ..MD044Config::default()
3369 };
3370 let rule = MD044ProperNames::from_config_struct(config);
3371
3372 let content = "<!-- test text <https://www.example.test> -->\n";
3373 let ctx = create_context(content);
3374 let fixed = rule.fix(&ctx).unwrap();
3375
3376 assert!(
3378 fixed.contains("<https://www.example.test>"),
3379 "Fix should preserve angle-bracket URLs: {fixed}"
3380 );
3381 assert!(
3382 fixed.contains("Test text"),
3383 "Fix should correct prose 'test' to 'Test': {fixed}"
3384 );
3385 }
3386
3387 #[test]
3388 fn test_is_in_angle_bracket_url_helper() {
3389 let line = "text <https://example.test> more text";
3391
3392 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));
3405
3406 assert!(MD044ProperNames::is_in_angle_bracket_url(
3408 "<mailto:test@example.com>",
3409 10
3410 ));
3411
3412 assert!(MD044ProperNames::is_in_angle_bracket_url(
3414 "<ftp://test.example.com>",
3415 10
3416 ));
3417 }
3418
3419 #[test]
3420 fn test_is_in_angle_bracket_url_uppercase_scheme() {
3421 assert!(MD044ProperNames::is_in_angle_bracket_url(
3423 "<HTTPS://test.example.com>",
3424 10
3425 ));
3426 assert!(MD044ProperNames::is_in_angle_bracket_url(
3427 "<Http://test.example.com>",
3428 10
3429 ));
3430 }
3431
3432 #[test]
3433 fn test_is_in_angle_bracket_url_uncommon_schemes() {
3434 assert!(MD044ProperNames::is_in_angle_bracket_url(
3436 "<ssh://test@example.com>",
3437 10
3438 ));
3439 assert!(MD044ProperNames::is_in_angle_bracket_url("<file:///test/path>", 10));
3441 assert!(MD044ProperNames::is_in_angle_bracket_url("<data:text/plain;test>", 10));
3443 }
3444
3445 #[test]
3446 fn test_is_in_angle_bracket_url_unclosed() {
3447 assert!(!MD044ProperNames::is_in_angle_bracket_url(
3449 "<https://test.example.com",
3450 10
3451 ));
3452 }
3453
3454 #[test]
3455 fn test_vale_inline_config_comments_not_flagged() {
3456 let config = MD044Config {
3457 names: vec!["Vale".to_string(), "JavaScript".to_string()],
3458 ..MD044Config::default()
3459 };
3460 let rule = MD044ProperNames::from_config_struct(config);
3461
3462 let content = "\
3463<!-- vale off -->
3464Some javascript text here.
3465<!-- vale on -->
3466<!-- vale Style.Rule = NO -->
3467More javascript text.
3468<!-- vale Style.Rule = YES -->
3469<!-- vale JavaScript.Grammar = NO -->
3470";
3471 let ctx = create_context(content);
3472 let result = rule.check(&ctx).unwrap();
3473
3474 assert_eq!(result.len(), 2, "Should only flag body lines, not Vale config comments");
3476 assert_eq!(result[0].line, 2);
3477 assert_eq!(result[1].line, 5);
3478 }
3479
3480 #[test]
3481 fn test_remark_lint_inline_config_comments_not_flagged() {
3482 let config = MD044Config {
3483 names: vec!["JavaScript".to_string()],
3484 ..MD044Config::default()
3485 };
3486 let rule = MD044ProperNames::from_config_struct(config);
3487
3488 let content = "\
3489<!-- lint disable remark-lint-some-rule -->
3490Some javascript text here.
3491<!-- lint enable remark-lint-some-rule -->
3492<!-- lint ignore remark-lint-some-rule -->
3493More javascript text.
3494";
3495 let ctx = create_context(content);
3496 let result = rule.check(&ctx).unwrap();
3497
3498 assert_eq!(
3499 result.len(),
3500 2,
3501 "Should only flag body lines, not remark-lint config comments"
3502 );
3503 assert_eq!(result[0].line, 2);
3504 assert_eq!(result[1].line, 5);
3505 }
3506
3507 #[test]
3508 fn test_fix_does_not_modify_vale_remark_lint_comments() {
3509 let config = MD044Config {
3510 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3511 ..MD044Config::default()
3512 };
3513 let rule = MD044ProperNames::from_config_struct(config);
3514
3515 let content = "\
3516<!-- vale off -->
3517Some javascript text.
3518<!-- vale on -->
3519<!-- lint disable remark-lint-some-rule -->
3520More javascript text.
3521<!-- lint enable remark-lint-some-rule -->
3522";
3523 let ctx = create_context(content);
3524 let fixed = rule.fix(&ctx).unwrap();
3525
3526 assert!(fixed.contains("<!-- vale off -->"));
3528 assert!(fixed.contains("<!-- vale on -->"));
3529 assert!(fixed.contains("<!-- lint disable remark-lint-some-rule -->"));
3530 assert!(fixed.contains("<!-- lint enable remark-lint-some-rule -->"));
3531 assert!(fixed.contains("Some JavaScript text."));
3533 assert!(fixed.contains("More JavaScript text."));
3534 }
3535
3536 #[test]
3537 fn test_mixed_tool_directives_all_skipped() {
3538 let config = MD044Config {
3539 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3540 ..MD044Config::default()
3541 };
3542 let rule = MD044ProperNames::from_config_struct(config);
3543
3544 let content = "\
3545<!-- rumdl-disable MD044 -->
3546Some javascript text.
3547<!-- markdownlint-disable -->
3548More javascript text.
3549<!-- vale off -->
3550Even more javascript text.
3551<!-- lint disable some-rule -->
3552Final javascript text.
3553<!-- rumdl-enable MD044 -->
3554<!-- markdownlint-enable -->
3555<!-- vale on -->
3556<!-- lint enable some-rule -->
3557";
3558 let ctx = create_context(content);
3559 let result = rule.check(&ctx).unwrap();
3560
3561 assert_eq!(
3563 result.len(),
3564 4,
3565 "Should only flag body lines, not any tool directive comments"
3566 );
3567 assert_eq!(result[0].line, 2);
3568 assert_eq!(result[1].line, 4);
3569 assert_eq!(result[2].line, 6);
3570 assert_eq!(result[3].line, 8);
3571 }
3572
3573 #[test]
3574 fn test_vale_remark_lint_edge_cases_not_matched() {
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 = "\
3589<!-- vale -->
3590<!-- vale is a tool for writing -->
3591<!-- valedictorian javascript -->
3592<!-- linting javascript tips -->
3593<!-- vale javascript -->
3594<!-- lint your javascript code -->
3595";
3596 let ctx = create_context(content);
3597 let result = rule.check(&ctx).unwrap();
3598
3599 assert_eq!(
3606 result.len(),
3607 7,
3608 "Should flag proper names in non-directive HTML comments: got {result:?}"
3609 );
3610 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); }
3618
3619 #[test]
3620 fn test_vale_style_directives_skipped() {
3621 let config = MD044Config {
3622 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3623 ..MD044Config::default()
3624 };
3625 let rule = MD044ProperNames::from_config_struct(config);
3626
3627 let content = "\
3629<!-- vale style = MyStyle -->
3630<!-- vale styles = Style1, Style2 -->
3631<!-- vale MyRule.Name = YES -->
3632<!-- vale MyRule.Name = NO -->
3633Some javascript text.
3634";
3635 let ctx = create_context(content);
3636 let result = rule.check(&ctx).unwrap();
3637
3638 assert_eq!(
3640 result.len(),
3641 1,
3642 "Should only flag body lines, not Vale style/rule directives: got {result:?}"
3643 );
3644 assert_eq!(result[0].line, 5);
3645 }
3646
3647 #[test]
3650 fn test_backtick_code_single_backticks() {
3651 let line = "hello `world` bye";
3652 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 7));
3654 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3656 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 14));
3658 }
3659
3660 #[test]
3661 fn test_backtick_code_double_backticks() {
3662 let line = "a ``code`` b";
3663 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3665 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3667 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 11));
3669 }
3670
3671 #[test]
3672 fn test_backtick_code_unclosed() {
3673 let line = "a `code b";
3674 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3676 }
3677
3678 #[test]
3679 fn test_backtick_code_mismatched_count() {
3680 let line = "a `code`` b";
3682 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3685 }
3686
3687 #[test]
3688 fn test_backtick_code_multiple_spans() {
3689 let line = "`first` and `second`";
3690 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
3692 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 8));
3694 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 13));
3696 }
3697
3698 #[test]
3699 fn test_backtick_code_on_backtick_boundary() {
3700 let line = "`code`";
3701 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3703 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 5));
3705 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
3707 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3708 }
3709
3710 #[test]
3716 fn test_double_bracket_link_url_not_flagged() {
3717 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3718 let content = "[[rumdl]](https://github.com/rvben/rumdl)";
3720 let ctx = create_context(content);
3721 let result = rule.check(&ctx).unwrap();
3722 assert!(
3723 result.is_empty(),
3724 "URL inside [[text]](url) must not be flagged, got: {result:?}"
3725 );
3726 }
3727
3728 #[test]
3729 fn test_double_bracket_link_url_not_fixed() {
3730 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3731 let content = "[[rumdl]](https://github.com/rvben/rumdl)\n";
3732 let ctx = create_context(content);
3733 let fixed = rule.fix(&ctx).unwrap();
3734 assert_eq!(
3735 fixed, content,
3736 "fix() must leave the URL inside [[text]](url) unchanged"
3737 );
3738 }
3739
3740 #[test]
3741 fn test_double_bracket_link_text_still_flagged() {
3742 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3743 let content = "[[github]](https://example.com)";
3745 let ctx = create_context(content);
3746 let result = rule.check(&ctx).unwrap();
3747 assert_eq!(
3748 result.len(),
3749 1,
3750 "Incorrect name in [[text]] link text should still be flagged, got: {result:?}"
3751 );
3752 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
3753 }
3754
3755 #[test]
3756 fn test_double_bracket_link_mixed_line() {
3757 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3758 let content = "See [[rumdl]](https://github.com/rvben/rumdl) and github for more.";
3760 let ctx = create_context(content);
3761 let result = rule.check(&ctx).unwrap();
3762 assert_eq!(
3763 result.len(),
3764 1,
3765 "Only the standalone 'github' after the link should be flagged, got: {result:?}"
3766 );
3767 assert!(result[0].message.contains("'github'"));
3768 assert_eq!(
3770 result[0].column, 51,
3771 "Flagged column should be the trailing 'github', not the one in the URL"
3772 );
3773 }
3774
3775 #[test]
3776 fn test_regular_link_url_still_not_flagged() {
3777 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3779 let content = "[rumdl](https://github.com/rvben/rumdl)";
3780 let ctx = create_context(content);
3781 let result = rule.check(&ctx).unwrap();
3782 assert!(
3783 result.is_empty(),
3784 "URL inside regular [text](url) must still not be flagged, got: {result:?}"
3785 );
3786 }
3787
3788 #[test]
3789 fn test_link_like_text_in_code_span_still_flagged_when_code_blocks_enabled() {
3790 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
3795 let content = "`[foo](https://github.com/org/repo)`";
3796 let ctx = create_context(content);
3797 let result = rule.check(&ctx).unwrap();
3798 assert_eq!(
3799 result.len(),
3800 1,
3801 "Proper name inside a code span must be flagged when code-blocks=true, got: {result:?}"
3802 );
3803 assert!(result[0].message.contains("'github'"));
3804 }
3805
3806 #[test]
3807 fn test_malformed_link_not_treated_as_url() {
3808 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3811 let content = "See [rumdl](github repo) for details.";
3812 let ctx = create_context(content);
3813 let result = rule.check(&ctx).unwrap();
3814 assert_eq!(
3815 result.len(),
3816 1,
3817 "Name inside malformed [text](url with spaces) must still be flagged, got: {result:?}"
3818 );
3819 assert!(result[0].message.contains("'github'"));
3820 }
3821
3822 #[test]
3823 fn test_wikilink_followed_by_prose_parens_still_flagged() {
3824 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3828 let content = "[[note]](github repo)";
3829 let ctx = create_context(content);
3830 let result = rule.check(&ctx).unwrap();
3831 assert_eq!(
3832 result.len(),
3833 1,
3834 "Name inside [[wikilink]](prose with spaces) must still be flagged, got: {result:?}"
3835 );
3836 assert!(result[0].message.contains("'github'"));
3837 }
3838
3839 #[test]
3841 fn test_roundtrip_fix_then_check_basic() {
3842 let rule = MD044ProperNames::new(
3843 vec![
3844 "JavaScript".to_string(),
3845 "TypeScript".to_string(),
3846 "Node.js".to_string(),
3847 ],
3848 true,
3849 );
3850 let content = "I love javascript, typescript, and nodejs!";
3851 let ctx = create_context(content);
3852 let fixed = rule.fix(&ctx).unwrap();
3853 let ctx2 = create_context(&fixed);
3854 let warnings = rule.check(&ctx2).unwrap();
3855 assert!(
3856 warnings.is_empty(),
3857 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3858 );
3859 }
3860
3861 #[test]
3863 fn test_roundtrip_fix_then_check_multiline() {
3864 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
3865 let content = "First line with rust.\nSecond line with python.\nThird line with RUST and PYTHON.\n";
3866 let ctx = create_context(content);
3867 let fixed = rule.fix(&ctx).unwrap();
3868 let ctx2 = create_context(&fixed);
3869 let warnings = rule.check(&ctx2).unwrap();
3870 assert!(
3871 warnings.is_empty(),
3872 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3873 );
3874 }
3875
3876 #[test]
3878 fn test_roundtrip_fix_then_check_inline_config() {
3879 let config = MD044Config {
3880 names: vec!["RUMDL".to_string()],
3881 ..MD044Config::default()
3882 };
3883 let rule = MD044ProperNames::from_config_struct(config);
3884 let content =
3885 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
3886 let ctx = create_context(content);
3887 let fixed = rule.fix(&ctx).unwrap();
3888 assert!(
3890 fixed.contains("Some rumdl text.\n"),
3891 "Disabled block text should be preserved"
3892 );
3893 assert!(
3894 fixed.contains("Some RUMDL text outside."),
3895 "Outside text should be fixed"
3896 );
3897 }
3898
3899 #[test]
3901 fn test_roundtrip_fix_then_check_html_comments() {
3902 let config = MD044Config {
3903 names: vec!["JavaScript".to_string()],
3904 ..MD044Config::default()
3905 };
3906 let rule = MD044ProperNames::from_config_struct(config);
3907 let content = "# Guide\n\n<!-- javascript mentioned here -->\n\njavascript outside\n";
3908 let ctx = create_context(content);
3909 let fixed = rule.fix(&ctx).unwrap();
3910 let ctx2 = create_context(&fixed);
3911 let warnings = rule.check(&ctx2).unwrap();
3912 assert!(
3913 warnings.is_empty(),
3914 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3915 );
3916 }
3917
3918 #[test]
3920 fn test_roundtrip_no_op_when_correct() {
3921 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
3922 let content = "This uses JavaScript and TypeScript correctly.\n";
3923 let ctx = create_context(content);
3924 let fixed = rule.fix(&ctx).unwrap();
3925 assert_eq!(fixed, content, "Fix should be a no-op when content is already correct");
3926 }
3927
3928 #[test]
3931 fn test_bare_domain_link_text_not_flagged() {
3932 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3936 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3937 let ctx = create_context(content);
3938 let result = rule.check(&ctx).unwrap();
3939 assert!(
3940 result.is_empty(),
3941 "Should not flag 'github' in a bare-domain link text that matches the link URL: {result:?}"
3942 );
3943 }
3944
3945 #[test]
3946 fn test_bare_domain_link_text_not_fixed() {
3947 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3949 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3950 let ctx = create_context(content);
3951 let fixed = rule.fix(&ctx).unwrap();
3952 assert_eq!(
3953 fixed, content,
3954 "fix() must not alter bare-domain link text that matches the destination URL"
3955 );
3956 }
3957
3958 #[test]
3959 fn test_bare_domain_link_text_with_path_not_flagged() {
3960 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3962 let content = "Visit [ravencentric.github.io](https://ravencentric.github.io/projects).\n";
3963 let ctx = create_context(content);
3964 let result = rule.check(&ctx).unwrap();
3965 assert!(
3966 result.is_empty(),
3967 "Should not flag 'github' when bare-domain text is the hostname of its destination URL: {result:?}"
3968 );
3969 }
3970
3971 #[test]
3972 fn test_bare_domain_link_text_full_path_not_flagged() {
3973 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3975 let content = "See [ravencentric.github.io/blog](https://ravencentric.github.io/blog).\n";
3976 let ctx = create_context(content);
3977 let result = rule.check(&ctx).unwrap();
3978 assert!(
3979 result.is_empty(),
3980 "Should not flag 'github' when link text is the full URL path without scheme: {result:?}"
3981 );
3982 }
3983
3984 #[test]
3985 fn test_github_product_name_in_link_text_still_flagged() {
3986 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3989 let content = "Hosted on [github pages](https://pages.github.com).\n";
3990 let ctx = create_context(content);
3991 let result = rule.check(&ctx).unwrap();
3992 assert!(
3993 !result.is_empty(),
3994 "Should still flag 'github' in descriptive link text that does not match the destination URL"
3995 );
3996 }
3997
3998 #[test]
3999 fn test_protocol_relative_bare_domain_link_text_not_flagged() {
4000 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4002 let content = "See [github.io](//github.io).\n";
4003 let ctx = create_context(content);
4004 let result = rule.check(&ctx).unwrap();
4005 assert!(
4006 result.is_empty(),
4007 "Should not flag 'github' in bare-domain text matching a protocol-relative destination: {result:?}"
4008 );
4009 }
4010
4011 #[test]
4012 fn test_dotted_wikilink_target_still_flagged() {
4013 let rule = MD044ProperNames::new(vec!["Node.js".to_string()], false);
4018 let content = "See [[node.js]] for details.\n";
4019 let ctx = create_context(content);
4020 let result = rule.check(&ctx).unwrap();
4021 assert!(
4022 !result.is_empty(),
4023 "Should flag 'node.js' in a dotted WikiLink target: {result:?}"
4024 );
4025 }
4026
4027 #[test]
4028 fn test_bare_domain_link_text_case_insensitive_url() {
4029 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4032 let content = "See [github.io](HTTPS://github.io).\n";
4033 let ctx = create_context(content);
4034 let result = rule.check(&ctx).unwrap();
4035 assert!(
4036 result.is_empty(),
4037 "Should not flag bare-domain text when destination URL has an uppercase scheme: {result:?}"
4038 );
4039 }
4040
4041 #[test]
4042 fn test_frontmatter_value_span_strips_trailing_comment() {
4043 let line = "link: docs/guide/myapp # canonical path";
4044 let (s, e) = MD044ProperNames::frontmatter_value_span(line).unwrap();
4045 assert_eq!(&line[s..e], "docs/guide/myapp");
4046 }
4047
4048 #[test]
4049 fn test_frontmatter_value_span_quoted_keeps_hash_and_spaces() {
4050 let line = "link: 'docs/My App/a#b'";
4051 let (s, e) = MD044ProperNames::frontmatter_value_span(line).unwrap();
4052 assert_eq!(&line[s..e], "docs/My App/a#b");
4053 }
4054
4055 #[test]
4056 fn test_frontmatter_value_span_plain_value() {
4057 let line = "title: Heading for myapp";
4058 let (s, e) = MD044ProperNames::frontmatter_value_span(line).unwrap();
4059 assert_eq!(&line[s..e], "Heading for myapp");
4060 }
4061
4062 #[test]
4063 fn test_frontmatter_value_span_none_for_key_only() {
4064 assert!(MD044ProperNames::frontmatter_value_span("seo:").is_none());
4065 assert!(MD044ProperNames::frontmatter_value_span("---").is_none());
4066 }
4067
4068 #[test]
4069 fn test_frontmatter_value_span_quoted_strips_trailing_comment() {
4070 let line = "link: 'docs/guide' # canonical path";
4071 let (s, e) = MD044ProperNames::frontmatter_value_span(line).unwrap();
4072 assert_eq!(&line[s..e], "docs/guide");
4073 }
4074
4075 #[test]
4076 fn test_frontmatter_value_span_empty_quoted_value_is_none() {
4077 assert!(MD044ProperNames::frontmatter_value_span("key: ''").is_none());
4078 }
4079
4080 #[test]
4081 fn test_frontmatter_value_span_unterminated_quote_strips_leading_quote() {
4082 let line = "link: 'docs/a";
4083 let (s, e) = MD044ProperNames::frontmatter_value_span(line).unwrap();
4084 assert_eq!(&line[s..e], "docs/a");
4085 }
4086
4087 fn at(line: &str, needle: &str) -> usize {
4089 line.find(needle).expect("needle present")
4090 }
4091
4092 #[test]
4093 fn test_path_like_exempts_single_token_frontmatter_paths() {
4094 for line in [
4095 "link: this/is/a/link/to/myapp.md",
4096 "link: docs/myapp.md",
4097 "link: /abs/path/myapp.md",
4098 "link: ./myapp.md",
4099 "link: ../shared/myapp.md",
4100 ] {
4101 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4102 let pos = at(line, "myapp");
4103 assert!(
4104 MD044ProperNames::is_in_path_like_token(line, pos, span),
4105 "should treat as a path: {line}"
4106 );
4107 }
4108 }
4109
4110 #[test]
4111 fn test_path_like_does_not_exempt_slash_conjunction_prose() {
4112 let line = "description: We support github/gitlab/bitbucket imports.";
4115 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4116 assert!(
4117 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
4118 "slash-separated prose is not a path"
4119 );
4120
4121 let line = "description: The javascript/typescript ecosystem is large.";
4122 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4123 assert!(!MD044ProperNames::is_in_path_like_token(
4124 line,
4125 at(line, "javascript"),
4126 span
4127 ));
4128 }
4129
4130 #[test]
4131 fn test_path_like_requires_a_slash_so_dotted_names_survive() {
4132 let line = "title: Use nodejs and myapp.md today.";
4133 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4134 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4135 }
4136
4137 #[test]
4138 fn test_path_like_no_slash_frontmatter_value_still_flagged() {
4139 let line = "slug: myapp-guide";
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_returns_false_outside_value_span() {
4150 let line = "myapp: docs/guide/myapp";
4153 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4154 let key_pos = 0;
4155 assert!(!MD044ProperNames::is_in_path_like_token(line, key_pos, span));
4156 }
4157
4158 #[test]
4159 fn test_path_like_three_segments_only_as_sole_frontmatter_value() {
4160 let line = "link: docs/guide/myapp";
4161 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4162 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4163
4164 let line = "description: We support github/gitlab/bitbucket now";
4165 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4166 assert!(
4167 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
4168 "multi-token value gets body treatment"
4169 );
4170 }
4171
4172 #[test]
4173 fn test_path_like_quoted_value_with_spaces() {
4174 let line = "link: 'docs/My App/myapp.md'";
4177 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4178 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4179 }
4180
4181 #[test]
4182 fn test_path_like_quoted_value_with_spaces_no_extension_not_exempt() {
4183 let line = "link: 'docs/My App/myapp'";
4190 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4191 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4192 }
4193
4194 #[test]
4195 fn test_path_like_trailing_comment_is_still_sole_value() {
4196 let line = "link: docs/guide/myapp # canonical path";
4197 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4198 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4199 }
4200
4201 #[test]
4202 fn test_path_like_trailing_punctuation_trimmed() {
4203 let line = "link: docs/myapp.md, then leave.";
4204 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4205 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4206 }
4207
4208 #[test]
4209 fn test_trim_token_bounds_reaches_fixpoint_after_punctuation_exposes_wrapper() {
4210 let line = r#"See "docs/myapp.md", then leave."#;
4211 let raw_start = at(line, "\"docs");
4212 let raw_end = raw_start + r#""docs/myapp.md","#.len();
4213 assert_eq!(&line[raw_start..raw_end], r#""docs/myapp.md","#);
4214 let (start, end) = MD044ProperNames::trim_token_bounds(line, raw_start, raw_end);
4215 assert_eq!(&line[start..end], "docs/myapp.md");
4216 }
4217
4218 #[test]
4219 fn test_trim_token_bounds_reaches_fixpoint_with_multiple_trailing_wrappers() {
4220 let line = r#"("docs/myapp.md")."#;
4221 let (start, end) = MD044ProperNames::trim_token_bounds(line, 0, line.len());
4222 assert_eq!(&line[start..end], "docs/myapp.md");
4223 }
4224
4225 #[test]
4226 fn test_frontmatter_link_path_not_flagged() {
4227 let content = "---\ntitle: Heading for MyApp\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
4228 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4229 let ctx = create_context(content);
4230 let result = rule.check(&ctx).unwrap();
4231 assert!(
4232 result.is_empty(),
4233 "path in a frontmatter value must not be flagged: {result:?}"
4234 );
4235 }
4236
4237 #[test]
4238 fn test_fix_does_not_corrupt_frontmatter_link_path() {
4239 let content = "---\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
4240 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4241 let ctx = create_context(content);
4242 assert_eq!(rule.fix(&ctx).unwrap(), content, "fix must not rewrite a path");
4243 }
4244
4245 #[test]
4256 fn test_body_prose_parenthesized_disambiguator_is_case_corrected() {
4257 let content = "See docs/myapp(1).md here.\n";
4258 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4259 let ctx = create_context(content);
4260 let result = rule.check(&ctx).unwrap();
4261 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
4262 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp(1).md here.\n");
4263 }
4264
4265 #[test]
4266 fn test_body_prose_bracketed_dynamic_segment_is_case_corrected() {
4267 let content = "See docs/[myapp].md here.\n";
4268 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4269 let ctx = create_context(content);
4270 let result = rule.check(&ctx).unwrap();
4271 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
4272 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/[MyApp].md here.\n");
4273 }
4274
4275 #[test]
4276 fn test_body_prose_nextjs_catch_all_segment_is_case_corrected() {
4277 let content = "pages/[[...myapp]].tsx are catch-all routes.\n";
4280 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4281 let ctx = create_context(content);
4282 let result = rule.check(&ctx).unwrap();
4283 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
4284 assert_eq!(
4285 rule.fix(&ctx).unwrap(),
4286 "pages/[[...MyApp]].tsx are catch-all routes.\n"
4287 );
4288 }
4289
4290 #[test]
4291 fn test_two_adjacent_whitespace_free_links_both_flagged() {
4292 let content = "[myapp](https://a.com)[github](https://b.com)\n";
4296 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitHub".to_string()], false);
4297 let ctx = create_context(content);
4298 let result = rule.check(&ctx).unwrap();
4299 assert_eq!(result.len(), 2, "both link texts must be flagged: {result:?}");
4300 assert!(result.iter().any(|w| w.message.contains("'myapp'")));
4301 assert!(result.iter().any(|w| w.message.contains("'github'")));
4302 }
4303
4304 #[test]
4305 fn test_fix_does_not_corrupt_frontmatter_path_with_route_group_named_after_proper_name() {
4306 let content = "---\nlink: src/(myapp)/page.tsx\n---\n\nBody.\n";
4309 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4310 let ctx = create_context(content);
4311 assert_eq!(
4312 rule.fix(&ctx).unwrap(),
4313 content,
4314 "fix must not rewrite a frontmatter path whose route-group directory name is the proper name"
4315 );
4316 }
4317
4318 #[test]
4319 fn test_quoted_frontmatter_value_slash_conjunction_prose_still_flagged() {
4320 let content = "---\ndescription: \"We support github/gitlab/bitbucket now\"\n---\n\nBody.\n";
4325 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4326 let ctx = create_context(content);
4327 let result = rule.check(&ctx).unwrap();
4328 assert_eq!(
4329 result.len(),
4330 1,
4331 "quoted prose value must still flag 'github': {result:?}"
4332 );
4333 }
4334
4335 #[test]
4336 fn test_quoted_frontmatter_value_single_slash_word_with_unrelated_dot_still_flagged() {
4337 let content = "---\ndescription: \"We use myapp/gitlab and version 1.0 e.g. weekly\"\n---\n\nBody.\n";
4340 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4341 let ctx = create_context(content);
4342 let result = rule.check(&ctx).unwrap();
4343 assert_eq!(
4344 result.len(),
4345 1,
4346 "quoted prose value must still flag 'myapp': {result:?}"
4347 );
4348 }
4349
4350 #[test]
4351 fn test_quoted_toml_frontmatter_value_slash_conjunction_prose_still_flagged() {
4352 let content = "+++\ndescription = \"We support github/gitlab/bitbucket now\"\n+++\n\nBody.\n";
4355 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4356 let ctx = create_context(content);
4357 let result = rule.check(&ctx).unwrap();
4358 assert_eq!(
4359 result.len(),
4360 1,
4361 "TOML quoted prose value must still flag 'github': {result:?}"
4362 );
4363 }
4364
4365 #[test]
4366 fn test_path_like_collapsed_multiword_no_extension_not_exempt() {
4367 for line in [
4374 r#"description: "myapp/gitlab github/bitbucket""#,
4375 r#"description: "and/or this/that myapp/gitlab""#,
4376 r#"description: "he/him she/her myapp/gitlab""#,
4377 ] {
4378 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4379 for needle in ["myapp", "gitlab"] {
4380 if let Some(byte_pos) = line.find(needle) {
4381 assert!(
4382 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
4383 "collapsed multi-word value must not exempt '{needle}': {line}"
4384 );
4385 }
4386 }
4387 }
4388 }
4389
4390 #[test]
4391 fn test_path_like_collapsed_multiword_no_extension_not_exempt_toml() {
4392 let line = r#"description = "myapp/gitlab github/bitbucket""#;
4393 let span = MD044ProperNames::frontmatter_value_span(line).unwrap();
4394 for needle in ["myapp", "gitlab", "github", "bitbucket"] {
4395 let byte_pos = at(line, needle);
4396 assert!(
4397 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
4398 "collapsed multi-word TOML value must not exempt '{needle}'"
4399 );
4400 }
4401 }
4402
4403 #[test]
4404 fn test_frontmatter_collapsed_multiword_names_all_flagged_yaml() {
4405 let content = "---\ndescription: \"myapp/gitlab github/bitbucket\"\n---\n\nBody.\n";
4406 let rule = MD044ProperNames::new(
4407 vec![
4408 "MyApp".to_string(),
4409 "GitLab".to_string(),
4410 "GitHub".to_string(),
4411 "Bitbucket".to_string(),
4412 ],
4413 false,
4414 );
4415 let ctx = create_context(content);
4416 let result = rule.check(&ctx).unwrap();
4417 assert_eq!(
4418 result.len(),
4419 4,
4420 "all four names in the collapsed multi-word value must be flagged: {result:?}"
4421 );
4422 }
4423
4424 #[test]
4425 fn test_frontmatter_collapsed_multiword_names_all_flagged_toml() {
4426 let content = "+++\ndescription = \"myapp/gitlab github/bitbucket\"\n+++\n\nBody.\n";
4427 let rule = MD044ProperNames::new(
4428 vec![
4429 "MyApp".to_string(),
4430 "GitLab".to_string(),
4431 "GitHub".to_string(),
4432 "Bitbucket".to_string(),
4433 ],
4434 false,
4435 );
4436 let ctx = create_context(content);
4437 let result = rule.check(&ctx).unwrap();
4438 assert_eq!(
4439 result.len(),
4440 4,
4441 "all four names in the collapsed multi-word TOML value must be flagged: {result:?}"
4442 );
4443 }
4444
4445 #[test]
4446 fn test_frontmatter_collapsed_multiword_conjunction_pairs_flagged() {
4447 let content = "---\ndescription: \"and/or this/that myapp/gitlab\"\n---\n\nBody.\n";
4448 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4449 let ctx = create_context(content);
4450 let result = rule.check(&ctx).unwrap();
4451 assert_eq!(
4452 result.len(),
4453 2,
4454 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4455 );
4456 }
4457
4458 #[test]
4459 fn test_frontmatter_collapsed_multiword_pronoun_pairs_flagged() {
4460 let content = "---\ndescription: \"he/him she/her myapp/gitlab\"\n---\n\nBody.\n";
4461 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4462 let ctx = create_context(content);
4463 let result = rule.check(&ctx).unwrap();
4464 assert_eq!(
4465 result.len(),
4466 2,
4467 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4468 );
4469 }
4470
4471 #[test]
4477 fn test_body_prose_path_is_flagged_frontmatter_only_scope() {
4478 let content = "See docs/myapp.md for details about myapp.\n";
4479 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4480 let ctx = create_context(content);
4481 let result = rule.check(&ctx).unwrap();
4482 assert_eq!(
4483 result.len(),
4484 2,
4485 "both the path occurrence and the prose occurrence are flagged in body text: {result:?}"
4486 );
4487 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp.md for details about MyApp.\n");
4488 }
4489
4490 #[test]
4491 fn test_slash_conjunction_prose_still_flagged() {
4492 let content = "We support github/gitlab imports.\n";
4493 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4494 let ctx = create_context(content);
4495 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
4496 }
4497}