1use crate::utils::fast_hash;
2use crate::utils::regex_cache::{escape_regex, get_cached_regex};
3
4use crate::lint_context::SourcePositions;
5use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::utils::frontmatter_values;
7use std::collections::{HashMap, HashSet};
8use std::sync::{Arc, Mutex};
9
10mod md044_config;
11pub(super) use md044_config::MD044Config;
12
13type WarningPosition = (usize, usize, String, usize); #[derive(Default)]
19struct FallbackDelimiters {
20 scanned_bytes: usize,
21 pairs: Option<HashMap<usize, usize>>,
22}
23
24impl FallbackDelimiters {
25 fn find(&mut self, bytes: &[u8], from: usize, open: u8, close: u8) -> Option<usize> {
26 debug_assert!(from > 0 && bytes.get(from - 1) == Some(&open));
27 if self.pairs.is_none() && self.scanned_bytes >= bytes.len().saturating_mul(2) {
28 let mut pairs = HashMap::new();
29 let mut pending = Vec::new();
30 let mut depth = 0usize;
31 let mut pos = 0;
32 while pos < bytes.len() {
33 match bytes[pos] {
34 b'\\' => {
35 if bytes.get(pos + 1) == Some(&open) {
36 pending.push((depth, pos + 2));
37 }
38 pos += 1;
39 }
40 byte if byte == open => {
41 depth += 1;
42 pending.push((depth, pos + 1));
43 }
44 byte if byte == close => {
45 while pending.last().is_some_and(|&(level, _)| level == depth) {
46 let (_, start) = pending.pop().unwrap();
47 pairs.insert(start, pos + 1);
48 }
49 depth = depth.saturating_sub(1);
50 }
51 _ => {}
52 }
53 pos += 1;
54 }
55 self.pairs = Some(pairs);
56 }
57 if let Some(pairs) = &self.pairs {
58 return pairs.get(&from).copied();
59 }
60 let end = Self::scan(bytes, from, open, close);
61 self.scanned_bytes = self.scanned_bytes.saturating_add(end.unwrap_or(bytes.len()) - from);
62 end
63 }
64
65 fn scan(bytes: &[u8], from: usize, open: u8, close: u8) -> Option<usize> {
66 let mut depth = 1usize;
67 let mut pos = from;
68 while pos < bytes.len() {
69 match bytes[pos] {
70 b'\\' => pos += 1,
71 byte if byte == open => depth += 1,
72 byte if byte == close => {
73 depth -= 1;
74 if depth == 0 {
75 return Some(pos + 1);
76 }
77 }
78 _ => {}
79 }
80 pos += 1;
81 }
82 None
83 }
84}
85
86#[derive(Clone)]
142pub struct MD044ProperNames {
143 config: MD044Config,
144 combined_pattern: Option<String>,
146 name_variants: HashMap<String, usize>,
148 ignore_fields: HashSet<String>,
150 content_cache: Arc<Mutex<HashMap<u64, Vec<WarningPosition>>>>,
159}
160
161impl MD044ProperNames {
162 pub fn new(names: Vec<String>, code_blocks: bool) -> Self {
163 let config = MD044Config {
164 names,
165 code_blocks,
166 ..Default::default()
167 };
168 Self::from_config_struct(config)
169 }
170
171 fn ascii_normalize(s: &str) -> String {
173 s.replace(['é', 'è', 'ê', 'ë'], "e")
174 .replace(['à', 'á', 'â', 'ä', 'ã', 'å'], "a")
175 .replace(['ï', 'î', 'í', 'ì'], "i")
176 .replace(['ü', 'ú', 'ù', 'û'], "u")
177 .replace(['ö', 'ó', 'ò', 'ô', 'õ'], "o")
178 .replace('ñ', "n")
179 .replace('ç', "c")
180 }
181
182 pub fn from_config_struct(config: MD044Config) -> Self {
183 let combined_pattern = Self::create_combined_pattern(&config);
184 let name_variants = Self::build_name_variants(&config);
185 let ignore_fields = config
186 .ignore_frontmatter_fields
187 .iter()
188 .flatten()
189 .map(|f| f.to_lowercase())
190 .collect();
191 Self {
192 config,
193 combined_pattern,
194 name_variants,
195 ignore_fields,
196 content_cache: Arc::new(Mutex::new(HashMap::new())),
197 }
198 }
199
200 fn create_combined_pattern(config: &MD044Config) -> Option<String> {
202 if config.names.is_empty() {
203 return None;
204 }
205
206 let mut patterns: Vec<String> = config
208 .names
209 .iter()
210 .flat_map(|name| {
211 let mut variations = vec![];
212 let lower_name = name.to_lowercase();
213
214 variations.push(escape_regex(&lower_name));
216
217 let lower_name_no_dots = lower_name.replace('.', "");
219 if lower_name != lower_name_no_dots {
220 variations.push(escape_regex(&lower_name_no_dots));
221 }
222
223 let ascii_normalized = Self::ascii_normalize(&lower_name);
225
226 if ascii_normalized != lower_name {
227 variations.push(escape_regex(&ascii_normalized));
228
229 let ascii_no_dots = ascii_normalized.replace('.', "");
231 if ascii_normalized != ascii_no_dots {
232 variations.push(escape_regex(&ascii_no_dots));
233 }
234 }
235
236 variations
237 })
238 .collect();
239
240 patterns.sort_by_key(|b| std::cmp::Reverse(b.len()));
242
243 Some(format!(r"(?i)({})", patterns.join("|")))
246 }
247
248 fn build_name_variants(config: &MD044Config) -> HashMap<String, usize> {
249 let mut variants = HashMap::new();
250 for (index, name) in config.names.iter().enumerate() {
251 let lower_name = name.to_lowercase();
252 variants.entry(lower_name.clone()).or_insert(index);
253
254 let lower_no_dots = lower_name.replace('.', "");
255 if lower_name != lower_no_dots {
256 variants.entry(lower_no_dots).or_insert(index);
257 }
258
259 let ascii_normalized = Self::ascii_normalize(&lower_name);
260 if ascii_normalized != lower_name {
261 variants.entry(ascii_normalized.clone()).or_insert(index);
262
263 let ascii_no_dots = ascii_normalized.replace('.', "");
264 if ascii_normalized != ascii_no_dots {
265 variants.entry(ascii_no_dots).or_insert(index);
266 }
267 }
268 }
269
270 variants
271 }
272
273 fn find_name_violations(&self, ctx: &crate::lint_context::LintContext) -> Vec<WarningPosition> {
275 let content = ctx.content;
276 let hash = fast_hash(content);
278 {
279 if let Ok(cache) = self.content_cache.lock()
281 && let Some(cached) = cache.get(&hash)
282 {
283 return cached.clone();
284 }
285 }
286
287 let mut violations = Vec::new();
288
289 let combined_regex = match &self.combined_pattern {
291 Some(pattern) => match get_cached_regex(pattern) {
292 Ok(regex) => regex,
293 Err(_) => return Vec::new(),
294 },
295 None => return Vec::new(),
296 };
297
298 let field_map = if self.ignore_fields.is_empty() {
300 Vec::new()
301 } else {
302 frontmatter_values::field_map(ctx)
303 };
304
305 let directive_comments =
310 crate::inline_config::config_comment_ranges(content, |open| ctx.is_in_code_block_or_span(open));
311
312 let mut wikilink_urls = None;
313
314 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
316 let line_num = line_idx + 1;
317 let line = line_info.content(ctx.content);
318
319 let trimmed = line.trim_start();
321 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
322 continue;
323 }
324
325 if !self.config.code_blocks && line_info.in_code_block {
327 continue;
328 }
329
330 if !self.config.html_elements && line_info.in_html_block {
332 continue;
333 }
334
335 if !self.config.html_comments && line_info.in_html_comment {
337 continue;
338 }
339
340 if line_info.in_jsx_expression || line_info.in_mdx_comment {
342 continue;
343 }
344
345 if line_info.in_obsidian_comment {
347 continue;
348 }
349
350 let fm_value_offset = if line_info.in_front_matter {
353 frontmatter_values::value_offset(line)
354 } else {
355 0
356 };
357 if fm_value_offset == usize::MAX {
358 continue;
359 }
360 if line_info.in_front_matter
361 && let Some(Some(field)) = field_map.get(line_idx)
362 && self.ignore_fields.contains(field)
363 {
364 continue;
365 }
366 let fm_value_span = if line_info.in_front_matter {
367 frontmatter_values::value_span(line)
368 } else {
369 None
370 };
371
372 let line_lower = line.to_lowercase();
374 let has_line_matches = self.name_variants.keys().any(|name| line_lower.contains(name));
375
376 if !has_line_matches {
377 continue;
378 }
379
380 let mut angle_urls = None;
382 let mut fallback_code = None;
383 let mut fallback_urls = None;
384
385 for cap in combined_regex.find_iter(line) {
387 let found_name = &line[cap.start()..cap.end()];
388
389 let start_pos = cap.start();
391 let end_pos = cap.end();
392
393 if start_pos < fm_value_offset {
395 continue;
396 }
397
398 let byte_pos = line_info.byte_offset + start_pos;
400 if crate::inline_config::offset_in_ranges(&directive_comments, byte_pos) {
401 continue;
402 }
403 if ctx.is_in_html_tag(byte_pos) {
404 continue;
405 }
406
407 if ctx.is_in_shortcode(byte_pos) {
414 continue;
415 }
416
417 if !Self::is_at_word_boundary(line, start_pos, true) || !Self::is_at_word_boundary(line, end_pos, false)
418 {
419 continue; }
421
422 if Self::is_file_extension_suffix(line, start_pos, found_name) {
423 continue;
424 }
425
426 if self.config.whole_words
427 && ((Self::is_joined_identifier_boundary(line, start_pos, true)
428 && !Self::is_underscore_emphasis_boundary(ctx, byte_pos, true))
429 || (Self::is_joined_identifier_boundary(line, end_pos, false)
430 && !Self::is_underscore_emphasis_boundary(ctx, byte_pos + found_name.len(), false)))
431 {
432 continue;
433 }
434
435 if !self.config.code_blocks {
437 if ctx.is_in_code_block_or_span(byte_pos) {
438 continue;
439 }
440 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
444 && crate::inline_config::offset_in_ranges(
445 fallback_code.get_or_insert_with(|| Self::backtick_code_ranges(line)),
446 start_pos,
447 )
448 {
449 continue;
450 }
451 }
452
453 if Self::is_in_link(ctx, byte_pos) {
455 continue;
456 }
457
458 if crate::inline_config::offset_in_ranges(
462 angle_urls.get_or_insert_with(|| Self::angle_bracket_url_ranges(line)),
463 start_pos,
464 ) {
465 continue;
466 }
467
468 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
472 && crate::inline_config::offset_in_ranges(
473 fallback_urls.get_or_insert_with(|| Self::markdown_link_url_ranges(line)),
474 start_pos,
475 )
476 {
477 continue;
478 }
479
480 if crate::inline_config::offset_in_ranges(
485 wikilink_urls.get_or_insert_with(|| Self::wikilink_url_ranges(ctx)),
486 byte_pos,
487 ) {
488 continue;
489 }
490
491 if ctx.is_in_bare_url(byte_pos) {
497 continue;
498 }
499
500 if let Some(fm_value) = fm_value_span
507 && Self::is_in_path_like_token(line, start_pos, fm_value)
508 {
509 continue;
510 }
511
512 if let Some(&proper_name_index) = self.name_variants.get(&found_name.to_lowercase()) {
514 if found_name != self.config.names[proper_name_index] {
516 violations.push((line_num, cap.start() + 1, found_name.to_string(), proper_name_index));
517 }
518 }
519 }
520 }
521
522 if let Ok(mut cache) = self.content_cache.lock() {
524 cache.insert(hash, violations.clone());
525 }
526 violations
527 }
528
529 fn is_in_link(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
537 use pulldown_cmark::LinkType;
538
539 if let Some(link) = ctx.link_containing(byte_pos) {
540 let (text_start, text_end) = if matches!(link.link_type, LinkType::WikiLink { .. }) {
542 let span = &ctx.content[link.byte_offset..link.byte_end];
549 let start = match span.find('|') {
550 Some(pipe) => link.byte_offset + pipe + 1,
551 None => link.byte_offset + 2,
552 };
553 (start, link.byte_end.saturating_sub(2))
554 } else {
555 let start = link.byte_offset + 1;
556 (start, start + link.text.len())
557 };
558
559 if byte_pos >= text_start && byte_pos < text_end {
563 let is_wikilink = matches!(link.link_type, LinkType::WikiLink { .. });
564 if Self::link_text_is_url(&link.text)
565 || (!is_wikilink && Self::link_text_matches_link_url(&link.text, &link.url))
566 {
567 return true;
568 }
569 return Self::image_verdict(ctx, byte_pos).unwrap_or(false);
575 }
576 return true;
578 }
579
580 if let Some(verdict) = Self::image_verdict(ctx, byte_pos) {
581 return verdict;
582 }
583
584 ctx.is_in_reference_def(byte_pos)
586 }
587
588 fn image_verdict(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> Option<bool> {
593 let image = ctx.image_containing(byte_pos)?;
594
595 let alt_start = image.byte_offset + 2;
597 let alt_end = alt_start + image.alt_text.len();
598
599 Some(!(byte_pos >= alt_start && byte_pos < alt_end))
601 }
602
603 fn link_text_is_url(text: &str) -> bool {
605 let lower = text.trim().to_ascii_lowercase();
606 lower.starts_with("http://")
607 || lower.starts_with("https://")
608 || lower.starts_with("www.")
609 || lower.starts_with("//")
610 }
611
612 fn link_text_matches_link_url(text: &str, url: &str) -> bool {
624 let text = text.trim();
625 if !text.contains('.') {
627 return false;
628 }
629 let url_lower = url.to_ascii_lowercase();
630 let url_without_scheme = url_lower
631 .strip_prefix("https://")
632 .or_else(|| url_lower.strip_prefix("http://"))
633 .or_else(|| url_lower.strip_prefix("//"))
634 .unwrap_or(&url_lower);
635 let text_lower = text.to_ascii_lowercase();
636 if url_without_scheme == text_lower.as_str() {
638 return true;
639 }
640 url_without_scheme.len() > text_lower.len()
642 && url_without_scheme.starts_with(text_lower.as_str())
643 && matches!(
644 url_without_scheme.as_bytes().get(text_lower.len()),
645 Some(b'/') | Some(b'?') | Some(b'#')
646 )
647 }
648
649 fn angle_bracket_url_ranges(line: &str) -> Vec<std::ops::Range<usize>> {
655 let mut ranges = Vec::new();
656 let bytes = line.as_bytes();
657 let len = bytes.len();
658 let mut i = 0;
659 while i < len {
660 if bytes[i] == b'<' {
661 let after_open = i + 1;
662 if after_open < len && bytes[after_open].is_ascii_alphabetic() {
666 let mut s = after_open + 1;
667 let scheme_max = (after_open + 32).min(len);
668 while s < scheme_max
669 && (bytes[s].is_ascii_alphanumeric()
670 || bytes[s] == b'+'
671 || bytes[s] == b'-'
672 || bytes[s] == b'.')
673 {
674 s += 1;
675 }
676 if s < len && bytes[s] == b':' {
677 let mut j = s + 1;
679 let mut found_close = false;
680 while j < len {
681 match bytes[j] {
682 b'>' => {
683 found_close = true;
684 break;
685 }
686 b' ' | b'<' => break,
687 _ => j += 1,
688 }
689 }
690 if found_close {
691 ranges.push(i..j + 1);
692 i = j + 1;
693 continue;
694 }
695 }
696 }
697 }
698 i += 1;
699 }
700 ranges
701 }
702
703 #[cfg(test)]
704 fn is_in_angle_bracket_url(line: &str, pos: usize) -> bool {
705 crate::inline_config::offset_in_ranges(&Self::angle_bracket_url_ranges(line), pos)
706 }
707
708 fn wikilink_url_ranges(ctx: &crate::lint_context::LintContext) -> Vec<std::ops::Range<usize>> {
713 use pulldown_cmark::LinkType;
714 let content = ctx.content.as_bytes();
715 let starts: HashSet<_> = ctx
716 .links()
717 .iter()
718 .filter(|link| matches!(link.link_type, LinkType::WikiLink { .. }))
719 .map(|link| link.byte_end)
720 .filter(|&end| content.get(end) == Some(&b'('))
721 .collect();
722 let mut ranges: Vec<std::ops::Range<usize>> = Vec::new();
723 let Some(mut pos) = starts.iter().min().copied() else {
724 return ranges;
725 };
726 let last_start = *starts.iter().max().unwrap();
727 let mut stack = Vec::new();
730 let mut depth = 0usize;
731 while pos < content.len() {
732 match content[pos] {
733 b'\\' => pos += 1,
734 b'(' => {
735 depth += 1;
736 if starts.contains(&pos) {
737 stack.push((pos, depth));
738 }
739 }
740 b')' => {
741 if stack.last().is_some_and(|&(_, level)| level == depth) {
742 let (open, _) = stack.pop().unwrap();
743 while ranges.last().is_some_and(|range| range.start > open) {
747 ranges.pop();
748 }
749 ranges.push(open + 1..pos + 1);
750 }
751 depth = depth.saturating_sub(1);
752 }
753 b' ' | b'\t' | b'\n' | b'\r' => {
755 stack.clear();
756 depth = 0;
757 }
758 _ => {}
759 }
760 if pos >= last_start && stack.is_empty() {
761 break;
762 }
763 pos += 1;
764 }
765 ranges
766 }
767
768 fn markdown_link_url_ranges(line: &str) -> Vec<std::ops::Range<usize>> {
777 let mut ranges = Vec::new();
778 let bytes = line.as_bytes();
779 let mut brackets = FallbackDelimiters::default();
780 let mut parentheses = FallbackDelimiters::default();
781 let mut i = 0;
782 while i < bytes.len() {
783 if bytes[i] == b'['
785 && (i == 0 || bytes[i - 1] != b'\\' || (i >= 2 && bytes[i - 2] == b'\\'))
786 && let Some(j) = brackets.find(bytes, i + 1, b'[', b']')
787 {
788 let end = match bytes.get(j) {
789 Some(b'(') => parentheses.find(bytes, j + 1, b'(', b')'),
790 Some(b'[') => brackets.find(bytes, j + 1, b'[', b']'),
791 _ => None,
792 };
793 if let Some(end) = end {
794 ranges.push(j + 1..end);
795 i = end;
796 continue;
797 }
798 }
799 i += 1;
800 }
801 ranges
802 }
803
804 fn backtick_code_ranges(line: &str) -> Vec<std::ops::Range<usize>> {
811 let mut ranges = Vec::new();
812 let bytes = line.as_bytes();
813 let len = bytes.len();
814 let mut i = 0;
815 while i < len {
816 if bytes[i] == b'`' {
817 let open_start = i;
819 while i < len && bytes[i] == b'`' {
820 i += 1;
821 }
822 let tick_len = i - open_start;
823
824 while i < len {
826 if bytes[i] == b'`' {
827 let close_start = i;
828 while i < len && bytes[i] == b'`' {
829 i += 1;
830 }
831 if i - close_start == tick_len {
832 let content_start = open_start + tick_len;
836 let content_end = close_start;
837 ranges.push(content_start..content_end);
838 break;
840 }
841 } else {
843 i += 1;
844 }
845 }
846 } else {
847 i += 1;
848 }
849 }
850 ranges
851 }
852
853 #[cfg(test)]
854 fn is_in_backtick_code_in_line(line: &str, pos: usize) -> bool {
855 crate::inline_config::offset_in_ranges(&Self::backtick_code_ranges(line), pos)
856 }
857
858 fn is_word_boundary_char(c: char) -> bool {
860 !c.is_alphanumeric()
861 }
862
863 fn is_at_word_boundary(content: &str, pos: usize, is_start: bool) -> bool {
865 if is_start {
866 if pos == 0 {
867 return true;
868 }
869 match content[..pos].chars().next_back() {
870 None => true,
871 Some(c) => Self::is_word_boundary_char(c),
872 }
873 } else {
874 if pos >= content.len() {
875 return true;
876 }
877 match content[pos..].chars().next() {
878 None => true,
879 Some(c) => Self::is_word_boundary_char(c),
880 }
881 }
882 }
883
884 fn is_file_extension_suffix(line: &str, start: usize, name: &str) -> bool {
889 let Some(prefix) = line[..start].strip_suffix('.') else {
890 return false;
891 };
892
893 !prefix.ends_with('.')
896 && name.starts_with(char::is_alphanumeric)
897 && name.ends_with(char::is_alphanumeric)
898 && name
899 .chars()
900 .all(|character| character.is_alphanumeric() || matches!(character, '.' | '-' | '_'))
901 }
902
903 fn is_joined_identifier_boundary(content: &str, pos: usize, is_start: bool) -> bool {
911 if is_start {
912 let mut chars = content[..pos].char_indices().rev();
913 let mut saw_separator = false;
914 for (_, character) in &mut chars {
915 if character == '-' || character == '_' {
916 saw_separator = true;
917 } else {
918 return saw_separator && character.is_alphanumeric();
919 }
920 }
921 false
922 } else {
923 let mut saw_separator = false;
924 for character in content[pos..].chars() {
925 if character == '-' || character == '_' {
926 saw_separator = true;
927 } else {
928 return saw_separator && character.is_alphanumeric();
929 }
930 }
931 false
932 }
933 }
934
935 fn is_underscore_emphasis_boundary(ctx: &crate::lint_context::LintContext, pos: usize, is_start: bool) -> bool {
940 ctx.source_emphasis_spans().iter().any(|span| {
941 if span.marker != '_' {
942 return false;
943 }
944 let width = if span.is_strong { 2 } else { 1 };
945 if is_start {
946 span.byte_offset + width == pos
947 } else {
948 span.byte_end.checked_sub(width) == Some(pos)
949 }
950 })
951 }
952
953 fn is_in_path_like_token(line: &str, match_start: usize, fm_value: (usize, usize)) -> bool {
982 let (value_start, value_end) = fm_value;
983 if match_start < value_start || match_start >= value_end {
984 return false;
985 }
986
987 let quoted_words: Vec<&str> = if frontmatter_values::value_is_quoted(line, value_start) {
995 line[value_start..value_end].split_whitespace().collect()
996 } else {
997 Vec::new()
998 };
999 let is_single_quoted_path = !quoted_words.is_empty() && quoted_words.iter().all(|word| word.contains('/'));
1000 let is_multi_word_collapse = is_single_quoted_path && quoted_words.len() > 1;
1008
1009 let (raw_start, raw_end) = if is_single_quoted_path {
1010 (value_start, value_end)
1011 } else {
1012 frontmatter_values::token_bounds(line, match_start, value_start, value_end)
1013 };
1014
1015 let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
1016 if match_start < start || match_start >= end {
1017 return false;
1018 }
1019
1020 let token = &line[start..end];
1021 if !token.contains('/') {
1022 return false;
1023 }
1024 if token.starts_with('/') || token.starts_with("./") || token.starts_with("../") || token.starts_with("~/") {
1025 return true;
1026 }
1027 if token.rsplit('/').next().is_some_and(|seg| seg.contains('.')) {
1028 return true;
1029 }
1030
1031 if is_multi_word_collapse {
1032 return false;
1033 }
1034
1035 let sole_value = {
1039 let (ts, te) = frontmatter_values::trim_token_bounds(line, value_start, value_end);
1040 ts == start && te == end
1041 };
1042 sole_value && token.split('/').filter(|s| !s.is_empty()).count() >= 3
1043 }
1044}
1045
1046impl Rule for MD044ProperNames {
1047 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
1048 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(44))
1049 }
1050
1051 fn name(&self) -> &'static str {
1052 "MD044"
1053 }
1054
1055 fn description(&self) -> &'static str {
1056 "Proper names should have the correct capitalization"
1057 }
1058
1059 fn category(&self) -> RuleCategory {
1060 RuleCategory::Other
1061 }
1062
1063 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1064 if self.config.names.is_empty() {
1065 return true;
1066 }
1067 let content_lower = if ctx.content.is_ascii() {
1069 ctx.content.to_ascii_lowercase()
1070 } else {
1071 ctx.content.to_lowercase()
1072 };
1073 !self.name_variants.keys().any(|name| content_lower.contains(name))
1074 }
1075
1076 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1077 let content = ctx.content;
1078 if content.is_empty() || self.config.names.is_empty() || self.combined_pattern.is_none() {
1079 return Ok(Vec::new());
1080 }
1081
1082 let content_lower = if content.is_ascii() {
1084 content.to_ascii_lowercase()
1085 } else {
1086 content.to_lowercase()
1087 };
1088
1089 let has_potential_matches = self.name_variants.keys().any(|name| content_lower.contains(name));
1091
1092 if !has_potential_matches {
1093 return Ok(Vec::new());
1094 }
1095 let violations = self.find_name_violations(ctx);
1096
1097 let mut positions = SourcePositions::new(content, &ctx.lines);
1098 let warnings = violations
1099 .into_iter()
1100 .map(|(line, column, found_name, proper_name_index)| {
1101 let proper_name = &self.config.names[proper_name_index];
1102 let line_start = ctx.line_start_byte(line).unwrap_or(0);
1107 let byte_start = line_start + (column - 1);
1108 let byte_end = byte_start + found_name.len();
1109 let char_col = positions.column_on_line(line - 1, byte_start) + 1;
1112 LintWarning {
1113 rule_name: Some(self.name().to_string()),
1114 line,
1115 column: char_col,
1116 end_line: line,
1117 end_column: char_col + found_name.chars().count(),
1118 message: format!("Proper name '{found_name}' should be '{proper_name}'"),
1119 severity: Severity::Warning,
1120 fix: Some(Fix::new(byte_start..byte_end, proper_name.clone())),
1121 }
1122 })
1123 .collect();
1124
1125 Ok(warnings)
1126 }
1127
1128 fn fixes_from_diagnostics(&self) -> bool {
1129 true
1130 }
1131
1132 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1133 if self.should_skip(ctx) {
1134 return Ok(ctx.content.to_string());
1135 }
1136 let warnings = self.check(ctx)?;
1137 if warnings.is_empty() {
1138 return Ok(ctx.content.to_string());
1139 }
1140 let warnings =
1141 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1142 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
1143 .map_err(crate::rule::LintError::InvalidInput)
1144 }
1145
1146 fn as_any(&self) -> &dyn std::any::Any {
1147 self
1148 }
1149
1150 crate::impl_rule_config_methods!(MD044Config);
1151}
1152
1153#[cfg(test)]
1154mod tests {
1155 use super::*;
1156 use crate::lint_context::LintContext;
1157
1158 fn create_context(content: &str) -> LintContext<'_> {
1159 LintContext::new(content, crate::config::MarkdownFlavor::Standard, None)
1160 }
1161
1162 fn field_map_for(content: &str) -> Vec<Option<String>> {
1163 let ctx = create_context(content);
1164 frontmatter_values::field_map(&ctx)
1165 }
1166
1167 fn whole_word_rule(names: &[&str]) -> MD044ProperNames {
1168 MD044ProperNames::from_config_struct(MD044Config {
1169 names: names.iter().map(|name| (*name).to_string()).collect(),
1170 whole_words: true,
1171 ..Default::default()
1172 })
1173 }
1174
1175 #[test]
1176 fn file_extension_suffixes_preserve_case_but_prose_is_checked() {
1177 let content = "I like toml in my favorite.toml file.\n\
1178 Extensions: .toml (.toml) [.toml] \".toml\" '.toml' “.toml” ‘.toml’.\n\
1179 Files: config.toml.example v2.toml café.toml my_conf.toml foo-.toml file(1).toml.\n\
1180 Patterns: *.toml **.toml** _.toml_ /.toml \\.toml.\n\
1181 Next.js uses js, and toml is a format.\n";
1182 let expected = content
1183 .replacen("I like toml", "I like TOML", 1)
1184 .replace("uses js, and toml", "uses JS, and TOML");
1185
1186 for whole_words in [false, true] {
1187 let rule = MD044ProperNames::from_config_struct(MD044Config {
1188 names: vec!["TOML".to_string(), "JS".to_string()],
1189 whole_words,
1190 ..Default::default()
1191 });
1192 let ctx = create_context(content);
1193 assert_eq!(rule.check(&ctx).unwrap().len(), 3);
1194 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1195 assert!(rule.check(&create_context(&expected)).unwrap().is_empty());
1196 assert_eq!(rule.fix(&create_context(&expected)).unwrap(), expected);
1197 }
1198 }
1199
1200 #[test]
1201 fn file_extensions_keep_dotted_names_stems_and_function_calls_checkable() {
1202 let rule = MD044ProperNames::new(
1203 ["TOML", "Oklch", "Node.js", "MyApp", ".NET", "GitHub"]
1204 .map(str::to_string)
1205 .to_vec(),
1206 false,
1207 );
1208 let content = "Use node.js and .net for myapp.\n\
1209 See docs/myapp.md and docs/myapp(1).md.\n\
1210 Oklch syntax is oklch(.5 .5 255); github(tm) is a reference.\n\
1211 I like TOML in my favorite.toml file.\n\
1212 Use `favorite.toml` and `oklch(.5 .5 255)`.\n";
1213 let expected = "Use Node.js and .NET for MyApp.\n\
1214 See docs/MyApp.md and docs/MyApp(1).md.\n\
1215 Oklch syntax is Oklch(.5 .5 255); GitHub(tm) is a reference.\n\
1216 I like TOML in my favorite.toml file.\n\
1217 Use `favorite.toml` and `oklch(.5 .5 255)`.\n";
1218 let ctx = create_context(content);
1219 assert_eq!(rule.check(&ctx).unwrap().len(), 7);
1220 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1221 assert!(rule.check(&create_context(expected)).unwrap().is_empty());
1222 }
1223
1224 #[test]
1225 fn file_extensions_do_not_exempt_ellipsis_or_multiword_prose() {
1226 let rule = MD044ProperNames::new(vec!["TOML".to_string(), "My App".to_string()], false);
1227 let content = "Wait...toml or …toml; e.g. toml and file. toml.\n\
1228 Sentence.my app is still prose.\n";
1229 let expected = "Wait...TOML or …TOML; e.g. TOML and file. TOML.\n\
1230 Sentence.My App is still prose.\n";
1231 let ctx = create_context(content);
1232 assert_eq!(rule.check(&ctx).unwrap().len(), 5);
1233 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1234 }
1235
1236 #[test]
1237 fn file_extension_syntax_applies_to_dotted_tokens_and_html_prose() {
1238 let rule = MD044ProperNames::new(["TOML", "Node.js", "GitHub"].map(str::to_string).to_vec(), false);
1239 let content = ".github api.node.js api.github.com i.e.toml\n\
1242 node.js github and toml\n\
1243 <!-- favorite.toml toml -->\n\
1244 <div>favorite.toml toml</div>\n";
1245 let expected = ".github api.node.js api.github.com i.e.toml\n\
1246 Node.js GitHub and TOML\n\
1247 <!-- favorite.toml TOML -->\n\
1248 <div>favorite.toml TOML</div>\n";
1249 let ctx = create_context(content);
1250 assert_eq!(rule.check(&ctx).unwrap().len(), 5);
1251 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1252 assert!(rule.check(&create_context(expected)).unwrap().is_empty());
1253 }
1254
1255 #[test]
1256 fn file_extensions_in_markdown_preserve_suffixes_and_check_prose() {
1257 let content = "**favorite.toml** *favorite.toml* _favorite.toml_\n\
1258 [favorite.toml](other.md) [toml](favorite.toml)\n\
1259  \n\
1260 [[favorite.toml]] [[Page|favorite.toml]] [[Page|toml]]\n\
1261 `favorite.toml toml`\n\
1262 ```text\n\
1263 favorite.toml toml\n\
1264 ```\n";
1265 for code_blocks in [false, true] {
1266 let rule = MD044ProperNames::new(vec!["TOML".to_string()], code_blocks);
1267 let mut expected = content.replace("[toml]", "[TOML]").replace("|toml]]", "|TOML]]");
1268 if code_blocks {
1269 expected = expected.replace("favorite.toml toml", "favorite.toml TOML");
1270 }
1271 for flavor in [
1272 crate::config::MarkdownFlavor::Standard,
1273 crate::config::MarkdownFlavor::Obsidian,
1274 ] {
1275 let ctx = LintContext::new(content, flavor, None);
1276 assert_eq!(rule.check(&ctx).unwrap().len(), if code_blocks { 5 } else { 3 });
1277 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1278 }
1279 }
1280 }
1281
1282 #[test]
1283 fn file_extensions_in_frontmatter_preserve_suffixes_and_check_prose() {
1284 let rule = MD044ProperNames::new(["TOML", "Node.js", "MyApp"].map(str::to_string).to_vec(), false);
1285 for content in [
1286 "---\ntoml: favorite.toml\ntitle: node.js guide to toml\nimage: myapp.png\npath: docs/myapp.md\n---\n",
1287 "+++\ntoml = \"favorite.toml\"\ntitle = \"node.js guide to toml\"\nimage = \"myapp.png\"\npath = \"docs/myapp.md\"\n+++\n",
1288 ] {
1289 let expected = content
1290 .replace("node.js guide to toml", "Node.js guide to TOML")
1291 .replace("myapp.png", "MyApp.png");
1292 let ctx = create_context(content);
1293 assert_eq!(rule.check(&ctx).unwrap().len(), 3);
1294 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1295 }
1296 }
1297
1298 #[test]
1299 fn whole_words_preserves_compound_identifiers_but_checks_standalone_names() {
1300 let rule = whole_word_rule(&["ABC"]);
1301 let content = "123-abc-def\nfoo_abc_bar\nabc\n(abc) [abc]\n";
1302 let ctx = create_context(content);
1303
1304 let warnings = rule.check(&ctx).unwrap();
1305 assert_eq!(warnings.len(), 3);
1306 assert_eq!(
1307 warnings.iter().map(|warning| warning.line).collect::<Vec<_>>(),
1308 [3, 4, 4]
1309 );
1310 assert_eq!(rule.fix(&ctx).unwrap(), "123-abc-def\nfoo_abc_bar\nABC\n(ABC) [ABC]\n");
1311 }
1312
1313 #[test]
1314 fn whole_words_preserves_identifiers_inside_emphasis() {
1315 let rule = whole_word_rule(&["ABC"]);
1316 let content = "_123-abc-def_ _foo_abc_bar_ __foo__abc__bar__\n\
1317 _abc_def_ _foo_abc_\n\
1318 _abc_ __abc__ ___abc___ foo-__abc__-bar\n";
1319 let ctx = create_context(content);
1320 let warnings = rule.check(&ctx).unwrap();
1321 assert_eq!(warnings.len(), 4);
1322 assert!(warnings.iter().all(|warning| warning.line == 3));
1323 let fixed = rule.fix(&ctx).unwrap();
1324 assert_eq!(
1325 fixed,
1326 "_123-abc-def_ _foo_abc_bar_ __foo__abc__bar__\n\
1327 _abc_def_ _foo_abc_\n\
1328 _ABC_ __ABC__ ___ABC___ foo-__ABC__-bar\n"
1329 );
1330 assert!(rule.check(&create_context(&fixed)).unwrap().is_empty());
1331 assert_eq!(rule.fix(&create_context(&fixed)).unwrap(), fixed);
1332 }
1333
1334 #[test]
1335 fn whole_words_distinguishes_unicode_compounds_from_markdown_emphasis() {
1336 let rule = whole_word_rule(&["ABC"]);
1337 let content = "café_abc_menu 東京-abc-駅 foo__abc__bar _abc_ __abc__ foo-__abc__-bar\n";
1338 let ctx = create_context(content);
1339
1340 assert_eq!(rule.check(&ctx).unwrap().len(), 3);
1341 let fixed = rule.fix(&ctx).unwrap();
1342 assert_eq!(
1343 fixed,
1344 "café_abc_menu 東京-abc-駅 foo__abc__bar _ABC_ __ABC__ foo-__ABC__-bar\n"
1345 );
1346 assert_eq!(rule.fix(&create_context(&fixed)).unwrap(), fixed);
1347 }
1348
1349 #[test]
1350 fn repeated_intraword_underscores_keep_legacy_behavior_without_whole_words() {
1351 let rule = MD044ProperNames::new(vec!["ABC".to_string()], false);
1352 let content = "foo__abc__bar\n";
1353 let ctx = create_context(content);
1354
1355 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1356 assert_eq!(rule.fix(&ctx).unwrap(), "foo__ABC__bar\n");
1357 }
1358
1359 #[test]
1360 fn whole_words_respects_code_and_link_exclusions_and_multiword_names() {
1361 let rule = whole_word_rule(&["ABC", "My App"]);
1362 let content = "`abc` [label](https://example.test/abc) my app x-my app-y\n";
1363 let ctx = create_context(content);
1364
1365 let warnings = rule.check(&ctx).unwrap();
1366 assert_eq!(warnings.len(), 1);
1367 assert_eq!(warnings[0].message, "Proper name 'my app' should be 'My App'");
1368 let fixed = rule.fix(&ctx).unwrap();
1369 assert_eq!(fixed, "`abc` [label](https://example.test/abc) My App x-my app-y\n");
1370 assert_eq!(rule.fix(&create_context(&fixed)).unwrap(), fixed);
1371 }
1372
1373 #[test]
1374 fn first_configured_spelling_wins_for_colliding_variants() {
1375 for (names, content, expected) in [
1376 (["Node.js", "Nodejs"], "nodejs", "Node.js"),
1377 (["Nodejs", "Node.js"], "nodejs", "Nodejs"),
1378 (["Café", "Cafe"], "cafe", "Café"),
1379 (["Cafe", "Café"], "cafe", "Cafe"),
1380 (["Node.js", "NODE.JS"], "node.js", "Node.js"),
1381 ] {
1382 let rule = MD044ProperNames::new(names.map(str::to_string).to_vec(), false);
1383 let ctx = create_context(content);
1384 let warnings = rule.check(&ctx).unwrap();
1385 assert_eq!(warnings.len(), 1);
1386 assert_eq!(warnings[0].fix.as_ref().unwrap().replacement, expected);
1387 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1388 assert_eq!(rule.clone().check(&ctx).unwrap(), warnings);
1389 }
1390 }
1391
1392 #[test]
1393 fn test_field_map_nested_lines_inherit_top_level_key() {
1394 let map = field_map_for("---\nseo:\n canonical: docs/a.md\n keywords:\n - myapp\ntitle: x\n---\n");
1395 assert_eq!(map[2].as_deref(), Some("seo"));
1396 assert_eq!(map[4].as_deref(), Some("seo"));
1397 assert_eq!(map[5].as_deref(), Some("title"));
1398 }
1399
1400 #[test]
1401 fn test_field_map_block_scalar_bracket_does_not_swallow_next_key() {
1402 let map = field_map_for("---\ndescription: |\n [myapp\ntitle: myapp\n---\n");
1403 assert_eq!(map[2].as_deref(), Some("description"));
1404 assert_eq!(
1405 map[3].as_deref(),
1406 Some("title"),
1407 "an indent-0 key always starts a new key"
1408 );
1409 }
1410
1411 #[test]
1412 fn test_field_map_quoted_key_with_colon() {
1413 let map = field_map_for("---\n\"og:title\": myapp\n---\n");
1414 assert_eq!(map[1].as_deref(), Some("og:title"));
1415 }
1416
1417 #[test]
1418 fn test_field_map_top_level_sequence_clears_attribution() {
1419 let map = field_map_for("---\n- myapp\n---\n");
1420 assert_eq!(map[1], None);
1421 }
1422
1423 #[test]
1424 fn test_field_map_toml_table_body_belongs_to_table_root() {
1425 let map = field_map_for("+++\n[seo]\ncanonical = \"docs/a.md\"\n\n[[authors]]\nname = \"myapp\"\n+++\n");
1426 assert_eq!(map[2].as_deref(), Some("seo"));
1427 assert_eq!(map[5].as_deref(), Some("authors"));
1428 }
1429
1430 #[test]
1431 fn test_field_map_toml_dotted_assignment_uses_root() {
1432 let map = field_map_for("+++\nseo.canonical = \"docs/a.md\"\n+++\n");
1433 assert_eq!(map[1].as_deref(), Some("seo"));
1434 }
1435
1436 #[test]
1437 fn test_field_map_toml_array_continuation_inherits() {
1438 let map = field_map_for("+++\nseo = [\n\"docs/guide/myapp\"\n]\n+++\n");
1439 assert_eq!(map[2].as_deref(), Some("seo"));
1440 }
1441
1442 #[test]
1443 fn test_field_map_indent_zero_flow_continuation_is_not_attributed_to_parent() {
1444 let map = field_map_for("---\nseo: [\n{name: myapp}\n]\n---\n");
1447 assert_eq!(map[2].as_deref(), Some("{name"));
1448 }
1449
1450 #[test]
1451 fn test_field_map_toml_nested_array_inherits_and_title_not_corrupted() {
1452 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\ntitle = \"x\"\n+++\n");
1453 assert_eq!(map[2].as_deref(), Some("matrix"), "nested array line inherits matrix");
1454 assert_eq!(map[3].as_deref(), Some("matrix"), "nested array line inherits matrix");
1455 assert_eq!(
1456 map[5].as_deref(),
1457 Some("title"),
1458 "title must not inherit stale attribution from a closed nested array"
1459 );
1460 }
1461
1462 #[test]
1463 fn test_field_map_toml_nested_array_last_element_without_trailing_comma() {
1464 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [2]\n]\ntitle = \"x\"\n+++\n");
1465 assert_eq!(
1466 map[3].as_deref(),
1467 Some("matrix"),
1468 "last element without a trailing comma still inherits matrix"
1469 );
1470 assert_eq!(
1471 map[5].as_deref(),
1472 Some("title"),
1473 "title must not inherit stale attribution from a closed nested array"
1474 );
1475 }
1476
1477 #[test]
1478 fn test_field_map_toml_nested_array_then_real_table_header_non_regression_guard() {
1479 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\n\n[seo]\ncanonical = \"docs/a.md\"\n+++\n");
1486 assert_eq!(map[6].as_deref(), Some("seo"), "real table header after a closed array");
1487 assert_eq!(
1488 map[7].as_deref(),
1489 Some("seo"),
1490 "table body still attributes to the table"
1491 );
1492 }
1493
1494 #[test]
1495 fn test_field_map_toml_unclosed_array_resyncs_on_next_assignment() {
1496 let map = field_map_for("+++\nmatrix = [\n [1, 2],\ntitle = \"x\"\n+++\n");
1502 assert_eq!(
1503 map[3].as_deref(),
1504 Some("title"),
1505 "title must resync even though the array was never closed"
1506 );
1507 }
1508
1509 #[test]
1510 fn test_field_map_toml_column_zero_array_elements_inherit_and_title_not_corrupted() {
1511 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[3, 4],\n]\ntitle = \"x\"\n+++\n");
1517 assert_eq!(
1518 map[2].as_deref(),
1519 Some("matrix"),
1520 "column-0 array element inherits matrix"
1521 );
1522 assert_eq!(
1523 map[3].as_deref(),
1524 Some("matrix"),
1525 "column-0 array element inherits matrix"
1526 );
1527 assert_eq!(
1528 map[5].as_deref(),
1529 Some("title"),
1530 "title must not inherit stale attribution from a misread array element"
1531 );
1532 }
1533
1534 #[test]
1535 fn test_field_map_toml_column_zero_array_last_element_without_trailing_comma() {
1536 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[2]\n]\ntitle = \"x\"\n+++\n");
1537 assert_eq!(
1538 map[3].as_deref(),
1539 Some("matrix"),
1540 "column-0 last element without a trailing comma still inherits matrix"
1541 );
1542 assert_eq!(
1543 map[5].as_deref(),
1544 Some("title"),
1545 "title must not inherit stale attribution from a misread array element"
1546 );
1547 }
1548
1549 #[test]
1550 fn test_correctly_capitalized_names() {
1551 let rule = MD044ProperNames::new(
1552 vec![
1553 "JavaScript".to_string(),
1554 "TypeScript".to_string(),
1555 "Node.js".to_string(),
1556 ],
1557 true,
1558 );
1559
1560 let content = "This document uses JavaScript, TypeScript, and Node.js correctly.";
1561 let ctx = create_context(content);
1562 let result = rule.check(&ctx).unwrap();
1563 assert!(result.is_empty(), "Should not flag correctly capitalized names");
1564 }
1565
1566 #[test]
1567 fn test_incorrectly_capitalized_names() {
1568 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1569
1570 let content = "This document uses javascript and typescript incorrectly.";
1571 let ctx = create_context(content);
1572 let result = rule.check(&ctx).unwrap();
1573
1574 assert_eq!(result.len(), 2, "Should flag two incorrect capitalizations");
1575 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1576 assert_eq!(result[0].line, 1);
1577 assert_eq!(result[0].column, 20);
1578 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1579 assert_eq!(result[1].line, 1);
1580 assert_eq!(result[1].column, 35);
1581 }
1582
1583 #[test]
1584 fn test_names_at_beginning_of_sentences() {
1585 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "Python".to_string()], true);
1586
1587 let content = "javascript is a great language. python is also popular.";
1588 let ctx = create_context(content);
1589 let result = rule.check(&ctx).unwrap();
1590
1591 assert_eq!(result.len(), 2, "Should flag names at beginning of sentences");
1592 assert_eq!(result[0].line, 1);
1593 assert_eq!(result[0].column, 1);
1594 assert_eq!(result[1].line, 1);
1595 assert_eq!(result[1].column, 33);
1596 }
1597
1598 #[test]
1599 fn test_names_in_code_blocks_checked_by_default() {
1600 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1601
1602 let content = r#"Here is some text with JavaScript.
1603
1604```javascript
1605// This javascript should be checked
1606const lang = "javascript";
1607```
1608
1609But this javascript should be flagged."#;
1610
1611 let ctx = create_context(content);
1612 let result = rule.check(&ctx).unwrap();
1613
1614 assert_eq!(result.len(), 3, "Should flag javascript inside and outside code blocks");
1615 assert_eq!(result[0].line, 4);
1616 assert_eq!(result[1].line, 5);
1617 assert_eq!(result[2].line, 8);
1618 }
1619
1620 #[test]
1621 fn test_names_in_code_blocks_ignored_when_disabled() {
1622 let rule = MD044ProperNames::new(
1623 vec!["JavaScript".to_string()],
1624 false, );
1626
1627 let content = r#"```
1628javascript in code block
1629```"#;
1630
1631 let ctx = create_context(content);
1632 let result = rule.check(&ctx).unwrap();
1633
1634 assert_eq!(
1635 result.len(),
1636 0,
1637 "Should not flag javascript in code blocks when code_blocks is false"
1638 );
1639 }
1640
1641 #[test]
1642 fn test_names_in_inline_code_checked_by_default() {
1643 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1644
1645 let content = "This is `javascript` in inline code and javascript outside.";
1646 let ctx = create_context(content);
1647 let result = rule.check(&ctx).unwrap();
1648
1649 assert_eq!(result.len(), 2, "Should flag javascript inside and outside inline code");
1651 assert_eq!(result[0].column, 10); assert_eq!(result[1].column, 41); }
1654
1655 #[test]
1656 fn test_multiple_names_in_same_line() {
1657 let rule = MD044ProperNames::new(
1658 vec!["JavaScript".to_string(), "TypeScript".to_string(), "React".to_string()],
1659 true,
1660 );
1661
1662 let content = "I use javascript, typescript, and react in my projects.";
1663 let ctx = create_context(content);
1664 let result = rule.check(&ctx).unwrap();
1665
1666 assert_eq!(result.len(), 3, "Should flag all three incorrect names");
1667 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1668 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1669 assert_eq!(result[2].message, "Proper name 'react' should be 'React'");
1670 }
1671
1672 #[test]
1673 fn test_case_sensitivity() {
1674 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1675
1676 let content = "JAVASCRIPT, Javascript, javascript, and JavaScript variations.";
1677 let ctx = create_context(content);
1678 let result = rule.check(&ctx).unwrap();
1679
1680 assert_eq!(result.len(), 3, "Should flag all incorrect case variations");
1681 assert!(result.iter().all(|w| w.message.contains("should be 'JavaScript'")));
1683 }
1684
1685 #[test]
1686 fn test_configuration_with_custom_name_list() {
1687 let config = MD044Config {
1688 names: vec!["GitHub".to_string(), "GitLab".to_string(), "DevOps".to_string()],
1689 code_blocks: true,
1690 ..Default::default()
1691 };
1692 let rule = MD044ProperNames::from_config_struct(config);
1693
1694 let content = "We use github, gitlab, and devops for our workflow.";
1695 let ctx = create_context(content);
1696 let result = rule.check(&ctx).unwrap();
1697
1698 assert_eq!(result.len(), 3, "Should flag all custom names");
1699 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
1700 assert_eq!(result[1].message, "Proper name 'gitlab' should be 'GitLab'");
1701 assert_eq!(result[2].message, "Proper name 'devops' should be 'DevOps'");
1702 }
1703
1704 #[test]
1705 fn test_empty_configuration() {
1706 let rule = MD044ProperNames::new(vec![], true);
1707
1708 let content = "This has javascript and typescript but no configured names.";
1709 let ctx = create_context(content);
1710 let result = rule.check(&ctx).unwrap();
1711
1712 assert!(result.is_empty(), "Should not flag anything with empty configuration");
1713 }
1714
1715 #[test]
1716 fn test_names_with_special_characters() {
1717 let rule = MD044ProperNames::new(
1718 vec!["Node.js".to_string(), "ASP.NET".to_string(), "C++".to_string()],
1719 true,
1720 );
1721
1722 let content = "We use nodejs, asp.net, ASP.NET, and c++ in our stack.";
1723 let ctx = create_context(content);
1724 let result = rule.check(&ctx).unwrap();
1725
1726 assert_eq!(result.len(), 3, "Should handle special characters correctly");
1731
1732 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
1733 assert!(messages.contains(&"Proper name 'nodejs' should be 'Node.js'"));
1734 assert!(messages.contains(&"Proper name 'asp.net' should be 'ASP.NET'"));
1735 assert!(messages.contains(&"Proper name 'c++' should be 'C++'"));
1736 }
1737
1738 #[test]
1739 fn test_word_boundaries() {
1740 let rule = MD044ProperNames::new(vec!["Java".to_string(), "Script".to_string()], true);
1741
1742 let content = "JavaScript is not java or script, but Java and Script are separate.";
1743 let ctx = create_context(content);
1744 let result = rule.check(&ctx).unwrap();
1745
1746 assert_eq!(result.len(), 2, "Should respect word boundaries");
1748 assert!(result.iter().any(|w| w.column == 19)); assert!(result.iter().any(|w| w.column == 27)); }
1751
1752 #[test]
1753 fn test_fix_method() {
1754 let rule = MD044ProperNames::new(
1755 vec![
1756 "JavaScript".to_string(),
1757 "TypeScript".to_string(),
1758 "Node.js".to_string(),
1759 ],
1760 true,
1761 );
1762
1763 let content = "I love javascript, typescript, and nodejs!";
1764 let ctx = create_context(content);
1765 let fixed = rule.fix(&ctx).unwrap();
1766
1767 assert_eq!(fixed, "I love JavaScript, TypeScript, and Node.js!");
1768 }
1769
1770 #[test]
1771 fn test_fix_multiple_occurrences() {
1772 let rule = MD044ProperNames::new(vec!["Python".to_string()], true);
1773
1774 let content = "python is great. I use python daily. PYTHON is powerful.";
1775 let ctx = create_context(content);
1776 let fixed = rule.fix(&ctx).unwrap();
1777
1778 assert_eq!(fixed, "Python is great. I use Python daily. Python is powerful.");
1779 }
1780
1781 #[test]
1782 fn test_fix_checks_code_blocks_by_default() {
1783 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1784
1785 let content = r#"I love javascript.
1786
1787```
1788const lang = "javascript";
1789```
1790
1791More javascript here."#;
1792
1793 let ctx = create_context(content);
1794 let fixed = rule.fix(&ctx).unwrap();
1795
1796 let expected = r#"I love JavaScript.
1797
1798```
1799const lang = "JavaScript";
1800```
1801
1802More JavaScript here."#;
1803
1804 assert_eq!(fixed, expected);
1805 }
1806
1807 #[test]
1808 fn test_multiline_content() {
1809 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
1810
1811 let content = r#"First line with rust.
1812Second line with python.
1813Third line with RUST and PYTHON."#;
1814
1815 let ctx = create_context(content);
1816 let result = rule.check(&ctx).unwrap();
1817
1818 assert_eq!(result.len(), 4, "Should flag all incorrect occurrences");
1819 assert_eq!(result[0].line, 1);
1820 assert_eq!(result[1].line, 2);
1821 assert_eq!(result[2].line, 3);
1822 assert_eq!(result[3].line, 3);
1823 }
1824
1825 #[test]
1826 fn test_default_config() {
1827 let config = MD044Config::default();
1828 assert!(config.names.is_empty());
1829 assert!(!config.code_blocks);
1830 assert!(config.html_elements);
1831 assert!(config.html_comments);
1832 }
1833
1834 #[test]
1835 fn test_default_config_checks_html_comments() {
1836 let config = MD044Config {
1837 names: vec!["JavaScript".to_string()],
1838 ..MD044Config::default()
1839 };
1840 let rule = MD044ProperNames::from_config_struct(config);
1841
1842 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1843 let ctx = create_context(content);
1844 let result = rule.check(&ctx).unwrap();
1845
1846 assert_eq!(result.len(), 1, "Default config should check HTML comments");
1847 assert_eq!(result[0].line, 3);
1848 }
1849
1850 #[test]
1851 fn test_default_config_skips_code_blocks() {
1852 let config = MD044Config {
1853 names: vec!["JavaScript".to_string()],
1854 ..MD044Config::default()
1855 };
1856 let rule = MD044ProperNames::from_config_struct(config);
1857
1858 let content = "# Guide\n\n```\njavascript in code\n```\n";
1859 let ctx = create_context(content);
1860 let result = rule.check(&ctx).unwrap();
1861
1862 assert_eq!(result.len(), 0, "Default config should skip code blocks");
1863 }
1864
1865 #[test]
1866 fn test_standalone_html_comment_checked() {
1867 let config = MD044Config {
1868 names: vec!["Test".to_string()],
1869 ..MD044Config::default()
1870 };
1871 let rule = MD044ProperNames::from_config_struct(config);
1872
1873 let content = "# Heading\n\n<!-- this is a test example -->\n";
1874 let ctx = create_context(content);
1875 let result = rule.check(&ctx).unwrap();
1876
1877 assert_eq!(result.len(), 1, "Should flag proper name in standalone HTML comment");
1878 assert_eq!(result[0].line, 3);
1879 }
1880
1881 #[test]
1882 fn test_inline_config_comments_not_flagged() {
1883 let config = MD044Config {
1884 names: vec!["RUMDL".to_string()],
1885 ..MD044Config::default()
1886 };
1887 let rule = MD044ProperNames::from_config_struct(config);
1888
1889 let content = "<!-- rumdl-disable MD044 -->\nSome rumdl text here.\n<!-- rumdl-enable MD044 -->\n<!-- markdownlint-disable -->\nMore rumdl text.\n<!-- markdownlint-enable -->\n";
1893 let ctx = create_context(content);
1894 let result = rule.check(&ctx).unwrap();
1895
1896 assert_eq!(result.len(), 2, "Should only flag body lines, not config comments");
1897 assert_eq!(result[0].line, 2);
1898 assert_eq!(result[1].line, 5);
1899 }
1900
1901 #[test]
1902 fn test_compact_inline_config_comments_not_flagged() {
1903 let config = MD044Config {
1904 names: vec!["RUMDL".to_string(), "Markdownlint".to_string()],
1905 ..MD044Config::default()
1906 };
1907 let rule = MD044ProperNames::from_config_struct(config);
1908
1909 let content = "<!--rumdl-disable MD044-->\nSome rumdl text.\n<!--markdownlint-enable MD044 -->\n<!--\trumdl-capture-->\nMore rumdl text.\n<!-- note markdownlint -->\n";
1912 let ctx = create_context(content);
1913 let result = rule.check(&ctx).unwrap();
1914 let lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1915 assert_eq!(lines, vec![2, 5, 6], "{result:?}");
1916 }
1917
1918 #[test]
1919 fn test_html_comment_skipped_when_disabled() {
1920 let config = MD044Config {
1921 names: vec!["Test".to_string()],
1922 code_blocks: true,
1923 html_comments: false,
1924 ..Default::default()
1925 };
1926 let rule = MD044ProperNames::from_config_struct(config);
1927
1928 let content = "# Heading\n\n<!-- this is a test example -->\n\nRegular test here.\n";
1929 let ctx = create_context(content);
1930 let result = rule.check(&ctx).unwrap();
1931
1932 assert_eq!(
1933 result.len(),
1934 1,
1935 "Should only flag 'test' outside HTML comment when html_comments=false"
1936 );
1937 assert_eq!(result[0].line, 5);
1938 }
1939
1940 #[test]
1941 fn test_fix_corrects_html_comment_content() {
1942 let config = MD044Config {
1943 names: vec!["JavaScript".to_string()],
1944 ..MD044Config::default()
1945 };
1946 let rule = MD044ProperNames::from_config_struct(config);
1947
1948 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1949 let ctx = create_context(content);
1950 let fixed = rule.fix(&ctx).unwrap();
1951
1952 assert_eq!(fixed, "# Guide\n\n<!-- JavaScript mentioned here -->\n");
1953 }
1954
1955 #[test]
1956 fn test_fix_does_not_modify_inline_config_comments() {
1957 let config = MD044Config {
1958 names: vec!["RUMDL".to_string()],
1959 ..MD044Config::default()
1960 };
1961 let rule = MD044ProperNames::from_config_struct(config);
1962
1963 let content = "<!-- rumdl-disable -->\nSome rumdl text.\n<!-- rumdl-enable -->\n";
1964 let ctx = create_context(content);
1965 let fixed = rule.fix(&ctx).unwrap();
1966
1967 assert!(fixed.contains("<!-- rumdl-disable -->"));
1969 assert!(fixed.contains("<!-- rumdl-enable -->"));
1970 assert!(
1972 fixed.contains("Some rumdl text."),
1973 "Line inside rumdl-disable block should not be modified by fix()"
1974 );
1975 }
1976
1977 #[test]
1978 fn test_fix_respects_inline_disable_partial() {
1979 let config = MD044Config {
1980 names: vec!["RUMDL".to_string()],
1981 ..MD044Config::default()
1982 };
1983 let rule = MD044ProperNames::from_config_struct(config);
1984
1985 let content =
1986 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
1987 let ctx = create_context(content);
1988 let fixed = rule.fix(&ctx).unwrap();
1989
1990 assert!(
1992 fixed.contains("Some rumdl text.\n<!-- rumdl-enable"),
1993 "Line inside disable block should not be modified"
1994 );
1995 assert!(
1997 fixed.contains("Some RUMDL text outside."),
1998 "Line outside disable block should be fixed"
1999 );
2000 }
2001
2002 #[test]
2003 fn test_performance_with_many_names() {
2004 let mut names = vec![];
2005 for i in 0..50 {
2006 names.push(format!("ProperName{i}"));
2007 }
2008
2009 let rule = MD044ProperNames::new(names, true);
2010
2011 let content = "This has propername0, propername25, and propername49 incorrectly.";
2012 let ctx = create_context(content);
2013 let result = rule.check(&ctx).unwrap();
2014
2015 assert_eq!(result.len(), 3, "Should handle many configured names efficiently");
2016 }
2017
2018 #[test]
2019 fn test_large_name_count_performance() {
2020 let names = (0..1000).map(|i| format!("ProperName{i}")).collect::<Vec<_>>();
2023
2024 let rule = MD044ProperNames::new(names, true);
2025
2026 assert!(rule.combined_pattern.is_some());
2028
2029 let content = "This has propername0 and propername999 in it.";
2031 let ctx = create_context(content);
2032 let result = rule.check(&ctx).unwrap();
2033
2034 assert_eq!(result.len(), 2, "Should handle 1000 names without issues");
2036 }
2037
2038 #[test]
2039 fn test_cache_behavior() {
2040 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2041
2042 let content = "Using javascript here.";
2043 let ctx = create_context(content);
2044
2045 let result1 = rule.check(&ctx).unwrap();
2047 assert_eq!(result1.len(), 1);
2048
2049 let result2 = rule.check(&ctx).unwrap();
2051 assert_eq!(result2.len(), 1);
2052
2053 assert_eq!(result1[0].line, result2[0].line);
2055 assert_eq!(result1[0].column, result2[0].column);
2056 }
2057
2058 #[test]
2059 fn test_html_comments_not_checked_when_disabled() {
2060 let config = MD044Config {
2061 names: vec!["JavaScript".to_string()],
2062 code_blocks: true, html_comments: false, ..Default::default()
2065 };
2066 let rule = MD044ProperNames::from_config_struct(config);
2067
2068 let content = r#"Regular javascript here.
2069<!-- This javascript in HTML comment should be ignored -->
2070More javascript outside."#;
2071
2072 let ctx = create_context(content);
2073 let result = rule.check(&ctx).unwrap();
2074
2075 assert_eq!(result.len(), 2, "Should only flag javascript outside HTML comments");
2076 assert_eq!(result[0].line, 1);
2077 assert_eq!(result[1].line, 3);
2078 }
2079
2080 #[test]
2081 fn test_html_comments_checked_when_enabled() {
2082 let config = MD044Config {
2083 names: vec!["JavaScript".to_string()],
2084 code_blocks: true, ..Default::default()
2086 };
2087 let rule = MD044ProperNames::from_config_struct(config);
2088
2089 let content = r#"Regular javascript here.
2090<!-- This javascript in HTML comment should be checked -->
2091More javascript outside."#;
2092
2093 let ctx = create_context(content);
2094 let result = rule.check(&ctx).unwrap();
2095
2096 assert_eq!(
2097 result.len(),
2098 3,
2099 "Should flag all javascript occurrences including in HTML comments"
2100 );
2101 }
2102
2103 #[test]
2104 fn test_indented_html_comment_escapes_via_link_and_backticks() {
2105 let config = MD044Config {
2110 names: vec!["Test".to_string()],
2111 ..Default::default()
2112 };
2113 let rule = MD044ProperNames::from_config_struct(config);
2114
2115 let content = "<!-- see the [relevant page](test.md). -->\n<!-- see `test.md` -->\n <!-- see the [relevant page](test.md). -->\n <!-- see `test.md` -->\n";
2116
2117 let ctx = create_context(content);
2118 let result = rule.check(&ctx).unwrap();
2119
2120 assert!(
2121 result.is_empty(),
2122 "'test' inside a link URL or backticks must be ignored in both column-0 and indented comments, got: {result:?}"
2123 );
2124 }
2125
2126 #[test]
2127 fn test_indented_html_comment_still_checks_bare_prose() {
2128 let config = MD044Config {
2131 names: vec!["Test".to_string()],
2132 ..Default::default()
2133 };
2134 let rule = MD044ProperNames::from_config_struct(config);
2135
2136 let content = " <!-- this is a test comment -->\n";
2137
2138 let ctx = create_context(content);
2139 let result = rule.check(&ctx).unwrap();
2140
2141 assert_eq!(
2142 result.len(),
2143 1,
2144 "bare 'test' in an indented comment is still a violation"
2145 );
2146 assert_eq!(result[0].line, 1);
2147 }
2148
2149 #[test]
2150 fn test_multiline_html_comments() {
2151 let config = MD044Config {
2152 names: vec!["Python".to_string(), "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 python here.
2160<!--
2161This is a multiline comment
2162with javascript and python
2163that should be ignored
2164-->
2165More javascript outside."#;
2166
2167 let ctx = create_context(content);
2168 let result = rule.check(&ctx).unwrap();
2169
2170 assert_eq!(result.len(), 2, "Should only flag names outside HTML comments");
2171 assert_eq!(result[0].line, 1); assert_eq!(result[1].line, 7); }
2174
2175 #[test]
2176 fn test_fix_preserves_html_comments_when_disabled() {
2177 let config = MD044Config {
2178 names: vec!["JavaScript".to_string()],
2179 code_blocks: true, html_comments: false, ..Default::default()
2182 };
2183 let rule = MD044ProperNames::from_config_struct(config);
2184
2185 let content = r#"javascript here.
2186<!-- javascript in comment -->
2187More javascript."#;
2188
2189 let ctx = create_context(content);
2190 let fixed = rule.fix(&ctx).unwrap();
2191
2192 let expected = r#"JavaScript here.
2193<!-- javascript in comment -->
2194More JavaScript."#;
2195
2196 assert_eq!(
2197 fixed, expected,
2198 "Should not fix names inside HTML comments when disabled"
2199 );
2200 }
2201
2202 #[test]
2203 fn test_proper_names_in_link_text_are_flagged() {
2204 let rule = MD044ProperNames::new(
2205 vec!["JavaScript".to_string(), "Node.js".to_string(), "Python".to_string()],
2206 true,
2207 );
2208
2209 let content = r#"Check this [javascript documentation](https://javascript.info) for info.
2210
2211Visit [node.js homepage](https://nodejs.org) and [python tutorial](https://python.org).
2212
2213Real javascript should be flagged.
2214
2215Also see the [typescript guide][ts-ref] for more.
2216
2217Real python should be flagged too.
2218
2219[ts-ref]: https://typescript.org/handbook"#;
2220
2221 let ctx = create_context(content);
2222 let result = rule.check(&ctx).unwrap();
2223
2224 assert_eq!(result.len(), 5, "Expected 5 warnings: 3 in link text + 2 standalone");
2231
2232 let line_1_warnings: Vec<_> = result.iter().filter(|w| w.line == 1).collect();
2234 assert_eq!(line_1_warnings.len(), 1);
2235 assert!(
2236 line_1_warnings[0]
2237 .message
2238 .contains("'javascript' should be 'JavaScript'")
2239 );
2240
2241 let line_3_warnings: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
2242 assert_eq!(line_3_warnings.len(), 2); assert!(result.iter().any(|w| w.line == 5 && w.message.contains("'javascript'")));
2246 assert!(result.iter().any(|w| w.line == 9 && w.message.contains("'python'")));
2247 }
2248
2249 #[test]
2250 fn test_link_urls_not_flagged() {
2251 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2252
2253 let content = r#"[Link Text](https://javascript.info/guide)"#;
2255
2256 let ctx = create_context(content);
2257 let result = rule.check(&ctx).unwrap();
2258
2259 assert!(result.is_empty(), "URLs should not be checked for proper names");
2261 }
2262
2263 #[test]
2264 fn test_bare_urls_not_flagged() {
2265 let rule = MD044ProperNames::new(vec!["Foo".to_string(), "JavaScript".to_string()], true);
2266
2267 let content =
2270 "https://foo.com\n\nSee https://javascript.info/foo/guide for details.\n\nMail foo@foo.com about it.\n";
2271
2272 let ctx = create_context(content);
2273 let result = rule.check(&ctx).unwrap();
2274
2275 assert!(
2276 result.is_empty(),
2277 "Bare URLs and emails should not be checked for proper names: {result:?}"
2278 );
2279 }
2280
2281 #[test]
2282 fn test_prose_around_bare_url_still_flagged() {
2283 let rule = MD044ProperNames::new(vec!["Foo".to_string()], true);
2284
2285 let content = "Use foo at https://foo.com because foo is great.\n";
2288
2289 let ctx = create_context(content);
2290 let result = rule.check(&ctx).unwrap();
2291
2292 assert_eq!(
2293 result.len(),
2294 2,
2295 "Prose occurrences around a bare URL must still be flagged: {result:?}"
2296 );
2297 assert!(result.iter().all(|w| w.message.contains("'foo' should be 'Foo'")));
2298 }
2299
2300 #[test]
2301 fn test_proper_names_in_image_alt_text_are_flagged() {
2302 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2303
2304 let content = r#"Here is a  image.
2305
2306Real javascript should be flagged."#;
2307
2308 let ctx = create_context(content);
2309 let result = rule.check(&ctx).unwrap();
2310
2311 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in alt text + 1 standalone");
2315 assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
2316 assert!(result[0].line == 1); assert!(result[1].message.contains("'javascript' should be 'JavaScript'"));
2318 assert!(result[1].line == 3); }
2320
2321 #[test]
2322 fn test_image_urls_not_flagged() {
2323 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2324
2325 let content = r#""#;
2327
2328 let ctx = create_context(content);
2329 let result = rule.check(&ctx).unwrap();
2330
2331 assert!(result.is_empty(), "Image URLs should not be checked for proper names");
2333 }
2334
2335 #[test]
2336 fn test_reference_link_text_flagged_but_definition_not() {
2337 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2338
2339 let content = r#"Check the [javascript guide][js-ref] for details.
2340
2341Real javascript should be flagged.
2342
2343[js-ref]: https://javascript.info/typescript/guide"#;
2344
2345 let ctx = create_context(content);
2346 let result = rule.check(&ctx).unwrap();
2347
2348 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in link text + 1 standalone");
2353 assert!(result.iter().any(|w| w.line == 1 && w.message.contains("'javascript'")));
2354 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2355 }
2356
2357 #[test]
2358 fn test_reference_definitions_not_flagged() {
2359 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2360
2361 let content = r#"[js-ref]: https://javascript.info/guide"#;
2363
2364 let ctx = create_context(content);
2365 let result = rule.check(&ctx).unwrap();
2366
2367 assert!(result.is_empty(), "Reference definitions should not be checked");
2369 }
2370
2371 #[test]
2372 fn test_wikilinks_text_is_flagged() {
2373 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2374
2375 let content = r#"[[javascript]]
2377
2378Regular javascript here.
2379
2380[[JavaScript|display text]]"#;
2381
2382 let ctx = create_context(content);
2383 let result = rule.check(&ctx).unwrap();
2384
2385 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in WikiLink + 1 standalone");
2389 assert!(
2390 result
2391 .iter()
2392 .any(|w| w.line == 1 && w.column == 3 && w.message.contains("'javascript'"))
2393 );
2394 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2395 }
2396
2397 #[test]
2398 fn test_url_link_text_not_flagged() {
2399 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2400
2401 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2403
2404[http://github.com/org/repo](http://github.com/org/repo)
2405
2406[www.github.com/org/repo](https://www.github.com/org/repo)"#;
2407
2408 let ctx = create_context(content);
2409 let result = rule.check(&ctx).unwrap();
2410
2411 assert!(
2412 result.is_empty(),
2413 "URL-like link text should not be flagged, got: {result:?}"
2414 );
2415 }
2416
2417 #[test]
2418 fn test_url_link_text_with_leading_space_not_flagged() {
2419 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2420
2421 let content = r#"[ https://github.com/org/repo](https://github.com/org/repo)"#;
2423
2424 let ctx = create_context(content);
2425 let result = rule.check(&ctx).unwrap();
2426
2427 assert!(
2428 result.is_empty(),
2429 "URL-like link text with leading space should not be flagged, got: {result:?}"
2430 );
2431 }
2432
2433 #[test]
2434 fn test_url_link_text_uppercase_scheme_not_flagged() {
2435 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2436
2437 let content = r#"[HTTPS://GITHUB.COM/org/repo](https://github.com/org/repo)"#;
2438
2439 let ctx = create_context(content);
2440 let result = rule.check(&ctx).unwrap();
2441
2442 assert!(
2443 result.is_empty(),
2444 "URL-like link text with uppercase scheme should not be flagged, got: {result:?}"
2445 );
2446 }
2447
2448 #[test]
2449 fn test_non_url_link_text_still_flagged() {
2450 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2451
2452 let content = r#"[github.com/org/repo](https://github.com/org/repo)
2456
2457[Visit github](https://github.com/org/repo)
2458
2459[//github.com/org/repo](//github.com/org/repo)
2460
2461[ftp://github.com/org/repo](ftp://github.com/org/repo)"#;
2462
2463 let ctx = create_context(content);
2464 let result = rule.check(&ctx).unwrap();
2465
2466 assert_eq!(
2471 result.len(),
2472 1,
2473 "Only prose link text should be flagged, got: {result:?}"
2474 );
2475 assert!(
2476 result.iter().any(|w| w.line == 3),
2477 "Expected 'Visit github' on line 3 to be flagged"
2478 );
2479 }
2480
2481 #[test]
2482 fn test_url_link_text_fix_not_applied() {
2483 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2484
2485 let content = "[https://github.com/org/repo](https://github.com/org/repo)\n";
2486
2487 let ctx = create_context(content);
2488 let result = rule.fix(&ctx).unwrap();
2489
2490 assert_eq!(result, content, "Fix should not modify URL-like link text");
2491 }
2492
2493 #[test]
2494 fn test_mixed_url_and_regular_link_text() {
2495 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2496
2497 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2499
2500Visit [github documentation](https://github.com/docs) for details.
2501
2502[www.github.com/pricing](https://www.github.com/pricing)"#;
2503
2504 let ctx = create_context(content);
2505 let result = rule.check(&ctx).unwrap();
2506
2507 assert_eq!(
2509 result.len(),
2510 1,
2511 "Only non-URL link text should be flagged, got: {result:?}"
2512 );
2513 assert_eq!(result[0].line, 3);
2514 }
2515
2516 #[test]
2517 fn test_html_attribute_values_not_flagged() {
2518 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2521 let content = "# Heading\n\ntest\n\n<img src=\"www.example.test/test_image.png\">\n";
2522 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2523 let result = rule.check(&ctx).unwrap();
2524
2525 let line5_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2527 assert!(
2528 line5_violations.is_empty(),
2529 "Should not flag anything inside HTML tag attributes: {line5_violations:?}"
2530 );
2531
2532 let line3_violations: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
2534 assert_eq!(line3_violations.len(), 1, "Plain 'test' on line 3 should be flagged");
2535 }
2536
2537 #[test]
2538 fn test_html_text_content_still_flagged() {
2539 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2541 let content = "# Heading\n\n<a href=\"https://example.test/page\">test link</a>\n";
2542 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2543 let result = rule.check(&ctx).unwrap();
2544
2545 assert_eq!(
2548 result.len(),
2549 1,
2550 "Should flag only 'test' in anchor text, not in href: {result:?}"
2551 );
2552 assert_eq!(result[0].column, 37, "Should flag col 37 ('test link' in anchor text)");
2553 }
2554
2555 #[test]
2556 fn test_html_attribute_various_not_flagged() {
2557 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2559 let content = concat!(
2560 "# Heading\n\n",
2561 "<img src=\"test.png\" alt=\"test image\">\n",
2562 "<span class=\"test-class\" data-test=\"value\">test content</span>\n",
2563 );
2564 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2565 let result = rule.check(&ctx).unwrap();
2566
2567 assert_eq!(
2569 result.len(),
2570 1,
2571 "Should flag only 'test content' between tags: {result:?}"
2572 );
2573 assert_eq!(result[0].line, 4);
2574 }
2575
2576 #[test]
2577 fn test_plain_text_underscore_boundary_unchanged() {
2578 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2581 let content = "# Heading\n\ntest_image is here and just_test ends here\n";
2582 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2583 let result = rule.check(&ctx).unwrap();
2584
2585 assert_eq!(
2588 result.len(),
2589 2,
2590 "Should flag 'test' in both 'test_image' and 'just_test': {result:?}"
2591 );
2592 let cols: Vec<usize> = result.iter().map(|w| w.column).collect();
2593 assert!(cols.contains(&1), "Should flag col 1 (test_image): {cols:?}");
2594 assert!(cols.contains(&29), "Should flag col 29 (just_test): {cols:?}");
2595 }
2596
2597 #[test]
2598 fn test_frontmatter_yaml_keys_not_flagged() {
2599 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2602
2603 let content = "---\ntitle: Heading\ntest: Some Test value\n---\n\nTest\n";
2604 let ctx = create_context(content);
2605 let result = rule.check(&ctx).unwrap();
2606
2607 assert!(
2611 result.is_empty(),
2612 "Should not flag YAML keys or correctly capitalized values: {result:?}"
2613 );
2614 }
2615
2616 #[test]
2617 fn test_frontmatter_yaml_values_flagged() {
2618 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2620
2621 let content = "---\ntitle: Heading\nkey: a test value\n---\n\nTest\n";
2622 let ctx = create_context(content);
2623 let result = rule.check(&ctx).unwrap();
2624
2625 assert_eq!(result.len(), 1, "Should flag 'test' in YAML value: {result:?}");
2627 assert_eq!(result[0].line, 3);
2628 assert_eq!(result[0].column, 8); }
2630
2631 #[test]
2632 fn test_frontmatter_key_matches_name_not_flagged() {
2633 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2635
2636 let content = "---\ntest: other value\n---\n\nBody text\n";
2637 let ctx = create_context(content);
2638 let result = rule.check(&ctx).unwrap();
2639
2640 assert!(
2641 result.is_empty(),
2642 "Should not flag YAML key that matches configured name: {result:?}"
2643 );
2644 }
2645
2646 #[test]
2647 fn test_frontmatter_empty_value_not_flagged() {
2648 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2650
2651 let content = "---\ntest:\ntest: \n---\n\nBody text\n";
2652 let ctx = create_context(content);
2653 let result = rule.check(&ctx).unwrap();
2654
2655 assert!(
2656 result.is_empty(),
2657 "Should not flag YAML keys with empty values: {result:?}"
2658 );
2659 }
2660
2661 #[test]
2662 fn test_frontmatter_nested_yaml_key_not_flagged() {
2663 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2665
2666 let content = "---\nparent:\n test: nested value\n---\n\nBody text\n";
2667 let ctx = create_context(content);
2668 let result = rule.check(&ctx).unwrap();
2669
2670 assert!(result.is_empty(), "Should not flag nested YAML keys: {result:?}");
2672 }
2673
2674 #[test]
2675 fn test_frontmatter_list_items_checked() {
2676 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2678
2679 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2680 let ctx = create_context(content);
2681 let result = rule.check(&ctx).unwrap();
2682
2683 assert_eq!(result.len(), 1, "Should flag 'test' in YAML list item: {result:?}");
2685 assert_eq!(result[0].line, 3);
2686 }
2687
2688 #[test]
2689 fn test_frontmatter_value_with_multiple_colons() {
2690 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2692
2693 let content = "---\ntest: description: a test thing\n---\n\nBody text\n";
2694 let ctx = create_context(content);
2695 let result = rule.check(&ctx).unwrap();
2696
2697 assert_eq!(
2700 result.len(),
2701 1,
2702 "Should flag 'test' in value after first colon: {result:?}"
2703 );
2704 assert_eq!(result[0].line, 2);
2705 assert!(result[0].column > 6, "Violation column should be in value portion");
2706 }
2707
2708 #[test]
2709 fn test_frontmatter_does_not_affect_body() {
2710 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2712
2713 let content = "---\ntitle: Heading\n---\n\ntest should be flagged here\n";
2714 let ctx = create_context(content);
2715 let result = rule.check(&ctx).unwrap();
2716
2717 assert_eq!(result.len(), 1, "Should flag 'test' in body text: {result:?}");
2718 assert_eq!(result[0].line, 5);
2719 }
2720
2721 #[test]
2722 fn test_frontmatter_fix_corrects_values_preserves_keys() {
2723 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2725
2726 let content = "---\ntest: a test value\n---\n\ntest here\n";
2727 let ctx = create_context(content);
2728 let fixed = rule.fix(&ctx).unwrap();
2729
2730 assert_eq!(fixed, "---\ntest: a Test value\n---\n\nTest here\n");
2732 }
2733
2734 #[test]
2735 fn test_frontmatter_multiword_value_flagged() {
2736 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2738
2739 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2740 let ctx = create_context(content);
2741 let result = rule.check(&ctx).unwrap();
2742
2743 assert_eq!(result.len(), 2, "Should flag both names in YAML value: {result:?}");
2744 assert!(result.iter().all(|w| w.line == 2));
2745 }
2746
2747 #[test]
2748 fn test_frontmatter_yaml_comments_not_checked() {
2749 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2751
2752 let content = "---\n# test comment\ntitle: Heading\n---\n\nBody text\n";
2753 let ctx = create_context(content);
2754 let result = rule.check(&ctx).unwrap();
2755
2756 assert!(result.is_empty(), "Should not flag names in YAML comments: {result:?}");
2757 }
2758
2759 #[test]
2760 fn test_frontmatter_delimiters_not_checked() {
2761 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2763
2764 let content = "---\ntitle: Heading\n---\n\ntest here\n";
2765 let ctx = create_context(content);
2766 let result = rule.check(&ctx).unwrap();
2767
2768 assert_eq!(result.len(), 1, "Should only flag body text: {result:?}");
2770 assert_eq!(result[0].line, 5);
2771 }
2772
2773 #[test]
2774 fn test_frontmatter_continuation_lines_checked() {
2775 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2777
2778 let content = "---\ndescription: >\n a test value\n continued here\n---\n\nBody\n";
2779 let ctx = create_context(content);
2780 let result = rule.check(&ctx).unwrap();
2781
2782 assert_eq!(result.len(), 1, "Should flag 'test' in continuation line: {result:?}");
2784 assert_eq!(result[0].line, 3);
2785 }
2786
2787 #[test]
2788 fn test_frontmatter_quoted_values_checked() {
2789 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2791
2792 let content = "---\ntitle: \"a test title\"\n---\n\nBody\n";
2793 let ctx = create_context(content);
2794 let result = rule.check(&ctx).unwrap();
2795
2796 assert_eq!(result.len(), 1, "Should flag 'test' in quoted YAML value: {result:?}");
2797 assert_eq!(result[0].line, 2);
2798 }
2799
2800 #[test]
2801 fn test_frontmatter_single_quoted_values_checked() {
2802 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2804
2805 let content = "---\ntitle: 'a test title'\n---\n\nBody\n";
2806 let ctx = create_context(content);
2807 let result = rule.check(&ctx).unwrap();
2808
2809 assert_eq!(
2810 result.len(),
2811 1,
2812 "Should flag 'test' in single-quoted YAML value: {result:?}"
2813 );
2814 assert_eq!(result[0].line, 2);
2815 }
2816
2817 #[test]
2818 fn test_frontmatter_fix_multiword_values() {
2819 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2821
2822 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2823 let ctx = create_context(content);
2824 let fixed = rule.fix(&ctx).unwrap();
2825
2826 assert_eq!(
2827 fixed,
2828 "---\ndescription: Learn JavaScript and TypeScript\n---\n\nBody\n"
2829 );
2830 }
2831
2832 #[test]
2833 fn test_frontmatter_fix_preserves_yaml_structure() {
2834 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2836
2837 let content = "---\ntags:\n - test\n - other\ntitle: a test doc\n---\n\ntest body\n";
2838 let ctx = create_context(content);
2839 let fixed = rule.fix(&ctx).unwrap();
2840
2841 assert_eq!(
2842 fixed,
2843 "---\ntags:\n - Test\n - other\ntitle: a Test doc\n---\n\nTest body\n"
2844 );
2845 }
2846
2847 #[test]
2848 fn test_frontmatter_toml_delimiters_not_checked() {
2849 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2851
2852 let content = "+++\ntitle = \"a test title\"\n+++\n\ntest body\n";
2853 let ctx = create_context(content);
2854 let result = rule.check(&ctx).unwrap();
2855
2856 assert_eq!(result.len(), 2, "Should flag TOML value and body: {result:?}");
2860 let fm_violations: Vec<_> = result.iter().filter(|w| w.line == 2).collect();
2861 assert_eq!(fm_violations.len(), 1, "Should flag 'test' in TOML value: {result:?}");
2862 let body_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2863 assert_eq!(body_violations.len(), 1, "Should flag body 'test': {result:?}");
2864 }
2865
2866 #[test]
2867 fn test_frontmatter_toml_key_not_flagged() {
2868 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2870
2871 let content = "+++\ntest = \"other value\"\n+++\n\nBody text\n";
2872 let ctx = create_context(content);
2873 let result = rule.check(&ctx).unwrap();
2874
2875 assert!(
2876 result.is_empty(),
2877 "Should not flag TOML key that matches configured name: {result:?}"
2878 );
2879 }
2880
2881 #[test]
2882 fn test_frontmatter_toml_fix_preserves_keys() {
2883 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2885
2886 let content = "+++\ntest = \"a test value\"\n+++\n\ntest here\n";
2887 let ctx = create_context(content);
2888 let fixed = rule.fix(&ctx).unwrap();
2889
2890 assert_eq!(fixed, "+++\ntest = \"a Test value\"\n+++\n\nTest here\n");
2892 }
2893
2894 #[test]
2895 fn test_frontmatter_list_item_mapping_key_not_flagged() {
2896 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2899
2900 let content = "---\nitems:\n - test: nested value\n---\n\nBody text\n";
2901 let ctx = create_context(content);
2902 let result = rule.check(&ctx).unwrap();
2903
2904 assert!(
2905 result.is_empty(),
2906 "Should not flag YAML key in list-item mapping: {result:?}"
2907 );
2908 }
2909
2910 #[test]
2911 fn test_frontmatter_list_item_mapping_value_flagged() {
2912 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2914
2915 let content = "---\nitems:\n - key: a test value\n---\n\nBody text\n";
2916 let ctx = create_context(content);
2917 let result = rule.check(&ctx).unwrap();
2918
2919 assert_eq!(
2920 result.len(),
2921 1,
2922 "Should flag 'test' in list-item mapping value: {result:?}"
2923 );
2924 assert_eq!(result[0].line, 3);
2925 }
2926
2927 #[test]
2928 fn test_frontmatter_bare_list_item_still_flagged() {
2929 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2931
2932 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2933 let ctx = create_context(content);
2934 let result = rule.check(&ctx).unwrap();
2935
2936 assert_eq!(result.len(), 1, "Should flag 'test' in bare list item: {result:?}");
2937 assert_eq!(result[0].line, 3);
2938 }
2939
2940 #[test]
2941 fn test_frontmatter_flow_mapping_not_flagged() {
2942 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2945
2946 let content = "---\nflow_map: {test: value, other: test}\n---\n\nBody text\n";
2947 let ctx = create_context(content);
2948 let result = rule.check(&ctx).unwrap();
2949
2950 assert!(
2951 result.is_empty(),
2952 "Should not flag names inside flow mappings: {result:?}"
2953 );
2954 }
2955
2956 #[test]
2957 fn test_frontmatter_flow_sequence_not_flagged() {
2958 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2960
2961 let content = "---\nitems: [test, other, test]\n---\n\nBody text\n";
2962 let ctx = create_context(content);
2963 let result = rule.check(&ctx).unwrap();
2964
2965 assert!(
2966 result.is_empty(),
2967 "Should not flag names inside flow sequences: {result:?}"
2968 );
2969 }
2970
2971 #[test]
2972 fn test_frontmatter_list_item_mapping_fix_preserves_key() {
2973 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2975
2976 let content = "---\nitems:\n - test: a test value\n---\n\ntest here\n";
2977 let ctx = create_context(content);
2978 let fixed = rule.fix(&ctx).unwrap();
2979
2980 assert_eq!(fixed, "---\nitems:\n - test: a Test value\n---\n\nTest here\n");
2983 }
2984
2985 #[test]
2986 fn test_frontmatter_backtick_code_not_flagged() {
2987 let config = MD044Config {
2989 names: vec!["GoodApplication".to_string()],
2990 code_blocks: false,
2991 ..MD044Config::default()
2992 };
2993 let rule = MD044ProperNames::from_config_struct(config);
2994
2995 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2996 let ctx = create_context(content);
2997 let result = rule.check(&ctx).unwrap();
2998
2999 assert!(
3001 result.is_empty(),
3002 "Should not flag names inside backticks in frontmatter or body: {result:?}"
3003 );
3004 }
3005
3006 #[test]
3007 fn test_frontmatter_unquoted_backtick_code_not_flagged() {
3008 let config = MD044Config {
3010 names: vec!["GoodApplication".to_string()],
3011 code_blocks: false,
3012 ..MD044Config::default()
3013 };
3014 let rule = MD044ProperNames::from_config_struct(config);
3015
3016 let content = "---\ntitle: `goodapplication` CLI\n---\n\nIntroductory `goodapplication` CLI text.\n";
3017 let ctx = create_context(content);
3018 let result = rule.check(&ctx).unwrap();
3019
3020 assert!(
3021 result.is_empty(),
3022 "Should not flag names inside backticks in unquoted YAML frontmatter: {result:?}"
3023 );
3024 }
3025
3026 #[test]
3027 fn test_frontmatter_bare_name_still_flagged_with_backtick_nearby() {
3028 let config = MD044Config {
3030 names: vec!["GoodApplication".to_string()],
3031 code_blocks: false,
3032 ..MD044Config::default()
3033 };
3034 let rule = MD044ProperNames::from_config_struct(config);
3035
3036 let content = "---\ntitle: goodapplication `goodapplication` CLI\n---\n\nBody\n";
3037 let ctx = create_context(content);
3038 let result = rule.check(&ctx).unwrap();
3039
3040 assert_eq!(
3042 result.len(),
3043 1,
3044 "Should flag bare name but not backtick-wrapped name: {result:?}"
3045 );
3046 assert_eq!(result[0].line, 2);
3047 assert_eq!(result[0].column, 8); }
3049
3050 #[test]
3051 fn test_frontmatter_backtick_code_with_code_blocks_true() {
3052 let config = MD044Config {
3054 names: vec!["GoodApplication".to_string()],
3055 code_blocks: true,
3056 ..MD044Config::default()
3057 };
3058 let rule = MD044ProperNames::from_config_struct(config);
3059
3060 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nBody\n";
3061 let ctx = create_context(content);
3062 let result = rule.check(&ctx).unwrap();
3063
3064 assert_eq!(
3066 result.len(),
3067 1,
3068 "Should flag backtick-wrapped name when code_blocks=true: {result:?}"
3069 );
3070 assert_eq!(result[0].line, 2);
3071 }
3072
3073 #[test]
3074 fn test_frontmatter_fix_preserves_backtick_code() {
3075 let config = MD044Config {
3077 names: vec!["GoodApplication".to_string()],
3078 code_blocks: false,
3079 ..MD044Config::default()
3080 };
3081 let rule = MD044ProperNames::from_config_struct(config);
3082
3083 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
3084 let ctx = create_context(content);
3085 let fixed = rule.fix(&ctx).unwrap();
3086
3087 assert_eq!(
3089 fixed, content,
3090 "Fix should not modify names inside backticks in frontmatter"
3091 );
3092 }
3093
3094 fn rule_ignoring(names: &[&str], ignore: &[&str]) -> MD044ProperNames {
3095 MD044ProperNames::from_config_struct(MD044Config {
3096 names: names.iter().map(ToString::to_string).collect(),
3097 ignore_frontmatter_fields: Some(ignore.iter().map(ToString::to_string).collect()),
3098 ..Default::default()
3099 })
3100 }
3101
3102 #[test]
3103 fn test_ignore_frontmatter_field_suppresses_only_that_field() {
3104 let content = "---\ntitle: Heading for myapp\nslug: myapp-guide\n---\n";
3105 let rule = rule_ignoring(&["MyApp"], &["slug"]);
3106 let result = rule.check(&create_context(content)).unwrap();
3107 assert_eq!(result.len(), 1, "only title is flagged: {result:?}");
3108 assert_eq!(result[0].line, 2);
3109 }
3110
3111 #[test]
3112 fn test_ignore_frontmatter_field_is_case_insensitive() {
3113 let content = "---\nSlug: myapp-guide\n---\n";
3114 let rule = rule_ignoring(&["MyApp"], &["SLUG"]);
3115 assert!(rule.check(&create_context(content)).unwrap().is_empty());
3116 }
3117
3118 #[test]
3119 fn test_ignore_frontmatter_field_covers_nested_subtree() {
3120 let content = "---\nseo:\n canonical: myapp\n keywords:\n - myapp\n---\n";
3121 let rule = rule_ignoring(&["MyApp"], &["seo"]);
3122 assert!(rule.check(&create_context(content)).unwrap().is_empty());
3123 }
3124
3125 #[test]
3126 fn test_ignore_frontmatter_field_does_not_affect_body() {
3127 let content = "---\nslug: myapp\n---\n\nBody mentions myapp.\n";
3128 let rule = rule_ignoring(&["MyApp"], &["slug"]);
3129 let result = rule.check(&create_context(content)).unwrap();
3130 assert_eq!(result.len(), 1);
3131 assert_eq!(result[0].line, 5);
3132 }
3133
3134 #[test]
3135 fn test_ignore_frontmatter_field_toml_table() {
3136 let content = "+++\n[seo]\ncanonical = \"myapp\"\n+++\n";
3137 let rule = rule_ignoring(&["MyApp"], &["seo"]);
3138 assert!(rule.check(&create_context(content)).unwrap().is_empty());
3139 }
3140
3141 #[test]
3144 fn test_angle_bracket_url_in_html_comment_not_flagged() {
3145 let config = MD044Config {
3147 names: vec!["Test".to_string()],
3148 ..MD044Config::default()
3149 };
3150 let rule = MD044ProperNames::from_config_struct(config);
3151
3152 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";
3153 let ctx = create_context(content);
3154 let result = rule.check(&ctx).unwrap();
3155
3156 let line8_warnings: Vec<_> = result.iter().filter(|w| w.line == 8).collect();
3164 assert!(
3165 line8_warnings.is_empty(),
3166 "Should not flag names inside angle-bracket URLs in HTML comments: {line8_warnings:?}"
3167 );
3168 }
3169
3170 #[test]
3171 fn test_bare_url_in_html_comment_still_flagged() {
3172 let config = MD044Config {
3174 names: vec!["Test".to_string()],
3175 ..MD044Config::default()
3176 };
3177 let rule = MD044ProperNames::from_config_struct(config);
3178
3179 let content = "<!-- This is a test https://www.example.test -->\n";
3180 let ctx = create_context(content);
3181 let result = rule.check(&ctx).unwrap();
3182
3183 assert!(
3186 !result.is_empty(),
3187 "Should flag 'test' in prose text of HTML comment with bare URL"
3188 );
3189 }
3190
3191 #[test]
3192 fn test_angle_bracket_url_in_regular_markdown_not_flagged() {
3193 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
3196
3197 let content = "<https://www.example.test>\n";
3198 let ctx = create_context(content);
3199 let result = rule.check(&ctx).unwrap();
3200
3201 assert!(
3202 result.is_empty(),
3203 "Should not flag names inside angle-bracket URLs in regular markdown: {result:?}"
3204 );
3205 }
3206
3207 #[test]
3208 fn test_multiple_angle_bracket_urls_in_one_comment() {
3209 let config = MD044Config {
3210 names: vec!["Test".to_string()],
3211 ..MD044Config::default()
3212 };
3213 let rule = MD044ProperNames::from_config_struct(config);
3214
3215 let content = "<!-- See <https://test.example.com> and <https://www.example.test> for details -->\n";
3216 let ctx = create_context(content);
3217 let result = rule.check(&ctx).unwrap();
3218
3219 assert!(
3221 result.is_empty(),
3222 "Should not flag names inside multiple angle-bracket URLs: {result:?}"
3223 );
3224 }
3225
3226 #[test]
3227 fn test_angle_bracket_non_url_still_flagged() {
3228 assert!(
3231 !MD044ProperNames::is_in_angle_bracket_url("<test> which is not a URL.", 1),
3232 "is_in_angle_bracket_url should return false for non-URL angle brackets"
3233 );
3234 }
3235
3236 #[test]
3237 fn test_angle_bracket_mailto_url_not_flagged() {
3238 let config = MD044Config {
3239 names: vec!["Test".to_string()],
3240 ..MD044Config::default()
3241 };
3242 let rule = MD044ProperNames::from_config_struct(config);
3243
3244 let content = "<!-- Contact <mailto:test@example.com> for help -->\n";
3245 let ctx = create_context(content);
3246 let result = rule.check(&ctx).unwrap();
3247
3248 assert!(
3249 result.is_empty(),
3250 "Should not flag names inside angle-bracket mailto URLs: {result:?}"
3251 );
3252 }
3253
3254 #[test]
3255 fn test_angle_bracket_ftp_url_not_flagged() {
3256 let config = MD044Config {
3257 names: vec!["Test".to_string()],
3258 ..MD044Config::default()
3259 };
3260 let rule = MD044ProperNames::from_config_struct(config);
3261
3262 let content = "<!-- Download from <ftp://test.example.com/file> -->\n";
3263 let ctx = create_context(content);
3264 let result = rule.check(&ctx).unwrap();
3265
3266 assert!(
3267 result.is_empty(),
3268 "Should not flag names inside angle-bracket FTP URLs: {result:?}"
3269 );
3270 }
3271
3272 #[test]
3273 fn test_angle_bracket_url_fix_preserves_url() {
3274 let config = MD044Config {
3276 names: vec!["Test".to_string()],
3277 ..MD044Config::default()
3278 };
3279 let rule = MD044ProperNames::from_config_struct(config);
3280
3281 let content = "<!-- test text <https://www.example.test> -->\n";
3282 let ctx = create_context(content);
3283 let fixed = rule.fix(&ctx).unwrap();
3284
3285 assert!(
3287 fixed.contains("<https://www.example.test>"),
3288 "Fix should preserve angle-bracket URLs: {fixed}"
3289 );
3290 assert!(
3291 fixed.contains("Test text"),
3292 "Fix should correct prose 'test' to 'Test': {fixed}"
3293 );
3294 }
3295
3296 #[test]
3297 fn test_fallback_delimiter_index_matches_direct_scans() {
3298 let alphabet = [b'a', b'\\', b'[', b']', b'(', b')'];
3299 for mut value in 0..alphabet.len().pow(6) {
3300 let mut bytes = [0; 6];
3301 for byte in &mut bytes {
3302 *byte = alphabet[value % alphabet.len()];
3303 value /= alphabet.len();
3304 }
3305 for (open, close) in [(b'[', b']'), (b'(', b')')] {
3306 let mut lookup = FallbackDelimiters {
3307 scanned_bytes: bytes.len() * 2,
3308 ..Default::default()
3309 };
3310 for from in (1..=bytes.len()).chain((1..=bytes.len()).rev()) {
3311 if bytes[from - 1] == open {
3312 assert_eq!(
3313 lookup.find(&bytes, from, open, close),
3314 FallbackDelimiters::scan(&bytes, from, open, close),
3315 "{bytes:?}, {from}, {open}"
3316 );
3317 }
3318 }
3319 }
3320 }
3321 }
3322
3323 #[test]
3324 fn test_fallback_ranges_match_scalar_queries() {
3325 fn scalar_links(line: &str, pos: usize) -> bool {
3326 let bytes = line.as_bytes();
3327 let len = bytes.len();
3328 let mut i = 0;
3329
3330 while i < len {
3331 if bytes[i] == b'[' && (i == 0 || bytes[i - 1] != b'\\' || (i >= 2 && bytes[i - 2] == b'\\')) {
3333 let mut depth: u32 = 1;
3335 let mut j = i + 1;
3336 while j < len && depth > 0 {
3337 match bytes[j] {
3338 b'\\' => {
3339 j += 1; }
3341 b'[' => depth += 1,
3342 b']' => depth -= 1,
3343 _ => {}
3344 }
3345 j += 1;
3346 }
3347
3348 if depth == 0 && j < len {
3350 if bytes[j] == b'(' {
3351 let url_start = j;
3353 let mut paren_depth: u32 = 1;
3354 let mut k = j + 1;
3355 while k < len && paren_depth > 0 {
3356 match bytes[k] {
3357 b'\\' => {
3358 k += 1; }
3360 b'(' => paren_depth += 1,
3361 b')' => paren_depth -= 1,
3362 _ => {}
3363 }
3364 k += 1;
3365 }
3366
3367 if paren_depth == 0 {
3368 if pos > url_start && pos < k {
3369 return true;
3370 }
3371 i = k;
3372 continue;
3373 }
3374 } else if bytes[j] == b'[' {
3375 let ref_start = j;
3377 let mut ref_depth: u32 = 1;
3378 let mut k = j + 1;
3379 while k < len && ref_depth > 0 {
3380 match bytes[k] {
3381 b'\\' => {
3382 k += 1;
3383 }
3384 b'[' => ref_depth += 1,
3385 b']' => ref_depth -= 1,
3386 _ => {}
3387 }
3388 k += 1;
3389 }
3390
3391 if ref_depth == 0 {
3392 if pos > ref_start && pos < k {
3393 return true;
3394 }
3395 i = k;
3396 continue;
3397 }
3398 }
3399 }
3400 }
3401 i += 1;
3402 }
3403 false
3404 }
3405
3406 fn scalar_code(line: &str, pos: usize) -> bool {
3407 let bytes = line.as_bytes();
3408 let len = bytes.len();
3409 let mut i = 0;
3410 while i < len {
3411 if bytes[i] == b'`' {
3412 let open_start = i;
3414 while i < len && bytes[i] == b'`' {
3415 i += 1;
3416 }
3417 let tick_len = i - open_start;
3418
3419 while i < len {
3421 if bytes[i] == b'`' {
3422 let close_start = i;
3423 while i < len && bytes[i] == b'`' {
3424 i += 1;
3425 }
3426 if i - close_start == tick_len {
3427 let content_start = open_start + tick_len;
3431 let content_end = close_start;
3432 if pos >= content_start && pos < content_end {
3433 return true;
3434 }
3435 break;
3437 }
3438 } else {
3440 i += 1;
3441 }
3442 }
3443 } else {
3444 i += 1;
3445 }
3446 }
3447 false
3448 }
3449
3450 let tokens = [
3451 "javascript",
3452 "`",
3453 "``",
3454 "```",
3455 "`javascript`",
3456 "``javascript``",
3457 "`javascript``",
3458 "\\`",
3459 "[javascript](url)",
3460 "[text][javascript]",
3461 "[[x]](url)",
3462 "[a [b] c](x(y)z)",
3463 "[a](x\\)y)",
3464 "[a][x\\]y]",
3465 "[a](x y)",
3466 "[a](x",
3467 "[a][x",
3468 "[[",
3469 "[",
3470 "]",
3471 "(",
3472 ")",
3473 "\\[",
3474 "\\\\[",
3475 "\\\\\\[",
3476 "\\\\",
3477 "é日本🙂",
3478 "<!--",
3479 "-->",
3480 ];
3481 for first in tokens {
3482 for second in tokens {
3483 let text = format!("{first}{second}");
3484 let urls = MD044ProperNames::markdown_link_url_ranges(&text);
3485 let code = MD044ProperNames::backtick_code_ranges(&text);
3486 for ranges in [&urls, &code] {
3487 assert!(ranges.windows(2).all(|pair| pair[0].end <= pair[1].start));
3488 }
3489 for byte in 0..=text.len() + 1 {
3490 assert_eq!(
3491 crate::inline_config::offset_in_ranges(&urls, byte),
3492 scalar_links(&text, byte),
3493 "links {text:?} at {byte}: {urls:?}"
3494 );
3495 assert_eq!(
3496 crate::inline_config::offset_in_ranges(&code, byte),
3497 scalar_code(&text, byte),
3498 "code {text:?} at {byte}: {code:?}"
3499 );
3500 }
3501 }
3502 }
3503 }
3504
3505 #[test]
3506 fn test_fallback_ranges_preserve_code_and_link_fixes() {
3507 let body =
3508 "javascript `javascript` [javascript](javascript) [javascript][javascript] <custom:javascript> CAFÉ ";
3509 let fixed =
3510 "JavaScript `javascript` [JavaScript](javascript) [JavaScript][javascript] <custom:javascript> Café ";
3511 for (prefix, suffix) in [
3512 ("<!--\n", "\n-->"),
3513 ("<div>\n", "\n</div>"),
3514 ("---\ntitle: ", "\n---\n"),
3515 ] {
3516 for ending in ["\n", "\r\n"] {
3517 let content = format!("{prefix}{}{suffix}", body.repeat(32)).replace('\n', ending);
3518 let expected = format!("{prefix}{}{suffix}", fixed.repeat(32)).replace('\n', ending);
3519 let ctx = create_context(&content);
3520 let rule = MD044ProperNames::new(vec!["JavaScript".into(), "Café".into()], false);
3521 let warnings = rule.check(&ctx).unwrap();
3522 assert_eq!(warnings.len(), 128, "{prefix:?} {ending:?}");
3523 assert_eq!(rule.check(&ctx).unwrap(), warnings);
3524 assert_eq!(rule.fix(&ctx).unwrap(), expected);
3525 }
3526 }
3527 }
3528
3529 #[test]
3530 fn test_wikilink_url_ranges_match_scalar_queries() {
3531 fn scalar(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
3532 use pulldown_cmark::LinkType;
3533 let content = ctx.content.as_bytes();
3534
3535 for link in ctx.links_starting_before_or_at(byte_pos) {
3536 if !matches!(link.link_type, LinkType::WikiLink { .. }) {
3537 continue;
3538 }
3539 let wiki_end = link.byte_end;
3540 if wiki_end >= byte_pos || wiki_end >= content.len() || content[wiki_end] != b'(' {
3542 continue;
3543 }
3544 let mut depth: u32 = 1;
3549 let mut k = wiki_end + 1;
3550 let mut valid_destination = true;
3551 while k < content.len() && depth > 0 {
3552 match content[k] {
3553 b'\\' => {
3554 k += 1; }
3556 b'(' => depth += 1,
3557 b')' => depth -= 1,
3558 b' ' | b'\t' | b'\n' | b'\r' => {
3559 valid_destination = false;
3560 break;
3561 }
3562 _ => {}
3563 }
3564 k += 1;
3565 }
3566 if valid_destination && depth == 0 && byte_pos > wiki_end && byte_pos < k {
3569 return true;
3570 }
3571 }
3572 false
3573 }
3574
3575 use crate::config::MarkdownFlavor;
3576 let tokens = [
3577 "[[target]](javascript)",
3578 "[[target]](a(b)c)",
3579 "[[target]](a\\)b)",
3580 "[[target]](a\\ b)",
3581 "[[target]](a b)",
3582 "[[target]](a\tb)",
3583 "[[target]](a\nb)",
3584 "[[target]](a\rb)",
3585 "[[target]](a",
3586 "[[target]](a[[inner]](b)c)",
3587 "[[target]](a[[inner]](b)",
3588 "\\[[target]](path)",
3589 "`[[target]](path)`",
3590 "",
3591 "é🙂 ",
3592 "[[x]]",
3593 "\\)",
3594 "([[target]](x))",
3595 ];
3596 for flavor in [
3597 MarkdownFlavor::Standard,
3598 MarkdownFlavor::Obsidian,
3599 MarkdownFlavor::MDX,
3600 MarkdownFlavor::Quarto,
3601 ] {
3602 for first in tokens {
3603 for second in tokens {
3604 let content = format!("{first}{second}");
3605 let ctx = LintContext::new(&content, flavor, None);
3606 let ranges = MD044ProperNames::wikilink_url_ranges(&ctx);
3607 assert!(ranges.windows(2).all(|pair| pair[0].end <= pair[1].start));
3608 for byte in 0..=content.len() + 1 {
3609 assert_eq!(
3610 crate::inline_config::offset_in_ranges(&ranges, byte),
3611 scalar(&ctx, byte),
3612 "{flavor:?} {content:?} at {byte}: {ranges:?}"
3613 );
3614 }
3615 }
3616 }
3617 }
3618 }
3619
3620 #[test]
3621 fn test_wikilink_destinations_preserve_prose_fixes() {
3622 use crate::config::MarkdownFlavor;
3623 for flavor in [MarkdownFlavor::Standard, MarkdownFlavor::Obsidian] {
3624 let content = "javascript [[target]](javascript) javascript [[other]](a(javascript)b) CAFÉ";
3625 let ctx = LintContext::new(content, flavor, None);
3626 let rule = MD044ProperNames::new(vec!["JavaScript".into(), "Café".into()], false);
3627 assert_eq!(rule.check(&ctx).unwrap().len(), 3);
3628 assert_eq!(
3629 rule.fix(&ctx).unwrap(),
3630 "JavaScript [[target]](javascript) JavaScript [[other]](a(javascript)b) Café"
3631 );
3632 }
3633 }
3634
3635 #[test]
3636 fn test_repeated_name_columns_and_fixes_with_unicode() {
3637 for ending in ["\n", "\r\n"] {
3638 let line = "é日本🙂 javascript CAFÉ JavaScript <custom:javascript> ".repeat(64);
3639 let content = format!("{line}{ending}{ending}{line}");
3640 let ctx = create_context(&content);
3641 let rule = MD044ProperNames::new(vec!["JavaScript".into(), "Café".into()], false);
3642 let warnings = rule.check(&ctx).unwrap();
3643 let mut expected = Vec::new();
3644 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
3645 let text = line_info.content(ctx.content);
3646 for (byte, _) in text.match_indices("javascript CAFÉ") {
3647 for (offset, found, replacement) in [(0, "javascript", "JavaScript"), (11, "CAFÉ", "Café")] {
3648 let start = byte + offset;
3649 let column = text[..start].chars().count() + 1;
3650 expected.push((
3651 line_idx + 1,
3652 column,
3653 column + found.chars().count(),
3654 line_info.byte_offset + start..line_info.byte_offset + start + found.len(),
3655 replacement,
3656 ));
3657 }
3658 }
3659 }
3660 assert_eq!(warnings.len(), 256);
3661 assert_eq!(warnings.len(), expected.len());
3662 for (warning, (line, column, end_column, range, replacement)) in warnings.iter().zip(expected) {
3663 assert_eq!(
3664 (warning.line, warning.column, warning.end_line, warning.end_column),
3665 (line, column, line, end_column)
3666 );
3667 assert_eq!(warning.fix, Some(Fix::new(range, replacement.to_owned())));
3668 }
3669 assert_eq!(rule.check(&ctx).unwrap(), warnings, "cached diagnostics");
3670 assert_eq!(rule.clone().check(&ctx).unwrap(), warnings, "shared cache");
3671 assert_eq!(
3672 rule.fix(&ctx).unwrap(),
3673 content.replace("javascript CAFÉ", "JavaScript Café")
3674 );
3675 }
3676 }
3677
3678 #[test]
3679 fn test_angle_url_ranges_preserve_boundaries() {
3680 let line = "<bad space> <x:one> <y:two\tthree> <z:unclosed <good:last> tail";
3681 let ranges = MD044ProperNames::angle_bracket_url_ranges(line);
3682 let expected = ["<x:one>", "<y:two\tthree>", "<good:last>"];
3683 assert_eq!(ranges.len(), expected.len());
3684 for (range, text) in ranges.iter().zip(expected) {
3685 assert_eq!(&line[range.clone()], text);
3686 assert!(MD044ProperNames::is_in_angle_bracket_url(line, range.start));
3687 assert!(MD044ProperNames::is_in_angle_bracket_url(line, range.end - 1));
3688 assert!(!MD044ProperNames::is_in_angle_bracket_url(line, range.end));
3689 }
3690 }
3691
3692 #[test]
3693 fn test_is_in_angle_bracket_url_helper() {
3694 let line = "text <https://example.test> more text";
3696
3697 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));
3710
3711 assert!(MD044ProperNames::is_in_angle_bracket_url(
3713 "<mailto:test@example.com>",
3714 10
3715 ));
3716
3717 assert!(MD044ProperNames::is_in_angle_bracket_url(
3719 "<ftp://test.example.com>",
3720 10
3721 ));
3722 }
3723
3724 #[test]
3725 fn test_is_in_angle_bracket_url_uppercase_scheme() {
3726 assert!(MD044ProperNames::is_in_angle_bracket_url(
3728 "<HTTPS://test.example.com>",
3729 10
3730 ));
3731 assert!(MD044ProperNames::is_in_angle_bracket_url(
3732 "<Http://test.example.com>",
3733 10
3734 ));
3735 }
3736
3737 #[test]
3738 fn test_is_in_angle_bracket_url_uncommon_schemes() {
3739 assert!(MD044ProperNames::is_in_angle_bracket_url(
3741 "<ssh://test@example.com>",
3742 10
3743 ));
3744 assert!(MD044ProperNames::is_in_angle_bracket_url("<file:///test/path>", 10));
3746 assert!(MD044ProperNames::is_in_angle_bracket_url("<data:text/plain;test>", 10));
3748 }
3749
3750 #[test]
3751 fn test_is_in_angle_bracket_url_unclosed() {
3752 assert!(!MD044ProperNames::is_in_angle_bracket_url(
3754 "<https://test.example.com",
3755 10
3756 ));
3757 }
3758
3759 #[test]
3760 fn test_vale_inline_config_comments_not_flagged() {
3761 let config = MD044Config {
3762 names: vec!["Vale".to_string(), "JavaScript".to_string()],
3763 ..MD044Config::default()
3764 };
3765 let rule = MD044ProperNames::from_config_struct(config);
3766
3767 let content = "\
3768<!-- vale off -->
3769Some javascript text here.
3770<!-- vale on -->
3771<!-- vale Style.Rule = NO -->
3772More javascript text.
3773<!-- vale Style.Rule = YES -->
3774<!-- vale JavaScript.Grammar = NO -->
3775";
3776 let ctx = create_context(content);
3777 let result = rule.check(&ctx).unwrap();
3778
3779 assert_eq!(result.len(), 2, "Should only flag body lines, not Vale config comments");
3781 assert_eq!(result[0].line, 2);
3782 assert_eq!(result[1].line, 5);
3783 }
3784
3785 #[test]
3786 fn test_remark_lint_inline_config_comments_not_flagged() {
3787 let config = MD044Config {
3788 names: vec!["JavaScript".to_string()],
3789 ..MD044Config::default()
3790 };
3791 let rule = MD044ProperNames::from_config_struct(config);
3792
3793 let content = "\
3794<!-- lint disable remark-lint-some-rule -->
3795Some javascript text here.
3796<!-- lint enable remark-lint-some-rule -->
3797<!-- lint ignore remark-lint-some-rule -->
3798More javascript text.
3799";
3800 let ctx = create_context(content);
3801 let result = rule.check(&ctx).unwrap();
3802
3803 assert_eq!(
3804 result.len(),
3805 2,
3806 "Should only flag body lines, not remark-lint config comments"
3807 );
3808 assert_eq!(result[0].line, 2);
3809 assert_eq!(result[1].line, 5);
3810 }
3811
3812 #[test]
3813 fn test_fix_does_not_modify_vale_remark_lint_comments() {
3814 let config = MD044Config {
3815 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3816 ..MD044Config::default()
3817 };
3818 let rule = MD044ProperNames::from_config_struct(config);
3819
3820 let content = "\
3821<!-- vale off -->
3822Some javascript text.
3823<!-- vale on -->
3824<!-- lint disable remark-lint-some-rule -->
3825More javascript text.
3826<!-- lint enable remark-lint-some-rule -->
3827";
3828 let ctx = create_context(content);
3829 let fixed = rule.fix(&ctx).unwrap();
3830
3831 assert!(fixed.contains("<!-- vale off -->"));
3833 assert!(fixed.contains("<!-- vale on -->"));
3834 assert!(fixed.contains("<!-- lint disable remark-lint-some-rule -->"));
3835 assert!(fixed.contains("<!-- lint enable remark-lint-some-rule -->"));
3836 assert!(fixed.contains("Some JavaScript text."));
3838 assert!(fixed.contains("More JavaScript text."));
3839 }
3840
3841 #[test]
3842 fn test_mixed_tool_directives_all_skipped() {
3843 let config = MD044Config {
3844 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3845 ..MD044Config::default()
3846 };
3847 let rule = MD044ProperNames::from_config_struct(config);
3848
3849 let content = "\
3850<!-- rumdl-disable MD044 -->
3851Some javascript text.
3852<!-- markdownlint-disable -->
3853More javascript text.
3854<!-- vale off -->
3855Even more javascript text.
3856<!-- lint disable some-rule -->
3857Final javascript text.
3858<!-- rumdl-enable MD044 -->
3859<!-- markdownlint-enable -->
3860<!-- vale on -->
3861<!-- lint enable some-rule -->
3862";
3863 let ctx = create_context(content);
3864 let result = rule.check(&ctx).unwrap();
3865
3866 assert_eq!(
3868 result.len(),
3869 4,
3870 "Should only flag body lines, not any tool directive comments"
3871 );
3872 assert_eq!(result[0].line, 2);
3873 assert_eq!(result[1].line, 4);
3874 assert_eq!(result[2].line, 6);
3875 assert_eq!(result[3].line, 8);
3876 }
3877
3878 #[test]
3879 fn test_vale_remark_lint_edge_cases_not_matched() {
3880 let config = MD044Config {
3881 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3882 ..MD044Config::default()
3883 };
3884 let rule = MD044ProperNames::from_config_struct(config);
3885
3886 let content = "\
3894<!-- vale -->
3895<!-- vale is a tool for writing -->
3896<!-- valedictorian javascript -->
3897<!-- linting javascript tips -->
3898<!-- vale javascript -->
3899<!-- lint your javascript code -->
3900";
3901 let ctx = create_context(content);
3902 let result = rule.check(&ctx).unwrap();
3903
3904 assert_eq!(
3911 result.len(),
3912 7,
3913 "Should flag proper names in non-directive HTML comments: got {result:?}"
3914 );
3915 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); }
3923
3924 #[test]
3925 fn test_vale_style_directives_skipped() {
3926 let config = MD044Config {
3927 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3928 ..MD044Config::default()
3929 };
3930 let rule = MD044ProperNames::from_config_struct(config);
3931
3932 let content = "\
3934<!-- vale style = MyStyle -->
3935<!-- vale styles = Style1, Style2 -->
3936<!-- vale MyRule.Name = YES -->
3937<!-- vale MyRule.Name = NO -->
3938Some javascript text.
3939";
3940 let ctx = create_context(content);
3941 let result = rule.check(&ctx).unwrap();
3942
3943 assert_eq!(
3945 result.len(),
3946 1,
3947 "Should only flag body lines, not Vale style/rule directives: got {result:?}"
3948 );
3949 assert_eq!(result[0].line, 5);
3950 }
3951
3952 #[test]
3955 fn test_backtick_code_single_backticks() {
3956 let line = "hello `world` bye";
3957 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 7));
3959 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3961 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 14));
3963 }
3964
3965 #[test]
3966 fn test_backtick_code_double_backticks() {
3967 let line = "a ``code`` b";
3968 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3970 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3972 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 11));
3974 }
3975
3976 #[test]
3977 fn test_backtick_code_unclosed() {
3978 let line = "a `code b";
3979 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3981 }
3982
3983 #[test]
3984 fn test_backtick_code_mismatched_count() {
3985 let line = "a `code`` b";
3987 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3990 }
3991
3992 #[test]
3993 fn test_backtick_code_multiple_spans() {
3994 let line = "`first` and `second`";
3995 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
3997 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 8));
3999 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 13));
4001 }
4002
4003 #[test]
4004 fn test_backtick_code_on_backtick_boundary() {
4005 let line = "`code`";
4006 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
4008 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 5));
4010 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
4012 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
4013 }
4014
4015 #[test]
4021 fn test_double_bracket_link_url_not_flagged() {
4022 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4023 let content = "[[rumdl]](https://github.com/rvben/rumdl)";
4025 let ctx = create_context(content);
4026 let result = rule.check(&ctx).unwrap();
4027 assert!(
4028 result.is_empty(),
4029 "URL inside [[text]](url) must not be flagged, got: {result:?}"
4030 );
4031 }
4032
4033 #[test]
4034 fn test_double_bracket_link_url_not_fixed() {
4035 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4036 let content = "[[rumdl]](https://github.com/rvben/rumdl)\n";
4037 let ctx = create_context(content);
4038 let fixed = rule.fix(&ctx).unwrap();
4039 assert_eq!(
4040 fixed, content,
4041 "fix() must leave the URL inside [[text]](url) unchanged"
4042 );
4043 }
4044
4045 #[test]
4046 fn test_double_bracket_link_text_still_flagged() {
4047 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4048 let content = "[[github]](https://example.com)";
4050 let ctx = create_context(content);
4051 let result = rule.check(&ctx).unwrap();
4052 assert_eq!(
4053 result.len(),
4054 1,
4055 "Incorrect name in [[text]] link text should still be flagged, got: {result:?}"
4056 );
4057 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
4058 }
4059
4060 #[test]
4061 fn test_double_bracket_link_mixed_line() {
4062 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4063 let content = "See [[rumdl]](https://github.com/rvben/rumdl) and github for more.";
4065 let ctx = create_context(content);
4066 let result = rule.check(&ctx).unwrap();
4067 assert_eq!(
4068 result.len(),
4069 1,
4070 "Only the standalone 'github' after the link should be flagged, got: {result:?}"
4071 );
4072 assert!(result[0].message.contains("'github'"));
4073 assert_eq!(
4075 result[0].column, 51,
4076 "Flagged column should be the trailing 'github', not the one in the URL"
4077 );
4078 }
4079
4080 #[test]
4081 fn test_regular_link_url_still_not_flagged() {
4082 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4084 let content = "[rumdl](https://github.com/rvben/rumdl)";
4085 let ctx = create_context(content);
4086 let result = rule.check(&ctx).unwrap();
4087 assert!(
4088 result.is_empty(),
4089 "URL inside regular [text](url) must still not be flagged, got: {result:?}"
4090 );
4091 }
4092
4093 #[test]
4094 fn test_link_like_text_in_code_span_still_flagged_when_code_blocks_enabled() {
4095 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
4100 let content = "`[foo](https://github.com/org/repo)`";
4101 let ctx = create_context(content);
4102 let result = rule.check(&ctx).unwrap();
4103 assert_eq!(
4104 result.len(),
4105 1,
4106 "Proper name inside a code span must be flagged when code-blocks=true, got: {result:?}"
4107 );
4108 assert!(result[0].message.contains("'github'"));
4109 }
4110
4111 #[test]
4112 fn test_malformed_link_not_treated_as_url() {
4113 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4116 let content = "See [rumdl](github repo) for details.";
4117 let ctx = create_context(content);
4118 let result = rule.check(&ctx).unwrap();
4119 assert_eq!(
4120 result.len(),
4121 1,
4122 "Name inside malformed [text](url with spaces) must still be flagged, got: {result:?}"
4123 );
4124 assert!(result[0].message.contains("'github'"));
4125 }
4126
4127 #[test]
4128 fn test_wikilink_followed_by_prose_parens_still_flagged() {
4129 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4133 let content = "[[note]](github repo)";
4134 let ctx = create_context(content);
4135 let result = rule.check(&ctx).unwrap();
4136 assert_eq!(
4137 result.len(),
4138 1,
4139 "Name inside [[wikilink]](prose with spaces) must still be flagged, got: {result:?}"
4140 );
4141 assert!(result[0].message.contains("'github'"));
4142 }
4143
4144 #[test]
4146 fn test_roundtrip_fix_then_check_basic() {
4147 let rule = MD044ProperNames::new(
4148 vec![
4149 "JavaScript".to_string(),
4150 "TypeScript".to_string(),
4151 "Node.js".to_string(),
4152 ],
4153 true,
4154 );
4155 let content = "I love javascript, typescript, and nodejs!";
4156 let ctx = create_context(content);
4157 let fixed = rule.fix(&ctx).unwrap();
4158 let ctx2 = create_context(&fixed);
4159 let warnings = rule.check(&ctx2).unwrap();
4160 assert!(
4161 warnings.is_empty(),
4162 "Re-check after fix should produce zero warnings, got: {warnings:?}"
4163 );
4164 }
4165
4166 #[test]
4168 fn test_roundtrip_fix_then_check_multiline() {
4169 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
4170 let content = "First line with rust.\nSecond line with python.\nThird line with RUST and PYTHON.\n";
4171 let ctx = create_context(content);
4172 let fixed = rule.fix(&ctx).unwrap();
4173 let ctx2 = create_context(&fixed);
4174 let warnings = rule.check(&ctx2).unwrap();
4175 assert!(
4176 warnings.is_empty(),
4177 "Re-check after fix should produce zero warnings, got: {warnings:?}"
4178 );
4179 }
4180
4181 #[test]
4183 fn test_roundtrip_fix_then_check_inline_config() {
4184 let config = MD044Config {
4185 names: vec!["RUMDL".to_string()],
4186 ..MD044Config::default()
4187 };
4188 let rule = MD044ProperNames::from_config_struct(config);
4189 let content =
4190 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
4191 let ctx = create_context(content);
4192 let fixed = rule.fix(&ctx).unwrap();
4193 assert!(
4195 fixed.contains("Some rumdl text.\n"),
4196 "Disabled block text should be preserved"
4197 );
4198 assert!(
4199 fixed.contains("Some RUMDL text outside."),
4200 "Outside text should be fixed"
4201 );
4202 }
4203
4204 #[test]
4206 fn test_roundtrip_fix_then_check_html_comments() {
4207 let config = MD044Config {
4208 names: vec!["JavaScript".to_string()],
4209 ..MD044Config::default()
4210 };
4211 let rule = MD044ProperNames::from_config_struct(config);
4212 let content = "# Guide\n\n<!-- javascript mentioned here -->\n\njavascript outside\n";
4213 let ctx = create_context(content);
4214 let fixed = rule.fix(&ctx).unwrap();
4215 let ctx2 = create_context(&fixed);
4216 let warnings = rule.check(&ctx2).unwrap();
4217 assert!(
4218 warnings.is_empty(),
4219 "Re-check after fix should produce zero warnings, got: {warnings:?}"
4220 );
4221 }
4222
4223 #[test]
4225 fn test_roundtrip_no_op_when_correct() {
4226 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
4227 let content = "This uses JavaScript and TypeScript correctly.\n";
4228 let ctx = create_context(content);
4229 let fixed = rule.fix(&ctx).unwrap();
4230 assert_eq!(fixed, content, "Fix should be a no-op when content is already correct");
4231 }
4232
4233 #[test]
4236 fn test_bare_domain_link_text_not_flagged() {
4237 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4241 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
4242 let ctx = create_context(content);
4243 let result = rule.check(&ctx).unwrap();
4244 assert!(
4245 result.is_empty(),
4246 "Should not flag 'github' in a bare-domain link text that matches the link URL: {result:?}"
4247 );
4248 }
4249
4250 #[test]
4251 fn test_bare_domain_link_text_not_fixed() {
4252 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4254 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
4255 let ctx = create_context(content);
4256 let fixed = rule.fix(&ctx).unwrap();
4257 assert_eq!(
4258 fixed, content,
4259 "fix() must not alter bare-domain link text that matches the destination URL"
4260 );
4261 }
4262
4263 #[test]
4264 fn test_bare_domain_link_text_with_path_not_flagged() {
4265 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4267 let content = "Visit [ravencentric.github.io](https://ravencentric.github.io/projects).\n";
4268 let ctx = create_context(content);
4269 let result = rule.check(&ctx).unwrap();
4270 assert!(
4271 result.is_empty(),
4272 "Should not flag 'github' when bare-domain text is the hostname of its destination URL: {result:?}"
4273 );
4274 }
4275
4276 #[test]
4277 fn test_bare_domain_link_text_full_path_not_flagged() {
4278 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4280 let content = "See [ravencentric.github.io/blog](https://ravencentric.github.io/blog).\n";
4281 let ctx = create_context(content);
4282 let result = rule.check(&ctx).unwrap();
4283 assert!(
4284 result.is_empty(),
4285 "Should not flag 'github' when link text is the full URL path without scheme: {result:?}"
4286 );
4287 }
4288
4289 #[test]
4290 fn test_github_product_name_in_link_text_still_flagged() {
4291 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4294 let content = "Hosted on [github pages](https://pages.github.com).\n";
4295 let ctx = create_context(content);
4296 let result = rule.check(&ctx).unwrap();
4297 assert!(
4298 !result.is_empty(),
4299 "Should still flag 'github' in descriptive link text that does not match the destination URL"
4300 );
4301 }
4302
4303 #[test]
4304 fn test_protocol_relative_bare_domain_link_text_not_flagged() {
4305 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4307 let content = "See [github.io](//github.io).\n";
4308 let ctx = create_context(content);
4309 let result = rule.check(&ctx).unwrap();
4310 assert!(
4311 result.is_empty(),
4312 "Should not flag 'github' in bare-domain text matching a protocol-relative destination: {result:?}"
4313 );
4314 }
4315
4316 #[test]
4317 fn test_dotted_wikilink_target_still_flagged() {
4318 let rule = MD044ProperNames::new(vec!["Node.js".to_string()], false);
4323 let content = "See [[node.js]] for details.\n";
4324 let ctx = create_context(content);
4325 let result = rule.check(&ctx).unwrap();
4326 assert!(
4327 !result.is_empty(),
4328 "Should flag 'node.js' in a dotted WikiLink target: {result:?}"
4329 );
4330 }
4331
4332 #[test]
4333 fn test_bare_domain_link_text_case_insensitive_url() {
4334 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4337 let content = "See [github.io](HTTPS://github.io).\n";
4338 let ctx = create_context(content);
4339 let result = rule.check(&ctx).unwrap();
4340 assert!(
4341 result.is_empty(),
4342 "Should not flag bare-domain text when destination URL has an uppercase scheme: {result:?}"
4343 );
4344 }
4345
4346 #[test]
4347 fn test_frontmatter_value_span_strips_trailing_comment() {
4348 let line = "link: docs/guide/myapp # canonical path";
4349 let (s, e) = frontmatter_values::value_span(line).unwrap();
4350 assert_eq!(&line[s..e], "docs/guide/myapp");
4351 }
4352
4353 #[test]
4354 fn test_frontmatter_value_span_quoted_keeps_hash_and_spaces() {
4355 let line = "link: 'docs/My App/a#b'";
4356 let (s, e) = frontmatter_values::value_span(line).unwrap();
4357 assert_eq!(&line[s..e], "docs/My App/a#b");
4358 }
4359
4360 #[test]
4361 fn test_frontmatter_value_span_plain_value() {
4362 let line = "title: Heading for myapp";
4363 let (s, e) = frontmatter_values::value_span(line).unwrap();
4364 assert_eq!(&line[s..e], "Heading for myapp");
4365 }
4366
4367 #[test]
4368 fn test_frontmatter_value_span_none_for_key_only() {
4369 assert!(frontmatter_values::value_span("seo:").is_none());
4370 assert!(frontmatter_values::value_span("---").is_none());
4371 }
4372
4373 #[test]
4374 fn test_frontmatter_value_span_quoted_strips_trailing_comment() {
4375 let line = "link: 'docs/guide' # canonical path";
4376 let (s, e) = frontmatter_values::value_span(line).unwrap();
4377 assert_eq!(&line[s..e], "docs/guide");
4378 }
4379
4380 #[test]
4381 fn test_frontmatter_value_span_empty_quoted_value_is_none() {
4382 assert!(frontmatter_values::value_span("key: ''").is_none());
4383 }
4384
4385 #[test]
4386 fn test_frontmatter_value_span_unterminated_quote_strips_leading_quote() {
4387 let line = "link: 'docs/a";
4388 let (s, e) = frontmatter_values::value_span(line).unwrap();
4389 assert_eq!(&line[s..e], "docs/a");
4390 }
4391
4392 fn at(line: &str, needle: &str) -> usize {
4394 line.find(needle).expect("needle present")
4395 }
4396
4397 #[test]
4398 fn test_path_like_exempts_single_token_frontmatter_paths() {
4399 for line in [
4400 "link: this/is/a/link/to/myapp.md",
4401 "link: docs/myapp.md",
4402 "link: /abs/path/myapp.md",
4403 "link: ./myapp.md",
4404 "link: ../shared/myapp.md",
4405 ] {
4406 let span = frontmatter_values::value_span(line).unwrap();
4407 let pos = at(line, "myapp");
4408 assert!(
4409 MD044ProperNames::is_in_path_like_token(line, pos, span),
4410 "should treat as a path: {line}"
4411 );
4412 }
4413 }
4414
4415 #[test]
4416 fn test_path_like_does_not_exempt_slash_conjunction_prose() {
4417 let line = "description: We support github/gitlab/bitbucket imports.";
4420 let span = frontmatter_values::value_span(line).unwrap();
4421 assert!(
4422 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
4423 "slash-separated prose is not a path"
4424 );
4425
4426 let line = "description: The javascript/typescript ecosystem is large.";
4427 let span = frontmatter_values::value_span(line).unwrap();
4428 assert!(!MD044ProperNames::is_in_path_like_token(
4429 line,
4430 at(line, "javascript"),
4431 span
4432 ));
4433 }
4434
4435 #[test]
4436 fn test_path_like_requires_a_slash_so_dotted_names_survive() {
4437 let line = "title: Use nodejs and myapp.md today.";
4438 let span = frontmatter_values::value_span(line).unwrap();
4439 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4440 }
4441
4442 #[test]
4443 fn test_path_like_no_slash_frontmatter_value_still_flagged() {
4444 let line = "slug: myapp-guide";
4449 let span = frontmatter_values::value_span(line).unwrap();
4450 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4451 }
4452
4453 #[test]
4454 fn test_path_like_returns_false_outside_value_span() {
4455 let line = "myapp: docs/guide/myapp";
4458 let span = frontmatter_values::value_span(line).unwrap();
4459 let key_pos = 0;
4460 assert!(!MD044ProperNames::is_in_path_like_token(line, key_pos, span));
4461 }
4462
4463 #[test]
4464 fn test_path_like_three_segments_only_as_sole_frontmatter_value() {
4465 let line = "link: docs/guide/myapp";
4466 let span = frontmatter_values::value_span(line).unwrap();
4467 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4468
4469 let line = "description: We support github/gitlab/bitbucket now";
4470 let span = frontmatter_values::value_span(line).unwrap();
4471 assert!(
4472 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
4473 "multi-token value gets body treatment"
4474 );
4475 }
4476
4477 #[test]
4478 fn test_path_like_quoted_value_with_spaces() {
4479 let line = "link: 'docs/My App/myapp.md'";
4482 let span = frontmatter_values::value_span(line).unwrap();
4483 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4484 }
4485
4486 #[test]
4487 fn test_path_like_quoted_value_with_spaces_no_extension_not_exempt() {
4488 let line = "link: 'docs/My App/myapp'";
4495 let span = frontmatter_values::value_span(line).unwrap();
4496 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4497 }
4498
4499 #[test]
4500 fn test_path_like_trailing_comment_is_still_sole_value() {
4501 let line = "link: docs/guide/myapp # canonical path";
4502 let span = frontmatter_values::value_span(line).unwrap();
4503 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4504 }
4505
4506 #[test]
4507 fn test_path_like_trailing_punctuation_trimmed() {
4508 let line = "link: docs/myapp.md, then leave.";
4509 let span = frontmatter_values::value_span(line).unwrap();
4510 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4511 }
4512
4513 #[test]
4514 fn test_trim_token_bounds_reaches_fixpoint_after_punctuation_exposes_wrapper() {
4515 let line = r#"See "docs/myapp.md", then leave."#;
4516 let raw_start = at(line, "\"docs");
4517 let raw_end = raw_start + r#""docs/myapp.md","#.len();
4518 assert_eq!(&line[raw_start..raw_end], r#""docs/myapp.md","#);
4519 let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
4520 assert_eq!(&line[start..end], "docs/myapp.md");
4521 }
4522
4523 #[test]
4524 fn test_trim_token_bounds_reaches_fixpoint_with_multiple_trailing_wrappers() {
4525 let line = r#"("docs/myapp.md")."#;
4526 let (start, end) = frontmatter_values::trim_token_bounds(line, 0, line.len());
4527 assert_eq!(&line[start..end], "docs/myapp.md");
4528 }
4529
4530 #[test]
4531 fn test_frontmatter_link_path_not_flagged() {
4532 let content = "---\ntitle: Heading for MyApp\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
4533 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4534 let ctx = create_context(content);
4535 let result = rule.check(&ctx).unwrap();
4536 assert!(
4537 result.is_empty(),
4538 "path in a frontmatter value must not be flagged: {result:?}"
4539 );
4540 }
4541
4542 #[test]
4543 fn test_fix_does_not_corrupt_frontmatter_link_path() {
4544 let content = "---\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
4545 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4546 let ctx = create_context(content);
4547 assert_eq!(rule.fix(&ctx).unwrap(), content, "fix must not rewrite a path");
4548 }
4549
4550 #[test]
4561 fn test_body_prose_parenthesized_disambiguator_is_case_corrected() {
4562 let content = "See docs/myapp(1).md here.\n";
4563 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4564 let ctx = create_context(content);
4565 let result = rule.check(&ctx).unwrap();
4566 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
4567 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp(1).md here.\n");
4568 }
4569
4570 #[test]
4571 fn test_body_prose_bracketed_dynamic_segment_is_case_corrected() {
4572 let content = "See docs/[myapp].md here.\n";
4573 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4574 let ctx = create_context(content);
4575 let result = rule.check(&ctx).unwrap();
4576 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
4577 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/[MyApp].md here.\n");
4578 }
4579
4580 #[test]
4581 fn test_body_prose_nextjs_catch_all_segment_is_case_corrected() {
4582 let content = "pages/[[...myapp]].tsx are catch-all routes.\n";
4585 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4586 let ctx = create_context(content);
4587 let result = rule.check(&ctx).unwrap();
4588 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
4589 assert_eq!(
4590 rule.fix(&ctx).unwrap(),
4591 "pages/[[...MyApp]].tsx are catch-all routes.\n"
4592 );
4593 }
4594
4595 #[test]
4596 fn test_two_adjacent_whitespace_free_links_both_flagged() {
4597 let content = "[myapp](https://a.com)[github](https://b.com)\n";
4601 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitHub".to_string()], false);
4602 let ctx = create_context(content);
4603 let result = rule.check(&ctx).unwrap();
4604 assert_eq!(result.len(), 2, "both link texts must be flagged: {result:?}");
4605 assert!(result.iter().any(|w| w.message.contains("'myapp'")));
4606 assert!(result.iter().any(|w| w.message.contains("'github'")));
4607 }
4608
4609 #[test]
4610 fn test_fix_does_not_corrupt_frontmatter_path_with_route_group_named_after_proper_name() {
4611 let content = "---\nlink: src/(myapp)/page.tsx\n---\n\nBody.\n";
4614 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4615 let ctx = create_context(content);
4616 assert_eq!(
4617 rule.fix(&ctx).unwrap(),
4618 content,
4619 "fix must not rewrite a frontmatter path whose route-group directory name is the proper name"
4620 );
4621 }
4622
4623 #[test]
4624 fn test_quoted_frontmatter_value_slash_conjunction_prose_still_flagged() {
4625 let content = "---\ndescription: \"We support github/gitlab/bitbucket now\"\n---\n\nBody.\n";
4630 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4631 let ctx = create_context(content);
4632 let result = rule.check(&ctx).unwrap();
4633 assert_eq!(
4634 result.len(),
4635 1,
4636 "quoted prose value must still flag 'github': {result:?}"
4637 );
4638 }
4639
4640 #[test]
4641 fn test_quoted_frontmatter_value_single_slash_word_with_unrelated_dot_still_flagged() {
4642 let content = "---\ndescription: \"We use myapp/gitlab and version 1.0 e.g. weekly\"\n---\n\nBody.\n";
4645 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4646 let ctx = create_context(content);
4647 let result = rule.check(&ctx).unwrap();
4648 assert_eq!(
4649 result.len(),
4650 1,
4651 "quoted prose value must still flag 'myapp': {result:?}"
4652 );
4653 }
4654
4655 #[test]
4656 fn test_quoted_toml_frontmatter_value_slash_conjunction_prose_still_flagged() {
4657 let content = "+++\ndescription = \"We support github/gitlab/bitbucket now\"\n+++\n\nBody.\n";
4660 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4661 let ctx = create_context(content);
4662 let result = rule.check(&ctx).unwrap();
4663 assert_eq!(
4664 result.len(),
4665 1,
4666 "TOML quoted prose value must still flag 'github': {result:?}"
4667 );
4668 }
4669
4670 #[test]
4671 fn test_path_like_collapsed_multiword_no_extension_not_exempt() {
4672 for line in [
4679 r#"description: "myapp/gitlab github/bitbucket""#,
4680 r#"description: "and/or this/that myapp/gitlab""#,
4681 r#"description: "he/him she/her myapp/gitlab""#,
4682 ] {
4683 let span = frontmatter_values::value_span(line).unwrap();
4684 for needle in ["myapp", "gitlab"] {
4685 if let Some(byte_pos) = line.find(needle) {
4686 assert!(
4687 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
4688 "collapsed multi-word value must not exempt '{needle}': {line}"
4689 );
4690 }
4691 }
4692 }
4693 }
4694
4695 #[test]
4696 fn test_path_like_collapsed_multiword_no_extension_not_exempt_toml() {
4697 let line = r#"description = "myapp/gitlab github/bitbucket""#;
4698 let span = frontmatter_values::value_span(line).unwrap();
4699 for needle in ["myapp", "gitlab", "github", "bitbucket"] {
4700 let byte_pos = at(line, needle);
4701 assert!(
4702 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
4703 "collapsed multi-word TOML value must not exempt '{needle}'"
4704 );
4705 }
4706 }
4707
4708 #[test]
4709 fn test_frontmatter_collapsed_multiword_names_all_flagged_yaml() {
4710 let content = "---\ndescription: \"myapp/gitlab github/bitbucket\"\n---\n\nBody.\n";
4711 let rule = MD044ProperNames::new(
4712 vec![
4713 "MyApp".to_string(),
4714 "GitLab".to_string(),
4715 "GitHub".to_string(),
4716 "Bitbucket".to_string(),
4717 ],
4718 false,
4719 );
4720 let ctx = create_context(content);
4721 let result = rule.check(&ctx).unwrap();
4722 assert_eq!(
4723 result.len(),
4724 4,
4725 "all four names in the collapsed multi-word value must be flagged: {result:?}"
4726 );
4727 }
4728
4729 #[test]
4730 fn test_frontmatter_collapsed_multiword_names_all_flagged_toml() {
4731 let content = "+++\ndescription = \"myapp/gitlab github/bitbucket\"\n+++\n\nBody.\n";
4732 let rule = MD044ProperNames::new(
4733 vec![
4734 "MyApp".to_string(),
4735 "GitLab".to_string(),
4736 "GitHub".to_string(),
4737 "Bitbucket".to_string(),
4738 ],
4739 false,
4740 );
4741 let ctx = create_context(content);
4742 let result = rule.check(&ctx).unwrap();
4743 assert_eq!(
4744 result.len(),
4745 4,
4746 "all four names in the collapsed multi-word TOML value must be flagged: {result:?}"
4747 );
4748 }
4749
4750 #[test]
4751 fn test_frontmatter_collapsed_multiword_conjunction_pairs_flagged() {
4752 let content = "---\ndescription: \"and/or this/that myapp/gitlab\"\n---\n\nBody.\n";
4753 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4754 let ctx = create_context(content);
4755 let result = rule.check(&ctx).unwrap();
4756 assert_eq!(
4757 result.len(),
4758 2,
4759 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4760 );
4761 }
4762
4763 #[test]
4764 fn test_frontmatter_collapsed_multiword_pronoun_pairs_flagged() {
4765 let content = "---\ndescription: \"he/him she/her myapp/gitlab\"\n---\n\nBody.\n";
4766 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4767 let ctx = create_context(content);
4768 let result = rule.check(&ctx).unwrap();
4769 assert_eq!(
4770 result.len(),
4771 2,
4772 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4773 );
4774 }
4775
4776 #[test]
4782 fn test_body_prose_path_is_flagged_frontmatter_only_scope() {
4783 let content = "See docs/myapp.md for details about myapp.\n";
4784 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4785 let ctx = create_context(content);
4786 let result = rule.check(&ctx).unwrap();
4787 assert_eq!(
4788 result.len(),
4789 2,
4790 "both the path occurrence and the prose occurrence are flagged in body text: {result:?}"
4791 );
4792 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp.md for details about MyApp.\n");
4793 }
4794
4795 #[test]
4796 fn test_slash_conjunction_prose_still_flagged() {
4797 let content = "We support github/gitlab imports.\n";
4798 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4799 let ctx = create_context(content);
4800 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
4801 }
4802}