1use crate::utils::fast_hash;
2use crate::utils::regex_cache::{escape_regex, get_cached_regex};
3
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::frontmatter_values;
6use crate::utils::range_utils::byte_to_char_count;
7use std::collections::{HashMap, HashSet};
8use std::sync::{Arc, Mutex};
9
10mod md044_config;
11pub(super) use md044_config::MD044Config;
12
13type WarningPosition = (usize, usize, String); fn is_inline_config_comment(trimmed: &str) -> bool {
72 trimmed.starts_with("<!-- rumdl-")
73 || trimmed.starts_with("<!-- markdownlint-")
74 || trimmed.starts_with("<!-- vale off")
75 || trimmed.starts_with("<!-- vale on")
76 || (trimmed.starts_with("<!-- vale ") && trimmed.contains(" = "))
77 || trimmed.starts_with("<!-- vale style")
78 || trimmed.starts_with("<!-- lint disable ")
79 || trimmed.starts_with("<!-- lint enable ")
80 || trimmed.starts_with("<!-- lint ignore ")
81}
82
83#[derive(Clone)]
84pub struct MD044ProperNames {
85 config: MD044Config,
86 combined_pattern: Option<String>,
88 name_variants: Vec<String>,
90 ignore_fields: HashSet<String>,
92 content_cache: Arc<Mutex<HashMap<u64, Vec<WarningPosition>>>>,
101}
102
103impl MD044ProperNames {
104 pub fn new(names: Vec<String>, code_blocks: bool) -> Self {
105 let config = MD044Config {
106 names,
107 code_blocks,
108 ..Default::default()
109 };
110 let combined_pattern = Self::create_combined_pattern(&config);
111 let name_variants = Self::build_name_variants(&config);
112 let ignore_fields = config
113 .ignore_frontmatter_fields
114 .iter()
115 .flatten()
116 .map(|f| f.to_lowercase())
117 .collect();
118 Self {
119 config,
120 combined_pattern,
121 name_variants,
122 ignore_fields,
123 content_cache: Arc::new(Mutex::new(HashMap::new())),
124 }
125 }
126
127 fn ascii_normalize(s: &str) -> String {
129 s.replace(['é', 'è', 'ê', 'ë'], "e")
130 .replace(['à', 'á', 'â', 'ä', 'ã', 'å'], "a")
131 .replace(['ï', 'î', 'í', 'ì'], "i")
132 .replace(['ü', 'ú', 'ù', 'û'], "u")
133 .replace(['ö', 'ó', 'ò', 'ô', 'õ'], "o")
134 .replace('ñ', "n")
135 .replace('ç', "c")
136 }
137
138 pub fn from_config_struct(config: MD044Config) -> Self {
139 let combined_pattern = Self::create_combined_pattern(&config);
140 let name_variants = Self::build_name_variants(&config);
141 let ignore_fields = config
142 .ignore_frontmatter_fields
143 .iter()
144 .flatten()
145 .map(|f| f.to_lowercase())
146 .collect();
147 Self {
148 config,
149 combined_pattern,
150 name_variants,
151 ignore_fields,
152 content_cache: Arc::new(Mutex::new(HashMap::new())),
153 }
154 }
155
156 fn create_combined_pattern(config: &MD044Config) -> Option<String> {
158 if config.names.is_empty() {
159 return None;
160 }
161
162 let mut patterns: Vec<String> = config
164 .names
165 .iter()
166 .flat_map(|name| {
167 let mut variations = vec![];
168 let lower_name = name.to_lowercase();
169
170 variations.push(escape_regex(&lower_name));
172
173 let lower_name_no_dots = lower_name.replace('.', "");
175 if lower_name != lower_name_no_dots {
176 variations.push(escape_regex(&lower_name_no_dots));
177 }
178
179 let ascii_normalized = Self::ascii_normalize(&lower_name);
181
182 if ascii_normalized != lower_name {
183 variations.push(escape_regex(&ascii_normalized));
184
185 let ascii_no_dots = ascii_normalized.replace('.', "");
187 if ascii_normalized != ascii_no_dots {
188 variations.push(escape_regex(&ascii_no_dots));
189 }
190 }
191
192 variations
193 })
194 .collect();
195
196 patterns.sort_by_key(|b| std::cmp::Reverse(b.len()));
198
199 Some(format!(r"(?i)({})", patterns.join("|")))
202 }
203
204 fn build_name_variants(config: &MD044Config) -> Vec<String> {
205 let mut variants = HashSet::new();
206 for name in &config.names {
207 let lower_name = name.to_lowercase();
208 variants.insert(lower_name.clone());
209
210 let lower_no_dots = lower_name.replace('.', "");
211 if lower_name != lower_no_dots {
212 variants.insert(lower_no_dots);
213 }
214
215 let ascii_normalized = Self::ascii_normalize(&lower_name);
216 if ascii_normalized != lower_name {
217 variants.insert(ascii_normalized.clone());
218
219 let ascii_no_dots = ascii_normalized.replace('.', "");
220 if ascii_normalized != ascii_no_dots {
221 variants.insert(ascii_no_dots);
222 }
223 }
224 }
225
226 variants.into_iter().collect()
227 }
228
229 fn find_name_violations(
232 &self,
233 content: &str,
234 ctx: &crate::lint_context::LintContext,
235 content_lower: &str,
236 ) -> Vec<WarningPosition> {
237 if self.config.names.is_empty() || content.is_empty() || self.combined_pattern.is_none() {
239 return Vec::new();
240 }
241
242 let has_potential_matches = self.name_variants.iter().any(|name| content_lower.contains(name));
244
245 if !has_potential_matches {
246 return Vec::new();
247 }
248
249 let hash = fast_hash(content);
251 {
252 if let Ok(cache) = self.content_cache.lock()
254 && let Some(cached) = cache.get(&hash)
255 {
256 return cached.clone();
257 }
258 }
259
260 let mut violations = Vec::new();
261
262 let combined_regex = match &self.combined_pattern {
264 Some(pattern) => match get_cached_regex(pattern) {
265 Ok(regex) => regex,
266 Err(_) => return Vec::new(),
267 },
268 None => return Vec::new(),
269 };
270
271 let field_map = if self.ignore_fields.is_empty() {
273 Vec::new()
274 } else {
275 frontmatter_values::field_map(ctx)
276 };
277
278 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
280 let line_num = line_idx + 1;
281 let line = line_info.content(ctx.content);
282
283 let trimmed = line.trim_start();
285 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
286 continue;
287 }
288
289 if !self.config.code_blocks && line_info.in_code_block {
291 continue;
292 }
293
294 if !self.config.html_elements && line_info.in_html_block {
296 continue;
297 }
298
299 if !self.config.html_comments && line_info.in_html_comment {
301 continue;
302 }
303
304 if line_info.in_jsx_expression || line_info.in_mdx_comment {
306 continue;
307 }
308
309 if line_info.in_obsidian_comment {
311 continue;
312 }
313
314 let fm_value_offset = if line_info.in_front_matter {
317 frontmatter_values::value_offset(line)
318 } else {
319 0
320 };
321 if fm_value_offset == usize::MAX {
322 continue;
323 }
324 if line_info.in_front_matter
325 && let Some(Some(field)) = field_map.get(line_idx)
326 && self.ignore_fields.contains(field)
327 {
328 continue;
329 }
330 let fm_value_span = if line_info.in_front_matter {
331 frontmatter_values::value_span(line)
332 } else {
333 None
334 };
335
336 if is_inline_config_comment(trimmed) {
338 continue;
339 }
340
341 let line_lower = line.to_lowercase();
343 let has_line_matches = self.name_variants.iter().any(|name| line_lower.contains(name));
344
345 if !has_line_matches {
346 continue;
347 }
348
349 for cap in combined_regex.find_iter(line) {
351 let found_name = &line[cap.start()..cap.end()];
352
353 let start_pos = cap.start();
355 let end_pos = cap.end();
356
357 if start_pos < fm_value_offset {
359 continue;
360 }
361
362 let byte_pos = line_info.byte_offset + start_pos;
364 if ctx.is_in_html_tag(byte_pos) {
365 continue;
366 }
367
368 if ctx.is_in_shortcode(byte_pos) {
375 continue;
376 }
377
378 if !Self::is_at_word_boundary(line, start_pos, true) || !Self::is_at_word_boundary(line, end_pos, false)
379 {
380 continue; }
382
383 if !self.config.code_blocks {
385 if ctx.is_in_code_block_or_span(byte_pos) {
386 continue;
387 }
388 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
392 && Self::is_in_backtick_code_in_line(line, start_pos)
393 {
394 continue;
395 }
396 }
397
398 if Self::is_in_link(ctx, byte_pos) {
400 continue;
401 }
402
403 if Self::is_in_angle_bracket_url(line, start_pos) {
407 continue;
408 }
409
410 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
414 && Self::is_in_markdown_link_url(line, start_pos)
415 {
416 continue;
417 }
418
419 if Self::is_in_wikilink_url(ctx, byte_pos) {
424 continue;
425 }
426
427 if ctx.is_in_bare_url(byte_pos) {
433 continue;
434 }
435
436 if let Some(fm_value) = fm_value_span
443 && Self::is_in_path_like_token(line, start_pos, fm_value)
444 {
445 continue;
446 }
447
448 if let Some(proper_name) = self.get_proper_name_for(found_name) {
450 if found_name != proper_name {
452 violations.push((line_num, cap.start() + 1, found_name.to_string()));
453 }
454 }
455 }
456 }
457
458 if let Ok(mut cache) = self.content_cache.lock() {
460 cache.insert(hash, violations.clone());
461 }
462 violations
463 }
464
465 fn is_in_link(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
473 use pulldown_cmark::LinkType;
474
475 if let Some(link) = ctx.link_containing(byte_pos) {
476 let (text_start, text_end) = if matches!(link.link_type, LinkType::WikiLink { .. }) {
478 let span = &ctx.content[link.byte_offset..link.byte_end];
485 let start = match span.find('|') {
486 Some(pipe) => link.byte_offset + pipe + 1,
487 None => link.byte_offset + 2,
488 };
489 (start, link.byte_end.saturating_sub(2))
490 } else {
491 let start = link.byte_offset + 1;
492 (start, start + link.text.len())
493 };
494
495 if byte_pos >= text_start && byte_pos < text_end {
499 let is_wikilink = matches!(link.link_type, LinkType::WikiLink { .. });
500 if Self::link_text_is_url(&link.text)
501 || (!is_wikilink && Self::link_text_matches_link_url(&link.text, &link.url))
502 {
503 return true;
504 }
505 return Self::image_verdict(ctx, byte_pos).unwrap_or(false);
511 }
512 return true;
514 }
515
516 if let Some(verdict) = Self::image_verdict(ctx, byte_pos) {
517 return verdict;
518 }
519
520 ctx.is_in_reference_def(byte_pos)
522 }
523
524 fn image_verdict(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> Option<bool> {
529 let image = ctx.image_containing(byte_pos)?;
530
531 let alt_start = image.byte_offset + 2;
533 let alt_end = alt_start + image.alt_text.len();
534
535 Some(!(byte_pos >= alt_start && byte_pos < alt_end))
537 }
538
539 fn link_text_is_url(text: &str) -> bool {
541 let lower = text.trim().to_ascii_lowercase();
542 lower.starts_with("http://")
543 || lower.starts_with("https://")
544 || lower.starts_with("www.")
545 || lower.starts_with("//")
546 }
547
548 fn link_text_matches_link_url(text: &str, url: &str) -> bool {
560 let text = text.trim();
561 if !text.contains('.') {
563 return false;
564 }
565 let url_lower = url.to_ascii_lowercase();
566 let url_without_scheme = url_lower
567 .strip_prefix("https://")
568 .or_else(|| url_lower.strip_prefix("http://"))
569 .or_else(|| url_lower.strip_prefix("//"))
570 .unwrap_or(&url_lower);
571 let text_lower = text.to_ascii_lowercase();
572 if url_without_scheme == text_lower.as_str() {
574 return true;
575 }
576 url_without_scheme.len() > text_lower.len()
578 && url_without_scheme.starts_with(text_lower.as_str())
579 && matches!(
580 url_without_scheme.as_bytes().get(text_lower.len()),
581 Some(b'/') | Some(b'?') | Some(b'#')
582 )
583 }
584
585 fn is_in_angle_bracket_url(line: &str, pos: usize) -> bool {
591 let bytes = line.as_bytes();
592 let len = bytes.len();
593 let mut i = 0;
594 while i < len {
595 if bytes[i] == b'<' {
596 let after_open = i + 1;
597 if after_open < len && bytes[after_open].is_ascii_alphabetic() {
601 let mut s = after_open + 1;
602 let scheme_max = (after_open + 32).min(len);
603 while s < scheme_max
604 && (bytes[s].is_ascii_alphanumeric()
605 || bytes[s] == b'+'
606 || bytes[s] == b'-'
607 || bytes[s] == b'.')
608 {
609 s += 1;
610 }
611 if s < len && bytes[s] == b':' {
612 let mut j = s + 1;
614 let mut found_close = false;
615 while j < len {
616 match bytes[j] {
617 b'>' => {
618 found_close = true;
619 break;
620 }
621 b' ' | b'<' => break,
622 _ => j += 1,
623 }
624 }
625 if found_close && pos >= i && pos <= j {
626 return true;
627 }
628 if found_close {
629 i = j + 1;
630 continue;
631 }
632 }
633 }
634 }
635 i += 1;
636 }
637 false
638 }
639
640 fn is_in_wikilink_url(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
653 use pulldown_cmark::LinkType;
654 let content = ctx.content.as_bytes();
655
656 for link in ctx.links_starting_before_or_at(byte_pos) {
657 if !matches!(link.link_type, LinkType::WikiLink { .. }) {
658 continue;
659 }
660 let wiki_end = link.byte_end;
661 if wiki_end >= byte_pos || wiki_end >= content.len() || content[wiki_end] != b'(' {
663 continue;
664 }
665 let mut depth: u32 = 1;
670 let mut k = wiki_end + 1;
671 let mut valid_destination = true;
672 while k < content.len() && depth > 0 {
673 match content[k] {
674 b'\\' => {
675 k += 1; }
677 b'(' => depth += 1,
678 b')' => depth -= 1,
679 b' ' | b'\t' | b'\n' | b'\r' => {
680 valid_destination = false;
681 break;
682 }
683 _ => {}
684 }
685 k += 1;
686 }
687 if valid_destination && depth == 0 && byte_pos > wiki_end && byte_pos < k {
690 return true;
691 }
692 }
693 false
694 }
695
696 fn is_in_markdown_link_url(line: &str, pos: usize) -> bool {
706 let bytes = line.as_bytes();
707 let len = bytes.len();
708 let mut i = 0;
709
710 while i < len {
711 if bytes[i] == b'[' && (i == 0 || bytes[i - 1] != b'\\' || (i >= 2 && bytes[i - 2] == b'\\')) {
713 let mut depth: u32 = 1;
715 let mut j = i + 1;
716 while j < len && depth > 0 {
717 match bytes[j] {
718 b'\\' => {
719 j += 1; }
721 b'[' => depth += 1,
722 b']' => depth -= 1,
723 _ => {}
724 }
725 j += 1;
726 }
727
728 if depth == 0 && j < len {
730 if bytes[j] == b'(' {
731 let url_start = j;
733 let mut paren_depth: u32 = 1;
734 let mut k = j + 1;
735 while k < len && paren_depth > 0 {
736 match bytes[k] {
737 b'\\' => {
738 k += 1; }
740 b'(' => paren_depth += 1,
741 b')' => paren_depth -= 1,
742 _ => {}
743 }
744 k += 1;
745 }
746
747 if paren_depth == 0 {
748 if pos > url_start && pos < k {
749 return true;
750 }
751 i = k;
752 continue;
753 }
754 } else if bytes[j] == b'[' {
755 let ref_start = j;
757 let mut ref_depth: u32 = 1;
758 let mut k = j + 1;
759 while k < len && ref_depth > 0 {
760 match bytes[k] {
761 b'\\' => {
762 k += 1;
763 }
764 b'[' => ref_depth += 1,
765 b']' => ref_depth -= 1,
766 _ => {}
767 }
768 k += 1;
769 }
770
771 if ref_depth == 0 {
772 if pos > ref_start && pos < k {
773 return true;
774 }
775 i = k;
776 continue;
777 }
778 }
779 }
780 }
781 i += 1;
782 }
783 false
784 }
785
786 fn is_in_backtick_code_in_line(line: &str, pos: usize) -> bool {
794 let bytes = line.as_bytes();
795 let len = bytes.len();
796 let mut i = 0;
797 while i < len {
798 if bytes[i] == b'`' {
799 let open_start = i;
801 while i < len && bytes[i] == b'`' {
802 i += 1;
803 }
804 let tick_len = i - open_start;
805
806 while i < len {
808 if bytes[i] == b'`' {
809 let close_start = i;
810 while i < len && bytes[i] == b'`' {
811 i += 1;
812 }
813 if i - close_start == tick_len {
814 let content_start = open_start + tick_len;
818 let content_end = close_start;
819 if pos >= content_start && pos < content_end {
820 return true;
821 }
822 break;
824 }
825 } else {
827 i += 1;
828 }
829 }
830 } else {
831 i += 1;
832 }
833 }
834 false
835 }
836
837 fn is_word_boundary_char(c: char) -> bool {
839 !c.is_alphanumeric()
840 }
841
842 fn is_at_word_boundary(content: &str, pos: usize, is_start: bool) -> bool {
844 if is_start {
845 if pos == 0 {
846 return true;
847 }
848 match content[..pos].chars().next_back() {
849 None => true,
850 Some(c) => Self::is_word_boundary_char(c),
851 }
852 } else {
853 if pos >= content.len() {
854 return true;
855 }
856 match content[pos..].chars().next() {
857 None => true,
858 Some(c) => Self::is_word_boundary_char(c),
859 }
860 }
861 }
862
863 fn is_in_path_like_token(line: &str, match_start: usize, fm_value: (usize, usize)) -> bool {
891 let (value_start, value_end) = fm_value;
892 if match_start < value_start || match_start >= value_end {
893 return false;
894 }
895
896 let quoted_words: Vec<&str> = if frontmatter_values::value_is_quoted(line, value_start) {
904 line[value_start..value_end].split_whitespace().collect()
905 } else {
906 Vec::new()
907 };
908 let is_single_quoted_path = !quoted_words.is_empty() && quoted_words.iter().all(|word| word.contains('/'));
909 let is_multi_word_collapse = is_single_quoted_path && quoted_words.len() > 1;
917
918 let (raw_start, raw_end) = if is_single_quoted_path {
919 (value_start, value_end)
920 } else {
921 frontmatter_values::token_bounds(line, match_start, value_start, value_end)
922 };
923
924 let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
925 if match_start < start || match_start >= end {
926 return false;
927 }
928
929 let token = &line[start..end];
930 if !token.contains('/') {
931 return false;
932 }
933 if token.starts_with('/') || token.starts_with("./") || token.starts_with("../") || token.starts_with("~/") {
934 return true;
935 }
936 if token.rsplit('/').next().is_some_and(|seg| seg.contains('.')) {
937 return true;
938 }
939
940 if is_multi_word_collapse {
941 return false;
942 }
943
944 let sole_value = {
948 let (ts, te) = frontmatter_values::trim_token_bounds(line, value_start, value_end);
949 ts == start && te == end
950 };
951 sole_value && token.split('/').filter(|s| !s.is_empty()).count() >= 3
952 }
953
954 fn get_proper_name_for(&self, found_name: &str) -> Option<String> {
956 let found_lower = found_name.to_lowercase();
957
958 for name in &self.config.names {
960 let lower_name = name.to_lowercase();
961 let lower_name_no_dots = lower_name.replace('.', "");
962
963 if found_lower == lower_name || found_lower == lower_name_no_dots {
965 return Some(name.clone());
966 }
967
968 let ascii_normalized = Self::ascii_normalize(&lower_name);
970
971 let ascii_no_dots = ascii_normalized.replace('.', "");
972
973 if found_lower == ascii_normalized || found_lower == ascii_no_dots {
974 return Some(name.clone());
975 }
976 }
977 None
978 }
979}
980
981impl Rule for MD044ProperNames {
982 fn name(&self) -> &'static str {
983 "MD044"
984 }
985
986 fn description(&self) -> &'static str {
987 "Proper names should have the correct capitalization"
988 }
989
990 fn category(&self) -> RuleCategory {
991 RuleCategory::Other
992 }
993
994 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
995 if self.config.names.is_empty() {
996 return true;
997 }
998 let content_lower = if ctx.content.is_ascii() {
1000 ctx.content.to_ascii_lowercase()
1001 } else {
1002 ctx.content.to_lowercase()
1003 };
1004 !self.name_variants.iter().any(|name| content_lower.contains(name))
1005 }
1006
1007 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1008 let content = ctx.content;
1009 if content.is_empty() || self.config.names.is_empty() || self.combined_pattern.is_none() {
1010 return Ok(Vec::new());
1011 }
1012
1013 let content_lower = if content.is_ascii() {
1015 content.to_ascii_lowercase()
1016 } else {
1017 content.to_lowercase()
1018 };
1019
1020 let has_potential_matches = self.name_variants.iter().any(|name| content_lower.contains(name));
1022
1023 if !has_potential_matches {
1024 return Ok(Vec::new());
1025 }
1026 let violations = self.find_name_violations(content, ctx, &content_lower);
1027
1028 let warnings = violations
1029 .into_iter()
1030 .filter_map(|(line, column, found_name)| {
1031 self.get_proper_name_for(&found_name).map(|proper_name| {
1032 let line_start = ctx.line_start_byte(line).unwrap_or(0);
1037 let byte_start = line_start + (column - 1);
1038 let byte_end = byte_start + found_name.len();
1039 let line_text = ctx.line_info(line).map_or("", |li| li.content(ctx.content));
1042 let char_col = byte_to_char_count(line_text, column - 1);
1043 LintWarning {
1044 rule_name: Some(self.name().to_string()),
1045 line,
1046 column: char_col,
1047 end_line: line,
1048 end_column: char_col + found_name.chars().count(),
1049 message: format!("Proper name '{found_name}' should be '{proper_name}'"),
1050 severity: Severity::Warning,
1051 fix: Some(Fix::new(byte_start..byte_end, proper_name)),
1052 }
1053 })
1054 })
1055 .collect();
1056
1057 Ok(warnings)
1058 }
1059
1060 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1061 if self.should_skip(ctx) {
1062 return Ok(ctx.content.to_string());
1063 }
1064 let warnings = self.check(ctx)?;
1065 if warnings.is_empty() {
1066 return Ok(ctx.content.to_string());
1067 }
1068 let warnings =
1069 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1070 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
1071 .map_err(crate::rule::LintError::InvalidInput)
1072 }
1073
1074 fn as_any(&self) -> &dyn std::any::Any {
1075 self
1076 }
1077
1078 crate::impl_rule_config_methods!(MD044Config);
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083 use super::*;
1084 use crate::lint_context::LintContext;
1085
1086 fn create_context(content: &str) -> LintContext<'_> {
1087 LintContext::new(content, crate::config::MarkdownFlavor::Standard, None)
1088 }
1089
1090 fn field_map_for(content: &str) -> Vec<Option<String>> {
1091 let ctx = create_context(content);
1092 frontmatter_values::field_map(&ctx)
1093 }
1094
1095 #[test]
1096 fn test_field_map_nested_lines_inherit_top_level_key() {
1097 let map = field_map_for("---\nseo:\n canonical: docs/a.md\n keywords:\n - myapp\ntitle: x\n---\n");
1098 assert_eq!(map[2].as_deref(), Some("seo"));
1099 assert_eq!(map[4].as_deref(), Some("seo"));
1100 assert_eq!(map[5].as_deref(), Some("title"));
1101 }
1102
1103 #[test]
1104 fn test_field_map_block_scalar_bracket_does_not_swallow_next_key() {
1105 let map = field_map_for("---\ndescription: |\n [myapp\ntitle: myapp\n---\n");
1106 assert_eq!(map[2].as_deref(), Some("description"));
1107 assert_eq!(
1108 map[3].as_deref(),
1109 Some("title"),
1110 "an indent-0 key always starts a new key"
1111 );
1112 }
1113
1114 #[test]
1115 fn test_field_map_quoted_key_with_colon() {
1116 let map = field_map_for("---\n\"og:title\": myapp\n---\n");
1117 assert_eq!(map[1].as_deref(), Some("og:title"));
1118 }
1119
1120 #[test]
1121 fn test_field_map_top_level_sequence_clears_attribution() {
1122 let map = field_map_for("---\n- myapp\n---\n");
1123 assert_eq!(map[1], None);
1124 }
1125
1126 #[test]
1127 fn test_field_map_toml_table_body_belongs_to_table_root() {
1128 let map = field_map_for("+++\n[seo]\ncanonical = \"docs/a.md\"\n\n[[authors]]\nname = \"myapp\"\n+++\n");
1129 assert_eq!(map[2].as_deref(), Some("seo"));
1130 assert_eq!(map[5].as_deref(), Some("authors"));
1131 }
1132
1133 #[test]
1134 fn test_field_map_toml_dotted_assignment_uses_root() {
1135 let map = field_map_for("+++\nseo.canonical = \"docs/a.md\"\n+++\n");
1136 assert_eq!(map[1].as_deref(), Some("seo"));
1137 }
1138
1139 #[test]
1140 fn test_field_map_toml_array_continuation_inherits() {
1141 let map = field_map_for("+++\nseo = [\n\"docs/guide/myapp\"\n]\n+++\n");
1142 assert_eq!(map[2].as_deref(), Some("seo"));
1143 }
1144
1145 #[test]
1146 fn test_field_map_indent_zero_flow_continuation_is_not_attributed_to_parent() {
1147 let map = field_map_for("---\nseo: [\n{name: myapp}\n]\n---\n");
1150 assert_eq!(map[2].as_deref(), Some("{name"));
1151 }
1152
1153 #[test]
1154 fn test_field_map_toml_nested_array_inherits_and_title_not_corrupted() {
1155 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\ntitle = \"x\"\n+++\n");
1156 assert_eq!(map[2].as_deref(), Some("matrix"), "nested array line inherits matrix");
1157 assert_eq!(map[3].as_deref(), Some("matrix"), "nested array line inherits matrix");
1158 assert_eq!(
1159 map[5].as_deref(),
1160 Some("title"),
1161 "title must not inherit stale attribution from a closed nested array"
1162 );
1163 }
1164
1165 #[test]
1166 fn test_field_map_toml_nested_array_last_element_without_trailing_comma() {
1167 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [2]\n]\ntitle = \"x\"\n+++\n");
1168 assert_eq!(
1169 map[3].as_deref(),
1170 Some("matrix"),
1171 "last element without a trailing comma still inherits matrix"
1172 );
1173 assert_eq!(
1174 map[5].as_deref(),
1175 Some("title"),
1176 "title must not inherit stale attribution from a closed nested array"
1177 );
1178 }
1179
1180 #[test]
1181 fn test_field_map_toml_nested_array_then_real_table_header_non_regression_guard() {
1182 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\n\n[seo]\ncanonical = \"docs/a.md\"\n+++\n");
1189 assert_eq!(map[6].as_deref(), Some("seo"), "real table header after a closed array");
1190 assert_eq!(
1191 map[7].as_deref(),
1192 Some("seo"),
1193 "table body still attributes to the table"
1194 );
1195 }
1196
1197 #[test]
1198 fn test_field_map_toml_unclosed_array_resyncs_on_next_assignment() {
1199 let map = field_map_for("+++\nmatrix = [\n [1, 2],\ntitle = \"x\"\n+++\n");
1205 assert_eq!(
1206 map[3].as_deref(),
1207 Some("title"),
1208 "title must resync even though the array was never closed"
1209 );
1210 }
1211
1212 #[test]
1213 fn test_field_map_toml_column_zero_array_elements_inherit_and_title_not_corrupted() {
1214 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[3, 4],\n]\ntitle = \"x\"\n+++\n");
1220 assert_eq!(
1221 map[2].as_deref(),
1222 Some("matrix"),
1223 "column-0 array element inherits matrix"
1224 );
1225 assert_eq!(
1226 map[3].as_deref(),
1227 Some("matrix"),
1228 "column-0 array element inherits matrix"
1229 );
1230 assert_eq!(
1231 map[5].as_deref(),
1232 Some("title"),
1233 "title must not inherit stale attribution from a misread array element"
1234 );
1235 }
1236
1237 #[test]
1238 fn test_field_map_toml_column_zero_array_last_element_without_trailing_comma() {
1239 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[2]\n]\ntitle = \"x\"\n+++\n");
1240 assert_eq!(
1241 map[3].as_deref(),
1242 Some("matrix"),
1243 "column-0 last element without a trailing comma still inherits matrix"
1244 );
1245 assert_eq!(
1246 map[5].as_deref(),
1247 Some("title"),
1248 "title must not inherit stale attribution from a misread array element"
1249 );
1250 }
1251
1252 #[test]
1253 fn test_correctly_capitalized_names() {
1254 let rule = MD044ProperNames::new(
1255 vec![
1256 "JavaScript".to_string(),
1257 "TypeScript".to_string(),
1258 "Node.js".to_string(),
1259 ],
1260 true,
1261 );
1262
1263 let content = "This document uses JavaScript, TypeScript, and Node.js correctly.";
1264 let ctx = create_context(content);
1265 let result = rule.check(&ctx).unwrap();
1266 assert!(result.is_empty(), "Should not flag correctly capitalized names");
1267 }
1268
1269 #[test]
1270 fn test_incorrectly_capitalized_names() {
1271 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1272
1273 let content = "This document uses javascript and typescript incorrectly.";
1274 let ctx = create_context(content);
1275 let result = rule.check(&ctx).unwrap();
1276
1277 assert_eq!(result.len(), 2, "Should flag two incorrect capitalizations");
1278 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1279 assert_eq!(result[0].line, 1);
1280 assert_eq!(result[0].column, 20);
1281 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1282 assert_eq!(result[1].line, 1);
1283 assert_eq!(result[1].column, 35);
1284 }
1285
1286 #[test]
1287 fn test_names_at_beginning_of_sentences() {
1288 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "Python".to_string()], true);
1289
1290 let content = "javascript is a great language. python is also popular.";
1291 let ctx = create_context(content);
1292 let result = rule.check(&ctx).unwrap();
1293
1294 assert_eq!(result.len(), 2, "Should flag names at beginning of sentences");
1295 assert_eq!(result[0].line, 1);
1296 assert_eq!(result[0].column, 1);
1297 assert_eq!(result[1].line, 1);
1298 assert_eq!(result[1].column, 33);
1299 }
1300
1301 #[test]
1302 fn test_names_in_code_blocks_checked_by_default() {
1303 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1304
1305 let content = r#"Here is some text with JavaScript.
1306
1307```javascript
1308// This javascript should be checked
1309const lang = "javascript";
1310```
1311
1312But this javascript should be flagged."#;
1313
1314 let ctx = create_context(content);
1315 let result = rule.check(&ctx).unwrap();
1316
1317 assert_eq!(result.len(), 3, "Should flag javascript inside and outside code blocks");
1318 assert_eq!(result[0].line, 4);
1319 assert_eq!(result[1].line, 5);
1320 assert_eq!(result[2].line, 8);
1321 }
1322
1323 #[test]
1324 fn test_names_in_code_blocks_ignored_when_disabled() {
1325 let rule = MD044ProperNames::new(
1326 vec!["JavaScript".to_string()],
1327 false, );
1329
1330 let content = r#"```
1331javascript in code block
1332```"#;
1333
1334 let ctx = create_context(content);
1335 let result = rule.check(&ctx).unwrap();
1336
1337 assert_eq!(
1338 result.len(),
1339 0,
1340 "Should not flag javascript in code blocks when code_blocks is false"
1341 );
1342 }
1343
1344 #[test]
1345 fn test_names_in_inline_code_checked_by_default() {
1346 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1347
1348 let content = "This is `javascript` in inline code and javascript outside.";
1349 let ctx = create_context(content);
1350 let result = rule.check(&ctx).unwrap();
1351
1352 assert_eq!(result.len(), 2, "Should flag javascript inside and outside inline code");
1354 assert_eq!(result[0].column, 10); assert_eq!(result[1].column, 41); }
1357
1358 #[test]
1359 fn test_multiple_names_in_same_line() {
1360 let rule = MD044ProperNames::new(
1361 vec!["JavaScript".to_string(), "TypeScript".to_string(), "React".to_string()],
1362 true,
1363 );
1364
1365 let content = "I use javascript, typescript, and react in my projects.";
1366 let ctx = create_context(content);
1367 let result = rule.check(&ctx).unwrap();
1368
1369 assert_eq!(result.len(), 3, "Should flag all three incorrect names");
1370 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1371 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1372 assert_eq!(result[2].message, "Proper name 'react' should be 'React'");
1373 }
1374
1375 #[test]
1376 fn test_case_sensitivity() {
1377 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1378
1379 let content = "JAVASCRIPT, Javascript, javascript, and JavaScript variations.";
1380 let ctx = create_context(content);
1381 let result = rule.check(&ctx).unwrap();
1382
1383 assert_eq!(result.len(), 3, "Should flag all incorrect case variations");
1384 assert!(result.iter().all(|w| w.message.contains("should be 'JavaScript'")));
1386 }
1387
1388 #[test]
1389 fn test_configuration_with_custom_name_list() {
1390 let config = MD044Config {
1391 names: vec!["GitHub".to_string(), "GitLab".to_string(), "DevOps".to_string()],
1392 code_blocks: true,
1393 ..Default::default()
1394 };
1395 let rule = MD044ProperNames::from_config_struct(config);
1396
1397 let content = "We use github, gitlab, and devops for our workflow.";
1398 let ctx = create_context(content);
1399 let result = rule.check(&ctx).unwrap();
1400
1401 assert_eq!(result.len(), 3, "Should flag all custom names");
1402 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
1403 assert_eq!(result[1].message, "Proper name 'gitlab' should be 'GitLab'");
1404 assert_eq!(result[2].message, "Proper name 'devops' should be 'DevOps'");
1405 }
1406
1407 #[test]
1408 fn test_empty_configuration() {
1409 let rule = MD044ProperNames::new(vec![], true);
1410
1411 let content = "This has javascript and typescript but no configured names.";
1412 let ctx = create_context(content);
1413 let result = rule.check(&ctx).unwrap();
1414
1415 assert!(result.is_empty(), "Should not flag anything with empty configuration");
1416 }
1417
1418 #[test]
1419 fn test_names_with_special_characters() {
1420 let rule = MD044ProperNames::new(
1421 vec!["Node.js".to_string(), "ASP.NET".to_string(), "C++".to_string()],
1422 true,
1423 );
1424
1425 let content = "We use nodejs, asp.net, ASP.NET, and c++ in our stack.";
1426 let ctx = create_context(content);
1427 let result = rule.check(&ctx).unwrap();
1428
1429 assert_eq!(result.len(), 3, "Should handle special characters correctly");
1434
1435 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
1436 assert!(messages.contains(&"Proper name 'nodejs' should be 'Node.js'"));
1437 assert!(messages.contains(&"Proper name 'asp.net' should be 'ASP.NET'"));
1438 assert!(messages.contains(&"Proper name 'c++' should be 'C++'"));
1439 }
1440
1441 #[test]
1442 fn test_word_boundaries() {
1443 let rule = MD044ProperNames::new(vec!["Java".to_string(), "Script".to_string()], true);
1444
1445 let content = "JavaScript is not java or script, but Java and Script are separate.";
1446 let ctx = create_context(content);
1447 let result = rule.check(&ctx).unwrap();
1448
1449 assert_eq!(result.len(), 2, "Should respect word boundaries");
1451 assert!(result.iter().any(|w| w.column == 19)); assert!(result.iter().any(|w| w.column == 27)); }
1454
1455 #[test]
1456 fn test_fix_method() {
1457 let rule = MD044ProperNames::new(
1458 vec![
1459 "JavaScript".to_string(),
1460 "TypeScript".to_string(),
1461 "Node.js".to_string(),
1462 ],
1463 true,
1464 );
1465
1466 let content = "I love javascript, typescript, and nodejs!";
1467 let ctx = create_context(content);
1468 let fixed = rule.fix(&ctx).unwrap();
1469
1470 assert_eq!(fixed, "I love JavaScript, TypeScript, and Node.js!");
1471 }
1472
1473 #[test]
1474 fn test_fix_multiple_occurrences() {
1475 let rule = MD044ProperNames::new(vec!["Python".to_string()], true);
1476
1477 let content = "python is great. I use python daily. PYTHON is powerful.";
1478 let ctx = create_context(content);
1479 let fixed = rule.fix(&ctx).unwrap();
1480
1481 assert_eq!(fixed, "Python is great. I use Python daily. Python is powerful.");
1482 }
1483
1484 #[test]
1485 fn test_fix_checks_code_blocks_by_default() {
1486 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1487
1488 let content = r#"I love javascript.
1489
1490```
1491const lang = "javascript";
1492```
1493
1494More javascript here."#;
1495
1496 let ctx = create_context(content);
1497 let fixed = rule.fix(&ctx).unwrap();
1498
1499 let expected = r#"I love JavaScript.
1500
1501```
1502const lang = "JavaScript";
1503```
1504
1505More JavaScript here."#;
1506
1507 assert_eq!(fixed, expected);
1508 }
1509
1510 #[test]
1511 fn test_multiline_content() {
1512 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
1513
1514 let content = r#"First line with rust.
1515Second line with python.
1516Third line with RUST and PYTHON."#;
1517
1518 let ctx = create_context(content);
1519 let result = rule.check(&ctx).unwrap();
1520
1521 assert_eq!(result.len(), 4, "Should flag all incorrect occurrences");
1522 assert_eq!(result[0].line, 1);
1523 assert_eq!(result[1].line, 2);
1524 assert_eq!(result[2].line, 3);
1525 assert_eq!(result[3].line, 3);
1526 }
1527
1528 #[test]
1529 fn test_default_config() {
1530 let config = MD044Config::default();
1531 assert!(config.names.is_empty());
1532 assert!(!config.code_blocks);
1533 assert!(config.html_elements);
1534 assert!(config.html_comments);
1535 }
1536
1537 #[test]
1538 fn test_default_config_checks_html_comments() {
1539 let config = MD044Config {
1540 names: vec!["JavaScript".to_string()],
1541 ..MD044Config::default()
1542 };
1543 let rule = MD044ProperNames::from_config_struct(config);
1544
1545 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1546 let ctx = create_context(content);
1547 let result = rule.check(&ctx).unwrap();
1548
1549 assert_eq!(result.len(), 1, "Default config should check HTML comments");
1550 assert_eq!(result[0].line, 3);
1551 }
1552
1553 #[test]
1554 fn test_default_config_skips_code_blocks() {
1555 let config = MD044Config {
1556 names: vec!["JavaScript".to_string()],
1557 ..MD044Config::default()
1558 };
1559 let rule = MD044ProperNames::from_config_struct(config);
1560
1561 let content = "# Guide\n\n```\njavascript in code\n```\n";
1562 let ctx = create_context(content);
1563 let result = rule.check(&ctx).unwrap();
1564
1565 assert_eq!(result.len(), 0, "Default config should skip code blocks");
1566 }
1567
1568 #[test]
1569 fn test_standalone_html_comment_checked() {
1570 let config = MD044Config {
1571 names: vec!["Test".to_string()],
1572 ..MD044Config::default()
1573 };
1574 let rule = MD044ProperNames::from_config_struct(config);
1575
1576 let content = "# Heading\n\n<!-- this is a test example -->\n";
1577 let ctx = create_context(content);
1578 let result = rule.check(&ctx).unwrap();
1579
1580 assert_eq!(result.len(), 1, "Should flag proper name in standalone HTML comment");
1581 assert_eq!(result[0].line, 3);
1582 }
1583
1584 #[test]
1585 fn test_inline_config_comments_not_flagged() {
1586 let config = MD044Config {
1587 names: vec!["RUMDL".to_string()],
1588 ..MD044Config::default()
1589 };
1590 let rule = MD044ProperNames::from_config_struct(config);
1591
1592 let content = "<!-- rumdl-disable MD044 -->\nSome rumdl text here.\n<!-- rumdl-enable MD044 -->\n<!-- markdownlint-disable -->\nMore rumdl text.\n<!-- markdownlint-enable -->\n";
1596 let ctx = create_context(content);
1597 let result = rule.check(&ctx).unwrap();
1598
1599 assert_eq!(result.len(), 2, "Should only flag body lines, not config comments");
1600 assert_eq!(result[0].line, 2);
1601 assert_eq!(result[1].line, 5);
1602 }
1603
1604 #[test]
1605 fn test_html_comment_skipped_when_disabled() {
1606 let config = MD044Config {
1607 names: vec!["Test".to_string()],
1608 code_blocks: true,
1609 html_comments: false,
1610 ..Default::default()
1611 };
1612 let rule = MD044ProperNames::from_config_struct(config);
1613
1614 let content = "# Heading\n\n<!-- this is a test example -->\n\nRegular test here.\n";
1615 let ctx = create_context(content);
1616 let result = rule.check(&ctx).unwrap();
1617
1618 assert_eq!(
1619 result.len(),
1620 1,
1621 "Should only flag 'test' outside HTML comment when html_comments=false"
1622 );
1623 assert_eq!(result[0].line, 5);
1624 }
1625
1626 #[test]
1627 fn test_fix_corrects_html_comment_content() {
1628 let config = MD044Config {
1629 names: vec!["JavaScript".to_string()],
1630 ..MD044Config::default()
1631 };
1632 let rule = MD044ProperNames::from_config_struct(config);
1633
1634 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1635 let ctx = create_context(content);
1636 let fixed = rule.fix(&ctx).unwrap();
1637
1638 assert_eq!(fixed, "# Guide\n\n<!-- JavaScript mentioned here -->\n");
1639 }
1640
1641 #[test]
1642 fn test_fix_does_not_modify_inline_config_comments() {
1643 let config = MD044Config {
1644 names: vec!["RUMDL".to_string()],
1645 ..MD044Config::default()
1646 };
1647 let rule = MD044ProperNames::from_config_struct(config);
1648
1649 let content = "<!-- rumdl-disable -->\nSome rumdl text.\n<!-- rumdl-enable -->\n";
1650 let ctx = create_context(content);
1651 let fixed = rule.fix(&ctx).unwrap();
1652
1653 assert!(fixed.contains("<!-- rumdl-disable -->"));
1655 assert!(fixed.contains("<!-- rumdl-enable -->"));
1656 assert!(
1658 fixed.contains("Some rumdl text."),
1659 "Line inside rumdl-disable block should not be modified by fix()"
1660 );
1661 }
1662
1663 #[test]
1664 fn test_fix_respects_inline_disable_partial() {
1665 let config = MD044Config {
1666 names: vec!["RUMDL".to_string()],
1667 ..MD044Config::default()
1668 };
1669 let rule = MD044ProperNames::from_config_struct(config);
1670
1671 let content =
1672 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
1673 let ctx = create_context(content);
1674 let fixed = rule.fix(&ctx).unwrap();
1675
1676 assert!(
1678 fixed.contains("Some rumdl text.\n<!-- rumdl-enable"),
1679 "Line inside disable block should not be modified"
1680 );
1681 assert!(
1683 fixed.contains("Some RUMDL text outside."),
1684 "Line outside disable block should be fixed"
1685 );
1686 }
1687
1688 #[test]
1689 fn test_performance_with_many_names() {
1690 let mut names = vec![];
1691 for i in 0..50 {
1692 names.push(format!("ProperName{i}"));
1693 }
1694
1695 let rule = MD044ProperNames::new(names, true);
1696
1697 let content = "This has propername0, propername25, and propername49 incorrectly.";
1698 let ctx = create_context(content);
1699 let result = rule.check(&ctx).unwrap();
1700
1701 assert_eq!(result.len(), 3, "Should handle many configured names efficiently");
1702 }
1703
1704 #[test]
1705 fn test_large_name_count_performance() {
1706 let names = (0..1000).map(|i| format!("ProperName{i}")).collect::<Vec<_>>();
1709
1710 let rule = MD044ProperNames::new(names, true);
1711
1712 assert!(rule.combined_pattern.is_some());
1714
1715 let content = "This has propername0 and propername999 in it.";
1717 let ctx = create_context(content);
1718 let result = rule.check(&ctx).unwrap();
1719
1720 assert_eq!(result.len(), 2, "Should handle 1000 names without issues");
1722 }
1723
1724 #[test]
1725 fn test_cache_behavior() {
1726 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1727
1728 let content = "Using javascript here.";
1729 let ctx = create_context(content);
1730
1731 let result1 = rule.check(&ctx).unwrap();
1733 assert_eq!(result1.len(), 1);
1734
1735 let result2 = rule.check(&ctx).unwrap();
1737 assert_eq!(result2.len(), 1);
1738
1739 assert_eq!(result1[0].line, result2[0].line);
1741 assert_eq!(result1[0].column, result2[0].column);
1742 }
1743
1744 #[test]
1745 fn test_html_comments_not_checked_when_disabled() {
1746 let config = MD044Config {
1747 names: vec!["JavaScript".to_string()],
1748 code_blocks: true, html_comments: false, ..Default::default()
1751 };
1752 let rule = MD044ProperNames::from_config_struct(config);
1753
1754 let content = r#"Regular javascript here.
1755<!-- This javascript in HTML comment should be ignored -->
1756More javascript outside."#;
1757
1758 let ctx = create_context(content);
1759 let result = rule.check(&ctx).unwrap();
1760
1761 assert_eq!(result.len(), 2, "Should only flag javascript outside HTML comments");
1762 assert_eq!(result[0].line, 1);
1763 assert_eq!(result[1].line, 3);
1764 }
1765
1766 #[test]
1767 fn test_html_comments_checked_when_enabled() {
1768 let config = MD044Config {
1769 names: vec!["JavaScript".to_string()],
1770 code_blocks: true, ..Default::default()
1772 };
1773 let rule = MD044ProperNames::from_config_struct(config);
1774
1775 let content = r#"Regular javascript here.
1776<!-- This javascript in HTML comment should be checked -->
1777More javascript outside."#;
1778
1779 let ctx = create_context(content);
1780 let result = rule.check(&ctx).unwrap();
1781
1782 assert_eq!(
1783 result.len(),
1784 3,
1785 "Should flag all javascript occurrences including in HTML comments"
1786 );
1787 }
1788
1789 #[test]
1790 fn test_indented_html_comment_escapes_via_link_and_backticks() {
1791 let config = MD044Config {
1796 names: vec!["Test".to_string()],
1797 ..Default::default()
1798 };
1799 let rule = MD044ProperNames::from_config_struct(config);
1800
1801 let content = "<!-- see the [relevant page](test.md). -->\n<!-- see `test.md` -->\n <!-- see the [relevant page](test.md). -->\n <!-- see `test.md` -->\n";
1802
1803 let ctx = create_context(content);
1804 let result = rule.check(&ctx).unwrap();
1805
1806 assert!(
1807 result.is_empty(),
1808 "'test' inside a link URL or backticks must be ignored in both column-0 and indented comments, got: {result:?}"
1809 );
1810 }
1811
1812 #[test]
1813 fn test_indented_html_comment_still_checks_bare_prose() {
1814 let config = MD044Config {
1817 names: vec!["Test".to_string()],
1818 ..Default::default()
1819 };
1820 let rule = MD044ProperNames::from_config_struct(config);
1821
1822 let content = " <!-- this is a test comment -->\n";
1823
1824 let ctx = create_context(content);
1825 let result = rule.check(&ctx).unwrap();
1826
1827 assert_eq!(
1828 result.len(),
1829 1,
1830 "bare 'test' in an indented comment is still a violation"
1831 );
1832 assert_eq!(result[0].line, 1);
1833 }
1834
1835 #[test]
1836 fn test_multiline_html_comments() {
1837 let config = MD044Config {
1838 names: vec!["Python".to_string(), "JavaScript".to_string()],
1839 code_blocks: true, html_comments: false, ..Default::default()
1842 };
1843 let rule = MD044ProperNames::from_config_struct(config);
1844
1845 let content = r#"Regular python here.
1846<!--
1847This is a multiline comment
1848with javascript and python
1849that should be ignored
1850-->
1851More javascript outside."#;
1852
1853 let ctx = create_context(content);
1854 let result = rule.check(&ctx).unwrap();
1855
1856 assert_eq!(result.len(), 2, "Should only flag names outside HTML comments");
1857 assert_eq!(result[0].line, 1); assert_eq!(result[1].line, 7); }
1860
1861 #[test]
1862 fn test_fix_preserves_html_comments_when_disabled() {
1863 let config = MD044Config {
1864 names: vec!["JavaScript".to_string()],
1865 code_blocks: true, html_comments: false, ..Default::default()
1868 };
1869 let rule = MD044ProperNames::from_config_struct(config);
1870
1871 let content = r#"javascript here.
1872<!-- javascript in comment -->
1873More javascript."#;
1874
1875 let ctx = create_context(content);
1876 let fixed = rule.fix(&ctx).unwrap();
1877
1878 let expected = r#"JavaScript here.
1879<!-- javascript in comment -->
1880More JavaScript."#;
1881
1882 assert_eq!(
1883 fixed, expected,
1884 "Should not fix names inside HTML comments when disabled"
1885 );
1886 }
1887
1888 #[test]
1889 fn test_proper_names_in_link_text_are_flagged() {
1890 let rule = MD044ProperNames::new(
1891 vec!["JavaScript".to_string(), "Node.js".to_string(), "Python".to_string()],
1892 true,
1893 );
1894
1895 let content = r#"Check this [javascript documentation](https://javascript.info) for info.
1896
1897Visit [node.js homepage](https://nodejs.org) and [python tutorial](https://python.org).
1898
1899Real javascript should be flagged.
1900
1901Also see the [typescript guide][ts-ref] for more.
1902
1903Real python should be flagged too.
1904
1905[ts-ref]: https://typescript.org/handbook"#;
1906
1907 let ctx = create_context(content);
1908 let result = rule.check(&ctx).unwrap();
1909
1910 assert_eq!(result.len(), 5, "Expected 5 warnings: 3 in link text + 2 standalone");
1917
1918 let line_1_warnings: Vec<_> = result.iter().filter(|w| w.line == 1).collect();
1920 assert_eq!(line_1_warnings.len(), 1);
1921 assert!(
1922 line_1_warnings[0]
1923 .message
1924 .contains("'javascript' should be 'JavaScript'")
1925 );
1926
1927 let line_3_warnings: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
1928 assert_eq!(line_3_warnings.len(), 2); assert!(result.iter().any(|w| w.line == 5 && w.message.contains("'javascript'")));
1932 assert!(result.iter().any(|w| w.line == 9 && w.message.contains("'python'")));
1933 }
1934
1935 #[test]
1936 fn test_link_urls_not_flagged() {
1937 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1938
1939 let content = r#"[Link Text](https://javascript.info/guide)"#;
1941
1942 let ctx = create_context(content);
1943 let result = rule.check(&ctx).unwrap();
1944
1945 assert!(result.is_empty(), "URLs should not be checked for proper names");
1947 }
1948
1949 #[test]
1950 fn test_bare_urls_not_flagged() {
1951 let rule = MD044ProperNames::new(vec!["Foo".to_string(), "JavaScript".to_string()], true);
1952
1953 let content =
1956 "https://foo.com\n\nSee https://javascript.info/foo/guide for details.\n\nMail foo@foo.com about it.\n";
1957
1958 let ctx = create_context(content);
1959 let result = rule.check(&ctx).unwrap();
1960
1961 assert!(
1962 result.is_empty(),
1963 "Bare URLs and emails should not be checked for proper names: {result:?}"
1964 );
1965 }
1966
1967 #[test]
1968 fn test_prose_around_bare_url_still_flagged() {
1969 let rule = MD044ProperNames::new(vec!["Foo".to_string()], true);
1970
1971 let content = "Use foo at https://foo.com because foo is great.\n";
1974
1975 let ctx = create_context(content);
1976 let result = rule.check(&ctx).unwrap();
1977
1978 assert_eq!(
1979 result.len(),
1980 2,
1981 "Prose occurrences around a bare URL must still be flagged: {result:?}"
1982 );
1983 assert!(result.iter().all(|w| w.message.contains("'foo' should be 'Foo'")));
1984 }
1985
1986 #[test]
1987 fn test_proper_names_in_image_alt_text_are_flagged() {
1988 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1989
1990 let content = r#"Here is a  image.
1991
1992Real javascript should be flagged."#;
1993
1994 let ctx = create_context(content);
1995 let result = rule.check(&ctx).unwrap();
1996
1997 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in alt text + 1 standalone");
2001 assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
2002 assert!(result[0].line == 1); assert!(result[1].message.contains("'javascript' should be 'JavaScript'"));
2004 assert!(result[1].line == 3); }
2006
2007 #[test]
2008 fn test_image_urls_not_flagged() {
2009 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2010
2011 let content = r#""#;
2013
2014 let ctx = create_context(content);
2015 let result = rule.check(&ctx).unwrap();
2016
2017 assert!(result.is_empty(), "Image URLs should not be checked for proper names");
2019 }
2020
2021 #[test]
2022 fn test_reference_link_text_flagged_but_definition_not() {
2023 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2024
2025 let content = r#"Check the [javascript guide][js-ref] for details.
2026
2027Real javascript should be flagged.
2028
2029[js-ref]: https://javascript.info/typescript/guide"#;
2030
2031 let ctx = create_context(content);
2032 let result = rule.check(&ctx).unwrap();
2033
2034 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in link text + 1 standalone");
2039 assert!(result.iter().any(|w| w.line == 1 && w.message.contains("'javascript'")));
2040 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2041 }
2042
2043 #[test]
2044 fn test_reference_definitions_not_flagged() {
2045 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2046
2047 let content = r#"[js-ref]: https://javascript.info/guide"#;
2049
2050 let ctx = create_context(content);
2051 let result = rule.check(&ctx).unwrap();
2052
2053 assert!(result.is_empty(), "Reference definitions should not be checked");
2055 }
2056
2057 #[test]
2058 fn test_wikilinks_text_is_flagged() {
2059 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2060
2061 let content = r#"[[javascript]]
2063
2064Regular javascript here.
2065
2066[[JavaScript|display text]]"#;
2067
2068 let ctx = create_context(content);
2069 let result = rule.check(&ctx).unwrap();
2070
2071 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in WikiLink + 1 standalone");
2075 assert!(
2076 result
2077 .iter()
2078 .any(|w| w.line == 1 && w.column == 3 && w.message.contains("'javascript'"))
2079 );
2080 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2081 }
2082
2083 #[test]
2084 fn test_url_link_text_not_flagged() {
2085 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2086
2087 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2089
2090[http://github.com/org/repo](http://github.com/org/repo)
2091
2092[www.github.com/org/repo](https://www.github.com/org/repo)"#;
2093
2094 let ctx = create_context(content);
2095 let result = rule.check(&ctx).unwrap();
2096
2097 assert!(
2098 result.is_empty(),
2099 "URL-like link text should not be flagged, got: {result:?}"
2100 );
2101 }
2102
2103 #[test]
2104 fn test_url_link_text_with_leading_space_not_flagged() {
2105 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2106
2107 let content = r#"[ https://github.com/org/repo](https://github.com/org/repo)"#;
2109
2110 let ctx = create_context(content);
2111 let result = rule.check(&ctx).unwrap();
2112
2113 assert!(
2114 result.is_empty(),
2115 "URL-like link text with leading space should not be flagged, got: {result:?}"
2116 );
2117 }
2118
2119 #[test]
2120 fn test_url_link_text_uppercase_scheme_not_flagged() {
2121 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2122
2123 let content = r#"[HTTPS://GITHUB.COM/org/repo](https://github.com/org/repo)"#;
2124
2125 let ctx = create_context(content);
2126 let result = rule.check(&ctx).unwrap();
2127
2128 assert!(
2129 result.is_empty(),
2130 "URL-like link text with uppercase scheme should not be flagged, got: {result:?}"
2131 );
2132 }
2133
2134 #[test]
2135 fn test_non_url_link_text_still_flagged() {
2136 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2137
2138 let content = r#"[github.com/org/repo](https://github.com/org/repo)
2142
2143[Visit github](https://github.com/org/repo)
2144
2145[//github.com/org/repo](//github.com/org/repo)
2146
2147[ftp://github.com/org/repo](ftp://github.com/org/repo)"#;
2148
2149 let ctx = create_context(content);
2150 let result = rule.check(&ctx).unwrap();
2151
2152 assert_eq!(
2157 result.len(),
2158 1,
2159 "Only prose link text should be flagged, got: {result:?}"
2160 );
2161 assert!(
2162 result.iter().any(|w| w.line == 3),
2163 "Expected 'Visit github' on line 3 to be flagged"
2164 );
2165 }
2166
2167 #[test]
2168 fn test_url_link_text_fix_not_applied() {
2169 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2170
2171 let content = "[https://github.com/org/repo](https://github.com/org/repo)\n";
2172
2173 let ctx = create_context(content);
2174 let result = rule.fix(&ctx).unwrap();
2175
2176 assert_eq!(result, content, "Fix should not modify URL-like link text");
2177 }
2178
2179 #[test]
2180 fn test_mixed_url_and_regular_link_text() {
2181 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2182
2183 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2185
2186Visit [github documentation](https://github.com/docs) for details.
2187
2188[www.github.com/pricing](https://www.github.com/pricing)"#;
2189
2190 let ctx = create_context(content);
2191 let result = rule.check(&ctx).unwrap();
2192
2193 assert_eq!(
2195 result.len(),
2196 1,
2197 "Only non-URL link text should be flagged, got: {result:?}"
2198 );
2199 assert_eq!(result[0].line, 3);
2200 }
2201
2202 #[test]
2203 fn test_html_attribute_values_not_flagged() {
2204 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2207 let content = "# Heading\n\ntest\n\n<img src=\"www.example.test/test_image.png\">\n";
2208 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2209 let result = rule.check(&ctx).unwrap();
2210
2211 let line5_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2213 assert!(
2214 line5_violations.is_empty(),
2215 "Should not flag anything inside HTML tag attributes: {line5_violations:?}"
2216 );
2217
2218 let line3_violations: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
2220 assert_eq!(line3_violations.len(), 1, "Plain 'test' on line 3 should be flagged");
2221 }
2222
2223 #[test]
2224 fn test_html_text_content_still_flagged() {
2225 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2227 let content = "# Heading\n\n<a href=\"https://example.test/page\">test link</a>\n";
2228 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2229 let result = rule.check(&ctx).unwrap();
2230
2231 assert_eq!(
2234 result.len(),
2235 1,
2236 "Should flag only 'test' in anchor text, not in href: {result:?}"
2237 );
2238 assert_eq!(result[0].column, 37, "Should flag col 37 ('test link' in anchor text)");
2239 }
2240
2241 #[test]
2242 fn test_html_attribute_various_not_flagged() {
2243 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2245 let content = concat!(
2246 "# Heading\n\n",
2247 "<img src=\"test.png\" alt=\"test image\">\n",
2248 "<span class=\"test-class\" data-test=\"value\">test content</span>\n",
2249 );
2250 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2251 let result = rule.check(&ctx).unwrap();
2252
2253 assert_eq!(
2255 result.len(),
2256 1,
2257 "Should flag only 'test content' between tags: {result:?}"
2258 );
2259 assert_eq!(result[0].line, 4);
2260 }
2261
2262 #[test]
2263 fn test_plain_text_underscore_boundary_unchanged() {
2264 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2267 let content = "# Heading\n\ntest_image is here and just_test ends here\n";
2268 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2269 let result = rule.check(&ctx).unwrap();
2270
2271 assert_eq!(
2274 result.len(),
2275 2,
2276 "Should flag 'test' in both 'test_image' and 'just_test': {result:?}"
2277 );
2278 let cols: Vec<usize> = result.iter().map(|w| w.column).collect();
2279 assert!(cols.contains(&1), "Should flag col 1 (test_image): {cols:?}");
2280 assert!(cols.contains(&29), "Should flag col 29 (just_test): {cols:?}");
2281 }
2282
2283 #[test]
2284 fn test_frontmatter_yaml_keys_not_flagged() {
2285 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2288
2289 let content = "---\ntitle: Heading\ntest: Some Test value\n---\n\nTest\n";
2290 let ctx = create_context(content);
2291 let result = rule.check(&ctx).unwrap();
2292
2293 assert!(
2297 result.is_empty(),
2298 "Should not flag YAML keys or correctly capitalized values: {result:?}"
2299 );
2300 }
2301
2302 #[test]
2303 fn test_frontmatter_yaml_values_flagged() {
2304 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2306
2307 let content = "---\ntitle: Heading\nkey: a test value\n---\n\nTest\n";
2308 let ctx = create_context(content);
2309 let result = rule.check(&ctx).unwrap();
2310
2311 assert_eq!(result.len(), 1, "Should flag 'test' in YAML value: {result:?}");
2313 assert_eq!(result[0].line, 3);
2314 assert_eq!(result[0].column, 8); }
2316
2317 #[test]
2318 fn test_frontmatter_key_matches_name_not_flagged() {
2319 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2321
2322 let content = "---\ntest: other value\n---\n\nBody text\n";
2323 let ctx = create_context(content);
2324 let result = rule.check(&ctx).unwrap();
2325
2326 assert!(
2327 result.is_empty(),
2328 "Should not flag YAML key that matches configured name: {result:?}"
2329 );
2330 }
2331
2332 #[test]
2333 fn test_frontmatter_empty_value_not_flagged() {
2334 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2336
2337 let content = "---\ntest:\ntest: \n---\n\nBody text\n";
2338 let ctx = create_context(content);
2339 let result = rule.check(&ctx).unwrap();
2340
2341 assert!(
2342 result.is_empty(),
2343 "Should not flag YAML keys with empty values: {result:?}"
2344 );
2345 }
2346
2347 #[test]
2348 fn test_frontmatter_nested_yaml_key_not_flagged() {
2349 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2351
2352 let content = "---\nparent:\n test: nested value\n---\n\nBody text\n";
2353 let ctx = create_context(content);
2354 let result = rule.check(&ctx).unwrap();
2355
2356 assert!(result.is_empty(), "Should not flag nested YAML keys: {result:?}");
2358 }
2359
2360 #[test]
2361 fn test_frontmatter_list_items_checked() {
2362 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2364
2365 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2366 let ctx = create_context(content);
2367 let result = rule.check(&ctx).unwrap();
2368
2369 assert_eq!(result.len(), 1, "Should flag 'test' in YAML list item: {result:?}");
2371 assert_eq!(result[0].line, 3);
2372 }
2373
2374 #[test]
2375 fn test_frontmatter_value_with_multiple_colons() {
2376 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2378
2379 let content = "---\ntest: description: a test thing\n---\n\nBody text\n";
2380 let ctx = create_context(content);
2381 let result = rule.check(&ctx).unwrap();
2382
2383 assert_eq!(
2386 result.len(),
2387 1,
2388 "Should flag 'test' in value after first colon: {result:?}"
2389 );
2390 assert_eq!(result[0].line, 2);
2391 assert!(result[0].column > 6, "Violation column should be in value portion");
2392 }
2393
2394 #[test]
2395 fn test_frontmatter_does_not_affect_body() {
2396 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2398
2399 let content = "---\ntitle: Heading\n---\n\ntest should be flagged here\n";
2400 let ctx = create_context(content);
2401 let result = rule.check(&ctx).unwrap();
2402
2403 assert_eq!(result.len(), 1, "Should flag 'test' in body text: {result:?}");
2404 assert_eq!(result[0].line, 5);
2405 }
2406
2407 #[test]
2408 fn test_frontmatter_fix_corrects_values_preserves_keys() {
2409 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2411
2412 let content = "---\ntest: a test value\n---\n\ntest here\n";
2413 let ctx = create_context(content);
2414 let fixed = rule.fix(&ctx).unwrap();
2415
2416 assert_eq!(fixed, "---\ntest: a Test value\n---\n\nTest here\n");
2418 }
2419
2420 #[test]
2421 fn test_frontmatter_multiword_value_flagged() {
2422 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2424
2425 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2426 let ctx = create_context(content);
2427 let result = rule.check(&ctx).unwrap();
2428
2429 assert_eq!(result.len(), 2, "Should flag both names in YAML value: {result:?}");
2430 assert!(result.iter().all(|w| w.line == 2));
2431 }
2432
2433 #[test]
2434 fn test_frontmatter_yaml_comments_not_checked() {
2435 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2437
2438 let content = "---\n# test comment\ntitle: Heading\n---\n\nBody text\n";
2439 let ctx = create_context(content);
2440 let result = rule.check(&ctx).unwrap();
2441
2442 assert!(result.is_empty(), "Should not flag names in YAML comments: {result:?}");
2443 }
2444
2445 #[test]
2446 fn test_frontmatter_delimiters_not_checked() {
2447 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2449
2450 let content = "---\ntitle: Heading\n---\n\ntest here\n";
2451 let ctx = create_context(content);
2452 let result = rule.check(&ctx).unwrap();
2453
2454 assert_eq!(result.len(), 1, "Should only flag body text: {result:?}");
2456 assert_eq!(result[0].line, 5);
2457 }
2458
2459 #[test]
2460 fn test_frontmatter_continuation_lines_checked() {
2461 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2463
2464 let content = "---\ndescription: >\n a test value\n continued here\n---\n\nBody\n";
2465 let ctx = create_context(content);
2466 let result = rule.check(&ctx).unwrap();
2467
2468 assert_eq!(result.len(), 1, "Should flag 'test' in continuation line: {result:?}");
2470 assert_eq!(result[0].line, 3);
2471 }
2472
2473 #[test]
2474 fn test_frontmatter_quoted_values_checked() {
2475 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2477
2478 let content = "---\ntitle: \"a test title\"\n---\n\nBody\n";
2479 let ctx = create_context(content);
2480 let result = rule.check(&ctx).unwrap();
2481
2482 assert_eq!(result.len(), 1, "Should flag 'test' in quoted YAML value: {result:?}");
2483 assert_eq!(result[0].line, 2);
2484 }
2485
2486 #[test]
2487 fn test_frontmatter_single_quoted_values_checked() {
2488 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2490
2491 let content = "---\ntitle: 'a test title'\n---\n\nBody\n";
2492 let ctx = create_context(content);
2493 let result = rule.check(&ctx).unwrap();
2494
2495 assert_eq!(
2496 result.len(),
2497 1,
2498 "Should flag 'test' in single-quoted YAML value: {result:?}"
2499 );
2500 assert_eq!(result[0].line, 2);
2501 }
2502
2503 #[test]
2504 fn test_frontmatter_fix_multiword_values() {
2505 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2507
2508 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2509 let ctx = create_context(content);
2510 let fixed = rule.fix(&ctx).unwrap();
2511
2512 assert_eq!(
2513 fixed,
2514 "---\ndescription: Learn JavaScript and TypeScript\n---\n\nBody\n"
2515 );
2516 }
2517
2518 #[test]
2519 fn test_frontmatter_fix_preserves_yaml_structure() {
2520 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2522
2523 let content = "---\ntags:\n - test\n - other\ntitle: a test doc\n---\n\ntest body\n";
2524 let ctx = create_context(content);
2525 let fixed = rule.fix(&ctx).unwrap();
2526
2527 assert_eq!(
2528 fixed,
2529 "---\ntags:\n - Test\n - other\ntitle: a Test doc\n---\n\nTest body\n"
2530 );
2531 }
2532
2533 #[test]
2534 fn test_frontmatter_toml_delimiters_not_checked() {
2535 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2537
2538 let content = "+++\ntitle = \"a test title\"\n+++\n\ntest body\n";
2539 let ctx = create_context(content);
2540 let result = rule.check(&ctx).unwrap();
2541
2542 assert_eq!(result.len(), 2, "Should flag TOML value and body: {result:?}");
2546 let fm_violations: Vec<_> = result.iter().filter(|w| w.line == 2).collect();
2547 assert_eq!(fm_violations.len(), 1, "Should flag 'test' in TOML value: {result:?}");
2548 let body_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2549 assert_eq!(body_violations.len(), 1, "Should flag body 'test': {result:?}");
2550 }
2551
2552 #[test]
2553 fn test_frontmatter_toml_key_not_flagged() {
2554 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2556
2557 let content = "+++\ntest = \"other value\"\n+++\n\nBody text\n";
2558 let ctx = create_context(content);
2559 let result = rule.check(&ctx).unwrap();
2560
2561 assert!(
2562 result.is_empty(),
2563 "Should not flag TOML key that matches configured name: {result:?}"
2564 );
2565 }
2566
2567 #[test]
2568 fn test_frontmatter_toml_fix_preserves_keys() {
2569 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2571
2572 let content = "+++\ntest = \"a test value\"\n+++\n\ntest here\n";
2573 let ctx = create_context(content);
2574 let fixed = rule.fix(&ctx).unwrap();
2575
2576 assert_eq!(fixed, "+++\ntest = \"a Test value\"\n+++\n\nTest here\n");
2578 }
2579
2580 #[test]
2581 fn test_frontmatter_list_item_mapping_key_not_flagged() {
2582 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2585
2586 let content = "---\nitems:\n - test: nested value\n---\n\nBody text\n";
2587 let ctx = create_context(content);
2588 let result = rule.check(&ctx).unwrap();
2589
2590 assert!(
2591 result.is_empty(),
2592 "Should not flag YAML key in list-item mapping: {result:?}"
2593 );
2594 }
2595
2596 #[test]
2597 fn test_frontmatter_list_item_mapping_value_flagged() {
2598 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2600
2601 let content = "---\nitems:\n - key: a test value\n---\n\nBody text\n";
2602 let ctx = create_context(content);
2603 let result = rule.check(&ctx).unwrap();
2604
2605 assert_eq!(
2606 result.len(),
2607 1,
2608 "Should flag 'test' in list-item mapping value: {result:?}"
2609 );
2610 assert_eq!(result[0].line, 3);
2611 }
2612
2613 #[test]
2614 fn test_frontmatter_bare_list_item_still_flagged() {
2615 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2617
2618 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2619 let ctx = create_context(content);
2620 let result = rule.check(&ctx).unwrap();
2621
2622 assert_eq!(result.len(), 1, "Should flag 'test' in bare list item: {result:?}");
2623 assert_eq!(result[0].line, 3);
2624 }
2625
2626 #[test]
2627 fn test_frontmatter_flow_mapping_not_flagged() {
2628 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2631
2632 let content = "---\nflow_map: {test: value, other: test}\n---\n\nBody text\n";
2633 let ctx = create_context(content);
2634 let result = rule.check(&ctx).unwrap();
2635
2636 assert!(
2637 result.is_empty(),
2638 "Should not flag names inside flow mappings: {result:?}"
2639 );
2640 }
2641
2642 #[test]
2643 fn test_frontmatter_flow_sequence_not_flagged() {
2644 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2646
2647 let content = "---\nitems: [test, other, test]\n---\n\nBody text\n";
2648 let ctx = create_context(content);
2649 let result = rule.check(&ctx).unwrap();
2650
2651 assert!(
2652 result.is_empty(),
2653 "Should not flag names inside flow sequences: {result:?}"
2654 );
2655 }
2656
2657 #[test]
2658 fn test_frontmatter_list_item_mapping_fix_preserves_key() {
2659 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2661
2662 let content = "---\nitems:\n - test: a test value\n---\n\ntest here\n";
2663 let ctx = create_context(content);
2664 let fixed = rule.fix(&ctx).unwrap();
2665
2666 assert_eq!(fixed, "---\nitems:\n - test: a Test value\n---\n\nTest here\n");
2669 }
2670
2671 #[test]
2672 fn test_frontmatter_backtick_code_not_flagged() {
2673 let config = MD044Config {
2675 names: vec!["GoodApplication".to_string()],
2676 code_blocks: false,
2677 ..MD044Config::default()
2678 };
2679 let rule = MD044ProperNames::from_config_struct(config);
2680
2681 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2682 let ctx = create_context(content);
2683 let result = rule.check(&ctx).unwrap();
2684
2685 assert!(
2687 result.is_empty(),
2688 "Should not flag names inside backticks in frontmatter or body: {result:?}"
2689 );
2690 }
2691
2692 #[test]
2693 fn test_frontmatter_unquoted_backtick_code_not_flagged() {
2694 let config = MD044Config {
2696 names: vec!["GoodApplication".to_string()],
2697 code_blocks: false,
2698 ..MD044Config::default()
2699 };
2700 let rule = MD044ProperNames::from_config_struct(config);
2701
2702 let content = "---\ntitle: `goodapplication` CLI\n---\n\nIntroductory `goodapplication` CLI text.\n";
2703 let ctx = create_context(content);
2704 let result = rule.check(&ctx).unwrap();
2705
2706 assert!(
2707 result.is_empty(),
2708 "Should not flag names inside backticks in unquoted YAML frontmatter: {result:?}"
2709 );
2710 }
2711
2712 #[test]
2713 fn test_frontmatter_bare_name_still_flagged_with_backtick_nearby() {
2714 let config = MD044Config {
2716 names: vec!["GoodApplication".to_string()],
2717 code_blocks: false,
2718 ..MD044Config::default()
2719 };
2720 let rule = MD044ProperNames::from_config_struct(config);
2721
2722 let content = "---\ntitle: goodapplication `goodapplication` CLI\n---\n\nBody\n";
2723 let ctx = create_context(content);
2724 let result = rule.check(&ctx).unwrap();
2725
2726 assert_eq!(
2728 result.len(),
2729 1,
2730 "Should flag bare name but not backtick-wrapped name: {result:?}"
2731 );
2732 assert_eq!(result[0].line, 2);
2733 assert_eq!(result[0].column, 8); }
2735
2736 #[test]
2737 fn test_frontmatter_backtick_code_with_code_blocks_true() {
2738 let config = MD044Config {
2740 names: vec!["GoodApplication".to_string()],
2741 code_blocks: true,
2742 ..MD044Config::default()
2743 };
2744 let rule = MD044ProperNames::from_config_struct(config);
2745
2746 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nBody\n";
2747 let ctx = create_context(content);
2748 let result = rule.check(&ctx).unwrap();
2749
2750 assert_eq!(
2752 result.len(),
2753 1,
2754 "Should flag backtick-wrapped name when code_blocks=true: {result:?}"
2755 );
2756 assert_eq!(result[0].line, 2);
2757 }
2758
2759 #[test]
2760 fn test_frontmatter_fix_preserves_backtick_code() {
2761 let config = MD044Config {
2763 names: vec!["GoodApplication".to_string()],
2764 code_blocks: false,
2765 ..MD044Config::default()
2766 };
2767 let rule = MD044ProperNames::from_config_struct(config);
2768
2769 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2770 let ctx = create_context(content);
2771 let fixed = rule.fix(&ctx).unwrap();
2772
2773 assert_eq!(
2775 fixed, content,
2776 "Fix should not modify names inside backticks in frontmatter"
2777 );
2778 }
2779
2780 fn rule_ignoring(names: &[&str], ignore: &[&str]) -> MD044ProperNames {
2781 MD044ProperNames::from_config_struct(MD044Config {
2782 names: names.iter().map(ToString::to_string).collect(),
2783 ignore_frontmatter_fields: Some(ignore.iter().map(ToString::to_string).collect()),
2784 ..Default::default()
2785 })
2786 }
2787
2788 #[test]
2789 fn test_ignore_frontmatter_field_suppresses_only_that_field() {
2790 let content = "---\ntitle: Heading for myapp\nslug: myapp-guide\n---\n";
2791 let rule = rule_ignoring(&["MyApp"], &["slug"]);
2792 let result = rule.check(&create_context(content)).unwrap();
2793 assert_eq!(result.len(), 1, "only title is flagged: {result:?}");
2794 assert_eq!(result[0].line, 2);
2795 }
2796
2797 #[test]
2798 fn test_ignore_frontmatter_field_is_case_insensitive() {
2799 let content = "---\nSlug: myapp-guide\n---\n";
2800 let rule = rule_ignoring(&["MyApp"], &["SLUG"]);
2801 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2802 }
2803
2804 #[test]
2805 fn test_ignore_frontmatter_field_covers_nested_subtree() {
2806 let content = "---\nseo:\n canonical: myapp\n keywords:\n - myapp\n---\n";
2807 let rule = rule_ignoring(&["MyApp"], &["seo"]);
2808 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2809 }
2810
2811 #[test]
2812 fn test_ignore_frontmatter_field_does_not_affect_body() {
2813 let content = "---\nslug: myapp\n---\n\nBody mentions myapp.\n";
2814 let rule = rule_ignoring(&["MyApp"], &["slug"]);
2815 let result = rule.check(&create_context(content)).unwrap();
2816 assert_eq!(result.len(), 1);
2817 assert_eq!(result[0].line, 5);
2818 }
2819
2820 #[test]
2821 fn test_ignore_frontmatter_field_toml_table() {
2822 let content = "+++\n[seo]\ncanonical = \"myapp\"\n+++\n";
2823 let rule = rule_ignoring(&["MyApp"], &["seo"]);
2824 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2825 }
2826
2827 #[test]
2830 fn test_angle_bracket_url_in_html_comment_not_flagged() {
2831 let config = MD044Config {
2833 names: vec!["Test".to_string()],
2834 ..MD044Config::default()
2835 };
2836 let rule = MD044ProperNames::from_config_struct(config);
2837
2838 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";
2839 let ctx = create_context(content);
2840 let result = rule.check(&ctx).unwrap();
2841
2842 let line8_warnings: Vec<_> = result.iter().filter(|w| w.line == 8).collect();
2850 assert!(
2851 line8_warnings.is_empty(),
2852 "Should not flag names inside angle-bracket URLs in HTML comments: {line8_warnings:?}"
2853 );
2854 }
2855
2856 #[test]
2857 fn test_bare_url_in_html_comment_still_flagged() {
2858 let config = MD044Config {
2860 names: vec!["Test".to_string()],
2861 ..MD044Config::default()
2862 };
2863 let rule = MD044ProperNames::from_config_struct(config);
2864
2865 let content = "<!-- This is a test https://www.example.test -->\n";
2866 let ctx = create_context(content);
2867 let result = rule.check(&ctx).unwrap();
2868
2869 assert!(
2872 !result.is_empty(),
2873 "Should flag 'test' in prose text of HTML comment with bare URL"
2874 );
2875 }
2876
2877 #[test]
2878 fn test_angle_bracket_url_in_regular_markdown_not_flagged() {
2879 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2882
2883 let content = "<https://www.example.test>\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 names inside angle-bracket URLs in regular markdown: {result:?}"
2890 );
2891 }
2892
2893 #[test]
2894 fn test_multiple_angle_bracket_urls_in_one_comment() {
2895 let config = MD044Config {
2896 names: vec!["Test".to_string()],
2897 ..MD044Config::default()
2898 };
2899 let rule = MD044ProperNames::from_config_struct(config);
2900
2901 let content = "<!-- See <https://test.example.com> and <https://www.example.test> for details -->\n";
2902 let ctx = create_context(content);
2903 let result = rule.check(&ctx).unwrap();
2904
2905 assert!(
2907 result.is_empty(),
2908 "Should not flag names inside multiple angle-bracket URLs: {result:?}"
2909 );
2910 }
2911
2912 #[test]
2913 fn test_angle_bracket_non_url_still_flagged() {
2914 assert!(
2917 !MD044ProperNames::is_in_angle_bracket_url("<test> which is not a URL.", 1),
2918 "is_in_angle_bracket_url should return false for non-URL angle brackets"
2919 );
2920 }
2921
2922 #[test]
2923 fn test_angle_bracket_mailto_url_not_flagged() {
2924 let config = MD044Config {
2925 names: vec!["Test".to_string()],
2926 ..MD044Config::default()
2927 };
2928 let rule = MD044ProperNames::from_config_struct(config);
2929
2930 let content = "<!-- Contact <mailto:test@example.com> for help -->\n";
2931 let ctx = create_context(content);
2932 let result = rule.check(&ctx).unwrap();
2933
2934 assert!(
2935 result.is_empty(),
2936 "Should not flag names inside angle-bracket mailto URLs: {result:?}"
2937 );
2938 }
2939
2940 #[test]
2941 fn test_angle_bracket_ftp_url_not_flagged() {
2942 let config = MD044Config {
2943 names: vec!["Test".to_string()],
2944 ..MD044Config::default()
2945 };
2946 let rule = MD044ProperNames::from_config_struct(config);
2947
2948 let content = "<!-- Download from <ftp://test.example.com/file> -->\n";
2949 let ctx = create_context(content);
2950 let result = rule.check(&ctx).unwrap();
2951
2952 assert!(
2953 result.is_empty(),
2954 "Should not flag names inside angle-bracket FTP URLs: {result:?}"
2955 );
2956 }
2957
2958 #[test]
2959 fn test_angle_bracket_url_fix_preserves_url() {
2960 let config = MD044Config {
2962 names: vec!["Test".to_string()],
2963 ..MD044Config::default()
2964 };
2965 let rule = MD044ProperNames::from_config_struct(config);
2966
2967 let content = "<!-- test text <https://www.example.test> -->\n";
2968 let ctx = create_context(content);
2969 let fixed = rule.fix(&ctx).unwrap();
2970
2971 assert!(
2973 fixed.contains("<https://www.example.test>"),
2974 "Fix should preserve angle-bracket URLs: {fixed}"
2975 );
2976 assert!(
2977 fixed.contains("Test text"),
2978 "Fix should correct prose 'test' to 'Test': {fixed}"
2979 );
2980 }
2981
2982 #[test]
2983 fn test_is_in_angle_bracket_url_helper() {
2984 let line = "text <https://example.test> more text";
2986
2987 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));
3000
3001 assert!(MD044ProperNames::is_in_angle_bracket_url(
3003 "<mailto:test@example.com>",
3004 10
3005 ));
3006
3007 assert!(MD044ProperNames::is_in_angle_bracket_url(
3009 "<ftp://test.example.com>",
3010 10
3011 ));
3012 }
3013
3014 #[test]
3015 fn test_is_in_angle_bracket_url_uppercase_scheme() {
3016 assert!(MD044ProperNames::is_in_angle_bracket_url(
3018 "<HTTPS://test.example.com>",
3019 10
3020 ));
3021 assert!(MD044ProperNames::is_in_angle_bracket_url(
3022 "<Http://test.example.com>",
3023 10
3024 ));
3025 }
3026
3027 #[test]
3028 fn test_is_in_angle_bracket_url_uncommon_schemes() {
3029 assert!(MD044ProperNames::is_in_angle_bracket_url(
3031 "<ssh://test@example.com>",
3032 10
3033 ));
3034 assert!(MD044ProperNames::is_in_angle_bracket_url("<file:///test/path>", 10));
3036 assert!(MD044ProperNames::is_in_angle_bracket_url("<data:text/plain;test>", 10));
3038 }
3039
3040 #[test]
3041 fn test_is_in_angle_bracket_url_unclosed() {
3042 assert!(!MD044ProperNames::is_in_angle_bracket_url(
3044 "<https://test.example.com",
3045 10
3046 ));
3047 }
3048
3049 #[test]
3050 fn test_vale_inline_config_comments_not_flagged() {
3051 let config = MD044Config {
3052 names: vec!["Vale".to_string(), "JavaScript".to_string()],
3053 ..MD044Config::default()
3054 };
3055 let rule = MD044ProperNames::from_config_struct(config);
3056
3057 let content = "\
3058<!-- vale off -->
3059Some javascript text here.
3060<!-- vale on -->
3061<!-- vale Style.Rule = NO -->
3062More javascript text.
3063<!-- vale Style.Rule = YES -->
3064<!-- vale JavaScript.Grammar = NO -->
3065";
3066 let ctx = create_context(content);
3067 let result = rule.check(&ctx).unwrap();
3068
3069 assert_eq!(result.len(), 2, "Should only flag body lines, not Vale config comments");
3071 assert_eq!(result[0].line, 2);
3072 assert_eq!(result[1].line, 5);
3073 }
3074
3075 #[test]
3076 fn test_remark_lint_inline_config_comments_not_flagged() {
3077 let config = MD044Config {
3078 names: vec!["JavaScript".to_string()],
3079 ..MD044Config::default()
3080 };
3081 let rule = MD044ProperNames::from_config_struct(config);
3082
3083 let content = "\
3084<!-- lint disable remark-lint-some-rule -->
3085Some javascript text here.
3086<!-- lint enable remark-lint-some-rule -->
3087<!-- lint ignore remark-lint-some-rule -->
3088More javascript text.
3089";
3090 let ctx = create_context(content);
3091 let result = rule.check(&ctx).unwrap();
3092
3093 assert_eq!(
3094 result.len(),
3095 2,
3096 "Should only flag body lines, not remark-lint config comments"
3097 );
3098 assert_eq!(result[0].line, 2);
3099 assert_eq!(result[1].line, 5);
3100 }
3101
3102 #[test]
3103 fn test_fix_does_not_modify_vale_remark_lint_comments() {
3104 let config = MD044Config {
3105 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3106 ..MD044Config::default()
3107 };
3108 let rule = MD044ProperNames::from_config_struct(config);
3109
3110 let content = "\
3111<!-- vale off -->
3112Some javascript text.
3113<!-- vale on -->
3114<!-- lint disable remark-lint-some-rule -->
3115More javascript text.
3116<!-- lint enable remark-lint-some-rule -->
3117";
3118 let ctx = create_context(content);
3119 let fixed = rule.fix(&ctx).unwrap();
3120
3121 assert!(fixed.contains("<!-- vale off -->"));
3123 assert!(fixed.contains("<!-- vale on -->"));
3124 assert!(fixed.contains("<!-- lint disable remark-lint-some-rule -->"));
3125 assert!(fixed.contains("<!-- lint enable remark-lint-some-rule -->"));
3126 assert!(fixed.contains("Some JavaScript text."));
3128 assert!(fixed.contains("More JavaScript text."));
3129 }
3130
3131 #[test]
3132 fn test_mixed_tool_directives_all_skipped() {
3133 let config = MD044Config {
3134 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3135 ..MD044Config::default()
3136 };
3137 let rule = MD044ProperNames::from_config_struct(config);
3138
3139 let content = "\
3140<!-- rumdl-disable MD044 -->
3141Some javascript text.
3142<!-- markdownlint-disable -->
3143More javascript text.
3144<!-- vale off -->
3145Even more javascript text.
3146<!-- lint disable some-rule -->
3147Final javascript text.
3148<!-- rumdl-enable MD044 -->
3149<!-- markdownlint-enable -->
3150<!-- vale on -->
3151<!-- lint enable some-rule -->
3152";
3153 let ctx = create_context(content);
3154 let result = rule.check(&ctx).unwrap();
3155
3156 assert_eq!(
3158 result.len(),
3159 4,
3160 "Should only flag body lines, not any tool directive comments"
3161 );
3162 assert_eq!(result[0].line, 2);
3163 assert_eq!(result[1].line, 4);
3164 assert_eq!(result[2].line, 6);
3165 assert_eq!(result[3].line, 8);
3166 }
3167
3168 #[test]
3169 fn test_vale_remark_lint_edge_cases_not_matched() {
3170 let config = MD044Config {
3171 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3172 ..MD044Config::default()
3173 };
3174 let rule = MD044ProperNames::from_config_struct(config);
3175
3176 let content = "\
3184<!-- vale -->
3185<!-- vale is a tool for writing -->
3186<!-- valedictorian javascript -->
3187<!-- linting javascript tips -->
3188<!-- vale javascript -->
3189<!-- lint your javascript code -->
3190";
3191 let ctx = create_context(content);
3192 let result = rule.check(&ctx).unwrap();
3193
3194 assert_eq!(
3201 result.len(),
3202 7,
3203 "Should flag proper names in non-directive HTML comments: got {result:?}"
3204 );
3205 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); }
3213
3214 #[test]
3215 fn test_vale_style_directives_skipped() {
3216 let config = MD044Config {
3217 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3218 ..MD044Config::default()
3219 };
3220 let rule = MD044ProperNames::from_config_struct(config);
3221
3222 let content = "\
3224<!-- vale style = MyStyle -->
3225<!-- vale styles = Style1, Style2 -->
3226<!-- vale MyRule.Name = YES -->
3227<!-- vale MyRule.Name = NO -->
3228Some javascript text.
3229";
3230 let ctx = create_context(content);
3231 let result = rule.check(&ctx).unwrap();
3232
3233 assert_eq!(
3235 result.len(),
3236 1,
3237 "Should only flag body lines, not Vale style/rule directives: got {result:?}"
3238 );
3239 assert_eq!(result[0].line, 5);
3240 }
3241
3242 #[test]
3245 fn test_backtick_code_single_backticks() {
3246 let line = "hello `world` bye";
3247 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 7));
3249 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3251 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 14));
3253 }
3254
3255 #[test]
3256 fn test_backtick_code_double_backticks() {
3257 let line = "a ``code`` b";
3258 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3260 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3262 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 11));
3264 }
3265
3266 #[test]
3267 fn test_backtick_code_unclosed() {
3268 let line = "a `code b";
3269 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3271 }
3272
3273 #[test]
3274 fn test_backtick_code_mismatched_count() {
3275 let line = "a `code`` b";
3277 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3280 }
3281
3282 #[test]
3283 fn test_backtick_code_multiple_spans() {
3284 let line = "`first` and `second`";
3285 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
3287 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 8));
3289 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 13));
3291 }
3292
3293 #[test]
3294 fn test_backtick_code_on_backtick_boundary() {
3295 let line = "`code`";
3296 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3298 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 5));
3300 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
3302 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3303 }
3304
3305 #[test]
3311 fn test_double_bracket_link_url_not_flagged() {
3312 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3313 let content = "[[rumdl]](https://github.com/rvben/rumdl)";
3315 let ctx = create_context(content);
3316 let result = rule.check(&ctx).unwrap();
3317 assert!(
3318 result.is_empty(),
3319 "URL inside [[text]](url) must not be flagged, got: {result:?}"
3320 );
3321 }
3322
3323 #[test]
3324 fn test_double_bracket_link_url_not_fixed() {
3325 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3326 let content = "[[rumdl]](https://github.com/rvben/rumdl)\n";
3327 let ctx = create_context(content);
3328 let fixed = rule.fix(&ctx).unwrap();
3329 assert_eq!(
3330 fixed, content,
3331 "fix() must leave the URL inside [[text]](url) unchanged"
3332 );
3333 }
3334
3335 #[test]
3336 fn test_double_bracket_link_text_still_flagged() {
3337 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3338 let content = "[[github]](https://example.com)";
3340 let ctx = create_context(content);
3341 let result = rule.check(&ctx).unwrap();
3342 assert_eq!(
3343 result.len(),
3344 1,
3345 "Incorrect name in [[text]] link text should still be flagged, got: {result:?}"
3346 );
3347 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
3348 }
3349
3350 #[test]
3351 fn test_double_bracket_link_mixed_line() {
3352 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3353 let content = "See [[rumdl]](https://github.com/rvben/rumdl) and github for more.";
3355 let ctx = create_context(content);
3356 let result = rule.check(&ctx).unwrap();
3357 assert_eq!(
3358 result.len(),
3359 1,
3360 "Only the standalone 'github' after the link should be flagged, got: {result:?}"
3361 );
3362 assert!(result[0].message.contains("'github'"));
3363 assert_eq!(
3365 result[0].column, 51,
3366 "Flagged column should be the trailing 'github', not the one in the URL"
3367 );
3368 }
3369
3370 #[test]
3371 fn test_regular_link_url_still_not_flagged() {
3372 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3374 let content = "[rumdl](https://github.com/rvben/rumdl)";
3375 let ctx = create_context(content);
3376 let result = rule.check(&ctx).unwrap();
3377 assert!(
3378 result.is_empty(),
3379 "URL inside regular [text](url) must still not be flagged, got: {result:?}"
3380 );
3381 }
3382
3383 #[test]
3384 fn test_link_like_text_in_code_span_still_flagged_when_code_blocks_enabled() {
3385 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
3390 let content = "`[foo](https://github.com/org/repo)`";
3391 let ctx = create_context(content);
3392 let result = rule.check(&ctx).unwrap();
3393 assert_eq!(
3394 result.len(),
3395 1,
3396 "Proper name inside a code span must be flagged when code-blocks=true, got: {result:?}"
3397 );
3398 assert!(result[0].message.contains("'github'"));
3399 }
3400
3401 #[test]
3402 fn test_malformed_link_not_treated_as_url() {
3403 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3406 let content = "See [rumdl](github repo) for details.";
3407 let ctx = create_context(content);
3408 let result = rule.check(&ctx).unwrap();
3409 assert_eq!(
3410 result.len(),
3411 1,
3412 "Name inside malformed [text](url with spaces) must still be flagged, got: {result:?}"
3413 );
3414 assert!(result[0].message.contains("'github'"));
3415 }
3416
3417 #[test]
3418 fn test_wikilink_followed_by_prose_parens_still_flagged() {
3419 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3423 let content = "[[note]](github repo)";
3424 let ctx = create_context(content);
3425 let result = rule.check(&ctx).unwrap();
3426 assert_eq!(
3427 result.len(),
3428 1,
3429 "Name inside [[wikilink]](prose with spaces) must still be flagged, got: {result:?}"
3430 );
3431 assert!(result[0].message.contains("'github'"));
3432 }
3433
3434 #[test]
3436 fn test_roundtrip_fix_then_check_basic() {
3437 let rule = MD044ProperNames::new(
3438 vec![
3439 "JavaScript".to_string(),
3440 "TypeScript".to_string(),
3441 "Node.js".to_string(),
3442 ],
3443 true,
3444 );
3445 let content = "I love javascript, typescript, and nodejs!";
3446 let ctx = create_context(content);
3447 let fixed = rule.fix(&ctx).unwrap();
3448 let ctx2 = create_context(&fixed);
3449 let warnings = rule.check(&ctx2).unwrap();
3450 assert!(
3451 warnings.is_empty(),
3452 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3453 );
3454 }
3455
3456 #[test]
3458 fn test_roundtrip_fix_then_check_multiline() {
3459 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
3460 let content = "First line with rust.\nSecond line with python.\nThird line with RUST and PYTHON.\n";
3461 let ctx = create_context(content);
3462 let fixed = rule.fix(&ctx).unwrap();
3463 let ctx2 = create_context(&fixed);
3464 let warnings = rule.check(&ctx2).unwrap();
3465 assert!(
3466 warnings.is_empty(),
3467 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3468 );
3469 }
3470
3471 #[test]
3473 fn test_roundtrip_fix_then_check_inline_config() {
3474 let config = MD044Config {
3475 names: vec!["RUMDL".to_string()],
3476 ..MD044Config::default()
3477 };
3478 let rule = MD044ProperNames::from_config_struct(config);
3479 let content =
3480 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
3481 let ctx = create_context(content);
3482 let fixed = rule.fix(&ctx).unwrap();
3483 assert!(
3485 fixed.contains("Some rumdl text.\n"),
3486 "Disabled block text should be preserved"
3487 );
3488 assert!(
3489 fixed.contains("Some RUMDL text outside."),
3490 "Outside text should be fixed"
3491 );
3492 }
3493
3494 #[test]
3496 fn test_roundtrip_fix_then_check_html_comments() {
3497 let config = MD044Config {
3498 names: vec!["JavaScript".to_string()],
3499 ..MD044Config::default()
3500 };
3501 let rule = MD044ProperNames::from_config_struct(config);
3502 let content = "# Guide\n\n<!-- javascript mentioned here -->\n\njavascript outside\n";
3503 let ctx = create_context(content);
3504 let fixed = rule.fix(&ctx).unwrap();
3505 let ctx2 = create_context(&fixed);
3506 let warnings = rule.check(&ctx2).unwrap();
3507 assert!(
3508 warnings.is_empty(),
3509 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3510 );
3511 }
3512
3513 #[test]
3515 fn test_roundtrip_no_op_when_correct() {
3516 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
3517 let content = "This uses JavaScript and TypeScript correctly.\n";
3518 let ctx = create_context(content);
3519 let fixed = rule.fix(&ctx).unwrap();
3520 assert_eq!(fixed, content, "Fix should be a no-op when content is already correct");
3521 }
3522
3523 #[test]
3526 fn test_bare_domain_link_text_not_flagged() {
3527 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3531 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3532 let ctx = create_context(content);
3533 let result = rule.check(&ctx).unwrap();
3534 assert!(
3535 result.is_empty(),
3536 "Should not flag 'github' in a bare-domain link text that matches the link URL: {result:?}"
3537 );
3538 }
3539
3540 #[test]
3541 fn test_bare_domain_link_text_not_fixed() {
3542 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3544 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3545 let ctx = create_context(content);
3546 let fixed = rule.fix(&ctx).unwrap();
3547 assert_eq!(
3548 fixed, content,
3549 "fix() must not alter bare-domain link text that matches the destination URL"
3550 );
3551 }
3552
3553 #[test]
3554 fn test_bare_domain_link_text_with_path_not_flagged() {
3555 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3557 let content = "Visit [ravencentric.github.io](https://ravencentric.github.io/projects).\n";
3558 let ctx = create_context(content);
3559 let result = rule.check(&ctx).unwrap();
3560 assert!(
3561 result.is_empty(),
3562 "Should not flag 'github' when bare-domain text is the hostname of its destination URL: {result:?}"
3563 );
3564 }
3565
3566 #[test]
3567 fn test_bare_domain_link_text_full_path_not_flagged() {
3568 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3570 let content = "See [ravencentric.github.io/blog](https://ravencentric.github.io/blog).\n";
3571 let ctx = create_context(content);
3572 let result = rule.check(&ctx).unwrap();
3573 assert!(
3574 result.is_empty(),
3575 "Should not flag 'github' when link text is the full URL path without scheme: {result:?}"
3576 );
3577 }
3578
3579 #[test]
3580 fn test_github_product_name_in_link_text_still_flagged() {
3581 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3584 let content = "Hosted on [github pages](https://pages.github.com).\n";
3585 let ctx = create_context(content);
3586 let result = rule.check(&ctx).unwrap();
3587 assert!(
3588 !result.is_empty(),
3589 "Should still flag 'github' in descriptive link text that does not match the destination URL"
3590 );
3591 }
3592
3593 #[test]
3594 fn test_protocol_relative_bare_domain_link_text_not_flagged() {
3595 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3597 let content = "See [github.io](//github.io).\n";
3598 let ctx = create_context(content);
3599 let result = rule.check(&ctx).unwrap();
3600 assert!(
3601 result.is_empty(),
3602 "Should not flag 'github' in bare-domain text matching a protocol-relative destination: {result:?}"
3603 );
3604 }
3605
3606 #[test]
3607 fn test_dotted_wikilink_target_still_flagged() {
3608 let rule = MD044ProperNames::new(vec!["Node.js".to_string()], false);
3613 let content = "See [[node.js]] for details.\n";
3614 let ctx = create_context(content);
3615 let result = rule.check(&ctx).unwrap();
3616 assert!(
3617 !result.is_empty(),
3618 "Should flag 'node.js' in a dotted WikiLink target: {result:?}"
3619 );
3620 }
3621
3622 #[test]
3623 fn test_bare_domain_link_text_case_insensitive_url() {
3624 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3627 let content = "See [github.io](HTTPS://github.io).\n";
3628 let ctx = create_context(content);
3629 let result = rule.check(&ctx).unwrap();
3630 assert!(
3631 result.is_empty(),
3632 "Should not flag bare-domain text when destination URL has an uppercase scheme: {result:?}"
3633 );
3634 }
3635
3636 #[test]
3637 fn test_frontmatter_value_span_strips_trailing_comment() {
3638 let line = "link: docs/guide/myapp # canonical path";
3639 let (s, e) = frontmatter_values::value_span(line).unwrap();
3640 assert_eq!(&line[s..e], "docs/guide/myapp");
3641 }
3642
3643 #[test]
3644 fn test_frontmatter_value_span_quoted_keeps_hash_and_spaces() {
3645 let line = "link: 'docs/My App/a#b'";
3646 let (s, e) = frontmatter_values::value_span(line).unwrap();
3647 assert_eq!(&line[s..e], "docs/My App/a#b");
3648 }
3649
3650 #[test]
3651 fn test_frontmatter_value_span_plain_value() {
3652 let line = "title: Heading for myapp";
3653 let (s, e) = frontmatter_values::value_span(line).unwrap();
3654 assert_eq!(&line[s..e], "Heading for myapp");
3655 }
3656
3657 #[test]
3658 fn test_frontmatter_value_span_none_for_key_only() {
3659 assert!(frontmatter_values::value_span("seo:").is_none());
3660 assert!(frontmatter_values::value_span("---").is_none());
3661 }
3662
3663 #[test]
3664 fn test_frontmatter_value_span_quoted_strips_trailing_comment() {
3665 let line = "link: 'docs/guide' # canonical path";
3666 let (s, e) = frontmatter_values::value_span(line).unwrap();
3667 assert_eq!(&line[s..e], "docs/guide");
3668 }
3669
3670 #[test]
3671 fn test_frontmatter_value_span_empty_quoted_value_is_none() {
3672 assert!(frontmatter_values::value_span("key: ''").is_none());
3673 }
3674
3675 #[test]
3676 fn test_frontmatter_value_span_unterminated_quote_strips_leading_quote() {
3677 let line = "link: 'docs/a";
3678 let (s, e) = frontmatter_values::value_span(line).unwrap();
3679 assert_eq!(&line[s..e], "docs/a");
3680 }
3681
3682 fn at(line: &str, needle: &str) -> usize {
3684 line.find(needle).expect("needle present")
3685 }
3686
3687 #[test]
3688 fn test_path_like_exempts_single_token_frontmatter_paths() {
3689 for line in [
3690 "link: this/is/a/link/to/myapp.md",
3691 "link: docs/myapp.md",
3692 "link: /abs/path/myapp.md",
3693 "link: ./myapp.md",
3694 "link: ../shared/myapp.md",
3695 ] {
3696 let span = frontmatter_values::value_span(line).unwrap();
3697 let pos = at(line, "myapp");
3698 assert!(
3699 MD044ProperNames::is_in_path_like_token(line, pos, span),
3700 "should treat as a path: {line}"
3701 );
3702 }
3703 }
3704
3705 #[test]
3706 fn test_path_like_does_not_exempt_slash_conjunction_prose() {
3707 let line = "description: We support github/gitlab/bitbucket imports.";
3710 let span = frontmatter_values::value_span(line).unwrap();
3711 assert!(
3712 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
3713 "slash-separated prose is not a path"
3714 );
3715
3716 let line = "description: The javascript/typescript ecosystem is large.";
3717 let span = frontmatter_values::value_span(line).unwrap();
3718 assert!(!MD044ProperNames::is_in_path_like_token(
3719 line,
3720 at(line, "javascript"),
3721 span
3722 ));
3723 }
3724
3725 #[test]
3726 fn test_path_like_requires_a_slash_so_dotted_names_survive() {
3727 let line = "title: Use nodejs and myapp.md today.";
3728 let span = frontmatter_values::value_span(line).unwrap();
3729 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3730 }
3731
3732 #[test]
3733 fn test_path_like_no_slash_frontmatter_value_still_flagged() {
3734 let line = "slug: myapp-guide";
3739 let span = frontmatter_values::value_span(line).unwrap();
3740 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3741 }
3742
3743 #[test]
3744 fn test_path_like_returns_false_outside_value_span() {
3745 let line = "myapp: docs/guide/myapp";
3748 let span = frontmatter_values::value_span(line).unwrap();
3749 let key_pos = 0;
3750 assert!(!MD044ProperNames::is_in_path_like_token(line, key_pos, span));
3751 }
3752
3753 #[test]
3754 fn test_path_like_three_segments_only_as_sole_frontmatter_value() {
3755 let line = "link: docs/guide/myapp";
3756 let span = frontmatter_values::value_span(line).unwrap();
3757 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3758
3759 let line = "description: We support github/gitlab/bitbucket now";
3760 let span = frontmatter_values::value_span(line).unwrap();
3761 assert!(
3762 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
3763 "multi-token value gets body treatment"
3764 );
3765 }
3766
3767 #[test]
3768 fn test_path_like_quoted_value_with_spaces() {
3769 let line = "link: 'docs/My App/myapp.md'";
3772 let span = frontmatter_values::value_span(line).unwrap();
3773 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3774 }
3775
3776 #[test]
3777 fn test_path_like_quoted_value_with_spaces_no_extension_not_exempt() {
3778 let line = "link: 'docs/My App/myapp'";
3785 let span = frontmatter_values::value_span(line).unwrap();
3786 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3787 }
3788
3789 #[test]
3790 fn test_path_like_trailing_comment_is_still_sole_value() {
3791 let line = "link: docs/guide/myapp # canonical path";
3792 let span = frontmatter_values::value_span(line).unwrap();
3793 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3794 }
3795
3796 #[test]
3797 fn test_path_like_trailing_punctuation_trimmed() {
3798 let line = "link: docs/myapp.md, then leave.";
3799 let span = frontmatter_values::value_span(line).unwrap();
3800 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3801 }
3802
3803 #[test]
3804 fn test_trim_token_bounds_reaches_fixpoint_after_punctuation_exposes_wrapper() {
3805 let line = r#"See "docs/myapp.md", then leave."#;
3806 let raw_start = at(line, "\"docs");
3807 let raw_end = raw_start + r#""docs/myapp.md","#.len();
3808 assert_eq!(&line[raw_start..raw_end], r#""docs/myapp.md","#);
3809 let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
3810 assert_eq!(&line[start..end], "docs/myapp.md");
3811 }
3812
3813 #[test]
3814 fn test_trim_token_bounds_reaches_fixpoint_with_multiple_trailing_wrappers() {
3815 let line = r#"("docs/myapp.md")."#;
3816 let (start, end) = frontmatter_values::trim_token_bounds(line, 0, line.len());
3817 assert_eq!(&line[start..end], "docs/myapp.md");
3818 }
3819
3820 #[test]
3821 fn test_frontmatter_link_path_not_flagged() {
3822 let content = "---\ntitle: Heading for MyApp\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
3823 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3824 let ctx = create_context(content);
3825 let result = rule.check(&ctx).unwrap();
3826 assert!(
3827 result.is_empty(),
3828 "path in a frontmatter value must not be flagged: {result:?}"
3829 );
3830 }
3831
3832 #[test]
3833 fn test_fix_does_not_corrupt_frontmatter_link_path() {
3834 let content = "---\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
3835 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3836 let ctx = create_context(content);
3837 assert_eq!(rule.fix(&ctx).unwrap(), content, "fix must not rewrite a path");
3838 }
3839
3840 #[test]
3851 fn test_body_prose_parenthesized_disambiguator_is_case_corrected() {
3852 let content = "See docs/myapp(1).md here.\n";
3853 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3854 let ctx = create_context(content);
3855 let result = rule.check(&ctx).unwrap();
3856 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3857 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp(1).md here.\n");
3858 }
3859
3860 #[test]
3861 fn test_body_prose_bracketed_dynamic_segment_is_case_corrected() {
3862 let content = "See docs/[myapp].md here.\n";
3863 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3864 let ctx = create_context(content);
3865 let result = rule.check(&ctx).unwrap();
3866 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3867 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/[MyApp].md here.\n");
3868 }
3869
3870 #[test]
3871 fn test_body_prose_nextjs_catch_all_segment_is_case_corrected() {
3872 let content = "pages/[[...myapp]].tsx are catch-all routes.\n";
3875 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3876 let ctx = create_context(content);
3877 let result = rule.check(&ctx).unwrap();
3878 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3879 assert_eq!(
3880 rule.fix(&ctx).unwrap(),
3881 "pages/[[...MyApp]].tsx are catch-all routes.\n"
3882 );
3883 }
3884
3885 #[test]
3886 fn test_two_adjacent_whitespace_free_links_both_flagged() {
3887 let content = "[myapp](https://a.com)[github](https://b.com)\n";
3891 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitHub".to_string()], false);
3892 let ctx = create_context(content);
3893 let result = rule.check(&ctx).unwrap();
3894 assert_eq!(result.len(), 2, "both link texts must be flagged: {result:?}");
3895 assert!(result.iter().any(|w| w.message.contains("'myapp'")));
3896 assert!(result.iter().any(|w| w.message.contains("'github'")));
3897 }
3898
3899 #[test]
3900 fn test_fix_does_not_corrupt_frontmatter_path_with_route_group_named_after_proper_name() {
3901 let content = "---\nlink: src/(myapp)/page.tsx\n---\n\nBody.\n";
3904 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3905 let ctx = create_context(content);
3906 assert_eq!(
3907 rule.fix(&ctx).unwrap(),
3908 content,
3909 "fix must not rewrite a frontmatter path whose route-group directory name is the proper name"
3910 );
3911 }
3912
3913 #[test]
3914 fn test_quoted_frontmatter_value_slash_conjunction_prose_still_flagged() {
3915 let content = "---\ndescription: \"We support github/gitlab/bitbucket now\"\n---\n\nBody.\n";
3920 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3921 let ctx = create_context(content);
3922 let result = rule.check(&ctx).unwrap();
3923 assert_eq!(
3924 result.len(),
3925 1,
3926 "quoted prose value must still flag 'github': {result:?}"
3927 );
3928 }
3929
3930 #[test]
3931 fn test_quoted_frontmatter_value_single_slash_word_with_unrelated_dot_still_flagged() {
3932 let content = "---\ndescription: \"We use myapp/gitlab and version 1.0 e.g. weekly\"\n---\n\nBody.\n";
3935 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3936 let ctx = create_context(content);
3937 let result = rule.check(&ctx).unwrap();
3938 assert_eq!(
3939 result.len(),
3940 1,
3941 "quoted prose value must still flag 'myapp': {result:?}"
3942 );
3943 }
3944
3945 #[test]
3946 fn test_quoted_toml_frontmatter_value_slash_conjunction_prose_still_flagged() {
3947 let content = "+++\ndescription = \"We support github/gitlab/bitbucket now\"\n+++\n\nBody.\n";
3950 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3951 let ctx = create_context(content);
3952 let result = rule.check(&ctx).unwrap();
3953 assert_eq!(
3954 result.len(),
3955 1,
3956 "TOML quoted prose value must still flag 'github': {result:?}"
3957 );
3958 }
3959
3960 #[test]
3961 fn test_path_like_collapsed_multiword_no_extension_not_exempt() {
3962 for line in [
3969 r#"description: "myapp/gitlab github/bitbucket""#,
3970 r#"description: "and/or this/that myapp/gitlab""#,
3971 r#"description: "he/him she/her myapp/gitlab""#,
3972 ] {
3973 let span = frontmatter_values::value_span(line).unwrap();
3974 for needle in ["myapp", "gitlab"] {
3975 if let Some(byte_pos) = line.find(needle) {
3976 assert!(
3977 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
3978 "collapsed multi-word value must not exempt '{needle}': {line}"
3979 );
3980 }
3981 }
3982 }
3983 }
3984
3985 #[test]
3986 fn test_path_like_collapsed_multiword_no_extension_not_exempt_toml() {
3987 let line = r#"description = "myapp/gitlab github/bitbucket""#;
3988 let span = frontmatter_values::value_span(line).unwrap();
3989 for needle in ["myapp", "gitlab", "github", "bitbucket"] {
3990 let byte_pos = at(line, needle);
3991 assert!(
3992 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
3993 "collapsed multi-word TOML value must not exempt '{needle}'"
3994 );
3995 }
3996 }
3997
3998 #[test]
3999 fn test_frontmatter_collapsed_multiword_names_all_flagged_yaml() {
4000 let content = "---\ndescription: \"myapp/gitlab github/bitbucket\"\n---\n\nBody.\n";
4001 let rule = MD044ProperNames::new(
4002 vec![
4003 "MyApp".to_string(),
4004 "GitLab".to_string(),
4005 "GitHub".to_string(),
4006 "Bitbucket".to_string(),
4007 ],
4008 false,
4009 );
4010 let ctx = create_context(content);
4011 let result = rule.check(&ctx).unwrap();
4012 assert_eq!(
4013 result.len(),
4014 4,
4015 "all four names in the collapsed multi-word value must be flagged: {result:?}"
4016 );
4017 }
4018
4019 #[test]
4020 fn test_frontmatter_collapsed_multiword_names_all_flagged_toml() {
4021 let content = "+++\ndescription = \"myapp/gitlab github/bitbucket\"\n+++\n\nBody.\n";
4022 let rule = MD044ProperNames::new(
4023 vec![
4024 "MyApp".to_string(),
4025 "GitLab".to_string(),
4026 "GitHub".to_string(),
4027 "Bitbucket".to_string(),
4028 ],
4029 false,
4030 );
4031 let ctx = create_context(content);
4032 let result = rule.check(&ctx).unwrap();
4033 assert_eq!(
4034 result.len(),
4035 4,
4036 "all four names in the collapsed multi-word TOML value must be flagged: {result:?}"
4037 );
4038 }
4039
4040 #[test]
4041 fn test_frontmatter_collapsed_multiword_conjunction_pairs_flagged() {
4042 let content = "---\ndescription: \"and/or this/that myapp/gitlab\"\n---\n\nBody.\n";
4043 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4044 let ctx = create_context(content);
4045 let result = rule.check(&ctx).unwrap();
4046 assert_eq!(
4047 result.len(),
4048 2,
4049 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4050 );
4051 }
4052
4053 #[test]
4054 fn test_frontmatter_collapsed_multiword_pronoun_pairs_flagged() {
4055 let content = "---\ndescription: \"he/him she/her myapp/gitlab\"\n---\n\nBody.\n";
4056 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4057 let ctx = create_context(content);
4058 let result = rule.check(&ctx).unwrap();
4059 assert_eq!(
4060 result.len(),
4061 2,
4062 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4063 );
4064 }
4065
4066 #[test]
4072 fn test_body_prose_path_is_flagged_frontmatter_only_scope() {
4073 let content = "See docs/myapp.md for details about myapp.\n";
4074 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4075 let ctx = create_context(content);
4076 let result = rule.check(&ctx).unwrap();
4077 assert_eq!(
4078 result.len(),
4079 2,
4080 "both the path occurrence and the prose occurrence are flagged in body text: {result:?}"
4081 );
4082 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp.md for details about MyApp.\n");
4083 }
4084
4085 #[test]
4086 fn test_slash_conjunction_prose_still_flagged() {
4087 let content = "We support github/gitlab imports.\n";
4088 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4089 let ctx = create_context(content);
4090 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
4091 }
4092}