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 if line_info.is_myst_structural_line() {
353 continue;
354 }
355
356 let fm_value_offset = if line_info.in_front_matter {
359 frontmatter_values::value_offset(line)
360 } else {
361 0
362 };
363 if fm_value_offset == usize::MAX {
364 continue;
365 }
366 if line_info.in_front_matter
367 && let Some(Some(field)) = field_map.get(line_idx)
368 && self.ignore_fields.contains(field)
369 {
370 continue;
371 }
372 let fm_value_span = if line_info.in_front_matter {
373 frontmatter_values::value_span(line)
374 } else {
375 None
376 };
377
378 let line_lower = line.to_lowercase();
380 let has_line_matches = self.name_variants.keys().any(|name| line_lower.contains(name));
381
382 if !has_line_matches {
383 continue;
384 }
385
386 let mut angle_urls = None;
388 let mut fallback_code = None;
389 let mut fallback_urls = None;
390
391 for cap in combined_regex.find_iter(line) {
393 let found_name = &line[cap.start()..cap.end()];
394
395 let start_pos = cap.start();
397 let end_pos = cap.end();
398
399 if start_pos < fm_value_offset {
401 continue;
402 }
403
404 let byte_pos = line_info.byte_offset + start_pos;
406 if crate::inline_config::offset_in_ranges(&directive_comments, byte_pos) {
407 continue;
408 }
409 if ctx.is_in_html_tag(byte_pos) {
410 continue;
411 }
412
413 if ctx.is_in_shortcode(byte_pos) {
420 continue;
421 }
422
423 if !Self::is_at_word_boundary(line, start_pos, true) || !Self::is_at_word_boundary(line, end_pos, false)
424 {
425 continue; }
427
428 if Self::is_file_extension_suffix(line, start_pos, found_name) {
429 continue;
430 }
431
432 if self.config.whole_words
433 && ((Self::is_joined_identifier_boundary(line, start_pos, true)
434 && !Self::is_underscore_emphasis_boundary(ctx, byte_pos, true))
435 || (Self::is_joined_identifier_boundary(line, end_pos, false)
436 && !Self::is_underscore_emphasis_boundary(ctx, byte_pos + found_name.len(), false)))
437 {
438 continue;
439 }
440
441 if !self.config.code_blocks {
443 if ctx.is_in_code_block_or_span(byte_pos) {
444 continue;
445 }
446 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
450 && crate::inline_config::offset_in_ranges(
451 fallback_code.get_or_insert_with(|| Self::backtick_code_ranges(line)),
452 start_pos,
453 )
454 {
455 continue;
456 }
457 }
458
459 if Self::is_in_link(ctx, byte_pos) {
461 continue;
462 }
463
464 if ctx.is_in_unresolved_autoref_identifier(byte_pos) {
467 continue;
468 }
469
470 if crate::inline_config::offset_in_ranges(
474 angle_urls.get_or_insert_with(|| Self::angle_bracket_url_ranges(line)),
475 start_pos,
476 ) {
477 continue;
478 }
479
480 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
484 && crate::inline_config::offset_in_ranges(
485 fallback_urls.get_or_insert_with(|| Self::markdown_link_url_ranges(line)),
486 start_pos,
487 )
488 {
489 continue;
490 }
491
492 if crate::inline_config::offset_in_ranges(
497 wikilink_urls.get_or_insert_with(|| Self::wikilink_url_ranges(ctx)),
498 byte_pos,
499 ) {
500 continue;
501 }
502
503 if ctx.is_in_bare_url(byte_pos) {
509 continue;
510 }
511
512 if let Some(fm_value) = fm_value_span
519 && Self::is_in_path_like_token(line, start_pos, fm_value)
520 {
521 continue;
522 }
523
524 if let Some(&proper_name_index) = self.name_variants.get(&found_name.to_lowercase()) {
526 if found_name != self.config.names[proper_name_index] {
528 violations.push((line_num, cap.start() + 1, found_name.to_string(), proper_name_index));
529 }
530 }
531 }
532 }
533
534 if let Ok(mut cache) = self.content_cache.lock() {
536 cache.insert(hash, violations.clone());
537 }
538 violations
539 }
540
541 fn is_in_link(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
549 use pulldown_cmark::LinkType;
550
551 if let Some(link) = ctx.link_containing(byte_pos) {
552 let (text_start, text_end) = if matches!(link.link_type, LinkType::WikiLink { .. }) {
554 let span = &ctx.content[link.byte_offset..link.byte_end];
561 let start = match span.find('|') {
562 Some(pipe) => link.byte_offset + pipe + 1,
563 None => link.byte_offset + 2,
564 };
565 (start, link.byte_end.saturating_sub(2))
566 } else {
567 let start = link.byte_offset + 1;
568 (start, start + link.text.len())
569 };
570
571 if byte_pos >= text_start && byte_pos < text_end {
575 let is_wikilink = matches!(link.link_type, LinkType::WikiLink { .. });
576 if Self::link_text_is_url(&link.text)
577 || (!is_wikilink && Self::link_text_matches_link_url(&link.text, &link.url))
578 {
579 return true;
580 }
581 return Self::image_verdict(ctx, byte_pos).unwrap_or(false);
587 }
588 return true;
590 }
591
592 if let Some(verdict) = Self::image_verdict(ctx, byte_pos) {
593 return verdict;
594 }
595
596 ctx.is_in_reference_def(byte_pos)
598 }
599
600 fn image_verdict(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> Option<bool> {
605 let image = ctx.image_containing(byte_pos)?;
606
607 let alt_start = image.byte_offset + 2;
609 let alt_end = alt_start + image.alt_text.len();
610
611 Some(!(byte_pos >= alt_start && byte_pos < alt_end))
613 }
614
615 fn link_text_is_url(text: &str) -> bool {
617 let lower = text.trim().to_ascii_lowercase();
618 lower.starts_with("http://")
619 || lower.starts_with("https://")
620 || lower.starts_with("www.")
621 || lower.starts_with("//")
622 }
623
624 fn link_text_matches_link_url(text: &str, url: &str) -> bool {
636 let text = text.trim();
637 if !text.contains('.') {
639 return false;
640 }
641 let url_lower = url.to_ascii_lowercase();
642 let url_without_scheme = url_lower
643 .strip_prefix("https://")
644 .or_else(|| url_lower.strip_prefix("http://"))
645 .or_else(|| url_lower.strip_prefix("//"))
646 .unwrap_or(&url_lower);
647 let text_lower = text.to_ascii_lowercase();
648 if url_without_scheme == text_lower.as_str() {
650 return true;
651 }
652 url_without_scheme.len() > text_lower.len()
654 && url_without_scheme.starts_with(text_lower.as_str())
655 && matches!(
656 url_without_scheme.as_bytes().get(text_lower.len()),
657 Some(b'/') | Some(b'?') | Some(b'#')
658 )
659 }
660
661 fn angle_bracket_url_ranges(line: &str) -> Vec<std::ops::Range<usize>> {
667 let mut ranges = Vec::new();
668 let bytes = line.as_bytes();
669 let len = bytes.len();
670 let mut i = 0;
671 while i < len {
672 if bytes[i] == b'<' {
673 let after_open = i + 1;
674 if after_open < len && bytes[after_open].is_ascii_alphabetic() {
678 let mut s = after_open + 1;
679 let scheme_max = (after_open + 32).min(len);
680 while s < scheme_max
681 && (bytes[s].is_ascii_alphanumeric()
682 || bytes[s] == b'+'
683 || bytes[s] == b'-'
684 || bytes[s] == b'.')
685 {
686 s += 1;
687 }
688 if s < len && bytes[s] == b':' {
689 let mut j = s + 1;
691 let mut found_close = false;
692 while j < len {
693 match bytes[j] {
694 b'>' => {
695 found_close = true;
696 break;
697 }
698 b' ' | b'<' => break,
699 _ => j += 1,
700 }
701 }
702 if found_close {
703 ranges.push(i..j + 1);
704 i = j + 1;
705 continue;
706 }
707 }
708 }
709 }
710 i += 1;
711 }
712 ranges
713 }
714
715 #[cfg(test)]
716 fn is_in_angle_bracket_url(line: &str, pos: usize) -> bool {
717 crate::inline_config::offset_in_ranges(&Self::angle_bracket_url_ranges(line), pos)
718 }
719
720 fn wikilink_url_ranges(ctx: &crate::lint_context::LintContext) -> Vec<std::ops::Range<usize>> {
725 use pulldown_cmark::LinkType;
726 let content = ctx.content.as_bytes();
727 let starts: HashSet<_> = ctx
728 .links()
729 .iter()
730 .filter(|link| matches!(link.link_type, LinkType::WikiLink { .. }))
731 .map(|link| link.byte_end)
732 .filter(|&end| content.get(end) == Some(&b'('))
733 .collect();
734 let mut ranges: Vec<std::ops::Range<usize>> = Vec::new();
735 let Some(mut pos) = starts.iter().min().copied() else {
736 return ranges;
737 };
738 let last_start = *starts.iter().max().unwrap();
739 let mut stack = Vec::new();
742 let mut depth = 0usize;
743 while pos < content.len() {
744 match content[pos] {
745 b'\\' => pos += 1,
746 b'(' => {
747 depth += 1;
748 if starts.contains(&pos) {
749 stack.push((pos, depth));
750 }
751 }
752 b')' => {
753 if stack.last().is_some_and(|&(_, level)| level == depth) {
754 let (open, _) = stack.pop().unwrap();
755 while ranges.last().is_some_and(|range| range.start > open) {
759 ranges.pop();
760 }
761 ranges.push(open + 1..pos + 1);
762 }
763 depth = depth.saturating_sub(1);
764 }
765 b' ' | b'\t' | b'\n' | b'\r' => {
767 stack.clear();
768 depth = 0;
769 }
770 _ => {}
771 }
772 if pos >= last_start && stack.is_empty() {
773 break;
774 }
775 pos += 1;
776 }
777 ranges
778 }
779
780 fn markdown_link_url_ranges(line: &str) -> Vec<std::ops::Range<usize>> {
789 let mut ranges = Vec::new();
790 let bytes = line.as_bytes();
791 let mut brackets = FallbackDelimiters::default();
792 let mut parentheses = FallbackDelimiters::default();
793 let mut i = 0;
794 while i < bytes.len() {
795 if bytes[i] == b'['
797 && (i == 0 || bytes[i - 1] != b'\\' || (i >= 2 && bytes[i - 2] == b'\\'))
798 && let Some(j) = brackets.find(bytes, i + 1, b'[', b']')
799 {
800 let end = match bytes.get(j) {
801 Some(b'(') => parentheses.find(bytes, j + 1, b'(', b')'),
802 Some(b'[') => brackets.find(bytes, j + 1, b'[', b']'),
803 _ => None,
804 };
805 if let Some(end) = end {
806 ranges.push(j + 1..end);
807 i = end;
808 continue;
809 }
810 }
811 i += 1;
812 }
813 ranges
814 }
815
816 fn backtick_code_ranges(line: &str) -> Vec<std::ops::Range<usize>> {
823 let mut ranges = Vec::new();
824 let bytes = line.as_bytes();
825 let len = bytes.len();
826 let mut i = 0;
827 while i < len {
828 if bytes[i] == b'`' {
829 let open_start = i;
831 while i < len && bytes[i] == b'`' {
832 i += 1;
833 }
834 let tick_len = i - open_start;
835
836 while i < len {
838 if bytes[i] == b'`' {
839 let close_start = i;
840 while i < len && bytes[i] == b'`' {
841 i += 1;
842 }
843 if i - close_start == tick_len {
844 let content_start = open_start + tick_len;
848 let content_end = close_start;
849 ranges.push(content_start..content_end);
850 break;
852 }
853 } else {
855 i += 1;
856 }
857 }
858 } else {
859 i += 1;
860 }
861 }
862 ranges
863 }
864
865 #[cfg(test)]
866 fn is_in_backtick_code_in_line(line: &str, pos: usize) -> bool {
867 crate::inline_config::offset_in_ranges(&Self::backtick_code_ranges(line), pos)
868 }
869
870 fn is_word_boundary_char(c: char) -> bool {
872 !c.is_alphanumeric()
873 }
874
875 fn is_at_word_boundary(content: &str, pos: usize, is_start: bool) -> bool {
877 if is_start {
878 if pos == 0 {
879 return true;
880 }
881 match content[..pos].chars().next_back() {
882 None => true,
883 Some(c) => Self::is_word_boundary_char(c),
884 }
885 } else {
886 if pos >= content.len() {
887 return true;
888 }
889 match content[pos..].chars().next() {
890 None => true,
891 Some(c) => Self::is_word_boundary_char(c),
892 }
893 }
894 }
895
896 fn is_file_extension_suffix(line: &str, start: usize, name: &str) -> bool {
901 let Some(prefix) = line[..start].strip_suffix('.') else {
902 return false;
903 };
904
905 !prefix.ends_with('.')
908 && name.starts_with(char::is_alphanumeric)
909 && name.ends_with(char::is_alphanumeric)
910 && name
911 .chars()
912 .all(|character| character.is_alphanumeric() || matches!(character, '.' | '-' | '_'))
913 }
914
915 fn is_joined_identifier_boundary(content: &str, pos: usize, is_start: bool) -> bool {
923 if is_start {
924 let mut chars = content[..pos].char_indices().rev();
925 let mut saw_separator = false;
926 for (_, character) in &mut chars {
927 if character == '-' || character == '_' {
928 saw_separator = true;
929 } else {
930 return saw_separator && character.is_alphanumeric();
931 }
932 }
933 false
934 } else {
935 let mut saw_separator = false;
936 for character in content[pos..].chars() {
937 if character == '-' || character == '_' {
938 saw_separator = true;
939 } else {
940 return saw_separator && character.is_alphanumeric();
941 }
942 }
943 false
944 }
945 }
946
947 fn is_underscore_emphasis_boundary(ctx: &crate::lint_context::LintContext, pos: usize, is_start: bool) -> bool {
952 ctx.source_emphasis_spans().iter().any(|span| {
953 if span.marker != '_' {
954 return false;
955 }
956 let width = if span.is_strong { 2 } else { 1 };
957 if is_start {
958 span.byte_offset + width == pos
959 } else {
960 span.byte_end.checked_sub(width) == Some(pos)
961 }
962 })
963 }
964
965 fn is_in_path_like_token(line: &str, match_start: usize, fm_value: (usize, usize)) -> bool {
994 let (value_start, value_end) = fm_value;
995 if match_start < value_start || match_start >= value_end {
996 return false;
997 }
998
999 let quoted_words: Vec<&str> = if frontmatter_values::value_is_quoted(line, value_start) {
1007 line[value_start..value_end].split_whitespace().collect()
1008 } else {
1009 Vec::new()
1010 };
1011 let is_single_quoted_path = !quoted_words.is_empty() && quoted_words.iter().all(|word| word.contains('/'));
1012 let is_multi_word_collapse = is_single_quoted_path && quoted_words.len() > 1;
1020
1021 let (raw_start, raw_end) = if is_single_quoted_path {
1022 (value_start, value_end)
1023 } else {
1024 frontmatter_values::token_bounds(line, match_start, value_start, value_end)
1025 };
1026
1027 let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
1028 if match_start < start || match_start >= end {
1029 return false;
1030 }
1031
1032 let token = &line[start..end];
1033 if !token.contains('/') {
1034 return false;
1035 }
1036 if token.starts_with('/') || token.starts_with("./") || token.starts_with("../") || token.starts_with("~/") {
1037 return true;
1038 }
1039 if token.rsplit('/').next().is_some_and(|seg| seg.contains('.')) {
1040 return true;
1041 }
1042
1043 if is_multi_word_collapse {
1044 return false;
1045 }
1046
1047 let sole_value = {
1051 let (ts, te) = frontmatter_values::trim_token_bounds(line, value_start, value_end);
1052 ts == start && te == end
1053 };
1054 sole_value && token.split('/').filter(|s| !s.is_empty()).count() >= 3
1055 }
1056}
1057
1058impl Rule for MD044ProperNames {
1059 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
1060 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(44))
1061 }
1062
1063 fn name(&self) -> &'static str {
1064 "MD044"
1065 }
1066
1067 fn description(&self) -> &'static str {
1068 "Proper names should have the correct capitalization"
1069 }
1070
1071 fn category(&self) -> RuleCategory {
1072 RuleCategory::Other
1073 }
1074
1075 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1076 if self.config.names.is_empty() {
1077 return true;
1078 }
1079 let content_lower = if ctx.content.is_ascii() {
1081 ctx.content.to_ascii_lowercase()
1082 } else {
1083 ctx.content.to_lowercase()
1084 };
1085 !self.name_variants.keys().any(|name| content_lower.contains(name))
1086 }
1087
1088 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1089 let content = ctx.content;
1090 if content.is_empty() || self.config.names.is_empty() || self.combined_pattern.is_none() {
1091 return Ok(Vec::new());
1092 }
1093
1094 let content_lower = if content.is_ascii() {
1096 content.to_ascii_lowercase()
1097 } else {
1098 content.to_lowercase()
1099 };
1100
1101 let has_potential_matches = self.name_variants.keys().any(|name| content_lower.contains(name));
1103
1104 if !has_potential_matches {
1105 return Ok(Vec::new());
1106 }
1107 let violations = self.find_name_violations(ctx);
1108
1109 let mut positions = SourcePositions::new(content, &ctx.lines);
1110 let warnings = violations
1111 .into_iter()
1112 .map(|(line, column, found_name, proper_name_index)| {
1113 let proper_name = &self.config.names[proper_name_index];
1114 let line_start = ctx.line_start_byte(line).unwrap_or(0);
1119 let byte_start = line_start + (column - 1);
1120 let byte_end = byte_start + found_name.len();
1121 let char_col = positions.column_on_line(line - 1, byte_start) + 1;
1124 LintWarning {
1125 rule_name: Some(self.name().to_string()),
1126 line,
1127 column: char_col,
1128 end_line: line,
1129 end_column: char_col + found_name.chars().count(),
1130 message: format!("Proper name '{found_name}' should be '{proper_name}'"),
1131 severity: Severity::Warning,
1132 fix: Some(Fix::new(byte_start..byte_end, proper_name.clone())),
1133 }
1134 })
1135 .collect();
1136
1137 Ok(warnings)
1138 }
1139
1140 fn fixes_from_diagnostics(&self) -> bool {
1141 true
1142 }
1143
1144 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1145 if self.should_skip(ctx) {
1146 return Ok(ctx.content.to_string());
1147 }
1148 let warnings = self.check(ctx)?;
1149 if warnings.is_empty() {
1150 return Ok(ctx.content.to_string());
1151 }
1152 let warnings =
1153 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1154 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
1155 .map_err(crate::rule::LintError::InvalidInput)
1156 }
1157
1158 fn as_any(&self) -> &dyn std::any::Any {
1159 self
1160 }
1161
1162 crate::impl_rule_config_methods!(MD044Config);
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167 use super::*;
1168 use crate::lint_context::LintContext;
1169
1170 fn create_context(content: &str) -> LintContext<'_> {
1171 LintContext::new(content, crate::config::MarkdownFlavor::Standard, None)
1172 }
1173
1174 fn field_map_for(content: &str) -> Vec<Option<String>> {
1175 let ctx = create_context(content);
1176 frontmatter_values::field_map(&ctx)
1177 }
1178
1179 fn whole_word_rule(names: &[&str]) -> MD044ProperNames {
1180 MD044ProperNames::from_config_struct(MD044Config {
1181 names: names.iter().map(|name| (*name).to_string()).collect(),
1182 whole_words: true,
1183 ..Default::default()
1184 })
1185 }
1186
1187 #[test]
1188 fn file_extension_suffixes_preserve_case_but_prose_is_checked() {
1189 let content = "I like toml in my favorite.toml file.\n\
1190 Extensions: .toml (.toml) [.toml] \".toml\" '.toml' “.toml” ‘.toml’.\n\
1191 Files: config.toml.example v2.toml café.toml my_conf.toml foo-.toml file(1).toml.\n\
1192 Patterns: *.toml **.toml** _.toml_ /.toml \\.toml.\n\
1193 Next.js uses js, and toml is a format.\n";
1194 let expected = content
1195 .replacen("I like toml", "I like TOML", 1)
1196 .replace("uses js, and toml", "uses JS, and TOML");
1197
1198 for whole_words in [false, true] {
1199 let rule = MD044ProperNames::from_config_struct(MD044Config {
1200 names: vec!["TOML".to_string(), "JS".to_string()],
1201 whole_words,
1202 ..Default::default()
1203 });
1204 let ctx = create_context(content);
1205 assert_eq!(rule.check(&ctx).unwrap().len(), 3);
1206 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1207 assert!(rule.check(&create_context(&expected)).unwrap().is_empty());
1208 assert_eq!(rule.fix(&create_context(&expected)).unwrap(), expected);
1209 }
1210 }
1211
1212 #[test]
1213 fn file_extensions_keep_dotted_names_stems_and_function_calls_checkable() {
1214 let rule = MD044ProperNames::new(
1215 ["TOML", "Oklch", "Node.js", "MyApp", ".NET", "GitHub"]
1216 .map(str::to_string)
1217 .to_vec(),
1218 false,
1219 );
1220 let content = "Use node.js and .net for myapp.\n\
1221 See docs/myapp.md and docs/myapp(1).md.\n\
1222 Oklch syntax is oklch(.5 .5 255); github(tm) is a reference.\n\
1223 I like TOML in my favorite.toml file.\n\
1224 Use `favorite.toml` and `oklch(.5 .5 255)`.\n";
1225 let expected = "Use Node.js and .NET for MyApp.\n\
1226 See docs/MyApp.md and docs/MyApp(1).md.\n\
1227 Oklch syntax is Oklch(.5 .5 255); GitHub(tm) is a reference.\n\
1228 I like TOML in my favorite.toml file.\n\
1229 Use `favorite.toml` and `oklch(.5 .5 255)`.\n";
1230 let ctx = create_context(content);
1231 assert_eq!(rule.check(&ctx).unwrap().len(), 7);
1232 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1233 assert!(rule.check(&create_context(expected)).unwrap().is_empty());
1234 }
1235
1236 #[test]
1237 fn file_extensions_do_not_exempt_ellipsis_or_multiword_prose() {
1238 let rule = MD044ProperNames::new(vec!["TOML".to_string(), "My App".to_string()], false);
1239 let content = "Wait...toml or …toml; e.g. toml and file. toml.\n\
1240 Sentence.my app is still prose.\n";
1241 let expected = "Wait...TOML or …TOML; e.g. TOML and file. TOML.\n\
1242 Sentence.My App is still prose.\n";
1243 let ctx = create_context(content);
1244 assert_eq!(rule.check(&ctx).unwrap().len(), 5);
1245 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1246 }
1247
1248 #[test]
1249 fn file_extension_syntax_applies_to_dotted_tokens_and_html_prose() {
1250 let rule = MD044ProperNames::new(["TOML", "Node.js", "GitHub"].map(str::to_string).to_vec(), false);
1251 let content = ".github api.node.js api.github.com i.e.toml\n\
1254 node.js github and toml\n\
1255 <!-- favorite.toml toml -->\n\
1256 <div>favorite.toml toml</div>\n";
1257 let expected = ".github api.node.js api.github.com i.e.toml\n\
1258 Node.js GitHub and TOML\n\
1259 <!-- favorite.toml TOML -->\n\
1260 <div>favorite.toml TOML</div>\n";
1261 let ctx = create_context(content);
1262 assert_eq!(rule.check(&ctx).unwrap().len(), 5);
1263 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1264 assert!(rule.check(&create_context(expected)).unwrap().is_empty());
1265 }
1266
1267 #[test]
1268 fn file_extensions_in_markdown_preserve_suffixes_and_check_prose() {
1269 let content = "**favorite.toml** *favorite.toml* _favorite.toml_\n\
1270 [favorite.toml](other.md) [toml](favorite.toml)\n\
1271  \n\
1272 [[favorite.toml]] [[Page|favorite.toml]] [[Page|toml]]\n\
1273 `favorite.toml toml`\n\
1274 ```text\n\
1275 favorite.toml toml\n\
1276 ```\n";
1277 for code_blocks in [false, true] {
1278 let rule = MD044ProperNames::new(vec!["TOML".to_string()], code_blocks);
1279 let mut expected = content.replace("[toml]", "[TOML]").replace("|toml]]", "|TOML]]");
1280 if code_blocks {
1281 expected = expected.replace("favorite.toml toml", "favorite.toml TOML");
1282 }
1283 for flavor in [
1284 crate::config::MarkdownFlavor::Standard,
1285 crate::config::MarkdownFlavor::Obsidian,
1286 ] {
1287 let ctx = LintContext::new(content, flavor, None);
1288 assert_eq!(rule.check(&ctx).unwrap().len(), if code_blocks { 5 } else { 3 });
1289 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1290 }
1291 }
1292 }
1293
1294 #[test]
1295 fn file_extensions_in_frontmatter_preserve_suffixes_and_check_prose() {
1296 let rule = MD044ProperNames::new(["TOML", "Node.js", "MyApp"].map(str::to_string).to_vec(), false);
1297 for content in [
1298 "---\ntoml: favorite.toml\ntitle: node.js guide to toml\nimage: myapp.png\npath: docs/myapp.md\n---\n",
1299 "+++\ntoml = \"favorite.toml\"\ntitle = \"node.js guide to toml\"\nimage = \"myapp.png\"\npath = \"docs/myapp.md\"\n+++\n",
1300 ] {
1301 let expected = content
1302 .replace("node.js guide to toml", "Node.js guide to TOML")
1303 .replace("myapp.png", "MyApp.png");
1304 let ctx = create_context(content);
1305 assert_eq!(rule.check(&ctx).unwrap().len(), 3);
1306 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1307 }
1308 }
1309
1310 #[test]
1311 fn whole_words_preserves_compound_identifiers_but_checks_standalone_names() {
1312 let rule = whole_word_rule(&["ABC"]);
1313 let content = "123-abc-def\nfoo_abc_bar\nabc\n(abc) [abc]\n";
1314 let ctx = create_context(content);
1315
1316 let warnings = rule.check(&ctx).unwrap();
1317 assert_eq!(warnings.len(), 3);
1318 assert_eq!(
1319 warnings.iter().map(|warning| warning.line).collect::<Vec<_>>(),
1320 [3, 4, 4]
1321 );
1322 assert_eq!(rule.fix(&ctx).unwrap(), "123-abc-def\nfoo_abc_bar\nABC\n(ABC) [ABC]\n");
1323 }
1324
1325 #[test]
1326 fn whole_words_preserves_identifiers_inside_emphasis() {
1327 let rule = whole_word_rule(&["ABC"]);
1328 let content = "_123-abc-def_ _foo_abc_bar_ __foo__abc__bar__\n\
1329 _abc_def_ _foo_abc_\n\
1330 _abc_ __abc__ ___abc___ foo-__abc__-bar\n";
1331 let ctx = create_context(content);
1332 let warnings = rule.check(&ctx).unwrap();
1333 assert_eq!(warnings.len(), 4);
1334 assert!(warnings.iter().all(|warning| warning.line == 3));
1335 let fixed = rule.fix(&ctx).unwrap();
1336 assert_eq!(
1337 fixed,
1338 "_123-abc-def_ _foo_abc_bar_ __foo__abc__bar__\n\
1339 _abc_def_ _foo_abc_\n\
1340 _ABC_ __ABC__ ___ABC___ foo-__ABC__-bar\n"
1341 );
1342 assert!(rule.check(&create_context(&fixed)).unwrap().is_empty());
1343 assert_eq!(rule.fix(&create_context(&fixed)).unwrap(), fixed);
1344 }
1345
1346 #[test]
1347 fn whole_words_distinguishes_unicode_compounds_from_markdown_emphasis() {
1348 let rule = whole_word_rule(&["ABC"]);
1349 let content = "café_abc_menu 東京-abc-駅 foo__abc__bar _abc_ __abc__ foo-__abc__-bar\n";
1350 let ctx = create_context(content);
1351
1352 assert_eq!(rule.check(&ctx).unwrap().len(), 3);
1353 let fixed = rule.fix(&ctx).unwrap();
1354 assert_eq!(
1355 fixed,
1356 "café_abc_menu 東京-abc-駅 foo__abc__bar _ABC_ __ABC__ foo-__ABC__-bar\n"
1357 );
1358 assert_eq!(rule.fix(&create_context(&fixed)).unwrap(), fixed);
1359 }
1360
1361 #[test]
1362 fn repeated_intraword_underscores_keep_legacy_behavior_without_whole_words() {
1363 let rule = MD044ProperNames::new(vec!["ABC".to_string()], false);
1364 let content = "foo__abc__bar\n";
1365 let ctx = create_context(content);
1366
1367 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1368 assert_eq!(rule.fix(&ctx).unwrap(), "foo__ABC__bar\n");
1369 }
1370
1371 #[test]
1372 fn whole_words_respects_code_and_link_exclusions_and_multiword_names() {
1373 let rule = whole_word_rule(&["ABC", "My App"]);
1374 let content = "`abc` [label](https://example.test/abc) my app x-my app-y\n";
1375 let ctx = create_context(content);
1376
1377 let warnings = rule.check(&ctx).unwrap();
1378 assert_eq!(warnings.len(), 1);
1379 assert_eq!(warnings[0].message, "Proper name 'my app' should be 'My App'");
1380 let fixed = rule.fix(&ctx).unwrap();
1381 assert_eq!(fixed, "`abc` [label](https://example.test/abc) My App x-my app-y\n");
1382 assert_eq!(rule.fix(&create_context(&fixed)).unwrap(), fixed);
1383 }
1384
1385 #[test]
1386 fn first_configured_spelling_wins_for_colliding_variants() {
1387 for (names, content, expected) in [
1388 (["Node.js", "Nodejs"], "nodejs", "Node.js"),
1389 (["Nodejs", "Node.js"], "nodejs", "Nodejs"),
1390 (["Café", "Cafe"], "cafe", "Café"),
1391 (["Cafe", "Café"], "cafe", "Cafe"),
1392 (["Node.js", "NODE.JS"], "node.js", "Node.js"),
1393 ] {
1394 let rule = MD044ProperNames::new(names.map(str::to_string).to_vec(), false);
1395 let ctx = create_context(content);
1396 let warnings = rule.check(&ctx).unwrap();
1397 assert_eq!(warnings.len(), 1);
1398 assert_eq!(warnings[0].fix.as_ref().unwrap().replacement, expected);
1399 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1400 assert_eq!(rule.clone().check(&ctx).unwrap(), warnings);
1401 }
1402 }
1403
1404 #[test]
1405 fn test_field_map_nested_lines_inherit_top_level_key() {
1406 let map = field_map_for("---\nseo:\n canonical: docs/a.md\n keywords:\n - myapp\ntitle: x\n---\n");
1407 assert_eq!(map[2].as_deref(), Some("seo"));
1408 assert_eq!(map[4].as_deref(), Some("seo"));
1409 assert_eq!(map[5].as_deref(), Some("title"));
1410 }
1411
1412 #[test]
1413 fn test_field_map_block_scalar_bracket_does_not_swallow_next_key() {
1414 let map = field_map_for("---\ndescription: |\n [myapp\ntitle: myapp\n---\n");
1415 assert_eq!(map[2].as_deref(), Some("description"));
1416 assert_eq!(
1417 map[3].as_deref(),
1418 Some("title"),
1419 "an indent-0 key always starts a new key"
1420 );
1421 }
1422
1423 #[test]
1424 fn test_field_map_quoted_key_with_colon() {
1425 let map = field_map_for("---\n\"og:title\": myapp\n---\n");
1426 assert_eq!(map[1].as_deref(), Some("og:title"));
1427 }
1428
1429 #[test]
1430 fn test_field_map_top_level_sequence_clears_attribution() {
1431 let map = field_map_for("---\n- myapp\n---\n");
1432 assert_eq!(map[1], None);
1433 }
1434
1435 #[test]
1436 fn test_field_map_toml_table_body_belongs_to_table_root() {
1437 let map = field_map_for("+++\n[seo]\ncanonical = \"docs/a.md\"\n\n[[authors]]\nname = \"myapp\"\n+++\n");
1438 assert_eq!(map[2].as_deref(), Some("seo"));
1439 assert_eq!(map[5].as_deref(), Some("authors"));
1440 }
1441
1442 #[test]
1443 fn test_field_map_toml_dotted_assignment_uses_root() {
1444 let map = field_map_for("+++\nseo.canonical = \"docs/a.md\"\n+++\n");
1445 assert_eq!(map[1].as_deref(), Some("seo"));
1446 }
1447
1448 #[test]
1449 fn test_field_map_toml_array_continuation_inherits() {
1450 let map = field_map_for("+++\nseo = [\n\"docs/guide/myapp\"\n]\n+++\n");
1451 assert_eq!(map[2].as_deref(), Some("seo"));
1452 }
1453
1454 #[test]
1455 fn test_field_map_indent_zero_flow_continuation_is_not_attributed_to_parent() {
1456 let map = field_map_for("---\nseo: [\n{name: myapp}\n]\n---\n");
1459 assert_eq!(map[2].as_deref(), Some("{name"));
1460 }
1461
1462 #[test]
1463 fn test_field_map_toml_nested_array_inherits_and_title_not_corrupted() {
1464 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\ntitle = \"x\"\n+++\n");
1465 assert_eq!(map[2].as_deref(), Some("matrix"), "nested array line inherits matrix");
1466 assert_eq!(map[3].as_deref(), Some("matrix"), "nested array line inherits matrix");
1467 assert_eq!(
1468 map[5].as_deref(),
1469 Some("title"),
1470 "title must not inherit stale attribution from a closed nested array"
1471 );
1472 }
1473
1474 #[test]
1475 fn test_field_map_toml_nested_array_last_element_without_trailing_comma() {
1476 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [2]\n]\ntitle = \"x\"\n+++\n");
1477 assert_eq!(
1478 map[3].as_deref(),
1479 Some("matrix"),
1480 "last element without a trailing comma still inherits matrix"
1481 );
1482 assert_eq!(
1483 map[5].as_deref(),
1484 Some("title"),
1485 "title must not inherit stale attribution from a closed nested array"
1486 );
1487 }
1488
1489 #[test]
1490 fn test_field_map_toml_nested_array_then_real_table_header_non_regression_guard() {
1491 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\n\n[seo]\ncanonical = \"docs/a.md\"\n+++\n");
1498 assert_eq!(map[6].as_deref(), Some("seo"), "real table header after a closed array");
1499 assert_eq!(
1500 map[7].as_deref(),
1501 Some("seo"),
1502 "table body still attributes to the table"
1503 );
1504 }
1505
1506 #[test]
1507 fn test_field_map_toml_unclosed_array_resyncs_on_next_assignment() {
1508 let map = field_map_for("+++\nmatrix = [\n [1, 2],\ntitle = \"x\"\n+++\n");
1514 assert_eq!(
1515 map[3].as_deref(),
1516 Some("title"),
1517 "title must resync even though the array was never closed"
1518 );
1519 }
1520
1521 #[test]
1522 fn test_field_map_toml_column_zero_array_elements_inherit_and_title_not_corrupted() {
1523 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[3, 4],\n]\ntitle = \"x\"\n+++\n");
1529 assert_eq!(
1530 map[2].as_deref(),
1531 Some("matrix"),
1532 "column-0 array element inherits matrix"
1533 );
1534 assert_eq!(
1535 map[3].as_deref(),
1536 Some("matrix"),
1537 "column-0 array element inherits matrix"
1538 );
1539 assert_eq!(
1540 map[5].as_deref(),
1541 Some("title"),
1542 "title must not inherit stale attribution from a misread array element"
1543 );
1544 }
1545
1546 #[test]
1547 fn test_field_map_toml_column_zero_array_last_element_without_trailing_comma() {
1548 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[2]\n]\ntitle = \"x\"\n+++\n");
1549 assert_eq!(
1550 map[3].as_deref(),
1551 Some("matrix"),
1552 "column-0 last element without a trailing comma still inherits matrix"
1553 );
1554 assert_eq!(
1555 map[5].as_deref(),
1556 Some("title"),
1557 "title must not inherit stale attribution from a misread array element"
1558 );
1559 }
1560
1561 #[test]
1562 fn test_correctly_capitalized_names() {
1563 let rule = MD044ProperNames::new(
1564 vec![
1565 "JavaScript".to_string(),
1566 "TypeScript".to_string(),
1567 "Node.js".to_string(),
1568 ],
1569 true,
1570 );
1571
1572 let content = "This document uses JavaScript, TypeScript, and Node.js correctly.";
1573 let ctx = create_context(content);
1574 let result = rule.check(&ctx).unwrap();
1575 assert!(result.is_empty(), "Should not flag correctly capitalized names");
1576 }
1577
1578 #[test]
1579 fn test_incorrectly_capitalized_names() {
1580 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1581
1582 let content = "This document uses javascript and typescript incorrectly.";
1583 let ctx = create_context(content);
1584 let result = rule.check(&ctx).unwrap();
1585
1586 assert_eq!(result.len(), 2, "Should flag two incorrect capitalizations");
1587 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1588 assert_eq!(result[0].line, 1);
1589 assert_eq!(result[0].column, 20);
1590 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1591 assert_eq!(result[1].line, 1);
1592 assert_eq!(result[1].column, 35);
1593 }
1594
1595 #[test]
1596 fn test_names_at_beginning_of_sentences() {
1597 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "Python".to_string()], true);
1598
1599 let content = "javascript is a great language. python is also popular.";
1600 let ctx = create_context(content);
1601 let result = rule.check(&ctx).unwrap();
1602
1603 assert_eq!(result.len(), 2, "Should flag names at beginning of sentences");
1604 assert_eq!(result[0].line, 1);
1605 assert_eq!(result[0].column, 1);
1606 assert_eq!(result[1].line, 1);
1607 assert_eq!(result[1].column, 33);
1608 }
1609
1610 #[test]
1611 fn test_names_in_code_blocks_checked_by_default() {
1612 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1613
1614 let content = r#"Here is some text with JavaScript.
1615
1616```javascript
1617// This javascript should be checked
1618const lang = "javascript";
1619```
1620
1621But this javascript should be flagged."#;
1622
1623 let ctx = create_context(content);
1624 let result = rule.check(&ctx).unwrap();
1625
1626 assert_eq!(result.len(), 3, "Should flag javascript inside and outside code blocks");
1627 assert_eq!(result[0].line, 4);
1628 assert_eq!(result[1].line, 5);
1629 assert_eq!(result[2].line, 8);
1630 }
1631
1632 #[test]
1633 fn test_names_in_code_blocks_ignored_when_disabled() {
1634 let rule = MD044ProperNames::new(
1635 vec!["JavaScript".to_string()],
1636 false, );
1638
1639 let content = r#"```
1640javascript in code block
1641```"#;
1642
1643 let ctx = create_context(content);
1644 let result = rule.check(&ctx).unwrap();
1645
1646 assert_eq!(
1647 result.len(),
1648 0,
1649 "Should not flag javascript in code blocks when code_blocks is false"
1650 );
1651 }
1652
1653 #[test]
1654 fn test_names_in_inline_code_checked_by_default() {
1655 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1656
1657 let content = "This is `javascript` in inline code and javascript outside.";
1658 let ctx = create_context(content);
1659 let result = rule.check(&ctx).unwrap();
1660
1661 assert_eq!(result.len(), 2, "Should flag javascript inside and outside inline code");
1663 assert_eq!(result[0].column, 10); assert_eq!(result[1].column, 41); }
1666
1667 #[test]
1668 fn test_multiple_names_in_same_line() {
1669 let rule = MD044ProperNames::new(
1670 vec!["JavaScript".to_string(), "TypeScript".to_string(), "React".to_string()],
1671 true,
1672 );
1673
1674 let content = "I use javascript, typescript, and react in my projects.";
1675 let ctx = create_context(content);
1676 let result = rule.check(&ctx).unwrap();
1677
1678 assert_eq!(result.len(), 3, "Should flag all three incorrect names");
1679 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1680 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1681 assert_eq!(result[2].message, "Proper name 'react' should be 'React'");
1682 }
1683
1684 #[test]
1685 fn test_case_sensitivity() {
1686 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1687
1688 let content = "JAVASCRIPT, Javascript, javascript, and JavaScript variations.";
1689 let ctx = create_context(content);
1690 let result = rule.check(&ctx).unwrap();
1691
1692 assert_eq!(result.len(), 3, "Should flag all incorrect case variations");
1693 assert!(result.iter().all(|w| w.message.contains("should be 'JavaScript'")));
1695 }
1696
1697 #[test]
1698 fn test_configuration_with_custom_name_list() {
1699 let config = MD044Config {
1700 names: vec!["GitHub".to_string(), "GitLab".to_string(), "DevOps".to_string()],
1701 code_blocks: true,
1702 ..Default::default()
1703 };
1704 let rule = MD044ProperNames::from_config_struct(config);
1705
1706 let content = "We use github, gitlab, and devops for our workflow.";
1707 let ctx = create_context(content);
1708 let result = rule.check(&ctx).unwrap();
1709
1710 assert_eq!(result.len(), 3, "Should flag all custom names");
1711 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
1712 assert_eq!(result[1].message, "Proper name 'gitlab' should be 'GitLab'");
1713 assert_eq!(result[2].message, "Proper name 'devops' should be 'DevOps'");
1714 }
1715
1716 #[test]
1717 fn test_empty_configuration() {
1718 let rule = MD044ProperNames::new(vec![], true);
1719
1720 let content = "This has javascript and typescript but no configured names.";
1721 let ctx = create_context(content);
1722 let result = rule.check(&ctx).unwrap();
1723
1724 assert!(result.is_empty(), "Should not flag anything with empty configuration");
1725 }
1726
1727 #[test]
1728 fn test_names_with_special_characters() {
1729 let rule = MD044ProperNames::new(
1730 vec!["Node.js".to_string(), "ASP.NET".to_string(), "C++".to_string()],
1731 true,
1732 );
1733
1734 let content = "We use nodejs, asp.net, ASP.NET, and c++ in our stack.";
1735 let ctx = create_context(content);
1736 let result = rule.check(&ctx).unwrap();
1737
1738 assert_eq!(result.len(), 3, "Should handle special characters correctly");
1743
1744 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
1745 assert!(messages.contains(&"Proper name 'nodejs' should be 'Node.js'"));
1746 assert!(messages.contains(&"Proper name 'asp.net' should be 'ASP.NET'"));
1747 assert!(messages.contains(&"Proper name 'c++' should be 'C++'"));
1748 }
1749
1750 #[test]
1751 fn test_word_boundaries() {
1752 let rule = MD044ProperNames::new(vec!["Java".to_string(), "Script".to_string()], true);
1753
1754 let content = "JavaScript is not java or script, but Java and Script are separate.";
1755 let ctx = create_context(content);
1756 let result = rule.check(&ctx).unwrap();
1757
1758 assert_eq!(result.len(), 2, "Should respect word boundaries");
1760 assert!(result.iter().any(|w| w.column == 19)); assert!(result.iter().any(|w| w.column == 27)); }
1763
1764 #[test]
1765 fn test_fix_method() {
1766 let rule = MD044ProperNames::new(
1767 vec![
1768 "JavaScript".to_string(),
1769 "TypeScript".to_string(),
1770 "Node.js".to_string(),
1771 ],
1772 true,
1773 );
1774
1775 let content = "I love javascript, typescript, and nodejs!";
1776 let ctx = create_context(content);
1777 let fixed = rule.fix(&ctx).unwrap();
1778
1779 assert_eq!(fixed, "I love JavaScript, TypeScript, and Node.js!");
1780 }
1781
1782 #[test]
1783 fn test_fix_multiple_occurrences() {
1784 let rule = MD044ProperNames::new(vec!["Python".to_string()], true);
1785
1786 let content = "python is great. I use python daily. PYTHON is powerful.";
1787 let ctx = create_context(content);
1788 let fixed = rule.fix(&ctx).unwrap();
1789
1790 assert_eq!(fixed, "Python is great. I use Python daily. Python is powerful.");
1791 }
1792
1793 #[test]
1794 fn test_fix_checks_code_blocks_by_default() {
1795 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1796
1797 let content = r#"I love javascript.
1798
1799```
1800const lang = "javascript";
1801```
1802
1803More javascript here."#;
1804
1805 let ctx = create_context(content);
1806 let fixed = rule.fix(&ctx).unwrap();
1807
1808 let expected = r#"I love JavaScript.
1809
1810```
1811const lang = "JavaScript";
1812```
1813
1814More JavaScript here."#;
1815
1816 assert_eq!(fixed, expected);
1817 }
1818
1819 #[test]
1820 fn test_multiline_content() {
1821 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
1822
1823 let content = r#"First line with rust.
1824Second line with python.
1825Third line with RUST and PYTHON."#;
1826
1827 let ctx = create_context(content);
1828 let result = rule.check(&ctx).unwrap();
1829
1830 assert_eq!(result.len(), 4, "Should flag all incorrect occurrences");
1831 assert_eq!(result[0].line, 1);
1832 assert_eq!(result[1].line, 2);
1833 assert_eq!(result[2].line, 3);
1834 assert_eq!(result[3].line, 3);
1835 }
1836
1837 #[test]
1838 fn test_default_config() {
1839 let config = MD044Config::default();
1840 assert!(config.names.is_empty());
1841 assert!(!config.code_blocks);
1842 assert!(config.html_elements);
1843 assert!(config.html_comments);
1844 }
1845
1846 #[test]
1847 fn test_default_config_checks_html_comments() {
1848 let config = MD044Config {
1849 names: vec!["JavaScript".to_string()],
1850 ..MD044Config::default()
1851 };
1852 let rule = MD044ProperNames::from_config_struct(config);
1853
1854 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1855 let ctx = create_context(content);
1856 let result = rule.check(&ctx).unwrap();
1857
1858 assert_eq!(result.len(), 1, "Default config should check HTML comments");
1859 assert_eq!(result[0].line, 3);
1860 }
1861
1862 #[test]
1863 fn test_default_config_skips_code_blocks() {
1864 let config = MD044Config {
1865 names: vec!["JavaScript".to_string()],
1866 ..MD044Config::default()
1867 };
1868 let rule = MD044ProperNames::from_config_struct(config);
1869
1870 let content = "# Guide\n\n```\njavascript in code\n```\n";
1871 let ctx = create_context(content);
1872 let result = rule.check(&ctx).unwrap();
1873
1874 assert_eq!(result.len(), 0, "Default config should skip code blocks");
1875 }
1876
1877 #[test]
1878 fn test_standalone_html_comment_checked() {
1879 let config = MD044Config {
1880 names: vec!["Test".to_string()],
1881 ..MD044Config::default()
1882 };
1883 let rule = MD044ProperNames::from_config_struct(config);
1884
1885 let content = "# Heading\n\n<!-- this is a test example -->\n";
1886 let ctx = create_context(content);
1887 let result = rule.check(&ctx).unwrap();
1888
1889 assert_eq!(result.len(), 1, "Should flag proper name in standalone HTML comment");
1890 assert_eq!(result[0].line, 3);
1891 }
1892
1893 #[test]
1894 fn test_inline_config_comments_not_flagged() {
1895 let config = MD044Config {
1896 names: vec!["RUMDL".to_string()],
1897 ..MD044Config::default()
1898 };
1899 let rule = MD044ProperNames::from_config_struct(config);
1900
1901 let content = "<!-- rumdl-disable MD044 -->\nSome rumdl text here.\n<!-- rumdl-enable MD044 -->\n<!-- markdownlint-disable -->\nMore rumdl text.\n<!-- markdownlint-enable -->\n";
1905 let ctx = create_context(content);
1906 let result = rule.check(&ctx).unwrap();
1907
1908 assert_eq!(result.len(), 2, "Should only flag body lines, not config comments");
1909 assert_eq!(result[0].line, 2);
1910 assert_eq!(result[1].line, 5);
1911 }
1912
1913 #[test]
1914 fn test_compact_inline_config_comments_not_flagged() {
1915 let config = MD044Config {
1916 names: vec!["RUMDL".to_string(), "Markdownlint".to_string()],
1917 ..MD044Config::default()
1918 };
1919 let rule = MD044ProperNames::from_config_struct(config);
1920
1921 let content = "<!--rumdl-disable MD044-->\nSome rumdl text.\n<!--markdownlint-enable MD044 -->\n<!--\trumdl-capture-->\nMore rumdl text.\n<!-- note markdownlint -->\n";
1924 let ctx = create_context(content);
1925 let result = rule.check(&ctx).unwrap();
1926 let lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1927 assert_eq!(lines, vec![2, 5, 6], "{result:?}");
1928 }
1929
1930 #[test]
1931 fn test_html_comment_skipped_when_disabled() {
1932 let config = MD044Config {
1933 names: vec!["Test".to_string()],
1934 code_blocks: true,
1935 html_comments: false,
1936 ..Default::default()
1937 };
1938 let rule = MD044ProperNames::from_config_struct(config);
1939
1940 let content = "# Heading\n\n<!-- this is a test example -->\n\nRegular test here.\n";
1941 let ctx = create_context(content);
1942 let result = rule.check(&ctx).unwrap();
1943
1944 assert_eq!(
1945 result.len(),
1946 1,
1947 "Should only flag 'test' outside HTML comment when html_comments=false"
1948 );
1949 assert_eq!(result[0].line, 5);
1950 }
1951
1952 #[test]
1953 fn test_fix_corrects_html_comment_content() {
1954 let config = MD044Config {
1955 names: vec!["JavaScript".to_string()],
1956 ..MD044Config::default()
1957 };
1958 let rule = MD044ProperNames::from_config_struct(config);
1959
1960 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1961 let ctx = create_context(content);
1962 let fixed = rule.fix(&ctx).unwrap();
1963
1964 assert_eq!(fixed, "# Guide\n\n<!-- JavaScript mentioned here -->\n");
1965 }
1966
1967 #[test]
1968 fn test_fix_does_not_modify_inline_config_comments() {
1969 let config = MD044Config {
1970 names: vec!["RUMDL".to_string()],
1971 ..MD044Config::default()
1972 };
1973 let rule = MD044ProperNames::from_config_struct(config);
1974
1975 let content = "<!-- rumdl-disable -->\nSome rumdl text.\n<!-- rumdl-enable -->\n";
1976 let ctx = create_context(content);
1977 let fixed = rule.fix(&ctx).unwrap();
1978
1979 assert!(fixed.contains("<!-- rumdl-disable -->"));
1981 assert!(fixed.contains("<!-- rumdl-enable -->"));
1982 assert!(
1984 fixed.contains("Some rumdl text."),
1985 "Line inside rumdl-disable block should not be modified by fix()"
1986 );
1987 }
1988
1989 #[test]
1990 fn test_fix_respects_inline_disable_partial() {
1991 let config = MD044Config {
1992 names: vec!["RUMDL".to_string()],
1993 ..MD044Config::default()
1994 };
1995 let rule = MD044ProperNames::from_config_struct(config);
1996
1997 let content =
1998 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
1999 let ctx = create_context(content);
2000 let fixed = rule.fix(&ctx).unwrap();
2001
2002 assert!(
2004 fixed.contains("Some rumdl text.\n<!-- rumdl-enable"),
2005 "Line inside disable block should not be modified"
2006 );
2007 assert!(
2009 fixed.contains("Some RUMDL text outside."),
2010 "Line outside disable block should be fixed"
2011 );
2012 }
2013
2014 #[test]
2015 fn test_performance_with_many_names() {
2016 let mut names = vec![];
2017 for i in 0..50 {
2018 names.push(format!("ProperName{i}"));
2019 }
2020
2021 let rule = MD044ProperNames::new(names, true);
2022
2023 let content = "This has propername0, propername25, and propername49 incorrectly.";
2024 let ctx = create_context(content);
2025 let result = rule.check(&ctx).unwrap();
2026
2027 assert_eq!(result.len(), 3, "Should handle many configured names efficiently");
2028 }
2029
2030 #[test]
2031 fn test_large_name_count_performance() {
2032 let names = (0..1000).map(|i| format!("ProperName{i}")).collect::<Vec<_>>();
2035
2036 let rule = MD044ProperNames::new(names, true);
2037
2038 assert!(rule.combined_pattern.is_some());
2040
2041 let content = "This has propername0 and propername999 in it.";
2043 let ctx = create_context(content);
2044 let result = rule.check(&ctx).unwrap();
2045
2046 assert_eq!(result.len(), 2, "Should handle 1000 names without issues");
2048 }
2049
2050 #[test]
2051 fn test_cache_behavior() {
2052 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2053
2054 let content = "Using javascript here.";
2055 let ctx = create_context(content);
2056
2057 let result1 = rule.check(&ctx).unwrap();
2059 assert_eq!(result1.len(), 1);
2060
2061 let result2 = rule.check(&ctx).unwrap();
2063 assert_eq!(result2.len(), 1);
2064
2065 assert_eq!(result1[0].line, result2[0].line);
2067 assert_eq!(result1[0].column, result2[0].column);
2068 }
2069
2070 #[test]
2071 fn test_html_comments_not_checked_when_disabled() {
2072 let config = MD044Config {
2073 names: vec!["JavaScript".to_string()],
2074 code_blocks: true, html_comments: false, ..Default::default()
2077 };
2078 let rule = MD044ProperNames::from_config_struct(config);
2079
2080 let content = r#"Regular javascript here.
2081<!-- This javascript in HTML comment should be ignored -->
2082More javascript outside."#;
2083
2084 let ctx = create_context(content);
2085 let result = rule.check(&ctx).unwrap();
2086
2087 assert_eq!(result.len(), 2, "Should only flag javascript outside HTML comments");
2088 assert_eq!(result[0].line, 1);
2089 assert_eq!(result[1].line, 3);
2090 }
2091
2092 #[test]
2093 fn test_html_comments_checked_when_enabled() {
2094 let config = MD044Config {
2095 names: vec!["JavaScript".to_string()],
2096 code_blocks: true, ..Default::default()
2098 };
2099 let rule = MD044ProperNames::from_config_struct(config);
2100
2101 let content = r#"Regular javascript here.
2102<!-- This javascript in HTML comment should be checked -->
2103More javascript outside."#;
2104
2105 let ctx = create_context(content);
2106 let result = rule.check(&ctx).unwrap();
2107
2108 assert_eq!(
2109 result.len(),
2110 3,
2111 "Should flag all javascript occurrences including in HTML comments"
2112 );
2113 }
2114
2115 #[test]
2116 fn test_indented_html_comment_escapes_via_link_and_backticks() {
2117 let config = MD044Config {
2122 names: vec!["Test".to_string()],
2123 ..Default::default()
2124 };
2125 let rule = MD044ProperNames::from_config_struct(config);
2126
2127 let content = "<!-- see the [relevant page](test.md). -->\n<!-- see `test.md` -->\n <!-- see the [relevant page](test.md). -->\n <!-- see `test.md` -->\n";
2128
2129 let ctx = create_context(content);
2130 let result = rule.check(&ctx).unwrap();
2131
2132 assert!(
2133 result.is_empty(),
2134 "'test' inside a link URL or backticks must be ignored in both column-0 and indented comments, got: {result:?}"
2135 );
2136 }
2137
2138 #[test]
2139 fn test_indented_html_comment_still_checks_bare_prose() {
2140 let config = MD044Config {
2143 names: vec!["Test".to_string()],
2144 ..Default::default()
2145 };
2146 let rule = MD044ProperNames::from_config_struct(config);
2147
2148 let content = " <!-- this is a test comment -->\n";
2149
2150 let ctx = create_context(content);
2151 let result = rule.check(&ctx).unwrap();
2152
2153 assert_eq!(
2154 result.len(),
2155 1,
2156 "bare 'test' in an indented comment is still a violation"
2157 );
2158 assert_eq!(result[0].line, 1);
2159 }
2160
2161 #[test]
2162 fn test_multiline_html_comments() {
2163 let config = MD044Config {
2164 names: vec!["Python".to_string(), "JavaScript".to_string()],
2165 code_blocks: true, html_comments: false, ..Default::default()
2168 };
2169 let rule = MD044ProperNames::from_config_struct(config);
2170
2171 let content = r#"Regular python here.
2172<!--
2173This is a multiline comment
2174with javascript and python
2175that should be ignored
2176-->
2177More javascript outside."#;
2178
2179 let ctx = create_context(content);
2180 let result = rule.check(&ctx).unwrap();
2181
2182 assert_eq!(result.len(), 2, "Should only flag names outside HTML comments");
2183 assert_eq!(result[0].line, 1); assert_eq!(result[1].line, 7); }
2186
2187 #[test]
2188 fn test_fix_preserves_html_comments_when_disabled() {
2189 let config = MD044Config {
2190 names: vec!["JavaScript".to_string()],
2191 code_blocks: true, html_comments: false, ..Default::default()
2194 };
2195 let rule = MD044ProperNames::from_config_struct(config);
2196
2197 let content = r#"javascript here.
2198<!-- javascript in comment -->
2199More javascript."#;
2200
2201 let ctx = create_context(content);
2202 let fixed = rule.fix(&ctx).unwrap();
2203
2204 let expected = r#"JavaScript here.
2205<!-- javascript in comment -->
2206More JavaScript."#;
2207
2208 assert_eq!(
2209 fixed, expected,
2210 "Should not fix names inside HTML comments when disabled"
2211 );
2212 }
2213
2214 #[test]
2215 fn test_proper_names_in_link_text_are_flagged() {
2216 let rule = MD044ProperNames::new(
2217 vec!["JavaScript".to_string(), "Node.js".to_string(), "Python".to_string()],
2218 true,
2219 );
2220
2221 let content = r#"Check this [javascript documentation](https://javascript.info) for info.
2222
2223Visit [node.js homepage](https://nodejs.org) and [python tutorial](https://python.org).
2224
2225Real javascript should be flagged.
2226
2227Also see the [typescript guide][ts-ref] for more.
2228
2229Real python should be flagged too.
2230
2231[ts-ref]: https://typescript.org/handbook"#;
2232
2233 let ctx = create_context(content);
2234 let result = rule.check(&ctx).unwrap();
2235
2236 assert_eq!(result.len(), 5, "Expected 5 warnings: 3 in link text + 2 standalone");
2243
2244 let line_1_warnings: Vec<_> = result.iter().filter(|w| w.line == 1).collect();
2246 assert_eq!(line_1_warnings.len(), 1);
2247 assert!(
2248 line_1_warnings[0]
2249 .message
2250 .contains("'javascript' should be 'JavaScript'")
2251 );
2252
2253 let line_3_warnings: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
2254 assert_eq!(line_3_warnings.len(), 2); assert!(result.iter().any(|w| w.line == 5 && w.message.contains("'javascript'")));
2258 assert!(result.iter().any(|w| w.line == 9 && w.message.contains("'python'")));
2259 }
2260
2261 #[test]
2262 fn test_link_urls_not_flagged() {
2263 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2264
2265 let content = r#"[Link Text](https://javascript.info/guide)"#;
2267
2268 let ctx = create_context(content);
2269 let result = rule.check(&ctx).unwrap();
2270
2271 assert!(result.is_empty(), "URLs should not be checked for proper names");
2273 }
2274
2275 #[test]
2276 fn test_bare_urls_not_flagged() {
2277 let rule = MD044ProperNames::new(vec!["Foo".to_string(), "JavaScript".to_string()], true);
2278
2279 let content =
2282 "https://foo.com\n\nSee https://javascript.info/foo/guide for details.\n\nMail foo@foo.com about it.\n";
2283
2284 let ctx = create_context(content);
2285 let result = rule.check(&ctx).unwrap();
2286
2287 assert!(
2288 result.is_empty(),
2289 "Bare URLs and emails should not be checked for proper names: {result:?}"
2290 );
2291 }
2292
2293 #[test]
2294 fn test_prose_around_bare_url_still_flagged() {
2295 let rule = MD044ProperNames::new(vec!["Foo".to_string()], true);
2296
2297 let content = "Use foo at https://foo.com because foo is great.\n";
2300
2301 let ctx = create_context(content);
2302 let result = rule.check(&ctx).unwrap();
2303
2304 assert_eq!(
2305 result.len(),
2306 2,
2307 "Prose occurrences around a bare URL must still be flagged: {result:?}"
2308 );
2309 assert!(result.iter().all(|w| w.message.contains("'foo' should be 'Foo'")));
2310 }
2311
2312 #[test]
2313 fn test_proper_names_in_image_alt_text_are_flagged() {
2314 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2315
2316 let content = r#"Here is a  image.
2317
2318Real javascript should be flagged."#;
2319
2320 let ctx = create_context(content);
2321 let result = rule.check(&ctx).unwrap();
2322
2323 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in alt text + 1 standalone");
2327 assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
2328 assert!(result[0].line == 1); assert!(result[1].message.contains("'javascript' should be 'JavaScript'"));
2330 assert!(result[1].line == 3); }
2332
2333 #[test]
2334 fn test_image_urls_not_flagged() {
2335 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2336
2337 let content = r#""#;
2339
2340 let ctx = create_context(content);
2341 let result = rule.check(&ctx).unwrap();
2342
2343 assert!(result.is_empty(), "Image URLs should not be checked for proper names");
2345 }
2346
2347 #[test]
2348 fn test_reference_link_text_flagged_but_definition_not() {
2349 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2350
2351 let content = r#"Check the [javascript guide][js-ref] for details.
2352
2353Real javascript should be flagged.
2354
2355[js-ref]: https://javascript.info/typescript/guide"#;
2356
2357 let ctx = create_context(content);
2358 let result = rule.check(&ctx).unwrap();
2359
2360 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in link text + 1 standalone");
2365 assert!(result.iter().any(|w| w.line == 1 && w.message.contains("'javascript'")));
2366 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2367 }
2368
2369 #[test]
2370 fn test_reference_definitions_not_flagged() {
2371 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2372
2373 let content = r#"[js-ref]: https://javascript.info/guide"#;
2375
2376 let ctx = create_context(content);
2377 let result = rule.check(&ctx).unwrap();
2378
2379 assert!(result.is_empty(), "Reference definitions should not be checked");
2381 }
2382
2383 #[test]
2384 fn test_wikilinks_text_is_flagged() {
2385 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2386
2387 let content = r#"[[javascript]]
2389
2390Regular javascript here.
2391
2392[[JavaScript|display text]]"#;
2393
2394 let ctx = create_context(content);
2395 let result = rule.check(&ctx).unwrap();
2396
2397 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in WikiLink + 1 standalone");
2401 assert!(
2402 result
2403 .iter()
2404 .any(|w| w.line == 1 && w.column == 3 && w.message.contains("'javascript'"))
2405 );
2406 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2407 }
2408
2409 #[test]
2410 fn test_url_link_text_not_flagged() {
2411 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2412
2413 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2415
2416[http://github.com/org/repo](http://github.com/org/repo)
2417
2418[www.github.com/org/repo](https://www.github.com/org/repo)"#;
2419
2420 let ctx = create_context(content);
2421 let result = rule.check(&ctx).unwrap();
2422
2423 assert!(
2424 result.is_empty(),
2425 "URL-like link text should not be flagged, got: {result:?}"
2426 );
2427 }
2428
2429 #[test]
2430 fn test_url_link_text_with_leading_space_not_flagged() {
2431 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2432
2433 let content = r#"[ https://github.com/org/repo](https://github.com/org/repo)"#;
2435
2436 let ctx = create_context(content);
2437 let result = rule.check(&ctx).unwrap();
2438
2439 assert!(
2440 result.is_empty(),
2441 "URL-like link text with leading space should not be flagged, got: {result:?}"
2442 );
2443 }
2444
2445 #[test]
2446 fn test_url_link_text_uppercase_scheme_not_flagged() {
2447 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2448
2449 let content = r#"[HTTPS://GITHUB.COM/org/repo](https://github.com/org/repo)"#;
2450
2451 let ctx = create_context(content);
2452 let result = rule.check(&ctx).unwrap();
2453
2454 assert!(
2455 result.is_empty(),
2456 "URL-like link text with uppercase scheme should not be flagged, got: {result:?}"
2457 );
2458 }
2459
2460 #[test]
2461 fn test_non_url_link_text_still_flagged() {
2462 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2463
2464 let content = r#"[github.com/org/repo](https://github.com/org/repo)
2468
2469[Visit github](https://github.com/org/repo)
2470
2471[//github.com/org/repo](//github.com/org/repo)
2472
2473[ftp://github.com/org/repo](ftp://github.com/org/repo)"#;
2474
2475 let ctx = create_context(content);
2476 let result = rule.check(&ctx).unwrap();
2477
2478 assert_eq!(
2483 result.len(),
2484 1,
2485 "Only prose link text should be flagged, got: {result:?}"
2486 );
2487 assert!(
2488 result.iter().any(|w| w.line == 3),
2489 "Expected 'Visit github' on line 3 to be flagged"
2490 );
2491 }
2492
2493 #[test]
2494 fn test_url_link_text_fix_not_applied() {
2495 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2496
2497 let content = "[https://github.com/org/repo](https://github.com/org/repo)\n";
2498
2499 let ctx = create_context(content);
2500 let result = rule.fix(&ctx).unwrap();
2501
2502 assert_eq!(result, content, "Fix should not modify URL-like link text");
2503 }
2504
2505 #[test]
2506 fn test_mixed_url_and_regular_link_text() {
2507 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2508
2509 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2511
2512Visit [github documentation](https://github.com/docs) for details.
2513
2514[www.github.com/pricing](https://www.github.com/pricing)"#;
2515
2516 let ctx = create_context(content);
2517 let result = rule.check(&ctx).unwrap();
2518
2519 assert_eq!(
2521 result.len(),
2522 1,
2523 "Only non-URL link text should be flagged, got: {result:?}"
2524 );
2525 assert_eq!(result[0].line, 3);
2526 }
2527
2528 #[test]
2529 fn test_html_attribute_values_not_flagged() {
2530 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2533 let content = "# Heading\n\ntest\n\n<img src=\"www.example.test/test_image.png\">\n";
2534 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2535 let result = rule.check(&ctx).unwrap();
2536
2537 let line5_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2539 assert!(
2540 line5_violations.is_empty(),
2541 "Should not flag anything inside HTML tag attributes: {line5_violations:?}"
2542 );
2543
2544 let line3_violations: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
2546 assert_eq!(line3_violations.len(), 1, "Plain 'test' on line 3 should be flagged");
2547 }
2548
2549 #[test]
2550 fn test_html_text_content_still_flagged() {
2551 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2553 let content = "# Heading\n\n<a href=\"https://example.test/page\">test link</a>\n";
2554 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2555 let result = rule.check(&ctx).unwrap();
2556
2557 assert_eq!(
2560 result.len(),
2561 1,
2562 "Should flag only 'test' in anchor text, not in href: {result:?}"
2563 );
2564 assert_eq!(result[0].column, 37, "Should flag col 37 ('test link' in anchor text)");
2565 }
2566
2567 #[test]
2568 fn test_html_attribute_various_not_flagged() {
2569 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2571 let content = concat!(
2572 "# Heading\n\n",
2573 "<img src=\"test.png\" alt=\"test image\">\n",
2574 "<span class=\"test-class\" data-test=\"value\">test content</span>\n",
2575 );
2576 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2577 let result = rule.check(&ctx).unwrap();
2578
2579 assert_eq!(
2581 result.len(),
2582 1,
2583 "Should flag only 'test content' between tags: {result:?}"
2584 );
2585 assert_eq!(result[0].line, 4);
2586 }
2587
2588 #[test]
2589 fn test_plain_text_underscore_boundary_unchanged() {
2590 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2593 let content = "# Heading\n\ntest_image is here and just_test ends here\n";
2594 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2595 let result = rule.check(&ctx).unwrap();
2596
2597 assert_eq!(
2600 result.len(),
2601 2,
2602 "Should flag 'test' in both 'test_image' and 'just_test': {result:?}"
2603 );
2604 let cols: Vec<usize> = result.iter().map(|w| w.column).collect();
2605 assert!(cols.contains(&1), "Should flag col 1 (test_image): {cols:?}");
2606 assert!(cols.contains(&29), "Should flag col 29 (just_test): {cols:?}");
2607 }
2608
2609 #[test]
2610 fn test_frontmatter_yaml_keys_not_flagged() {
2611 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2614
2615 let content = "---\ntitle: Heading\ntest: Some Test value\n---\n\nTest\n";
2616 let ctx = create_context(content);
2617 let result = rule.check(&ctx).unwrap();
2618
2619 assert!(
2623 result.is_empty(),
2624 "Should not flag YAML keys or correctly capitalized values: {result:?}"
2625 );
2626 }
2627
2628 #[test]
2629 fn test_frontmatter_yaml_values_flagged() {
2630 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2632
2633 let content = "---\ntitle: Heading\nkey: a test value\n---\n\nTest\n";
2634 let ctx = create_context(content);
2635 let result = rule.check(&ctx).unwrap();
2636
2637 assert_eq!(result.len(), 1, "Should flag 'test' in YAML value: {result:?}");
2639 assert_eq!(result[0].line, 3);
2640 assert_eq!(result[0].column, 8); }
2642
2643 #[test]
2644 fn test_frontmatter_key_matches_name_not_flagged() {
2645 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2647
2648 let content = "---\ntest: other value\n---\n\nBody text\n";
2649 let ctx = create_context(content);
2650 let result = rule.check(&ctx).unwrap();
2651
2652 assert!(
2653 result.is_empty(),
2654 "Should not flag YAML key that matches configured name: {result:?}"
2655 );
2656 }
2657
2658 #[test]
2659 fn test_frontmatter_empty_value_not_flagged() {
2660 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2662
2663 let content = "---\ntest:\ntest: \n---\n\nBody text\n";
2664 let ctx = create_context(content);
2665 let result = rule.check(&ctx).unwrap();
2666
2667 assert!(
2668 result.is_empty(),
2669 "Should not flag YAML keys with empty values: {result:?}"
2670 );
2671 }
2672
2673 #[test]
2674 fn test_frontmatter_nested_yaml_key_not_flagged() {
2675 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2677
2678 let content = "---\nparent:\n test: nested value\n---\n\nBody text\n";
2679 let ctx = create_context(content);
2680 let result = rule.check(&ctx).unwrap();
2681
2682 assert!(result.is_empty(), "Should not flag nested YAML keys: {result:?}");
2684 }
2685
2686 #[test]
2687 fn test_frontmatter_list_items_checked() {
2688 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2690
2691 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2692 let ctx = create_context(content);
2693 let result = rule.check(&ctx).unwrap();
2694
2695 assert_eq!(result.len(), 1, "Should flag 'test' in YAML list item: {result:?}");
2697 assert_eq!(result[0].line, 3);
2698 }
2699
2700 #[test]
2701 fn test_frontmatter_value_with_multiple_colons() {
2702 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2704
2705 let content = "---\ntest: description: a test thing\n---\n\nBody text\n";
2706 let ctx = create_context(content);
2707 let result = rule.check(&ctx).unwrap();
2708
2709 assert_eq!(
2712 result.len(),
2713 1,
2714 "Should flag 'test' in value after first colon: {result:?}"
2715 );
2716 assert_eq!(result[0].line, 2);
2717 assert!(result[0].column > 6, "Violation column should be in value portion");
2718 }
2719
2720 #[test]
2721 fn test_frontmatter_does_not_affect_body() {
2722 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2724
2725 let content = "---\ntitle: Heading\n---\n\ntest should be flagged here\n";
2726 let ctx = create_context(content);
2727 let result = rule.check(&ctx).unwrap();
2728
2729 assert_eq!(result.len(), 1, "Should flag 'test' in body text: {result:?}");
2730 assert_eq!(result[0].line, 5);
2731 }
2732
2733 #[test]
2734 fn test_frontmatter_fix_corrects_values_preserves_keys() {
2735 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2737
2738 let content = "---\ntest: a test value\n---\n\ntest here\n";
2739 let ctx = create_context(content);
2740 let fixed = rule.fix(&ctx).unwrap();
2741
2742 assert_eq!(fixed, "---\ntest: a Test value\n---\n\nTest here\n");
2744 }
2745
2746 #[test]
2747 fn test_frontmatter_multiword_value_flagged() {
2748 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2750
2751 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2752 let ctx = create_context(content);
2753 let result = rule.check(&ctx).unwrap();
2754
2755 assert_eq!(result.len(), 2, "Should flag both names in YAML value: {result:?}");
2756 assert!(result.iter().all(|w| w.line == 2));
2757 }
2758
2759 #[test]
2760 fn test_frontmatter_yaml_comments_not_checked() {
2761 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2763
2764 let content = "---\n# test comment\ntitle: Heading\n---\n\nBody text\n";
2765 let ctx = create_context(content);
2766 let result = rule.check(&ctx).unwrap();
2767
2768 assert!(result.is_empty(), "Should not flag names in YAML comments: {result:?}");
2769 }
2770
2771 #[test]
2772 fn test_frontmatter_delimiters_not_checked() {
2773 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2775
2776 let content = "---\ntitle: Heading\n---\n\ntest here\n";
2777 let ctx = create_context(content);
2778 let result = rule.check(&ctx).unwrap();
2779
2780 assert_eq!(result.len(), 1, "Should only flag body text: {result:?}");
2782 assert_eq!(result[0].line, 5);
2783 }
2784
2785 #[test]
2786 fn test_frontmatter_continuation_lines_checked() {
2787 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2789
2790 let content = "---\ndescription: >\n a test value\n continued here\n---\n\nBody\n";
2791 let ctx = create_context(content);
2792 let result = rule.check(&ctx).unwrap();
2793
2794 assert_eq!(result.len(), 1, "Should flag 'test' in continuation line: {result:?}");
2796 assert_eq!(result[0].line, 3);
2797 }
2798
2799 #[test]
2800 fn test_frontmatter_quoted_values_checked() {
2801 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2803
2804 let content = "---\ntitle: \"a test title\"\n---\n\nBody\n";
2805 let ctx = create_context(content);
2806 let result = rule.check(&ctx).unwrap();
2807
2808 assert_eq!(result.len(), 1, "Should flag 'test' in quoted YAML value: {result:?}");
2809 assert_eq!(result[0].line, 2);
2810 }
2811
2812 #[test]
2813 fn test_frontmatter_single_quoted_values_checked() {
2814 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2816
2817 let content = "---\ntitle: 'a test title'\n---\n\nBody\n";
2818 let ctx = create_context(content);
2819 let result = rule.check(&ctx).unwrap();
2820
2821 assert_eq!(
2822 result.len(),
2823 1,
2824 "Should flag 'test' in single-quoted YAML value: {result:?}"
2825 );
2826 assert_eq!(result[0].line, 2);
2827 }
2828
2829 #[test]
2830 fn test_frontmatter_fix_multiword_values() {
2831 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2833
2834 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2835 let ctx = create_context(content);
2836 let fixed = rule.fix(&ctx).unwrap();
2837
2838 assert_eq!(
2839 fixed,
2840 "---\ndescription: Learn JavaScript and TypeScript\n---\n\nBody\n"
2841 );
2842 }
2843
2844 #[test]
2845 fn test_frontmatter_fix_preserves_yaml_structure() {
2846 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2848
2849 let content = "---\ntags:\n - test\n - other\ntitle: a test doc\n---\n\ntest body\n";
2850 let ctx = create_context(content);
2851 let fixed = rule.fix(&ctx).unwrap();
2852
2853 assert_eq!(
2854 fixed,
2855 "---\ntags:\n - Test\n - other\ntitle: a Test doc\n---\n\nTest body\n"
2856 );
2857 }
2858
2859 #[test]
2860 fn test_frontmatter_toml_delimiters_not_checked() {
2861 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2863
2864 let content = "+++\ntitle = \"a test title\"\n+++\n\ntest body\n";
2865 let ctx = create_context(content);
2866 let result = rule.check(&ctx).unwrap();
2867
2868 assert_eq!(result.len(), 2, "Should flag TOML value and body: {result:?}");
2872 let fm_violations: Vec<_> = result.iter().filter(|w| w.line == 2).collect();
2873 assert_eq!(fm_violations.len(), 1, "Should flag 'test' in TOML value: {result:?}");
2874 let body_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2875 assert_eq!(body_violations.len(), 1, "Should flag body 'test': {result:?}");
2876 }
2877
2878 #[test]
2879 fn test_frontmatter_toml_key_not_flagged() {
2880 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2882
2883 let content = "+++\ntest = \"other value\"\n+++\n\nBody text\n";
2884 let ctx = create_context(content);
2885 let result = rule.check(&ctx).unwrap();
2886
2887 assert!(
2888 result.is_empty(),
2889 "Should not flag TOML key that matches configured name: {result:?}"
2890 );
2891 }
2892
2893 #[test]
2894 fn test_frontmatter_toml_fix_preserves_keys() {
2895 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2897
2898 let content = "+++\ntest = \"a test value\"\n+++\n\ntest here\n";
2899 let ctx = create_context(content);
2900 let fixed = rule.fix(&ctx).unwrap();
2901
2902 assert_eq!(fixed, "+++\ntest = \"a Test value\"\n+++\n\nTest here\n");
2904 }
2905
2906 #[test]
2907 fn test_frontmatter_list_item_mapping_key_not_flagged() {
2908 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2911
2912 let content = "---\nitems:\n - test: nested value\n---\n\nBody text\n";
2913 let ctx = create_context(content);
2914 let result = rule.check(&ctx).unwrap();
2915
2916 assert!(
2917 result.is_empty(),
2918 "Should not flag YAML key in list-item mapping: {result:?}"
2919 );
2920 }
2921
2922 #[test]
2923 fn test_frontmatter_list_item_mapping_value_flagged() {
2924 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2926
2927 let content = "---\nitems:\n - key: a test value\n---\n\nBody text\n";
2928 let ctx = create_context(content);
2929 let result = rule.check(&ctx).unwrap();
2930
2931 assert_eq!(
2932 result.len(),
2933 1,
2934 "Should flag 'test' in list-item mapping value: {result:?}"
2935 );
2936 assert_eq!(result[0].line, 3);
2937 }
2938
2939 #[test]
2940 fn test_frontmatter_bare_list_item_still_flagged() {
2941 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2943
2944 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2945 let ctx = create_context(content);
2946 let result = rule.check(&ctx).unwrap();
2947
2948 assert_eq!(result.len(), 1, "Should flag 'test' in bare list item: {result:?}");
2949 assert_eq!(result[0].line, 3);
2950 }
2951
2952 #[test]
2953 fn test_frontmatter_flow_mapping_not_flagged() {
2954 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2957
2958 let content = "---\nflow_map: {test: value, other: test}\n---\n\nBody text\n";
2959 let ctx = create_context(content);
2960 let result = rule.check(&ctx).unwrap();
2961
2962 assert!(
2963 result.is_empty(),
2964 "Should not flag names inside flow mappings: {result:?}"
2965 );
2966 }
2967
2968 #[test]
2969 fn test_frontmatter_flow_sequence_not_flagged() {
2970 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2972
2973 let content = "---\nitems: [test, other, test]\n---\n\nBody text\n";
2974 let ctx = create_context(content);
2975 let result = rule.check(&ctx).unwrap();
2976
2977 assert!(
2978 result.is_empty(),
2979 "Should not flag names inside flow sequences: {result:?}"
2980 );
2981 }
2982
2983 #[test]
2984 fn test_frontmatter_list_item_mapping_fix_preserves_key() {
2985 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2987
2988 let content = "---\nitems:\n - test: a test value\n---\n\ntest here\n";
2989 let ctx = create_context(content);
2990 let fixed = rule.fix(&ctx).unwrap();
2991
2992 assert_eq!(fixed, "---\nitems:\n - test: a Test value\n---\n\nTest here\n");
2995 }
2996
2997 #[test]
2998 fn test_frontmatter_backtick_code_not_flagged() {
2999 let config = MD044Config {
3001 names: vec!["GoodApplication".to_string()],
3002 code_blocks: false,
3003 ..MD044Config::default()
3004 };
3005 let rule = MD044ProperNames::from_config_struct(config);
3006
3007 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
3008 let ctx = create_context(content);
3009 let result = rule.check(&ctx).unwrap();
3010
3011 assert!(
3013 result.is_empty(),
3014 "Should not flag names inside backticks in frontmatter or body: {result:?}"
3015 );
3016 }
3017
3018 #[test]
3019 fn test_frontmatter_unquoted_backtick_code_not_flagged() {
3020 let config = MD044Config {
3022 names: vec!["GoodApplication".to_string()],
3023 code_blocks: false,
3024 ..MD044Config::default()
3025 };
3026 let rule = MD044ProperNames::from_config_struct(config);
3027
3028 let content = "---\ntitle: `goodapplication` CLI\n---\n\nIntroductory `goodapplication` CLI text.\n";
3029 let ctx = create_context(content);
3030 let result = rule.check(&ctx).unwrap();
3031
3032 assert!(
3033 result.is_empty(),
3034 "Should not flag names inside backticks in unquoted YAML frontmatter: {result:?}"
3035 );
3036 }
3037
3038 #[test]
3039 fn test_frontmatter_bare_name_still_flagged_with_backtick_nearby() {
3040 let config = MD044Config {
3042 names: vec!["GoodApplication".to_string()],
3043 code_blocks: false,
3044 ..MD044Config::default()
3045 };
3046 let rule = MD044ProperNames::from_config_struct(config);
3047
3048 let content = "---\ntitle: goodapplication `goodapplication` CLI\n---\n\nBody\n";
3049 let ctx = create_context(content);
3050 let result = rule.check(&ctx).unwrap();
3051
3052 assert_eq!(
3054 result.len(),
3055 1,
3056 "Should flag bare name but not backtick-wrapped name: {result:?}"
3057 );
3058 assert_eq!(result[0].line, 2);
3059 assert_eq!(result[0].column, 8); }
3061
3062 #[test]
3063 fn test_frontmatter_backtick_code_with_code_blocks_true() {
3064 let config = MD044Config {
3066 names: vec!["GoodApplication".to_string()],
3067 code_blocks: true,
3068 ..MD044Config::default()
3069 };
3070 let rule = MD044ProperNames::from_config_struct(config);
3071
3072 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nBody\n";
3073 let ctx = create_context(content);
3074 let result = rule.check(&ctx).unwrap();
3075
3076 assert_eq!(
3078 result.len(),
3079 1,
3080 "Should flag backtick-wrapped name when code_blocks=true: {result:?}"
3081 );
3082 assert_eq!(result[0].line, 2);
3083 }
3084
3085 #[test]
3086 fn test_frontmatter_fix_preserves_backtick_code() {
3087 let config = MD044Config {
3089 names: vec!["GoodApplication".to_string()],
3090 code_blocks: false,
3091 ..MD044Config::default()
3092 };
3093 let rule = MD044ProperNames::from_config_struct(config);
3094
3095 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
3096 let ctx = create_context(content);
3097 let fixed = rule.fix(&ctx).unwrap();
3098
3099 assert_eq!(
3101 fixed, content,
3102 "Fix should not modify names inside backticks in frontmatter"
3103 );
3104 }
3105
3106 fn rule_ignoring(names: &[&str], ignore: &[&str]) -> MD044ProperNames {
3107 MD044ProperNames::from_config_struct(MD044Config {
3108 names: names.iter().map(ToString::to_string).collect(),
3109 ignore_frontmatter_fields: Some(ignore.iter().map(ToString::to_string).collect()),
3110 ..Default::default()
3111 })
3112 }
3113
3114 #[test]
3115 fn test_ignore_frontmatter_field_suppresses_only_that_field() {
3116 let content = "---\ntitle: Heading for myapp\nslug: myapp-guide\n---\n";
3117 let rule = rule_ignoring(&["MyApp"], &["slug"]);
3118 let result = rule.check(&create_context(content)).unwrap();
3119 assert_eq!(result.len(), 1, "only title is flagged: {result:?}");
3120 assert_eq!(result[0].line, 2);
3121 }
3122
3123 #[test]
3124 fn test_ignore_frontmatter_field_is_case_insensitive() {
3125 let content = "---\nSlug: myapp-guide\n---\n";
3126 let rule = rule_ignoring(&["MyApp"], &["SLUG"]);
3127 assert!(rule.check(&create_context(content)).unwrap().is_empty());
3128 }
3129
3130 #[test]
3131 fn test_ignore_frontmatter_field_covers_nested_subtree() {
3132 let content = "---\nseo:\n canonical: myapp\n keywords:\n - myapp\n---\n";
3133 let rule = rule_ignoring(&["MyApp"], &["seo"]);
3134 assert!(rule.check(&create_context(content)).unwrap().is_empty());
3135 }
3136
3137 #[test]
3138 fn test_ignore_frontmatter_field_does_not_affect_body() {
3139 let content = "---\nslug: myapp\n---\n\nBody mentions myapp.\n";
3140 let rule = rule_ignoring(&["MyApp"], &["slug"]);
3141 let result = rule.check(&create_context(content)).unwrap();
3142 assert_eq!(result.len(), 1);
3143 assert_eq!(result[0].line, 5);
3144 }
3145
3146 #[test]
3147 fn test_ignore_frontmatter_field_toml_table() {
3148 let content = "+++\n[seo]\ncanonical = \"myapp\"\n+++\n";
3149 let rule = rule_ignoring(&["MyApp"], &["seo"]);
3150 assert!(rule.check(&create_context(content)).unwrap().is_empty());
3151 }
3152
3153 #[test]
3156 fn test_angle_bracket_url_in_html_comment_not_flagged() {
3157 let config = MD044Config {
3159 names: vec!["Test".to_string()],
3160 ..MD044Config::default()
3161 };
3162 let rule = MD044ProperNames::from_config_struct(config);
3163
3164 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";
3165 let ctx = create_context(content);
3166 let result = rule.check(&ctx).unwrap();
3167
3168 let line8_warnings: Vec<_> = result.iter().filter(|w| w.line == 8).collect();
3176 assert!(
3177 line8_warnings.is_empty(),
3178 "Should not flag names inside angle-bracket URLs in HTML comments: {line8_warnings:?}"
3179 );
3180 }
3181
3182 #[test]
3183 fn test_bare_url_in_html_comment_still_flagged() {
3184 let config = MD044Config {
3186 names: vec!["Test".to_string()],
3187 ..MD044Config::default()
3188 };
3189 let rule = MD044ProperNames::from_config_struct(config);
3190
3191 let content = "<!-- This is a test https://www.example.test -->\n";
3192 let ctx = create_context(content);
3193 let result = rule.check(&ctx).unwrap();
3194
3195 assert!(
3198 !result.is_empty(),
3199 "Should flag 'test' in prose text of HTML comment with bare URL"
3200 );
3201 }
3202
3203 #[test]
3204 fn test_angle_bracket_url_in_regular_markdown_not_flagged() {
3205 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
3208
3209 let content = "<https://www.example.test>\n";
3210 let ctx = create_context(content);
3211 let result = rule.check(&ctx).unwrap();
3212
3213 assert!(
3214 result.is_empty(),
3215 "Should not flag names inside angle-bracket URLs in regular markdown: {result:?}"
3216 );
3217 }
3218
3219 #[test]
3220 fn test_multiple_angle_bracket_urls_in_one_comment() {
3221 let config = MD044Config {
3222 names: vec!["Test".to_string()],
3223 ..MD044Config::default()
3224 };
3225 let rule = MD044ProperNames::from_config_struct(config);
3226
3227 let content = "<!-- See <https://test.example.com> and <https://www.example.test> for details -->\n";
3228 let ctx = create_context(content);
3229 let result = rule.check(&ctx).unwrap();
3230
3231 assert!(
3233 result.is_empty(),
3234 "Should not flag names inside multiple angle-bracket URLs: {result:?}"
3235 );
3236 }
3237
3238 #[test]
3239 fn test_angle_bracket_non_url_still_flagged() {
3240 assert!(
3243 !MD044ProperNames::is_in_angle_bracket_url("<test> which is not a URL.", 1),
3244 "is_in_angle_bracket_url should return false for non-URL angle brackets"
3245 );
3246 }
3247
3248 #[test]
3249 fn test_angle_bracket_mailto_url_not_flagged() {
3250 let config = MD044Config {
3251 names: vec!["Test".to_string()],
3252 ..MD044Config::default()
3253 };
3254 let rule = MD044ProperNames::from_config_struct(config);
3255
3256 let content = "<!-- Contact <mailto:test@example.com> for help -->\n";
3257 let ctx = create_context(content);
3258 let result = rule.check(&ctx).unwrap();
3259
3260 assert!(
3261 result.is_empty(),
3262 "Should not flag names inside angle-bracket mailto URLs: {result:?}"
3263 );
3264 }
3265
3266 #[test]
3267 fn test_angle_bracket_ftp_url_not_flagged() {
3268 let config = MD044Config {
3269 names: vec!["Test".to_string()],
3270 ..MD044Config::default()
3271 };
3272 let rule = MD044ProperNames::from_config_struct(config);
3273
3274 let content = "<!-- Download from <ftp://test.example.com/file> -->\n";
3275 let ctx = create_context(content);
3276 let result = rule.check(&ctx).unwrap();
3277
3278 assert!(
3279 result.is_empty(),
3280 "Should not flag names inside angle-bracket FTP URLs: {result:?}"
3281 );
3282 }
3283
3284 #[test]
3285 fn test_angle_bracket_url_fix_preserves_url() {
3286 let config = MD044Config {
3288 names: vec!["Test".to_string()],
3289 ..MD044Config::default()
3290 };
3291 let rule = MD044ProperNames::from_config_struct(config);
3292
3293 let content = "<!-- test text <https://www.example.test> -->\n";
3294 let ctx = create_context(content);
3295 let fixed = rule.fix(&ctx).unwrap();
3296
3297 assert!(
3299 fixed.contains("<https://www.example.test>"),
3300 "Fix should preserve angle-bracket URLs: {fixed}"
3301 );
3302 assert!(
3303 fixed.contains("Test text"),
3304 "Fix should correct prose 'test' to 'Test': {fixed}"
3305 );
3306 }
3307
3308 #[test]
3309 fn test_fallback_delimiter_index_matches_direct_scans() {
3310 let alphabet = [b'a', b'\\', b'[', b']', b'(', b')'];
3311 for mut value in 0..alphabet.len().pow(6) {
3312 let mut bytes = [0; 6];
3313 for byte in &mut bytes {
3314 *byte = alphabet[value % alphabet.len()];
3315 value /= alphabet.len();
3316 }
3317 for (open, close) in [(b'[', b']'), (b'(', b')')] {
3318 let mut lookup = FallbackDelimiters {
3319 scanned_bytes: bytes.len() * 2,
3320 ..Default::default()
3321 };
3322 for from in (1..=bytes.len()).chain((1..=bytes.len()).rev()) {
3323 if bytes[from - 1] == open {
3324 assert_eq!(
3325 lookup.find(&bytes, from, open, close),
3326 FallbackDelimiters::scan(&bytes, from, open, close),
3327 "{bytes:?}, {from}, {open}"
3328 );
3329 }
3330 }
3331 }
3332 }
3333 }
3334
3335 #[test]
3336 fn test_fallback_ranges_match_scalar_queries() {
3337 fn scalar_links(line: &str, pos: usize) -> bool {
3338 let bytes = line.as_bytes();
3339 let len = bytes.len();
3340 let mut i = 0;
3341
3342 while i < len {
3343 if bytes[i] == b'[' && (i == 0 || bytes[i - 1] != b'\\' || (i >= 2 && bytes[i - 2] == b'\\')) {
3345 let mut depth: u32 = 1;
3347 let mut j = i + 1;
3348 while j < len && depth > 0 {
3349 match bytes[j] {
3350 b'\\' => {
3351 j += 1; }
3353 b'[' => depth += 1,
3354 b']' => depth -= 1,
3355 _ => {}
3356 }
3357 j += 1;
3358 }
3359
3360 if depth == 0 && j < len {
3362 if bytes[j] == b'(' {
3363 let url_start = j;
3365 let mut paren_depth: u32 = 1;
3366 let mut k = j + 1;
3367 while k < len && paren_depth > 0 {
3368 match bytes[k] {
3369 b'\\' => {
3370 k += 1; }
3372 b'(' => paren_depth += 1,
3373 b')' => paren_depth -= 1,
3374 _ => {}
3375 }
3376 k += 1;
3377 }
3378
3379 if paren_depth == 0 {
3380 if pos > url_start && pos < k {
3381 return true;
3382 }
3383 i = k;
3384 continue;
3385 }
3386 } else if bytes[j] == b'[' {
3387 let ref_start = j;
3389 let mut ref_depth: u32 = 1;
3390 let mut k = j + 1;
3391 while k < len && ref_depth > 0 {
3392 match bytes[k] {
3393 b'\\' => {
3394 k += 1;
3395 }
3396 b'[' => ref_depth += 1,
3397 b']' => ref_depth -= 1,
3398 _ => {}
3399 }
3400 k += 1;
3401 }
3402
3403 if ref_depth == 0 {
3404 if pos > ref_start && pos < k {
3405 return true;
3406 }
3407 i = k;
3408 continue;
3409 }
3410 }
3411 }
3412 }
3413 i += 1;
3414 }
3415 false
3416 }
3417
3418 fn scalar_code(line: &str, pos: usize) -> bool {
3419 let bytes = line.as_bytes();
3420 let len = bytes.len();
3421 let mut i = 0;
3422 while i < len {
3423 if bytes[i] == b'`' {
3424 let open_start = i;
3426 while i < len && bytes[i] == b'`' {
3427 i += 1;
3428 }
3429 let tick_len = i - open_start;
3430
3431 while i < len {
3433 if bytes[i] == b'`' {
3434 let close_start = i;
3435 while i < len && bytes[i] == b'`' {
3436 i += 1;
3437 }
3438 if i - close_start == tick_len {
3439 let content_start = open_start + tick_len;
3443 let content_end = close_start;
3444 if pos >= content_start && pos < content_end {
3445 return true;
3446 }
3447 break;
3449 }
3450 } else {
3452 i += 1;
3453 }
3454 }
3455 } else {
3456 i += 1;
3457 }
3458 }
3459 false
3460 }
3461
3462 let tokens = [
3463 "javascript",
3464 "`",
3465 "``",
3466 "```",
3467 "`javascript`",
3468 "``javascript``",
3469 "`javascript``",
3470 "\\`",
3471 "[javascript](url)",
3472 "[text][javascript]",
3473 "[[x]](url)",
3474 "[a [b] c](x(y)z)",
3475 "[a](x\\)y)",
3476 "[a][x\\]y]",
3477 "[a](x y)",
3478 "[a](x",
3479 "[a][x",
3480 "[[",
3481 "[",
3482 "]",
3483 "(",
3484 ")",
3485 "\\[",
3486 "\\\\[",
3487 "\\\\\\[",
3488 "\\\\",
3489 "é日本🙂",
3490 "<!--",
3491 "-->",
3492 ];
3493 for first in tokens {
3494 for second in tokens {
3495 let text = format!("{first}{second}");
3496 let urls = MD044ProperNames::markdown_link_url_ranges(&text);
3497 let code = MD044ProperNames::backtick_code_ranges(&text);
3498 for ranges in [&urls, &code] {
3499 assert!(ranges.windows(2).all(|pair| pair[0].end <= pair[1].start));
3500 }
3501 for byte in 0..=text.len() + 1 {
3502 assert_eq!(
3503 crate::inline_config::offset_in_ranges(&urls, byte),
3504 scalar_links(&text, byte),
3505 "links {text:?} at {byte}: {urls:?}"
3506 );
3507 assert_eq!(
3508 crate::inline_config::offset_in_ranges(&code, byte),
3509 scalar_code(&text, byte),
3510 "code {text:?} at {byte}: {code:?}"
3511 );
3512 }
3513 }
3514 }
3515 }
3516
3517 #[test]
3518 fn test_fallback_ranges_preserve_code_and_link_fixes() {
3519 let body =
3520 "javascript `javascript` [javascript](javascript) [javascript][javascript] <custom:javascript> CAFÉ ";
3521 let fixed =
3522 "JavaScript `javascript` [JavaScript](javascript) [JavaScript][javascript] <custom:javascript> Café ";
3523 for (prefix, suffix) in [
3524 ("<!--\n", "\n-->"),
3525 ("<div>\n", "\n</div>"),
3526 ("---\ntitle: ", "\n---\n"),
3527 ] {
3528 for ending in ["\n", "\r\n"] {
3529 let content = format!("{prefix}{}{suffix}", body.repeat(32)).replace('\n', ending);
3530 let expected = format!("{prefix}{}{suffix}", fixed.repeat(32)).replace('\n', ending);
3531 let ctx = create_context(&content);
3532 let rule = MD044ProperNames::new(vec!["JavaScript".into(), "Café".into()], false);
3533 let warnings = rule.check(&ctx).unwrap();
3534 assert_eq!(warnings.len(), 128, "{prefix:?} {ending:?}");
3535 assert_eq!(rule.check(&ctx).unwrap(), warnings);
3536 assert_eq!(rule.fix(&ctx).unwrap(), expected);
3537 }
3538 }
3539 }
3540
3541 #[test]
3542 fn test_wikilink_url_ranges_match_scalar_queries() {
3543 fn scalar(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
3544 use pulldown_cmark::LinkType;
3545 let content = ctx.content.as_bytes();
3546
3547 for link in ctx.links_starting_before_or_at(byte_pos) {
3548 if !matches!(link.link_type, LinkType::WikiLink { .. }) {
3549 continue;
3550 }
3551 let wiki_end = link.byte_end;
3552 if wiki_end >= byte_pos || wiki_end >= content.len() || content[wiki_end] != b'(' {
3554 continue;
3555 }
3556 let mut depth: u32 = 1;
3561 let mut k = wiki_end + 1;
3562 let mut valid_destination = true;
3563 while k < content.len() && depth > 0 {
3564 match content[k] {
3565 b'\\' => {
3566 k += 1; }
3568 b'(' => depth += 1,
3569 b')' => depth -= 1,
3570 b' ' | b'\t' | b'\n' | b'\r' => {
3571 valid_destination = false;
3572 break;
3573 }
3574 _ => {}
3575 }
3576 k += 1;
3577 }
3578 if valid_destination && depth == 0 && byte_pos > wiki_end && byte_pos < k {
3581 return true;
3582 }
3583 }
3584 false
3585 }
3586
3587 use crate::config::MarkdownFlavor;
3588 let tokens = [
3589 "[[target]](javascript)",
3590 "[[target]](a(b)c)",
3591 "[[target]](a\\)b)",
3592 "[[target]](a\\ b)",
3593 "[[target]](a b)",
3594 "[[target]](a\tb)",
3595 "[[target]](a\nb)",
3596 "[[target]](a\rb)",
3597 "[[target]](a",
3598 "[[target]](a[[inner]](b)c)",
3599 "[[target]](a[[inner]](b)",
3600 "\\[[target]](path)",
3601 "`[[target]](path)`",
3602 "",
3603 "é🙂 ",
3604 "[[x]]",
3605 "\\)",
3606 "([[target]](x))",
3607 ];
3608 for flavor in [
3609 MarkdownFlavor::Standard,
3610 MarkdownFlavor::Obsidian,
3611 MarkdownFlavor::MDX,
3612 MarkdownFlavor::Quarto,
3613 ] {
3614 for first in tokens {
3615 for second in tokens {
3616 let content = format!("{first}{second}");
3617 let ctx = LintContext::new(&content, flavor, None);
3618 let ranges = MD044ProperNames::wikilink_url_ranges(&ctx);
3619 assert!(ranges.windows(2).all(|pair| pair[0].end <= pair[1].start));
3620 for byte in 0..=content.len() + 1 {
3621 assert_eq!(
3622 crate::inline_config::offset_in_ranges(&ranges, byte),
3623 scalar(&ctx, byte),
3624 "{flavor:?} {content:?} at {byte}: {ranges:?}"
3625 );
3626 }
3627 }
3628 }
3629 }
3630 }
3631
3632 #[test]
3633 fn test_wikilink_destinations_preserve_prose_fixes() {
3634 use crate::config::MarkdownFlavor;
3635 for flavor in [MarkdownFlavor::Standard, MarkdownFlavor::Obsidian] {
3636 let content = "javascript [[target]](javascript) javascript [[other]](a(javascript)b) CAFÉ";
3637 let ctx = LintContext::new(content, flavor, None);
3638 let rule = MD044ProperNames::new(vec!["JavaScript".into(), "Café".into()], false);
3639 assert_eq!(rule.check(&ctx).unwrap().len(), 3);
3640 assert_eq!(
3641 rule.fix(&ctx).unwrap(),
3642 "JavaScript [[target]](javascript) JavaScript [[other]](a(javascript)b) Café"
3643 );
3644 }
3645 }
3646
3647 #[test]
3648 fn test_repeated_name_columns_and_fixes_with_unicode() {
3649 for ending in ["\n", "\r\n"] {
3650 let line = "é日本🙂 javascript CAFÉ JavaScript <custom:javascript> ".repeat(64);
3651 let content = format!("{line}{ending}{ending}{line}");
3652 let ctx = create_context(&content);
3653 let rule = MD044ProperNames::new(vec!["JavaScript".into(), "Café".into()], false);
3654 let warnings = rule.check(&ctx).unwrap();
3655 let mut expected = Vec::new();
3656 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
3657 let text = line_info.content(ctx.content);
3658 for (byte, _) in text.match_indices("javascript CAFÉ") {
3659 for (offset, found, replacement) in [(0, "javascript", "JavaScript"), (11, "CAFÉ", "Café")] {
3660 let start = byte + offset;
3661 let column = text[..start].chars().count() + 1;
3662 expected.push((
3663 line_idx + 1,
3664 column,
3665 column + found.chars().count(),
3666 line_info.byte_offset + start..line_info.byte_offset + start + found.len(),
3667 replacement,
3668 ));
3669 }
3670 }
3671 }
3672 assert_eq!(warnings.len(), 256);
3673 assert_eq!(warnings.len(), expected.len());
3674 for (warning, (line, column, end_column, range, replacement)) in warnings.iter().zip(expected) {
3675 assert_eq!(
3676 (warning.line, warning.column, warning.end_line, warning.end_column),
3677 (line, column, line, end_column)
3678 );
3679 assert_eq!(warning.fix, Some(Fix::new(range, replacement.to_owned())));
3680 }
3681 assert_eq!(rule.check(&ctx).unwrap(), warnings, "cached diagnostics");
3682 assert_eq!(rule.clone().check(&ctx).unwrap(), warnings, "shared cache");
3683 assert_eq!(
3684 rule.fix(&ctx).unwrap(),
3685 content.replace("javascript CAFÉ", "JavaScript Café")
3686 );
3687 }
3688 }
3689
3690 #[test]
3691 fn test_angle_url_ranges_preserve_boundaries() {
3692 let line = "<bad space> <x:one> <y:two\tthree> <z:unclosed <good:last> tail";
3693 let ranges = MD044ProperNames::angle_bracket_url_ranges(line);
3694 let expected = ["<x:one>", "<y:two\tthree>", "<good:last>"];
3695 assert_eq!(ranges.len(), expected.len());
3696 for (range, text) in ranges.iter().zip(expected) {
3697 assert_eq!(&line[range.clone()], text);
3698 assert!(MD044ProperNames::is_in_angle_bracket_url(line, range.start));
3699 assert!(MD044ProperNames::is_in_angle_bracket_url(line, range.end - 1));
3700 assert!(!MD044ProperNames::is_in_angle_bracket_url(line, range.end));
3701 }
3702 }
3703
3704 #[test]
3705 fn test_is_in_angle_bracket_url_helper() {
3706 let line = "text <https://example.test> more text";
3708
3709 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));
3722
3723 assert!(MD044ProperNames::is_in_angle_bracket_url(
3725 "<mailto:test@example.com>",
3726 10
3727 ));
3728
3729 assert!(MD044ProperNames::is_in_angle_bracket_url(
3731 "<ftp://test.example.com>",
3732 10
3733 ));
3734 }
3735
3736 #[test]
3737 fn test_is_in_angle_bracket_url_uppercase_scheme() {
3738 assert!(MD044ProperNames::is_in_angle_bracket_url(
3740 "<HTTPS://test.example.com>",
3741 10
3742 ));
3743 assert!(MD044ProperNames::is_in_angle_bracket_url(
3744 "<Http://test.example.com>",
3745 10
3746 ));
3747 }
3748
3749 #[test]
3750 fn test_is_in_angle_bracket_url_uncommon_schemes() {
3751 assert!(MD044ProperNames::is_in_angle_bracket_url(
3753 "<ssh://test@example.com>",
3754 10
3755 ));
3756 assert!(MD044ProperNames::is_in_angle_bracket_url("<file:///test/path>", 10));
3758 assert!(MD044ProperNames::is_in_angle_bracket_url("<data:text/plain;test>", 10));
3760 }
3761
3762 #[test]
3763 fn test_is_in_angle_bracket_url_unclosed() {
3764 assert!(!MD044ProperNames::is_in_angle_bracket_url(
3766 "<https://test.example.com",
3767 10
3768 ));
3769 }
3770
3771 #[test]
3772 fn test_vale_inline_config_comments_not_flagged() {
3773 let config = MD044Config {
3774 names: vec!["Vale".to_string(), "JavaScript".to_string()],
3775 ..MD044Config::default()
3776 };
3777 let rule = MD044ProperNames::from_config_struct(config);
3778
3779 let content = "\
3780<!-- vale off -->
3781Some javascript text here.
3782<!-- vale on -->
3783<!-- vale Style.Rule = NO -->
3784More javascript text.
3785<!-- vale Style.Rule = YES -->
3786<!-- vale JavaScript.Grammar = NO -->
3787";
3788 let ctx = create_context(content);
3789 let result = rule.check(&ctx).unwrap();
3790
3791 assert_eq!(result.len(), 2, "Should only flag body lines, not Vale config comments");
3793 assert_eq!(result[0].line, 2);
3794 assert_eq!(result[1].line, 5);
3795 }
3796
3797 #[test]
3798 fn test_remark_lint_inline_config_comments_not_flagged() {
3799 let config = MD044Config {
3800 names: vec!["JavaScript".to_string()],
3801 ..MD044Config::default()
3802 };
3803 let rule = MD044ProperNames::from_config_struct(config);
3804
3805 let content = "\
3806<!-- lint disable remark-lint-some-rule -->
3807Some javascript text here.
3808<!-- lint enable remark-lint-some-rule -->
3809<!-- lint ignore remark-lint-some-rule -->
3810More javascript text.
3811";
3812 let ctx = create_context(content);
3813 let result = rule.check(&ctx).unwrap();
3814
3815 assert_eq!(
3816 result.len(),
3817 2,
3818 "Should only flag body lines, not remark-lint config comments"
3819 );
3820 assert_eq!(result[0].line, 2);
3821 assert_eq!(result[1].line, 5);
3822 }
3823
3824 #[test]
3825 fn test_fix_does_not_modify_vale_remark_lint_comments() {
3826 let config = MD044Config {
3827 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3828 ..MD044Config::default()
3829 };
3830 let rule = MD044ProperNames::from_config_struct(config);
3831
3832 let content = "\
3833<!-- vale off -->
3834Some javascript text.
3835<!-- vale on -->
3836<!-- lint disable remark-lint-some-rule -->
3837More javascript text.
3838<!-- lint enable remark-lint-some-rule -->
3839";
3840 let ctx = create_context(content);
3841 let fixed = rule.fix(&ctx).unwrap();
3842
3843 assert!(fixed.contains("<!-- vale off -->"));
3845 assert!(fixed.contains("<!-- vale on -->"));
3846 assert!(fixed.contains("<!-- lint disable remark-lint-some-rule -->"));
3847 assert!(fixed.contains("<!-- lint enable remark-lint-some-rule -->"));
3848 assert!(fixed.contains("Some JavaScript text."));
3850 assert!(fixed.contains("More JavaScript text."));
3851 }
3852
3853 #[test]
3854 fn test_mixed_tool_directives_all_skipped() {
3855 let config = MD044Config {
3856 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3857 ..MD044Config::default()
3858 };
3859 let rule = MD044ProperNames::from_config_struct(config);
3860
3861 let content = "\
3862<!-- rumdl-disable MD044 -->
3863Some javascript text.
3864<!-- markdownlint-disable -->
3865More javascript text.
3866<!-- vale off -->
3867Even more javascript text.
3868<!-- lint disable some-rule -->
3869Final javascript text.
3870<!-- rumdl-enable MD044 -->
3871<!-- markdownlint-enable -->
3872<!-- vale on -->
3873<!-- lint enable some-rule -->
3874";
3875 let ctx = create_context(content);
3876 let result = rule.check(&ctx).unwrap();
3877
3878 assert_eq!(
3880 result.len(),
3881 4,
3882 "Should only flag body lines, not any tool directive comments"
3883 );
3884 assert_eq!(result[0].line, 2);
3885 assert_eq!(result[1].line, 4);
3886 assert_eq!(result[2].line, 6);
3887 assert_eq!(result[3].line, 8);
3888 }
3889
3890 #[test]
3891 fn test_vale_remark_lint_edge_cases_not_matched() {
3892 let config = MD044Config {
3893 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3894 ..MD044Config::default()
3895 };
3896 let rule = MD044ProperNames::from_config_struct(config);
3897
3898 let content = "\
3906<!-- vale -->
3907<!-- vale is a tool for writing -->
3908<!-- valedictorian javascript -->
3909<!-- linting javascript tips -->
3910<!-- vale javascript -->
3911<!-- lint your javascript code -->
3912";
3913 let ctx = create_context(content);
3914 let result = rule.check(&ctx).unwrap();
3915
3916 assert_eq!(
3923 result.len(),
3924 7,
3925 "Should flag proper names in non-directive HTML comments: got {result:?}"
3926 );
3927 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); }
3935
3936 #[test]
3937 fn test_vale_style_directives_skipped() {
3938 let config = MD044Config {
3939 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3940 ..MD044Config::default()
3941 };
3942 let rule = MD044ProperNames::from_config_struct(config);
3943
3944 let content = "\
3946<!-- vale style = MyStyle -->
3947<!-- vale styles = Style1, Style2 -->
3948<!-- vale MyRule.Name = YES -->
3949<!-- vale MyRule.Name = NO -->
3950Some javascript text.
3951";
3952 let ctx = create_context(content);
3953 let result = rule.check(&ctx).unwrap();
3954
3955 assert_eq!(
3957 result.len(),
3958 1,
3959 "Should only flag body lines, not Vale style/rule directives: got {result:?}"
3960 );
3961 assert_eq!(result[0].line, 5);
3962 }
3963
3964 #[test]
3967 fn test_backtick_code_single_backticks() {
3968 let line = "hello `world` bye";
3969 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 7));
3971 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3973 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 14));
3975 }
3976
3977 #[test]
3978 fn test_backtick_code_double_backticks() {
3979 let line = "a ``code`` b";
3980 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3982 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3984 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 11));
3986 }
3987
3988 #[test]
3989 fn test_backtick_code_unclosed() {
3990 let line = "a `code b";
3991 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3993 }
3994
3995 #[test]
3996 fn test_backtick_code_mismatched_count() {
3997 let line = "a `code`` b";
3999 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
4002 }
4003
4004 #[test]
4005 fn test_backtick_code_multiple_spans() {
4006 let line = "`first` and `second`";
4007 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
4009 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 8));
4011 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 13));
4013 }
4014
4015 #[test]
4016 fn test_backtick_code_on_backtick_boundary() {
4017 let line = "`code`";
4018 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
4020 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 5));
4022 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
4024 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
4025 }
4026
4027 #[test]
4033 fn test_double_bracket_link_url_not_flagged() {
4034 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4035 let content = "[[rumdl]](https://github.com/rvben/rumdl)";
4037 let ctx = create_context(content);
4038 let result = rule.check(&ctx).unwrap();
4039 assert!(
4040 result.is_empty(),
4041 "URL inside [[text]](url) must not be flagged, got: {result:?}"
4042 );
4043 }
4044
4045 #[test]
4046 fn test_double_bracket_link_url_not_fixed() {
4047 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4048 let content = "[[rumdl]](https://github.com/rvben/rumdl)\n";
4049 let ctx = create_context(content);
4050 let fixed = rule.fix(&ctx).unwrap();
4051 assert_eq!(
4052 fixed, content,
4053 "fix() must leave the URL inside [[text]](url) unchanged"
4054 );
4055 }
4056
4057 #[test]
4058 fn test_double_bracket_link_text_still_flagged() {
4059 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4060 let content = "[[github]](https://example.com)";
4062 let ctx = create_context(content);
4063 let result = rule.check(&ctx).unwrap();
4064 assert_eq!(
4065 result.len(),
4066 1,
4067 "Incorrect name in [[text]] link text should still be flagged, got: {result:?}"
4068 );
4069 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
4070 }
4071
4072 #[test]
4073 fn test_double_bracket_link_mixed_line() {
4074 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4075 let content = "See [[rumdl]](https://github.com/rvben/rumdl) and github for more.";
4077 let ctx = create_context(content);
4078 let result = rule.check(&ctx).unwrap();
4079 assert_eq!(
4080 result.len(),
4081 1,
4082 "Only the standalone 'github' after the link should be flagged, got: {result:?}"
4083 );
4084 assert!(result[0].message.contains("'github'"));
4085 assert_eq!(
4087 result[0].column, 51,
4088 "Flagged column should be the trailing 'github', not the one in the URL"
4089 );
4090 }
4091
4092 #[test]
4093 fn test_regular_link_url_still_not_flagged() {
4094 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4096 let content = "[rumdl](https://github.com/rvben/rumdl)";
4097 let ctx = create_context(content);
4098 let result = rule.check(&ctx).unwrap();
4099 assert!(
4100 result.is_empty(),
4101 "URL inside regular [text](url) must still not be flagged, got: {result:?}"
4102 );
4103 }
4104
4105 #[test]
4106 fn test_link_like_text_in_code_span_still_flagged_when_code_blocks_enabled() {
4107 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
4112 let content = "`[foo](https://github.com/org/repo)`";
4113 let ctx = create_context(content);
4114 let result = rule.check(&ctx).unwrap();
4115 assert_eq!(
4116 result.len(),
4117 1,
4118 "Proper name inside a code span must be flagged when code-blocks=true, got: {result:?}"
4119 );
4120 assert!(result[0].message.contains("'github'"));
4121 }
4122
4123 #[test]
4124 fn test_malformed_link_not_treated_as_url() {
4125 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4128 let content = "See [rumdl](github repo) for details.";
4129 let ctx = create_context(content);
4130 let result = rule.check(&ctx).unwrap();
4131 assert_eq!(
4132 result.len(),
4133 1,
4134 "Name inside malformed [text](url with spaces) must still be flagged, got: {result:?}"
4135 );
4136 assert!(result[0].message.contains("'github'"));
4137 }
4138
4139 #[test]
4140 fn test_wikilink_followed_by_prose_parens_still_flagged() {
4141 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4145 let content = "[[note]](github repo)";
4146 let ctx = create_context(content);
4147 let result = rule.check(&ctx).unwrap();
4148 assert_eq!(
4149 result.len(),
4150 1,
4151 "Name inside [[wikilink]](prose with spaces) must still be flagged, got: {result:?}"
4152 );
4153 assert!(result[0].message.contains("'github'"));
4154 }
4155
4156 #[test]
4158 fn test_roundtrip_fix_then_check_basic() {
4159 let rule = MD044ProperNames::new(
4160 vec![
4161 "JavaScript".to_string(),
4162 "TypeScript".to_string(),
4163 "Node.js".to_string(),
4164 ],
4165 true,
4166 );
4167 let content = "I love javascript, typescript, and nodejs!";
4168 let ctx = create_context(content);
4169 let fixed = rule.fix(&ctx).unwrap();
4170 let ctx2 = create_context(&fixed);
4171 let warnings = rule.check(&ctx2).unwrap();
4172 assert!(
4173 warnings.is_empty(),
4174 "Re-check after fix should produce zero warnings, got: {warnings:?}"
4175 );
4176 }
4177
4178 #[test]
4180 fn test_roundtrip_fix_then_check_multiline() {
4181 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
4182 let content = "First line with rust.\nSecond line with python.\nThird line with RUST and PYTHON.\n";
4183 let ctx = create_context(content);
4184 let fixed = rule.fix(&ctx).unwrap();
4185 let ctx2 = create_context(&fixed);
4186 let warnings = rule.check(&ctx2).unwrap();
4187 assert!(
4188 warnings.is_empty(),
4189 "Re-check after fix should produce zero warnings, got: {warnings:?}"
4190 );
4191 }
4192
4193 #[test]
4195 fn test_roundtrip_fix_then_check_inline_config() {
4196 let config = MD044Config {
4197 names: vec!["RUMDL".to_string()],
4198 ..MD044Config::default()
4199 };
4200 let rule = MD044ProperNames::from_config_struct(config);
4201 let content =
4202 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
4203 let ctx = create_context(content);
4204 let fixed = rule.fix(&ctx).unwrap();
4205 assert!(
4207 fixed.contains("Some rumdl text.\n"),
4208 "Disabled block text should be preserved"
4209 );
4210 assert!(
4211 fixed.contains("Some RUMDL text outside."),
4212 "Outside text should be fixed"
4213 );
4214 }
4215
4216 #[test]
4218 fn test_roundtrip_fix_then_check_html_comments() {
4219 let config = MD044Config {
4220 names: vec!["JavaScript".to_string()],
4221 ..MD044Config::default()
4222 };
4223 let rule = MD044ProperNames::from_config_struct(config);
4224 let content = "# Guide\n\n<!-- javascript mentioned here -->\n\njavascript outside\n";
4225 let ctx = create_context(content);
4226 let fixed = rule.fix(&ctx).unwrap();
4227 let ctx2 = create_context(&fixed);
4228 let warnings = rule.check(&ctx2).unwrap();
4229 assert!(
4230 warnings.is_empty(),
4231 "Re-check after fix should produce zero warnings, got: {warnings:?}"
4232 );
4233 }
4234
4235 #[test]
4237 fn test_roundtrip_no_op_when_correct() {
4238 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
4239 let content = "This uses JavaScript and TypeScript correctly.\n";
4240 let ctx = create_context(content);
4241 let fixed = rule.fix(&ctx).unwrap();
4242 assert_eq!(fixed, content, "Fix should be a no-op when content is already correct");
4243 }
4244
4245 #[test]
4248 fn test_bare_domain_link_text_not_flagged() {
4249 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4253 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
4254 let ctx = create_context(content);
4255 let result = rule.check(&ctx).unwrap();
4256 assert!(
4257 result.is_empty(),
4258 "Should not flag 'github' in a bare-domain link text that matches the link URL: {result:?}"
4259 );
4260 }
4261
4262 #[test]
4263 fn test_bare_domain_link_text_not_fixed() {
4264 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4266 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
4267 let ctx = create_context(content);
4268 let fixed = rule.fix(&ctx).unwrap();
4269 assert_eq!(
4270 fixed, content,
4271 "fix() must not alter bare-domain link text that matches the destination URL"
4272 );
4273 }
4274
4275 #[test]
4276 fn test_bare_domain_link_text_with_path_not_flagged() {
4277 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4279 let content = "Visit [ravencentric.github.io](https://ravencentric.github.io/projects).\n";
4280 let ctx = create_context(content);
4281 let result = rule.check(&ctx).unwrap();
4282 assert!(
4283 result.is_empty(),
4284 "Should not flag 'github' when bare-domain text is the hostname of its destination URL: {result:?}"
4285 );
4286 }
4287
4288 #[test]
4289 fn test_bare_domain_link_text_full_path_not_flagged() {
4290 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4292 let content = "See [ravencentric.github.io/blog](https://ravencentric.github.io/blog).\n";
4293 let ctx = create_context(content);
4294 let result = rule.check(&ctx).unwrap();
4295 assert!(
4296 result.is_empty(),
4297 "Should not flag 'github' when link text is the full URL path without scheme: {result:?}"
4298 );
4299 }
4300
4301 #[test]
4302 fn test_github_product_name_in_link_text_still_flagged() {
4303 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4306 let content = "Hosted on [github pages](https://pages.github.com).\n";
4307 let ctx = create_context(content);
4308 let result = rule.check(&ctx).unwrap();
4309 assert!(
4310 !result.is_empty(),
4311 "Should still flag 'github' in descriptive link text that does not match the destination URL"
4312 );
4313 }
4314
4315 #[test]
4316 fn test_protocol_relative_bare_domain_link_text_not_flagged() {
4317 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4319 let content = "See [github.io](//github.io).\n";
4320 let ctx = create_context(content);
4321 let result = rule.check(&ctx).unwrap();
4322 assert!(
4323 result.is_empty(),
4324 "Should not flag 'github' in bare-domain text matching a protocol-relative destination: {result:?}"
4325 );
4326 }
4327
4328 #[test]
4329 fn test_dotted_wikilink_target_still_flagged() {
4330 let rule = MD044ProperNames::new(vec!["Node.js".to_string()], false);
4335 let content = "See [[node.js]] for details.\n";
4336 let ctx = create_context(content);
4337 let result = rule.check(&ctx).unwrap();
4338 assert!(
4339 !result.is_empty(),
4340 "Should flag 'node.js' in a dotted WikiLink target: {result:?}"
4341 );
4342 }
4343
4344 #[test]
4345 fn test_bare_domain_link_text_case_insensitive_url() {
4346 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4349 let content = "See [github.io](HTTPS://github.io).\n";
4350 let ctx = create_context(content);
4351 let result = rule.check(&ctx).unwrap();
4352 assert!(
4353 result.is_empty(),
4354 "Should not flag bare-domain text when destination URL has an uppercase scheme: {result:?}"
4355 );
4356 }
4357
4358 #[test]
4359 fn test_frontmatter_value_span_strips_trailing_comment() {
4360 let line = "link: docs/guide/myapp # canonical path";
4361 let (s, e) = frontmatter_values::value_span(line).unwrap();
4362 assert_eq!(&line[s..e], "docs/guide/myapp");
4363 }
4364
4365 #[test]
4366 fn test_frontmatter_value_span_quoted_keeps_hash_and_spaces() {
4367 let line = "link: 'docs/My App/a#b'";
4368 let (s, e) = frontmatter_values::value_span(line).unwrap();
4369 assert_eq!(&line[s..e], "docs/My App/a#b");
4370 }
4371
4372 #[test]
4373 fn test_frontmatter_value_span_plain_value() {
4374 let line = "title: Heading for myapp";
4375 let (s, e) = frontmatter_values::value_span(line).unwrap();
4376 assert_eq!(&line[s..e], "Heading for myapp");
4377 }
4378
4379 #[test]
4380 fn test_frontmatter_value_span_none_for_key_only() {
4381 assert!(frontmatter_values::value_span("seo:").is_none());
4382 assert!(frontmatter_values::value_span("---").is_none());
4383 }
4384
4385 #[test]
4386 fn test_frontmatter_value_span_quoted_strips_trailing_comment() {
4387 let line = "link: 'docs/guide' # canonical path";
4388 let (s, e) = frontmatter_values::value_span(line).unwrap();
4389 assert_eq!(&line[s..e], "docs/guide");
4390 }
4391
4392 #[test]
4393 fn test_frontmatter_value_span_empty_quoted_value_is_none() {
4394 assert!(frontmatter_values::value_span("key: ''").is_none());
4395 }
4396
4397 #[test]
4398 fn test_frontmatter_value_span_unterminated_quote_strips_leading_quote() {
4399 let line = "link: 'docs/a";
4400 let (s, e) = frontmatter_values::value_span(line).unwrap();
4401 assert_eq!(&line[s..e], "docs/a");
4402 }
4403
4404 fn at(line: &str, needle: &str) -> usize {
4406 line.find(needle).expect("needle present")
4407 }
4408
4409 #[test]
4410 fn test_path_like_exempts_single_token_frontmatter_paths() {
4411 for line in [
4412 "link: this/is/a/link/to/myapp.md",
4413 "link: docs/myapp.md",
4414 "link: /abs/path/myapp.md",
4415 "link: ./myapp.md",
4416 "link: ../shared/myapp.md",
4417 ] {
4418 let span = frontmatter_values::value_span(line).unwrap();
4419 let pos = at(line, "myapp");
4420 assert!(
4421 MD044ProperNames::is_in_path_like_token(line, pos, span),
4422 "should treat as a path: {line}"
4423 );
4424 }
4425 }
4426
4427 #[test]
4428 fn test_path_like_does_not_exempt_slash_conjunction_prose() {
4429 let line = "description: We support github/gitlab/bitbucket imports.";
4432 let span = frontmatter_values::value_span(line).unwrap();
4433 assert!(
4434 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
4435 "slash-separated prose is not a path"
4436 );
4437
4438 let line = "description: The javascript/typescript ecosystem is large.";
4439 let span = frontmatter_values::value_span(line).unwrap();
4440 assert!(!MD044ProperNames::is_in_path_like_token(
4441 line,
4442 at(line, "javascript"),
4443 span
4444 ));
4445 }
4446
4447 #[test]
4448 fn test_path_like_requires_a_slash_so_dotted_names_survive() {
4449 let line = "title: Use nodejs and myapp.md today.";
4450 let span = frontmatter_values::value_span(line).unwrap();
4451 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4452 }
4453
4454 #[test]
4455 fn test_path_like_no_slash_frontmatter_value_still_flagged() {
4456 let line = "slug: myapp-guide";
4461 let span = frontmatter_values::value_span(line).unwrap();
4462 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4463 }
4464
4465 #[test]
4466 fn test_path_like_returns_false_outside_value_span() {
4467 let line = "myapp: docs/guide/myapp";
4470 let span = frontmatter_values::value_span(line).unwrap();
4471 let key_pos = 0;
4472 assert!(!MD044ProperNames::is_in_path_like_token(line, key_pos, span));
4473 }
4474
4475 #[test]
4476 fn test_path_like_three_segments_only_as_sole_frontmatter_value() {
4477 let line = "link: docs/guide/myapp";
4478 let span = frontmatter_values::value_span(line).unwrap();
4479 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4480
4481 let line = "description: We support github/gitlab/bitbucket now";
4482 let span = frontmatter_values::value_span(line).unwrap();
4483 assert!(
4484 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
4485 "multi-token value gets body treatment"
4486 );
4487 }
4488
4489 #[test]
4490 fn test_path_like_quoted_value_with_spaces() {
4491 let line = "link: 'docs/My App/myapp.md'";
4494 let span = frontmatter_values::value_span(line).unwrap();
4495 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4496 }
4497
4498 #[test]
4499 fn test_path_like_quoted_value_with_spaces_no_extension_not_exempt() {
4500 let line = "link: 'docs/My App/myapp'";
4507 let span = frontmatter_values::value_span(line).unwrap();
4508 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4509 }
4510
4511 #[test]
4512 fn test_path_like_trailing_comment_is_still_sole_value() {
4513 let line = "link: docs/guide/myapp # canonical path";
4514 let span = frontmatter_values::value_span(line).unwrap();
4515 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4516 }
4517
4518 #[test]
4519 fn test_path_like_trailing_punctuation_trimmed() {
4520 let line = "link: docs/myapp.md, then leave.";
4521 let span = frontmatter_values::value_span(line).unwrap();
4522 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4523 }
4524
4525 #[test]
4526 fn test_trim_token_bounds_reaches_fixpoint_after_punctuation_exposes_wrapper() {
4527 let line = r#"See "docs/myapp.md", then leave."#;
4528 let raw_start = at(line, "\"docs");
4529 let raw_end = raw_start + r#""docs/myapp.md","#.len();
4530 assert_eq!(&line[raw_start..raw_end], r#""docs/myapp.md","#);
4531 let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
4532 assert_eq!(&line[start..end], "docs/myapp.md");
4533 }
4534
4535 #[test]
4536 fn test_trim_token_bounds_reaches_fixpoint_with_multiple_trailing_wrappers() {
4537 let line = r#"("docs/myapp.md")."#;
4538 let (start, end) = frontmatter_values::trim_token_bounds(line, 0, line.len());
4539 assert_eq!(&line[start..end], "docs/myapp.md");
4540 }
4541
4542 #[test]
4543 fn test_frontmatter_link_path_not_flagged() {
4544 let content = "---\ntitle: Heading for MyApp\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 let result = rule.check(&ctx).unwrap();
4548 assert!(
4549 result.is_empty(),
4550 "path in a frontmatter value must not be flagged: {result:?}"
4551 );
4552 }
4553
4554 #[test]
4555 fn test_fix_does_not_corrupt_frontmatter_link_path() {
4556 let content = "---\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
4557 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4558 let ctx = create_context(content);
4559 assert_eq!(rule.fix(&ctx).unwrap(), content, "fix must not rewrite a path");
4560 }
4561
4562 #[test]
4573 fn test_body_prose_parenthesized_disambiguator_is_case_corrected() {
4574 let content = "See docs/myapp(1).md here.\n";
4575 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4576 let ctx = create_context(content);
4577 let result = rule.check(&ctx).unwrap();
4578 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
4579 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp(1).md here.\n");
4580 }
4581
4582 #[test]
4583 fn test_body_prose_bracketed_dynamic_segment_is_case_corrected() {
4584 let content = "See docs/[myapp].md here.\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!(rule.fix(&ctx).unwrap(), "See docs/[MyApp].md here.\n");
4590 }
4591
4592 #[test]
4593 fn test_body_prose_nextjs_catch_all_segment_is_case_corrected() {
4594 let content = "pages/[[...myapp]].tsx are catch-all routes.\n";
4597 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4598 let ctx = create_context(content);
4599 let result = rule.check(&ctx).unwrap();
4600 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
4601 assert_eq!(
4602 rule.fix(&ctx).unwrap(),
4603 "pages/[[...MyApp]].tsx are catch-all routes.\n"
4604 );
4605 }
4606
4607 #[test]
4608 fn test_two_adjacent_whitespace_free_links_both_flagged() {
4609 let content = "[myapp](https://a.com)[github](https://b.com)\n";
4613 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitHub".to_string()], false);
4614 let ctx = create_context(content);
4615 let result = rule.check(&ctx).unwrap();
4616 assert_eq!(result.len(), 2, "both link texts must be flagged: {result:?}");
4617 assert!(result.iter().any(|w| w.message.contains("'myapp'")));
4618 assert!(result.iter().any(|w| w.message.contains("'github'")));
4619 }
4620
4621 #[test]
4622 fn test_fix_does_not_corrupt_frontmatter_path_with_route_group_named_after_proper_name() {
4623 let content = "---\nlink: src/(myapp)/page.tsx\n---\n\nBody.\n";
4626 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4627 let ctx = create_context(content);
4628 assert_eq!(
4629 rule.fix(&ctx).unwrap(),
4630 content,
4631 "fix must not rewrite a frontmatter path whose route-group directory name is the proper name"
4632 );
4633 }
4634
4635 #[test]
4636 fn test_quoted_frontmatter_value_slash_conjunction_prose_still_flagged() {
4637 let content = "---\ndescription: \"We support github/gitlab/bitbucket now\"\n---\n\nBody.\n";
4642 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4643 let ctx = create_context(content);
4644 let result = rule.check(&ctx).unwrap();
4645 assert_eq!(
4646 result.len(),
4647 1,
4648 "quoted prose value must still flag 'github': {result:?}"
4649 );
4650 }
4651
4652 #[test]
4653 fn test_quoted_frontmatter_value_single_slash_word_with_unrelated_dot_still_flagged() {
4654 let content = "---\ndescription: \"We use myapp/gitlab and version 1.0 e.g. weekly\"\n---\n\nBody.\n";
4657 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4658 let ctx = create_context(content);
4659 let result = rule.check(&ctx).unwrap();
4660 assert_eq!(
4661 result.len(),
4662 1,
4663 "quoted prose value must still flag 'myapp': {result:?}"
4664 );
4665 }
4666
4667 #[test]
4668 fn test_quoted_toml_frontmatter_value_slash_conjunction_prose_still_flagged() {
4669 let content = "+++\ndescription = \"We support github/gitlab/bitbucket now\"\n+++\n\nBody.\n";
4672 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4673 let ctx = create_context(content);
4674 let result = rule.check(&ctx).unwrap();
4675 assert_eq!(
4676 result.len(),
4677 1,
4678 "TOML quoted prose value must still flag 'github': {result:?}"
4679 );
4680 }
4681
4682 #[test]
4683 fn test_path_like_collapsed_multiword_no_extension_not_exempt() {
4684 for line in [
4691 r#"description: "myapp/gitlab github/bitbucket""#,
4692 r#"description: "and/or this/that myapp/gitlab""#,
4693 r#"description: "he/him she/her myapp/gitlab""#,
4694 ] {
4695 let span = frontmatter_values::value_span(line).unwrap();
4696 for needle in ["myapp", "gitlab"] {
4697 if let Some(byte_pos) = line.find(needle) {
4698 assert!(
4699 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
4700 "collapsed multi-word value must not exempt '{needle}': {line}"
4701 );
4702 }
4703 }
4704 }
4705 }
4706
4707 #[test]
4708 fn test_path_like_collapsed_multiword_no_extension_not_exempt_toml() {
4709 let line = r#"description = "myapp/gitlab github/bitbucket""#;
4710 let span = frontmatter_values::value_span(line).unwrap();
4711 for needle in ["myapp", "gitlab", "github", "bitbucket"] {
4712 let byte_pos = at(line, needle);
4713 assert!(
4714 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
4715 "collapsed multi-word TOML value must not exempt '{needle}'"
4716 );
4717 }
4718 }
4719
4720 #[test]
4721 fn test_frontmatter_collapsed_multiword_names_all_flagged_yaml() {
4722 let content = "---\ndescription: \"myapp/gitlab github/bitbucket\"\n---\n\nBody.\n";
4723 let rule = MD044ProperNames::new(
4724 vec![
4725 "MyApp".to_string(),
4726 "GitLab".to_string(),
4727 "GitHub".to_string(),
4728 "Bitbucket".to_string(),
4729 ],
4730 false,
4731 );
4732 let ctx = create_context(content);
4733 let result = rule.check(&ctx).unwrap();
4734 assert_eq!(
4735 result.len(),
4736 4,
4737 "all four names in the collapsed multi-word value must be flagged: {result:?}"
4738 );
4739 }
4740
4741 #[test]
4742 fn test_frontmatter_collapsed_multiword_names_all_flagged_toml() {
4743 let content = "+++\ndescription = \"myapp/gitlab github/bitbucket\"\n+++\n\nBody.\n";
4744 let rule = MD044ProperNames::new(
4745 vec![
4746 "MyApp".to_string(),
4747 "GitLab".to_string(),
4748 "GitHub".to_string(),
4749 "Bitbucket".to_string(),
4750 ],
4751 false,
4752 );
4753 let ctx = create_context(content);
4754 let result = rule.check(&ctx).unwrap();
4755 assert_eq!(
4756 result.len(),
4757 4,
4758 "all four names in the collapsed multi-word TOML value must be flagged: {result:?}"
4759 );
4760 }
4761
4762 #[test]
4763 fn test_frontmatter_collapsed_multiword_conjunction_pairs_flagged() {
4764 let content = "---\ndescription: \"and/or this/that myapp/gitlab\"\n---\n\nBody.\n";
4765 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4766 let ctx = create_context(content);
4767 let result = rule.check(&ctx).unwrap();
4768 assert_eq!(
4769 result.len(),
4770 2,
4771 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4772 );
4773 }
4774
4775 #[test]
4776 fn test_frontmatter_collapsed_multiword_pronoun_pairs_flagged() {
4777 let content = "---\ndescription: \"he/him she/her myapp/gitlab\"\n---\n\nBody.\n";
4778 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4779 let ctx = create_context(content);
4780 let result = rule.check(&ctx).unwrap();
4781 assert_eq!(
4782 result.len(),
4783 2,
4784 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4785 );
4786 }
4787
4788 #[test]
4794 fn test_body_prose_path_is_flagged_frontmatter_only_scope() {
4795 let content = "See docs/myapp.md for details about myapp.\n";
4796 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4797 let ctx = create_context(content);
4798 let result = rule.check(&ctx).unwrap();
4799 assert_eq!(
4800 result.len(),
4801 2,
4802 "both the path occurrence and the prose occurrence are flagged in body text: {result:?}"
4803 );
4804 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp.md for details about MyApp.\n");
4805 }
4806
4807 #[test]
4808 fn test_slash_conjunction_prose_still_flagged() {
4809 let content = "We support github/gitlab imports.\n";
4810 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4811 let ctx = create_context(content);
4812 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
4813 }
4814}