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 !Self::is_at_word_boundary(line, start_pos, true) || !Self::is_at_word_boundary(line, end_pos, false)
369 {
370 continue; }
372
373 if !self.config.code_blocks {
375 if ctx.is_in_code_block_or_span(byte_pos) {
376 continue;
377 }
378 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
382 && Self::is_in_backtick_code_in_line(line, start_pos)
383 {
384 continue;
385 }
386 }
387
388 if Self::is_in_link(ctx, byte_pos) {
390 continue;
391 }
392
393 if Self::is_in_angle_bracket_url(line, start_pos) {
397 continue;
398 }
399
400 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
404 && Self::is_in_markdown_link_url(line, start_pos)
405 {
406 continue;
407 }
408
409 if Self::is_in_wikilink_url(ctx, byte_pos) {
414 continue;
415 }
416
417 if Self::is_in_bare_url(ctx, byte_pos) {
423 continue;
424 }
425
426 if let Some(fm_value) = fm_value_span
433 && Self::is_in_path_like_token(line, start_pos, fm_value)
434 {
435 continue;
436 }
437
438 if let Some(proper_name) = self.get_proper_name_for(found_name) {
440 if found_name != proper_name {
442 violations.push((line_num, cap.start() + 1, found_name.to_string()));
443 }
444 }
445 }
446 }
447
448 if let Ok(mut cache) = self.content_cache.lock() {
450 cache.insert(hash, violations.clone());
451 }
452 violations
453 }
454
455 fn is_in_bare_url(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
458 let bare_urls = ctx.bare_urls();
459 let idx = bare_urls.partition_point(|url| url.byte_offset <= byte_pos);
461 idx > 0 && byte_pos < bare_urls[idx - 1].byte_end
462 }
463
464 fn is_in_link(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
471 use pulldown_cmark::LinkType;
472
473 let link_idx = ctx.links.partition_point(|link| link.byte_offset <= byte_pos);
475 if link_idx > 0 {
476 let link = &ctx.links[link_idx - 1];
477 if byte_pos < link.byte_end {
478 let text_start = if matches!(link.link_type, LinkType::WikiLink { .. }) {
480 link.byte_offset + 2
481 } else {
482 link.byte_offset + 1
483 };
484 let text_end = text_start + link.text.len();
485
486 if byte_pos >= text_start && byte_pos < text_end {
490 let is_wikilink = matches!(link.link_type, LinkType::WikiLink { .. });
491 return Self::link_text_is_url(&link.text)
492 || (!is_wikilink && Self::link_text_matches_link_url(&link.text, &link.url));
493 }
494 return true;
496 }
497 }
498
499 let image_idx = ctx.images.partition_point(|img| img.byte_offset <= byte_pos);
501 if image_idx > 0 {
502 let image = &ctx.images[image_idx - 1];
503 if byte_pos < image.byte_end {
504 let alt_start = image.byte_offset + 2;
506 let alt_end = alt_start + image.alt_text.len();
507
508 if byte_pos >= alt_start && byte_pos < alt_end {
510 return false;
511 }
512 return true;
514 }
515 }
516
517 ctx.is_in_reference_def(byte_pos)
519 }
520
521 fn link_text_is_url(text: &str) -> bool {
523 let lower = text.trim().to_ascii_lowercase();
524 lower.starts_with("http://")
525 || lower.starts_with("https://")
526 || lower.starts_with("www.")
527 || lower.starts_with("//")
528 }
529
530 fn link_text_matches_link_url(text: &str, url: &str) -> bool {
542 let text = text.trim();
543 if !text.contains('.') {
545 return false;
546 }
547 let url_lower = url.to_ascii_lowercase();
548 let url_without_scheme = url_lower
549 .strip_prefix("https://")
550 .or_else(|| url_lower.strip_prefix("http://"))
551 .or_else(|| url_lower.strip_prefix("//"))
552 .unwrap_or(&url_lower);
553 let text_lower = text.to_ascii_lowercase();
554 if url_without_scheme == text_lower.as_str() {
556 return true;
557 }
558 url_without_scheme.len() > text_lower.len()
560 && url_without_scheme.starts_with(text_lower.as_str())
561 && matches!(
562 url_without_scheme.as_bytes().get(text_lower.len()),
563 Some(b'/') | Some(b'?') | Some(b'#')
564 )
565 }
566
567 fn is_in_angle_bracket_url(line: &str, pos: usize) -> bool {
573 let bytes = line.as_bytes();
574 let len = bytes.len();
575 let mut i = 0;
576 while i < len {
577 if bytes[i] == b'<' {
578 let after_open = i + 1;
579 if after_open < len && bytes[after_open].is_ascii_alphabetic() {
583 let mut s = after_open + 1;
584 let scheme_max = (after_open + 32).min(len);
585 while s < scheme_max
586 && (bytes[s].is_ascii_alphanumeric()
587 || bytes[s] == b'+'
588 || bytes[s] == b'-'
589 || bytes[s] == b'.')
590 {
591 s += 1;
592 }
593 if s < len && bytes[s] == b':' {
594 let mut j = s + 1;
596 let mut found_close = false;
597 while j < len {
598 match bytes[j] {
599 b'>' => {
600 found_close = true;
601 break;
602 }
603 b' ' | b'<' => break,
604 _ => j += 1,
605 }
606 }
607 if found_close && pos >= i && pos <= j {
608 return true;
609 }
610 if found_close {
611 i = j + 1;
612 continue;
613 }
614 }
615 }
616 }
617 i += 1;
618 }
619 false
620 }
621
622 fn is_in_wikilink_url(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
635 use pulldown_cmark::LinkType;
636 let content = ctx.content.as_bytes();
637
638 let end = ctx.links.partition_point(|l| l.byte_offset <= byte_pos);
641
642 for link in &ctx.links[..end] {
643 if !matches!(link.link_type, LinkType::WikiLink { .. }) {
644 continue;
645 }
646 let wiki_end = link.byte_end;
647 if wiki_end >= byte_pos || wiki_end >= content.len() || content[wiki_end] != b'(' {
649 continue;
650 }
651 let mut depth: u32 = 1;
656 let mut k = wiki_end + 1;
657 let mut valid_destination = true;
658 while k < content.len() && depth > 0 {
659 match content[k] {
660 b'\\' => {
661 k += 1; }
663 b'(' => depth += 1,
664 b')' => depth -= 1,
665 b' ' | b'\t' | b'\n' | b'\r' => {
666 valid_destination = false;
667 break;
668 }
669 _ => {}
670 }
671 k += 1;
672 }
673 if valid_destination && depth == 0 && byte_pos > wiki_end && byte_pos < k {
676 return true;
677 }
678 }
679 false
680 }
681
682 fn is_in_markdown_link_url(line: &str, pos: usize) -> bool {
692 let bytes = line.as_bytes();
693 let len = bytes.len();
694 let mut i = 0;
695
696 while i < len {
697 if bytes[i] == b'[' && (i == 0 || bytes[i - 1] != b'\\' || (i >= 2 && bytes[i - 2] == b'\\')) {
699 let mut depth: u32 = 1;
701 let mut j = i + 1;
702 while j < len && depth > 0 {
703 match bytes[j] {
704 b'\\' => {
705 j += 1; }
707 b'[' => depth += 1,
708 b']' => depth -= 1,
709 _ => {}
710 }
711 j += 1;
712 }
713
714 if depth == 0 && j < len {
716 if bytes[j] == b'(' {
717 let url_start = j;
719 let mut paren_depth: u32 = 1;
720 let mut k = j + 1;
721 while k < len && paren_depth > 0 {
722 match bytes[k] {
723 b'\\' => {
724 k += 1; }
726 b'(' => paren_depth += 1,
727 b')' => paren_depth -= 1,
728 _ => {}
729 }
730 k += 1;
731 }
732
733 if paren_depth == 0 {
734 if pos > url_start && pos < k {
735 return true;
736 }
737 i = k;
738 continue;
739 }
740 } else if bytes[j] == b'[' {
741 let ref_start = j;
743 let mut ref_depth: u32 = 1;
744 let mut k = j + 1;
745 while k < len && ref_depth > 0 {
746 match bytes[k] {
747 b'\\' => {
748 k += 1;
749 }
750 b'[' => ref_depth += 1,
751 b']' => ref_depth -= 1,
752 _ => {}
753 }
754 k += 1;
755 }
756
757 if ref_depth == 0 {
758 if pos > ref_start && pos < k {
759 return true;
760 }
761 i = k;
762 continue;
763 }
764 }
765 }
766 }
767 i += 1;
768 }
769 false
770 }
771
772 fn is_in_backtick_code_in_line(line: &str, pos: usize) -> bool {
780 let bytes = line.as_bytes();
781 let len = bytes.len();
782 let mut i = 0;
783 while i < len {
784 if bytes[i] == b'`' {
785 let open_start = i;
787 while i < len && bytes[i] == b'`' {
788 i += 1;
789 }
790 let tick_len = i - open_start;
791
792 while i < len {
794 if bytes[i] == b'`' {
795 let close_start = i;
796 while i < len && bytes[i] == b'`' {
797 i += 1;
798 }
799 if i - close_start == tick_len {
800 let content_start = open_start + tick_len;
804 let content_end = close_start;
805 if pos >= content_start && pos < content_end {
806 return true;
807 }
808 break;
810 }
811 } else {
813 i += 1;
814 }
815 }
816 } else {
817 i += 1;
818 }
819 }
820 false
821 }
822
823 fn is_word_boundary_char(c: char) -> bool {
825 !c.is_alphanumeric()
826 }
827
828 fn is_at_word_boundary(content: &str, pos: usize, is_start: bool) -> bool {
830 if is_start {
831 if pos == 0 {
832 return true;
833 }
834 match content[..pos].chars().next_back() {
835 None => true,
836 Some(c) => Self::is_word_boundary_char(c),
837 }
838 } else {
839 if pos >= content.len() {
840 return true;
841 }
842 match content[pos..].chars().next() {
843 None => true,
844 Some(c) => Self::is_word_boundary_char(c),
845 }
846 }
847 }
848
849 fn is_in_path_like_token(line: &str, match_start: usize, fm_value: (usize, usize)) -> bool {
877 let (value_start, value_end) = fm_value;
878 if match_start < value_start || match_start >= value_end {
879 return false;
880 }
881
882 let quoted_words: Vec<&str> = if frontmatter_values::value_is_quoted(line, value_start) {
890 line[value_start..value_end].split_whitespace().collect()
891 } else {
892 Vec::new()
893 };
894 let is_single_quoted_path = !quoted_words.is_empty() && quoted_words.iter().all(|word| word.contains('/'));
895 let is_multi_word_collapse = is_single_quoted_path && quoted_words.len() > 1;
903
904 let (raw_start, raw_end) = if is_single_quoted_path {
905 (value_start, value_end)
906 } else {
907 frontmatter_values::token_bounds(line, match_start, value_start, value_end)
908 };
909
910 let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
911 if match_start < start || match_start >= end {
912 return false;
913 }
914
915 let token = &line[start..end];
916 if !token.contains('/') {
917 return false;
918 }
919 if token.starts_with('/') || token.starts_with("./") || token.starts_with("../") || token.starts_with("~/") {
920 return true;
921 }
922 if token.rsplit('/').next().is_some_and(|seg| seg.contains('.')) {
923 return true;
924 }
925
926 if is_multi_word_collapse {
927 return false;
928 }
929
930 let sole_value = {
934 let (ts, te) = frontmatter_values::trim_token_bounds(line, value_start, value_end);
935 ts == start && te == end
936 };
937 sole_value && token.split('/').filter(|s| !s.is_empty()).count() >= 3
938 }
939
940 fn get_proper_name_for(&self, found_name: &str) -> Option<String> {
942 let found_lower = found_name.to_lowercase();
943
944 for name in &self.config.names {
946 let lower_name = name.to_lowercase();
947 let lower_name_no_dots = lower_name.replace('.', "");
948
949 if found_lower == lower_name || found_lower == lower_name_no_dots {
951 return Some(name.clone());
952 }
953
954 let ascii_normalized = Self::ascii_normalize(&lower_name);
956
957 let ascii_no_dots = ascii_normalized.replace('.', "");
958
959 if found_lower == ascii_normalized || found_lower == ascii_no_dots {
960 return Some(name.clone());
961 }
962 }
963 None
964 }
965}
966
967impl Rule for MD044ProperNames {
968 fn name(&self) -> &'static str {
969 "MD044"
970 }
971
972 fn description(&self) -> &'static str {
973 "Proper names should have the correct capitalization"
974 }
975
976 fn category(&self) -> RuleCategory {
977 RuleCategory::Other
978 }
979
980 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
981 if self.config.names.is_empty() {
982 return true;
983 }
984 let content_lower = if ctx.content.is_ascii() {
986 ctx.content.to_ascii_lowercase()
987 } else {
988 ctx.content.to_lowercase()
989 };
990 !self.name_variants.iter().any(|name| content_lower.contains(name))
991 }
992
993 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
994 let content = ctx.content;
995 if content.is_empty() || self.config.names.is_empty() || self.combined_pattern.is_none() {
996 return Ok(Vec::new());
997 }
998
999 let content_lower = if content.is_ascii() {
1001 content.to_ascii_lowercase()
1002 } else {
1003 content.to_lowercase()
1004 };
1005
1006 let has_potential_matches = self.name_variants.iter().any(|name| content_lower.contains(name));
1008
1009 if !has_potential_matches {
1010 return Ok(Vec::new());
1011 }
1012
1013 let line_index = &ctx.line_index;
1014 let violations = self.find_name_violations(content, ctx, &content_lower);
1015
1016 let warnings = violations
1017 .into_iter()
1018 .filter_map(|(line, column, found_name)| {
1019 self.get_proper_name_for(&found_name).map(|proper_name| {
1020 let line_start = line_index.get_line_start_byte(line).unwrap_or(0);
1025 let byte_start = line_start + (column - 1);
1026 let byte_end = byte_start + found_name.len();
1027 let line_text = ctx.line_info(line).map_or("", |li| li.content(ctx.content));
1030 let char_col = byte_to_char_count(line_text, column - 1);
1031 LintWarning {
1032 rule_name: Some(self.name().to_string()),
1033 line,
1034 column: char_col,
1035 end_line: line,
1036 end_column: char_col + found_name.chars().count(),
1037 message: format!("Proper name '{found_name}' should be '{proper_name}'"),
1038 severity: Severity::Warning,
1039 fix: Some(Fix::new(byte_start..byte_end, proper_name)),
1040 }
1041 })
1042 })
1043 .collect();
1044
1045 Ok(warnings)
1046 }
1047
1048 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1049 if self.should_skip(ctx) {
1050 return Ok(ctx.content.to_string());
1051 }
1052 let warnings = self.check(ctx)?;
1053 if warnings.is_empty() {
1054 return Ok(ctx.content.to_string());
1055 }
1056 let warnings =
1057 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1058 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
1059 .map_err(crate::rule::LintError::InvalidInput)
1060 }
1061
1062 fn as_any(&self) -> &dyn std::any::Any {
1063 self
1064 }
1065
1066 crate::impl_rule_config_methods!(MD044Config, nullable);
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071 use super::*;
1072 use crate::lint_context::LintContext;
1073
1074 fn create_context(content: &str) -> LintContext<'_> {
1075 LintContext::new(content, crate::config::MarkdownFlavor::Standard, None)
1076 }
1077
1078 fn field_map_for(content: &str) -> Vec<Option<String>> {
1079 let ctx = create_context(content);
1080 frontmatter_values::field_map(&ctx)
1081 }
1082
1083 #[test]
1084 fn test_field_map_nested_lines_inherit_top_level_key() {
1085 let map = field_map_for("---\nseo:\n canonical: docs/a.md\n keywords:\n - myapp\ntitle: x\n---\n");
1086 assert_eq!(map[2].as_deref(), Some("seo"));
1087 assert_eq!(map[4].as_deref(), Some("seo"));
1088 assert_eq!(map[5].as_deref(), Some("title"));
1089 }
1090
1091 #[test]
1092 fn test_field_map_block_scalar_bracket_does_not_swallow_next_key() {
1093 let map = field_map_for("---\ndescription: |\n [myapp\ntitle: myapp\n---\n");
1094 assert_eq!(map[2].as_deref(), Some("description"));
1095 assert_eq!(
1096 map[3].as_deref(),
1097 Some("title"),
1098 "an indent-0 key always starts a new key"
1099 );
1100 }
1101
1102 #[test]
1103 fn test_field_map_quoted_key_with_colon() {
1104 let map = field_map_for("---\n\"og:title\": myapp\n---\n");
1105 assert_eq!(map[1].as_deref(), Some("og:title"));
1106 }
1107
1108 #[test]
1109 fn test_field_map_top_level_sequence_clears_attribution() {
1110 let map = field_map_for("---\n- myapp\n---\n");
1111 assert_eq!(map[1], None);
1112 }
1113
1114 #[test]
1115 fn test_field_map_toml_table_body_belongs_to_table_root() {
1116 let map = field_map_for("+++\n[seo]\ncanonical = \"docs/a.md\"\n\n[[authors]]\nname = \"myapp\"\n+++\n");
1117 assert_eq!(map[2].as_deref(), Some("seo"));
1118 assert_eq!(map[5].as_deref(), Some("authors"));
1119 }
1120
1121 #[test]
1122 fn test_field_map_toml_dotted_assignment_uses_root() {
1123 let map = field_map_for("+++\nseo.canonical = \"docs/a.md\"\n+++\n");
1124 assert_eq!(map[1].as_deref(), Some("seo"));
1125 }
1126
1127 #[test]
1128 fn test_field_map_toml_array_continuation_inherits() {
1129 let map = field_map_for("+++\nseo = [\n\"docs/guide/myapp\"\n]\n+++\n");
1130 assert_eq!(map[2].as_deref(), Some("seo"));
1131 }
1132
1133 #[test]
1134 fn test_field_map_indent_zero_flow_continuation_is_not_attributed_to_parent() {
1135 let map = field_map_for("---\nseo: [\n{name: myapp}\n]\n---\n");
1138 assert_eq!(map[2].as_deref(), Some("{name"));
1139 }
1140
1141 #[test]
1142 fn test_field_map_toml_nested_array_inherits_and_title_not_corrupted() {
1143 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\ntitle = \"x\"\n+++\n");
1144 assert_eq!(map[2].as_deref(), Some("matrix"), "nested array line inherits matrix");
1145 assert_eq!(map[3].as_deref(), Some("matrix"), "nested array line inherits matrix");
1146 assert_eq!(
1147 map[5].as_deref(),
1148 Some("title"),
1149 "title must not inherit stale attribution from a closed nested array"
1150 );
1151 }
1152
1153 #[test]
1154 fn test_field_map_toml_nested_array_last_element_without_trailing_comma() {
1155 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [2]\n]\ntitle = \"x\"\n+++\n");
1156 assert_eq!(
1157 map[3].as_deref(),
1158 Some("matrix"),
1159 "last element without a trailing comma still inherits matrix"
1160 );
1161 assert_eq!(
1162 map[5].as_deref(),
1163 Some("title"),
1164 "title must not inherit stale attribution from a closed nested array"
1165 );
1166 }
1167
1168 #[test]
1169 fn test_field_map_toml_nested_array_then_real_table_header_non_regression_guard() {
1170 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\n\n[seo]\ncanonical = \"docs/a.md\"\n+++\n");
1177 assert_eq!(map[6].as_deref(), Some("seo"), "real table header after a closed array");
1178 assert_eq!(
1179 map[7].as_deref(),
1180 Some("seo"),
1181 "table body still attributes to the table"
1182 );
1183 }
1184
1185 #[test]
1186 fn test_field_map_toml_unclosed_array_resyncs_on_next_assignment() {
1187 let map = field_map_for("+++\nmatrix = [\n [1, 2],\ntitle = \"x\"\n+++\n");
1193 assert_eq!(
1194 map[3].as_deref(),
1195 Some("title"),
1196 "title must resync even though the array was never closed"
1197 );
1198 }
1199
1200 #[test]
1201 fn test_field_map_toml_column_zero_array_elements_inherit_and_title_not_corrupted() {
1202 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[3, 4],\n]\ntitle = \"x\"\n+++\n");
1208 assert_eq!(
1209 map[2].as_deref(),
1210 Some("matrix"),
1211 "column-0 array element inherits matrix"
1212 );
1213 assert_eq!(
1214 map[3].as_deref(),
1215 Some("matrix"),
1216 "column-0 array element inherits matrix"
1217 );
1218 assert_eq!(
1219 map[5].as_deref(),
1220 Some("title"),
1221 "title must not inherit stale attribution from a misread array element"
1222 );
1223 }
1224
1225 #[test]
1226 fn test_field_map_toml_column_zero_array_last_element_without_trailing_comma() {
1227 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[2]\n]\ntitle = \"x\"\n+++\n");
1228 assert_eq!(
1229 map[3].as_deref(),
1230 Some("matrix"),
1231 "column-0 last element without a trailing comma still inherits matrix"
1232 );
1233 assert_eq!(
1234 map[5].as_deref(),
1235 Some("title"),
1236 "title must not inherit stale attribution from a misread array element"
1237 );
1238 }
1239
1240 #[test]
1241 fn test_correctly_capitalized_names() {
1242 let rule = MD044ProperNames::new(
1243 vec![
1244 "JavaScript".to_string(),
1245 "TypeScript".to_string(),
1246 "Node.js".to_string(),
1247 ],
1248 true,
1249 );
1250
1251 let content = "This document uses JavaScript, TypeScript, and Node.js correctly.";
1252 let ctx = create_context(content);
1253 let result = rule.check(&ctx).unwrap();
1254 assert!(result.is_empty(), "Should not flag correctly capitalized names");
1255 }
1256
1257 #[test]
1258 fn test_incorrectly_capitalized_names() {
1259 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1260
1261 let content = "This document uses javascript and typescript incorrectly.";
1262 let ctx = create_context(content);
1263 let result = rule.check(&ctx).unwrap();
1264
1265 assert_eq!(result.len(), 2, "Should flag two incorrect capitalizations");
1266 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1267 assert_eq!(result[0].line, 1);
1268 assert_eq!(result[0].column, 20);
1269 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1270 assert_eq!(result[1].line, 1);
1271 assert_eq!(result[1].column, 35);
1272 }
1273
1274 #[test]
1275 fn test_names_at_beginning_of_sentences() {
1276 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "Python".to_string()], true);
1277
1278 let content = "javascript is a great language. python is also popular.";
1279 let ctx = create_context(content);
1280 let result = rule.check(&ctx).unwrap();
1281
1282 assert_eq!(result.len(), 2, "Should flag names at beginning of sentences");
1283 assert_eq!(result[0].line, 1);
1284 assert_eq!(result[0].column, 1);
1285 assert_eq!(result[1].line, 1);
1286 assert_eq!(result[1].column, 33);
1287 }
1288
1289 #[test]
1290 fn test_names_in_code_blocks_checked_by_default() {
1291 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1292
1293 let content = r#"Here is some text with JavaScript.
1294
1295```javascript
1296// This javascript should be checked
1297const lang = "javascript";
1298```
1299
1300But this javascript should be flagged."#;
1301
1302 let ctx = create_context(content);
1303 let result = rule.check(&ctx).unwrap();
1304
1305 assert_eq!(result.len(), 3, "Should flag javascript inside and outside code blocks");
1306 assert_eq!(result[0].line, 4);
1307 assert_eq!(result[1].line, 5);
1308 assert_eq!(result[2].line, 8);
1309 }
1310
1311 #[test]
1312 fn test_names_in_code_blocks_ignored_when_disabled() {
1313 let rule = MD044ProperNames::new(
1314 vec!["JavaScript".to_string()],
1315 false, );
1317
1318 let content = r#"```
1319javascript in code block
1320```"#;
1321
1322 let ctx = create_context(content);
1323 let result = rule.check(&ctx).unwrap();
1324
1325 assert_eq!(
1326 result.len(),
1327 0,
1328 "Should not flag javascript in code blocks when code_blocks is false"
1329 );
1330 }
1331
1332 #[test]
1333 fn test_names_in_inline_code_checked_by_default() {
1334 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1335
1336 let content = "This is `javascript` in inline code and javascript outside.";
1337 let ctx = create_context(content);
1338 let result = rule.check(&ctx).unwrap();
1339
1340 assert_eq!(result.len(), 2, "Should flag javascript inside and outside inline code");
1342 assert_eq!(result[0].column, 10); assert_eq!(result[1].column, 41); }
1345
1346 #[test]
1347 fn test_multiple_names_in_same_line() {
1348 let rule = MD044ProperNames::new(
1349 vec!["JavaScript".to_string(), "TypeScript".to_string(), "React".to_string()],
1350 true,
1351 );
1352
1353 let content = "I use javascript, typescript, and react in my projects.";
1354 let ctx = create_context(content);
1355 let result = rule.check(&ctx).unwrap();
1356
1357 assert_eq!(result.len(), 3, "Should flag all three incorrect names");
1358 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1359 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1360 assert_eq!(result[2].message, "Proper name 'react' should be 'React'");
1361 }
1362
1363 #[test]
1364 fn test_case_sensitivity() {
1365 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1366
1367 let content = "JAVASCRIPT, Javascript, javascript, and JavaScript variations.";
1368 let ctx = create_context(content);
1369 let result = rule.check(&ctx).unwrap();
1370
1371 assert_eq!(result.len(), 3, "Should flag all incorrect case variations");
1372 assert!(result.iter().all(|w| w.message.contains("should be 'JavaScript'")));
1374 }
1375
1376 #[test]
1377 fn test_configuration_with_custom_name_list() {
1378 let config = MD044Config {
1379 names: vec!["GitHub".to_string(), "GitLab".to_string(), "DevOps".to_string()],
1380 code_blocks: true,
1381 ..Default::default()
1382 };
1383 let rule = MD044ProperNames::from_config_struct(config);
1384
1385 let content = "We use github, gitlab, and devops for our workflow.";
1386 let ctx = create_context(content);
1387 let result = rule.check(&ctx).unwrap();
1388
1389 assert_eq!(result.len(), 3, "Should flag all custom names");
1390 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
1391 assert_eq!(result[1].message, "Proper name 'gitlab' should be 'GitLab'");
1392 assert_eq!(result[2].message, "Proper name 'devops' should be 'DevOps'");
1393 }
1394
1395 #[test]
1396 fn test_empty_configuration() {
1397 let rule = MD044ProperNames::new(vec![], true);
1398
1399 let content = "This has javascript and typescript but no configured names.";
1400 let ctx = create_context(content);
1401 let result = rule.check(&ctx).unwrap();
1402
1403 assert!(result.is_empty(), "Should not flag anything with empty configuration");
1404 }
1405
1406 #[test]
1407 fn test_names_with_special_characters() {
1408 let rule = MD044ProperNames::new(
1409 vec!["Node.js".to_string(), "ASP.NET".to_string(), "C++".to_string()],
1410 true,
1411 );
1412
1413 let content = "We use nodejs, asp.net, ASP.NET, and c++ in our stack.";
1414 let ctx = create_context(content);
1415 let result = rule.check(&ctx).unwrap();
1416
1417 assert_eq!(result.len(), 3, "Should handle special characters correctly");
1422
1423 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
1424 assert!(messages.contains(&"Proper name 'nodejs' should be 'Node.js'"));
1425 assert!(messages.contains(&"Proper name 'asp.net' should be 'ASP.NET'"));
1426 assert!(messages.contains(&"Proper name 'c++' should be 'C++'"));
1427 }
1428
1429 #[test]
1430 fn test_word_boundaries() {
1431 let rule = MD044ProperNames::new(vec!["Java".to_string(), "Script".to_string()], true);
1432
1433 let content = "JavaScript is not java or script, but Java and Script are separate.";
1434 let ctx = create_context(content);
1435 let result = rule.check(&ctx).unwrap();
1436
1437 assert_eq!(result.len(), 2, "Should respect word boundaries");
1439 assert!(result.iter().any(|w| w.column == 19)); assert!(result.iter().any(|w| w.column == 27)); }
1442
1443 #[test]
1444 fn test_fix_method() {
1445 let rule = MD044ProperNames::new(
1446 vec![
1447 "JavaScript".to_string(),
1448 "TypeScript".to_string(),
1449 "Node.js".to_string(),
1450 ],
1451 true,
1452 );
1453
1454 let content = "I love javascript, typescript, and nodejs!";
1455 let ctx = create_context(content);
1456 let fixed = rule.fix(&ctx).unwrap();
1457
1458 assert_eq!(fixed, "I love JavaScript, TypeScript, and Node.js!");
1459 }
1460
1461 #[test]
1462 fn test_fix_multiple_occurrences() {
1463 let rule = MD044ProperNames::new(vec!["Python".to_string()], true);
1464
1465 let content = "python is great. I use python daily. PYTHON is powerful.";
1466 let ctx = create_context(content);
1467 let fixed = rule.fix(&ctx).unwrap();
1468
1469 assert_eq!(fixed, "Python is great. I use Python daily. Python is powerful.");
1470 }
1471
1472 #[test]
1473 fn test_fix_checks_code_blocks_by_default() {
1474 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1475
1476 let content = r#"I love javascript.
1477
1478```
1479const lang = "javascript";
1480```
1481
1482More javascript here."#;
1483
1484 let ctx = create_context(content);
1485 let fixed = rule.fix(&ctx).unwrap();
1486
1487 let expected = r#"I love JavaScript.
1488
1489```
1490const lang = "JavaScript";
1491```
1492
1493More JavaScript here."#;
1494
1495 assert_eq!(fixed, expected);
1496 }
1497
1498 #[test]
1499 fn test_multiline_content() {
1500 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
1501
1502 let content = r#"First line with rust.
1503Second line with python.
1504Third line with RUST and PYTHON."#;
1505
1506 let ctx = create_context(content);
1507 let result = rule.check(&ctx).unwrap();
1508
1509 assert_eq!(result.len(), 4, "Should flag all incorrect occurrences");
1510 assert_eq!(result[0].line, 1);
1511 assert_eq!(result[1].line, 2);
1512 assert_eq!(result[2].line, 3);
1513 assert_eq!(result[3].line, 3);
1514 }
1515
1516 #[test]
1517 fn test_default_config() {
1518 let config = MD044Config::default();
1519 assert!(config.names.is_empty());
1520 assert!(!config.code_blocks);
1521 assert!(config.html_elements);
1522 assert!(config.html_comments);
1523 }
1524
1525 #[test]
1526 fn test_default_config_checks_html_comments() {
1527 let config = MD044Config {
1528 names: vec!["JavaScript".to_string()],
1529 ..MD044Config::default()
1530 };
1531 let rule = MD044ProperNames::from_config_struct(config);
1532
1533 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1534 let ctx = create_context(content);
1535 let result = rule.check(&ctx).unwrap();
1536
1537 assert_eq!(result.len(), 1, "Default config should check HTML comments");
1538 assert_eq!(result[0].line, 3);
1539 }
1540
1541 #[test]
1542 fn test_default_config_skips_code_blocks() {
1543 let config = MD044Config {
1544 names: vec!["JavaScript".to_string()],
1545 ..MD044Config::default()
1546 };
1547 let rule = MD044ProperNames::from_config_struct(config);
1548
1549 let content = "# Guide\n\n```\njavascript in code\n```\n";
1550 let ctx = create_context(content);
1551 let result = rule.check(&ctx).unwrap();
1552
1553 assert_eq!(result.len(), 0, "Default config should skip code blocks");
1554 }
1555
1556 #[test]
1557 fn test_standalone_html_comment_checked() {
1558 let config = MD044Config {
1559 names: vec!["Test".to_string()],
1560 ..MD044Config::default()
1561 };
1562 let rule = MD044ProperNames::from_config_struct(config);
1563
1564 let content = "# Heading\n\n<!-- this is a test example -->\n";
1565 let ctx = create_context(content);
1566 let result = rule.check(&ctx).unwrap();
1567
1568 assert_eq!(result.len(), 1, "Should flag proper name in standalone HTML comment");
1569 assert_eq!(result[0].line, 3);
1570 }
1571
1572 #[test]
1573 fn test_inline_config_comments_not_flagged() {
1574 let config = MD044Config {
1575 names: vec!["RUMDL".to_string()],
1576 ..MD044Config::default()
1577 };
1578 let rule = MD044ProperNames::from_config_struct(config);
1579
1580 let content = "<!-- rumdl-disable MD044 -->\nSome rumdl text here.\n<!-- rumdl-enable MD044 -->\n<!-- markdownlint-disable -->\nMore rumdl text.\n<!-- markdownlint-enable -->\n";
1584 let ctx = create_context(content);
1585 let result = rule.check(&ctx).unwrap();
1586
1587 assert_eq!(result.len(), 2, "Should only flag body lines, not config comments");
1588 assert_eq!(result[0].line, 2);
1589 assert_eq!(result[1].line, 5);
1590 }
1591
1592 #[test]
1593 fn test_html_comment_skipped_when_disabled() {
1594 let config = MD044Config {
1595 names: vec!["Test".to_string()],
1596 code_blocks: true,
1597 html_comments: false,
1598 ..Default::default()
1599 };
1600 let rule = MD044ProperNames::from_config_struct(config);
1601
1602 let content = "# Heading\n\n<!-- this is a test example -->\n\nRegular test here.\n";
1603 let ctx = create_context(content);
1604 let result = rule.check(&ctx).unwrap();
1605
1606 assert_eq!(
1607 result.len(),
1608 1,
1609 "Should only flag 'test' outside HTML comment when html_comments=false"
1610 );
1611 assert_eq!(result[0].line, 5);
1612 }
1613
1614 #[test]
1615 fn test_fix_corrects_html_comment_content() {
1616 let config = MD044Config {
1617 names: vec!["JavaScript".to_string()],
1618 ..MD044Config::default()
1619 };
1620 let rule = MD044ProperNames::from_config_struct(config);
1621
1622 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1623 let ctx = create_context(content);
1624 let fixed = rule.fix(&ctx).unwrap();
1625
1626 assert_eq!(fixed, "# Guide\n\n<!-- JavaScript mentioned here -->\n");
1627 }
1628
1629 #[test]
1630 fn test_fix_does_not_modify_inline_config_comments() {
1631 let config = MD044Config {
1632 names: vec!["RUMDL".to_string()],
1633 ..MD044Config::default()
1634 };
1635 let rule = MD044ProperNames::from_config_struct(config);
1636
1637 let content = "<!-- rumdl-disable -->\nSome rumdl text.\n<!-- rumdl-enable -->\n";
1638 let ctx = create_context(content);
1639 let fixed = rule.fix(&ctx).unwrap();
1640
1641 assert!(fixed.contains("<!-- rumdl-disable -->"));
1643 assert!(fixed.contains("<!-- rumdl-enable -->"));
1644 assert!(
1646 fixed.contains("Some rumdl text."),
1647 "Line inside rumdl-disable block should not be modified by fix()"
1648 );
1649 }
1650
1651 #[test]
1652 fn test_fix_respects_inline_disable_partial() {
1653 let config = MD044Config {
1654 names: vec!["RUMDL".to_string()],
1655 ..MD044Config::default()
1656 };
1657 let rule = MD044ProperNames::from_config_struct(config);
1658
1659 let content =
1660 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
1661 let ctx = create_context(content);
1662 let fixed = rule.fix(&ctx).unwrap();
1663
1664 assert!(
1666 fixed.contains("Some rumdl text.\n<!-- rumdl-enable"),
1667 "Line inside disable block should not be modified"
1668 );
1669 assert!(
1671 fixed.contains("Some RUMDL text outside."),
1672 "Line outside disable block should be fixed"
1673 );
1674 }
1675
1676 #[test]
1677 fn test_performance_with_many_names() {
1678 let mut names = vec![];
1679 for i in 0..50 {
1680 names.push(format!("ProperName{i}"));
1681 }
1682
1683 let rule = MD044ProperNames::new(names, true);
1684
1685 let content = "This has propername0, propername25, and propername49 incorrectly.";
1686 let ctx = create_context(content);
1687 let result = rule.check(&ctx).unwrap();
1688
1689 assert_eq!(result.len(), 3, "Should handle many configured names efficiently");
1690 }
1691
1692 #[test]
1693 fn test_large_name_count_performance() {
1694 let names = (0..1000).map(|i| format!("ProperName{i}")).collect::<Vec<_>>();
1697
1698 let rule = MD044ProperNames::new(names, true);
1699
1700 assert!(rule.combined_pattern.is_some());
1702
1703 let content = "This has propername0 and propername999 in it.";
1705 let ctx = create_context(content);
1706 let result = rule.check(&ctx).unwrap();
1707
1708 assert_eq!(result.len(), 2, "Should handle 1000 names without issues");
1710 }
1711
1712 #[test]
1713 fn test_cache_behavior() {
1714 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1715
1716 let content = "Using javascript here.";
1717 let ctx = create_context(content);
1718
1719 let result1 = rule.check(&ctx).unwrap();
1721 assert_eq!(result1.len(), 1);
1722
1723 let result2 = rule.check(&ctx).unwrap();
1725 assert_eq!(result2.len(), 1);
1726
1727 assert_eq!(result1[0].line, result2[0].line);
1729 assert_eq!(result1[0].column, result2[0].column);
1730 }
1731
1732 #[test]
1733 fn test_html_comments_not_checked_when_disabled() {
1734 let config = MD044Config {
1735 names: vec!["JavaScript".to_string()],
1736 code_blocks: true, html_comments: false, ..Default::default()
1739 };
1740 let rule = MD044ProperNames::from_config_struct(config);
1741
1742 let content = r#"Regular javascript here.
1743<!-- This javascript in HTML comment should be ignored -->
1744More javascript outside."#;
1745
1746 let ctx = create_context(content);
1747 let result = rule.check(&ctx).unwrap();
1748
1749 assert_eq!(result.len(), 2, "Should only flag javascript outside HTML comments");
1750 assert_eq!(result[0].line, 1);
1751 assert_eq!(result[1].line, 3);
1752 }
1753
1754 #[test]
1755 fn test_html_comments_checked_when_enabled() {
1756 let config = MD044Config {
1757 names: vec!["JavaScript".to_string()],
1758 code_blocks: true, ..Default::default()
1760 };
1761 let rule = MD044ProperNames::from_config_struct(config);
1762
1763 let content = r#"Regular javascript here.
1764<!-- This javascript in HTML comment should be checked -->
1765More javascript outside."#;
1766
1767 let ctx = create_context(content);
1768 let result = rule.check(&ctx).unwrap();
1769
1770 assert_eq!(
1771 result.len(),
1772 3,
1773 "Should flag all javascript occurrences including in HTML comments"
1774 );
1775 }
1776
1777 #[test]
1778 fn test_indented_html_comment_escapes_via_link_and_backticks() {
1779 let config = MD044Config {
1784 names: vec!["Test".to_string()],
1785 ..Default::default()
1786 };
1787 let rule = MD044ProperNames::from_config_struct(config);
1788
1789 let content = "<!-- see the [relevant page](test.md). -->\n<!-- see `test.md` -->\n <!-- see the [relevant page](test.md). -->\n <!-- see `test.md` -->\n";
1790
1791 let ctx = create_context(content);
1792 let result = rule.check(&ctx).unwrap();
1793
1794 assert!(
1795 result.is_empty(),
1796 "'test' inside a link URL or backticks must be ignored in both column-0 and indented comments, got: {result:?}"
1797 );
1798 }
1799
1800 #[test]
1801 fn test_indented_html_comment_still_checks_bare_prose() {
1802 let config = MD044Config {
1805 names: vec!["Test".to_string()],
1806 ..Default::default()
1807 };
1808 let rule = MD044ProperNames::from_config_struct(config);
1809
1810 let content = " <!-- this is a test comment -->\n";
1811
1812 let ctx = create_context(content);
1813 let result = rule.check(&ctx).unwrap();
1814
1815 assert_eq!(
1816 result.len(),
1817 1,
1818 "bare 'test' in an indented comment is still a violation"
1819 );
1820 assert_eq!(result[0].line, 1);
1821 }
1822
1823 #[test]
1824 fn test_multiline_html_comments() {
1825 let config = MD044Config {
1826 names: vec!["Python".to_string(), "JavaScript".to_string()],
1827 code_blocks: true, html_comments: false, ..Default::default()
1830 };
1831 let rule = MD044ProperNames::from_config_struct(config);
1832
1833 let content = r#"Regular python here.
1834<!--
1835This is a multiline comment
1836with javascript and python
1837that should be ignored
1838-->
1839More javascript outside."#;
1840
1841 let ctx = create_context(content);
1842 let result = rule.check(&ctx).unwrap();
1843
1844 assert_eq!(result.len(), 2, "Should only flag names outside HTML comments");
1845 assert_eq!(result[0].line, 1); assert_eq!(result[1].line, 7); }
1848
1849 #[test]
1850 fn test_fix_preserves_html_comments_when_disabled() {
1851 let config = MD044Config {
1852 names: vec!["JavaScript".to_string()],
1853 code_blocks: true, html_comments: false, ..Default::default()
1856 };
1857 let rule = MD044ProperNames::from_config_struct(config);
1858
1859 let content = r#"javascript here.
1860<!-- javascript in comment -->
1861More javascript."#;
1862
1863 let ctx = create_context(content);
1864 let fixed = rule.fix(&ctx).unwrap();
1865
1866 let expected = r#"JavaScript here.
1867<!-- javascript in comment -->
1868More JavaScript."#;
1869
1870 assert_eq!(
1871 fixed, expected,
1872 "Should not fix names inside HTML comments when disabled"
1873 );
1874 }
1875
1876 #[test]
1877 fn test_proper_names_in_link_text_are_flagged() {
1878 let rule = MD044ProperNames::new(
1879 vec!["JavaScript".to_string(), "Node.js".to_string(), "Python".to_string()],
1880 true,
1881 );
1882
1883 let content = r#"Check this [javascript documentation](https://javascript.info) for info.
1884
1885Visit [node.js homepage](https://nodejs.org) and [python tutorial](https://python.org).
1886
1887Real javascript should be flagged.
1888
1889Also see the [typescript guide][ts-ref] for more.
1890
1891Real python should be flagged too.
1892
1893[ts-ref]: https://typescript.org/handbook"#;
1894
1895 let ctx = create_context(content);
1896 let result = rule.check(&ctx).unwrap();
1897
1898 assert_eq!(result.len(), 5, "Expected 5 warnings: 3 in link text + 2 standalone");
1905
1906 let line_1_warnings: Vec<_> = result.iter().filter(|w| w.line == 1).collect();
1908 assert_eq!(line_1_warnings.len(), 1);
1909 assert!(
1910 line_1_warnings[0]
1911 .message
1912 .contains("'javascript' should be 'JavaScript'")
1913 );
1914
1915 let line_3_warnings: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
1916 assert_eq!(line_3_warnings.len(), 2); assert!(result.iter().any(|w| w.line == 5 && w.message.contains("'javascript'")));
1920 assert!(result.iter().any(|w| w.line == 9 && w.message.contains("'python'")));
1921 }
1922
1923 #[test]
1924 fn test_link_urls_not_flagged() {
1925 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1926
1927 let content = r#"[Link Text](https://javascript.info/guide)"#;
1929
1930 let ctx = create_context(content);
1931 let result = rule.check(&ctx).unwrap();
1932
1933 assert!(result.is_empty(), "URLs should not be checked for proper names");
1935 }
1936
1937 #[test]
1938 fn test_bare_urls_not_flagged() {
1939 let rule = MD044ProperNames::new(vec!["Foo".to_string(), "JavaScript".to_string()], true);
1940
1941 let content =
1944 "https://foo.com\n\nSee https://javascript.info/foo/guide for details.\n\nMail foo@foo.com about it.\n";
1945
1946 let ctx = create_context(content);
1947 let result = rule.check(&ctx).unwrap();
1948
1949 assert!(
1950 result.is_empty(),
1951 "Bare URLs and emails should not be checked for proper names: {result:?}"
1952 );
1953 }
1954
1955 #[test]
1956 fn test_prose_around_bare_url_still_flagged() {
1957 let rule = MD044ProperNames::new(vec!["Foo".to_string()], true);
1958
1959 let content = "Use foo at https://foo.com because foo is great.\n";
1962
1963 let ctx = create_context(content);
1964 let result = rule.check(&ctx).unwrap();
1965
1966 assert_eq!(
1967 result.len(),
1968 2,
1969 "Prose occurrences around a bare URL must still be flagged: {result:?}"
1970 );
1971 assert!(result.iter().all(|w| w.message.contains("'foo' should be 'Foo'")));
1972 }
1973
1974 #[test]
1975 fn test_proper_names_in_image_alt_text_are_flagged() {
1976 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1977
1978 let content = r#"Here is a  image.
1979
1980Real javascript should be flagged."#;
1981
1982 let ctx = create_context(content);
1983 let result = rule.check(&ctx).unwrap();
1984
1985 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in alt text + 1 standalone");
1989 assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
1990 assert!(result[0].line == 1); assert!(result[1].message.contains("'javascript' should be 'JavaScript'"));
1992 assert!(result[1].line == 3); }
1994
1995 #[test]
1996 fn test_image_urls_not_flagged() {
1997 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1998
1999 let content = r#""#;
2001
2002 let ctx = create_context(content);
2003 let result = rule.check(&ctx).unwrap();
2004
2005 assert!(result.is_empty(), "Image URLs should not be checked for proper names");
2007 }
2008
2009 #[test]
2010 fn test_reference_link_text_flagged_but_definition_not() {
2011 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2012
2013 let content = r#"Check the [javascript guide][js-ref] for details.
2014
2015Real javascript should be flagged.
2016
2017[js-ref]: https://javascript.info/typescript/guide"#;
2018
2019 let ctx = create_context(content);
2020 let result = rule.check(&ctx).unwrap();
2021
2022 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in link text + 1 standalone");
2027 assert!(result.iter().any(|w| w.line == 1 && w.message.contains("'javascript'")));
2028 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2029 }
2030
2031 #[test]
2032 fn test_reference_definitions_not_flagged() {
2033 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2034
2035 let content = r#"[js-ref]: https://javascript.info/guide"#;
2037
2038 let ctx = create_context(content);
2039 let result = rule.check(&ctx).unwrap();
2040
2041 assert!(result.is_empty(), "Reference definitions should not be checked");
2043 }
2044
2045 #[test]
2046 fn test_wikilinks_text_is_flagged() {
2047 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2048
2049 let content = r#"[[javascript]]
2051
2052Regular javascript here.
2053
2054[[JavaScript|display text]]"#;
2055
2056 let ctx = create_context(content);
2057 let result = rule.check(&ctx).unwrap();
2058
2059 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in WikiLink + 1 standalone");
2063 assert!(
2064 result
2065 .iter()
2066 .any(|w| w.line == 1 && w.column == 3 && w.message.contains("'javascript'"))
2067 );
2068 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2069 }
2070
2071 #[test]
2072 fn test_url_link_text_not_flagged() {
2073 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2074
2075 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2077
2078[http://github.com/org/repo](http://github.com/org/repo)
2079
2080[www.github.com/org/repo](https://www.github.com/org/repo)"#;
2081
2082 let ctx = create_context(content);
2083 let result = rule.check(&ctx).unwrap();
2084
2085 assert!(
2086 result.is_empty(),
2087 "URL-like link text should not be flagged, got: {result:?}"
2088 );
2089 }
2090
2091 #[test]
2092 fn test_url_link_text_with_leading_space_not_flagged() {
2093 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2094
2095 let content = r#"[ https://github.com/org/repo](https://github.com/org/repo)"#;
2097
2098 let ctx = create_context(content);
2099 let result = rule.check(&ctx).unwrap();
2100
2101 assert!(
2102 result.is_empty(),
2103 "URL-like link text with leading space should not be flagged, got: {result:?}"
2104 );
2105 }
2106
2107 #[test]
2108 fn test_url_link_text_uppercase_scheme_not_flagged() {
2109 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2110
2111 let content = r#"[HTTPS://GITHUB.COM/org/repo](https://github.com/org/repo)"#;
2112
2113 let ctx = create_context(content);
2114 let result = rule.check(&ctx).unwrap();
2115
2116 assert!(
2117 result.is_empty(),
2118 "URL-like link text with uppercase scheme should not be flagged, got: {result:?}"
2119 );
2120 }
2121
2122 #[test]
2123 fn test_non_url_link_text_still_flagged() {
2124 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2125
2126 let content = r#"[github.com/org/repo](https://github.com/org/repo)
2130
2131[Visit github](https://github.com/org/repo)
2132
2133[//github.com/org/repo](//github.com/org/repo)
2134
2135[ftp://github.com/org/repo](ftp://github.com/org/repo)"#;
2136
2137 let ctx = create_context(content);
2138 let result = rule.check(&ctx).unwrap();
2139
2140 assert_eq!(
2145 result.len(),
2146 1,
2147 "Only prose link text should be flagged, got: {result:?}"
2148 );
2149 assert!(
2150 result.iter().any(|w| w.line == 3),
2151 "Expected 'Visit github' on line 3 to be flagged"
2152 );
2153 }
2154
2155 #[test]
2156 fn test_url_link_text_fix_not_applied() {
2157 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2158
2159 let content = "[https://github.com/org/repo](https://github.com/org/repo)\n";
2160
2161 let ctx = create_context(content);
2162 let result = rule.fix(&ctx).unwrap();
2163
2164 assert_eq!(result, content, "Fix should not modify URL-like link text");
2165 }
2166
2167 #[test]
2168 fn test_mixed_url_and_regular_link_text() {
2169 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2170
2171 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2173
2174Visit [github documentation](https://github.com/docs) for details.
2175
2176[www.github.com/pricing](https://www.github.com/pricing)"#;
2177
2178 let ctx = create_context(content);
2179 let result = rule.check(&ctx).unwrap();
2180
2181 assert_eq!(
2183 result.len(),
2184 1,
2185 "Only non-URL link text should be flagged, got: {result:?}"
2186 );
2187 assert_eq!(result[0].line, 3);
2188 }
2189
2190 #[test]
2191 fn test_html_attribute_values_not_flagged() {
2192 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2195 let content = "# Heading\n\ntest\n\n<img src=\"www.example.test/test_image.png\">\n";
2196 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2197 let result = rule.check(&ctx).unwrap();
2198
2199 let line5_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2201 assert!(
2202 line5_violations.is_empty(),
2203 "Should not flag anything inside HTML tag attributes: {line5_violations:?}"
2204 );
2205
2206 let line3_violations: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
2208 assert_eq!(line3_violations.len(), 1, "Plain 'test' on line 3 should be flagged");
2209 }
2210
2211 #[test]
2212 fn test_html_text_content_still_flagged() {
2213 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2215 let content = "# Heading\n\n<a href=\"https://example.test/page\">test link</a>\n";
2216 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2217 let result = rule.check(&ctx).unwrap();
2218
2219 assert_eq!(
2222 result.len(),
2223 1,
2224 "Should flag only 'test' in anchor text, not in href: {result:?}"
2225 );
2226 assert_eq!(result[0].column, 37, "Should flag col 37 ('test link' in anchor text)");
2227 }
2228
2229 #[test]
2230 fn test_html_attribute_various_not_flagged() {
2231 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2233 let content = concat!(
2234 "# Heading\n\n",
2235 "<img src=\"test.png\" alt=\"test image\">\n",
2236 "<span class=\"test-class\" data-test=\"value\">test content</span>\n",
2237 );
2238 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2239 let result = rule.check(&ctx).unwrap();
2240
2241 assert_eq!(
2243 result.len(),
2244 1,
2245 "Should flag only 'test content' between tags: {result:?}"
2246 );
2247 assert_eq!(result[0].line, 4);
2248 }
2249
2250 #[test]
2251 fn test_plain_text_underscore_boundary_unchanged() {
2252 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2255 let content = "# Heading\n\ntest_image is here and just_test ends here\n";
2256 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2257 let result = rule.check(&ctx).unwrap();
2258
2259 assert_eq!(
2262 result.len(),
2263 2,
2264 "Should flag 'test' in both 'test_image' and 'just_test': {result:?}"
2265 );
2266 let cols: Vec<usize> = result.iter().map(|w| w.column).collect();
2267 assert!(cols.contains(&1), "Should flag col 1 (test_image): {cols:?}");
2268 assert!(cols.contains(&29), "Should flag col 29 (just_test): {cols:?}");
2269 }
2270
2271 #[test]
2272 fn test_frontmatter_yaml_keys_not_flagged() {
2273 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2276
2277 let content = "---\ntitle: Heading\ntest: Some Test value\n---\n\nTest\n";
2278 let ctx = create_context(content);
2279 let result = rule.check(&ctx).unwrap();
2280
2281 assert!(
2285 result.is_empty(),
2286 "Should not flag YAML keys or correctly capitalized values: {result:?}"
2287 );
2288 }
2289
2290 #[test]
2291 fn test_frontmatter_yaml_values_flagged() {
2292 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2294
2295 let content = "---\ntitle: Heading\nkey: a test value\n---\n\nTest\n";
2296 let ctx = create_context(content);
2297 let result = rule.check(&ctx).unwrap();
2298
2299 assert_eq!(result.len(), 1, "Should flag 'test' in YAML value: {result:?}");
2301 assert_eq!(result[0].line, 3);
2302 assert_eq!(result[0].column, 8); }
2304
2305 #[test]
2306 fn test_frontmatter_key_matches_name_not_flagged() {
2307 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2309
2310 let content = "---\ntest: other value\n---\n\nBody text\n";
2311 let ctx = create_context(content);
2312 let result = rule.check(&ctx).unwrap();
2313
2314 assert!(
2315 result.is_empty(),
2316 "Should not flag YAML key that matches configured name: {result:?}"
2317 );
2318 }
2319
2320 #[test]
2321 fn test_frontmatter_empty_value_not_flagged() {
2322 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2324
2325 let content = "---\ntest:\ntest: \n---\n\nBody text\n";
2326 let ctx = create_context(content);
2327 let result = rule.check(&ctx).unwrap();
2328
2329 assert!(
2330 result.is_empty(),
2331 "Should not flag YAML keys with empty values: {result:?}"
2332 );
2333 }
2334
2335 #[test]
2336 fn test_frontmatter_nested_yaml_key_not_flagged() {
2337 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2339
2340 let content = "---\nparent:\n test: nested value\n---\n\nBody text\n";
2341 let ctx = create_context(content);
2342 let result = rule.check(&ctx).unwrap();
2343
2344 assert!(result.is_empty(), "Should not flag nested YAML keys: {result:?}");
2346 }
2347
2348 #[test]
2349 fn test_frontmatter_list_items_checked() {
2350 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2352
2353 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2354 let ctx = create_context(content);
2355 let result = rule.check(&ctx).unwrap();
2356
2357 assert_eq!(result.len(), 1, "Should flag 'test' in YAML list item: {result:?}");
2359 assert_eq!(result[0].line, 3);
2360 }
2361
2362 #[test]
2363 fn test_frontmatter_value_with_multiple_colons() {
2364 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2366
2367 let content = "---\ntest: description: a test thing\n---\n\nBody text\n";
2368 let ctx = create_context(content);
2369 let result = rule.check(&ctx).unwrap();
2370
2371 assert_eq!(
2374 result.len(),
2375 1,
2376 "Should flag 'test' in value after first colon: {result:?}"
2377 );
2378 assert_eq!(result[0].line, 2);
2379 assert!(result[0].column > 6, "Violation column should be in value portion");
2380 }
2381
2382 #[test]
2383 fn test_frontmatter_does_not_affect_body() {
2384 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2386
2387 let content = "---\ntitle: Heading\n---\n\ntest should be flagged here\n";
2388 let ctx = create_context(content);
2389 let result = rule.check(&ctx).unwrap();
2390
2391 assert_eq!(result.len(), 1, "Should flag 'test' in body text: {result:?}");
2392 assert_eq!(result[0].line, 5);
2393 }
2394
2395 #[test]
2396 fn test_frontmatter_fix_corrects_values_preserves_keys() {
2397 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2399
2400 let content = "---\ntest: a test value\n---\n\ntest here\n";
2401 let ctx = create_context(content);
2402 let fixed = rule.fix(&ctx).unwrap();
2403
2404 assert_eq!(fixed, "---\ntest: a Test value\n---\n\nTest here\n");
2406 }
2407
2408 #[test]
2409 fn test_frontmatter_multiword_value_flagged() {
2410 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2412
2413 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2414 let ctx = create_context(content);
2415 let result = rule.check(&ctx).unwrap();
2416
2417 assert_eq!(result.len(), 2, "Should flag both names in YAML value: {result:?}");
2418 assert!(result.iter().all(|w| w.line == 2));
2419 }
2420
2421 #[test]
2422 fn test_frontmatter_yaml_comments_not_checked() {
2423 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2425
2426 let content = "---\n# test comment\ntitle: Heading\n---\n\nBody text\n";
2427 let ctx = create_context(content);
2428 let result = rule.check(&ctx).unwrap();
2429
2430 assert!(result.is_empty(), "Should not flag names in YAML comments: {result:?}");
2431 }
2432
2433 #[test]
2434 fn test_frontmatter_delimiters_not_checked() {
2435 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2437
2438 let content = "---\ntitle: Heading\n---\n\ntest here\n";
2439 let ctx = create_context(content);
2440 let result = rule.check(&ctx).unwrap();
2441
2442 assert_eq!(result.len(), 1, "Should only flag body text: {result:?}");
2444 assert_eq!(result[0].line, 5);
2445 }
2446
2447 #[test]
2448 fn test_frontmatter_continuation_lines_checked() {
2449 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2451
2452 let content = "---\ndescription: >\n a test value\n continued here\n---\n\nBody\n";
2453 let ctx = create_context(content);
2454 let result = rule.check(&ctx).unwrap();
2455
2456 assert_eq!(result.len(), 1, "Should flag 'test' in continuation line: {result:?}");
2458 assert_eq!(result[0].line, 3);
2459 }
2460
2461 #[test]
2462 fn test_frontmatter_quoted_values_checked() {
2463 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2465
2466 let content = "---\ntitle: \"a test title\"\n---\n\nBody\n";
2467 let ctx = create_context(content);
2468 let result = rule.check(&ctx).unwrap();
2469
2470 assert_eq!(result.len(), 1, "Should flag 'test' in quoted YAML value: {result:?}");
2471 assert_eq!(result[0].line, 2);
2472 }
2473
2474 #[test]
2475 fn test_frontmatter_single_quoted_values_checked() {
2476 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2478
2479 let content = "---\ntitle: 'a test title'\n---\n\nBody\n";
2480 let ctx = create_context(content);
2481 let result = rule.check(&ctx).unwrap();
2482
2483 assert_eq!(
2484 result.len(),
2485 1,
2486 "Should flag 'test' in single-quoted YAML value: {result:?}"
2487 );
2488 assert_eq!(result[0].line, 2);
2489 }
2490
2491 #[test]
2492 fn test_frontmatter_fix_multiword_values() {
2493 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2495
2496 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2497 let ctx = create_context(content);
2498 let fixed = rule.fix(&ctx).unwrap();
2499
2500 assert_eq!(
2501 fixed,
2502 "---\ndescription: Learn JavaScript and TypeScript\n---\n\nBody\n"
2503 );
2504 }
2505
2506 #[test]
2507 fn test_frontmatter_fix_preserves_yaml_structure() {
2508 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2510
2511 let content = "---\ntags:\n - test\n - other\ntitle: a test doc\n---\n\ntest body\n";
2512 let ctx = create_context(content);
2513 let fixed = rule.fix(&ctx).unwrap();
2514
2515 assert_eq!(
2516 fixed,
2517 "---\ntags:\n - Test\n - other\ntitle: a Test doc\n---\n\nTest body\n"
2518 );
2519 }
2520
2521 #[test]
2522 fn test_frontmatter_toml_delimiters_not_checked() {
2523 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2525
2526 let content = "+++\ntitle = \"a test title\"\n+++\n\ntest body\n";
2527 let ctx = create_context(content);
2528 let result = rule.check(&ctx).unwrap();
2529
2530 assert_eq!(result.len(), 2, "Should flag TOML value and body: {result:?}");
2534 let fm_violations: Vec<_> = result.iter().filter(|w| w.line == 2).collect();
2535 assert_eq!(fm_violations.len(), 1, "Should flag 'test' in TOML value: {result:?}");
2536 let body_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2537 assert_eq!(body_violations.len(), 1, "Should flag body 'test': {result:?}");
2538 }
2539
2540 #[test]
2541 fn test_frontmatter_toml_key_not_flagged() {
2542 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2544
2545 let content = "+++\ntest = \"other value\"\n+++\n\nBody text\n";
2546 let ctx = create_context(content);
2547 let result = rule.check(&ctx).unwrap();
2548
2549 assert!(
2550 result.is_empty(),
2551 "Should not flag TOML key that matches configured name: {result:?}"
2552 );
2553 }
2554
2555 #[test]
2556 fn test_frontmatter_toml_fix_preserves_keys() {
2557 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2559
2560 let content = "+++\ntest = \"a test value\"\n+++\n\ntest here\n";
2561 let ctx = create_context(content);
2562 let fixed = rule.fix(&ctx).unwrap();
2563
2564 assert_eq!(fixed, "+++\ntest = \"a Test value\"\n+++\n\nTest here\n");
2566 }
2567
2568 #[test]
2569 fn test_frontmatter_list_item_mapping_key_not_flagged() {
2570 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2573
2574 let content = "---\nitems:\n - test: nested value\n---\n\nBody text\n";
2575 let ctx = create_context(content);
2576 let result = rule.check(&ctx).unwrap();
2577
2578 assert!(
2579 result.is_empty(),
2580 "Should not flag YAML key in list-item mapping: {result:?}"
2581 );
2582 }
2583
2584 #[test]
2585 fn test_frontmatter_list_item_mapping_value_flagged() {
2586 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2588
2589 let content = "---\nitems:\n - key: a test value\n---\n\nBody text\n";
2590 let ctx = create_context(content);
2591 let result = rule.check(&ctx).unwrap();
2592
2593 assert_eq!(
2594 result.len(),
2595 1,
2596 "Should flag 'test' in list-item mapping value: {result:?}"
2597 );
2598 assert_eq!(result[0].line, 3);
2599 }
2600
2601 #[test]
2602 fn test_frontmatter_bare_list_item_still_flagged() {
2603 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2605
2606 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2607 let ctx = create_context(content);
2608 let result = rule.check(&ctx).unwrap();
2609
2610 assert_eq!(result.len(), 1, "Should flag 'test' in bare list item: {result:?}");
2611 assert_eq!(result[0].line, 3);
2612 }
2613
2614 #[test]
2615 fn test_frontmatter_flow_mapping_not_flagged() {
2616 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2619
2620 let content = "---\nflow_map: {test: value, other: test}\n---\n\nBody text\n";
2621 let ctx = create_context(content);
2622 let result = rule.check(&ctx).unwrap();
2623
2624 assert!(
2625 result.is_empty(),
2626 "Should not flag names inside flow mappings: {result:?}"
2627 );
2628 }
2629
2630 #[test]
2631 fn test_frontmatter_flow_sequence_not_flagged() {
2632 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2634
2635 let content = "---\nitems: [test, other, test]\n---\n\nBody text\n";
2636 let ctx = create_context(content);
2637 let result = rule.check(&ctx).unwrap();
2638
2639 assert!(
2640 result.is_empty(),
2641 "Should not flag names inside flow sequences: {result:?}"
2642 );
2643 }
2644
2645 #[test]
2646 fn test_frontmatter_list_item_mapping_fix_preserves_key() {
2647 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2649
2650 let content = "---\nitems:\n - test: a test value\n---\n\ntest here\n";
2651 let ctx = create_context(content);
2652 let fixed = rule.fix(&ctx).unwrap();
2653
2654 assert_eq!(fixed, "---\nitems:\n - test: a Test value\n---\n\nTest here\n");
2657 }
2658
2659 #[test]
2660 fn test_frontmatter_backtick_code_not_flagged() {
2661 let config = MD044Config {
2663 names: vec!["GoodApplication".to_string()],
2664 code_blocks: false,
2665 ..MD044Config::default()
2666 };
2667 let rule = MD044ProperNames::from_config_struct(config);
2668
2669 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2670 let ctx = create_context(content);
2671 let result = rule.check(&ctx).unwrap();
2672
2673 assert!(
2675 result.is_empty(),
2676 "Should not flag names inside backticks in frontmatter or body: {result:?}"
2677 );
2678 }
2679
2680 #[test]
2681 fn test_frontmatter_unquoted_backtick_code_not_flagged() {
2682 let config = MD044Config {
2684 names: vec!["GoodApplication".to_string()],
2685 code_blocks: false,
2686 ..MD044Config::default()
2687 };
2688 let rule = MD044ProperNames::from_config_struct(config);
2689
2690 let content = "---\ntitle: `goodapplication` CLI\n---\n\nIntroductory `goodapplication` CLI text.\n";
2691 let ctx = create_context(content);
2692 let result = rule.check(&ctx).unwrap();
2693
2694 assert!(
2695 result.is_empty(),
2696 "Should not flag names inside backticks in unquoted YAML frontmatter: {result:?}"
2697 );
2698 }
2699
2700 #[test]
2701 fn test_frontmatter_bare_name_still_flagged_with_backtick_nearby() {
2702 let config = MD044Config {
2704 names: vec!["GoodApplication".to_string()],
2705 code_blocks: false,
2706 ..MD044Config::default()
2707 };
2708 let rule = MD044ProperNames::from_config_struct(config);
2709
2710 let content = "---\ntitle: goodapplication `goodapplication` CLI\n---\n\nBody\n";
2711 let ctx = create_context(content);
2712 let result = rule.check(&ctx).unwrap();
2713
2714 assert_eq!(
2716 result.len(),
2717 1,
2718 "Should flag bare name but not backtick-wrapped name: {result:?}"
2719 );
2720 assert_eq!(result[0].line, 2);
2721 assert_eq!(result[0].column, 8); }
2723
2724 #[test]
2725 fn test_frontmatter_backtick_code_with_code_blocks_true() {
2726 let config = MD044Config {
2728 names: vec!["GoodApplication".to_string()],
2729 code_blocks: true,
2730 ..MD044Config::default()
2731 };
2732 let rule = MD044ProperNames::from_config_struct(config);
2733
2734 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nBody\n";
2735 let ctx = create_context(content);
2736 let result = rule.check(&ctx).unwrap();
2737
2738 assert_eq!(
2740 result.len(),
2741 1,
2742 "Should flag backtick-wrapped name when code_blocks=true: {result:?}"
2743 );
2744 assert_eq!(result[0].line, 2);
2745 }
2746
2747 #[test]
2748 fn test_frontmatter_fix_preserves_backtick_code() {
2749 let config = MD044Config {
2751 names: vec!["GoodApplication".to_string()],
2752 code_blocks: false,
2753 ..MD044Config::default()
2754 };
2755 let rule = MD044ProperNames::from_config_struct(config);
2756
2757 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2758 let ctx = create_context(content);
2759 let fixed = rule.fix(&ctx).unwrap();
2760
2761 assert_eq!(
2763 fixed, content,
2764 "Fix should not modify names inside backticks in frontmatter"
2765 );
2766 }
2767
2768 fn rule_ignoring(names: &[&str], ignore: &[&str]) -> MD044ProperNames {
2769 MD044ProperNames::from_config_struct(MD044Config {
2770 names: names.iter().map(ToString::to_string).collect(),
2771 ignore_frontmatter_fields: Some(ignore.iter().map(ToString::to_string).collect()),
2772 ..Default::default()
2773 })
2774 }
2775
2776 #[test]
2777 fn test_ignore_frontmatter_field_suppresses_only_that_field() {
2778 let content = "---\ntitle: Heading for myapp\nslug: myapp-guide\n---\n";
2779 let rule = rule_ignoring(&["MyApp"], &["slug"]);
2780 let result = rule.check(&create_context(content)).unwrap();
2781 assert_eq!(result.len(), 1, "only title is flagged: {result:?}");
2782 assert_eq!(result[0].line, 2);
2783 }
2784
2785 #[test]
2786 fn test_ignore_frontmatter_field_is_case_insensitive() {
2787 let content = "---\nSlug: myapp-guide\n---\n";
2788 let rule = rule_ignoring(&["MyApp"], &["SLUG"]);
2789 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2790 }
2791
2792 #[test]
2793 fn test_ignore_frontmatter_field_covers_nested_subtree() {
2794 let content = "---\nseo:\n canonical: myapp\n keywords:\n - myapp\n---\n";
2795 let rule = rule_ignoring(&["MyApp"], &["seo"]);
2796 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2797 }
2798
2799 #[test]
2800 fn test_ignore_frontmatter_field_does_not_affect_body() {
2801 let content = "---\nslug: myapp\n---\n\nBody mentions myapp.\n";
2802 let rule = rule_ignoring(&["MyApp"], &["slug"]);
2803 let result = rule.check(&create_context(content)).unwrap();
2804 assert_eq!(result.len(), 1);
2805 assert_eq!(result[0].line, 5);
2806 }
2807
2808 #[test]
2809 fn test_ignore_frontmatter_field_toml_table() {
2810 let content = "+++\n[seo]\ncanonical = \"myapp\"\n+++\n";
2811 let rule = rule_ignoring(&["MyApp"], &["seo"]);
2812 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2813 }
2814
2815 #[test]
2818 fn test_angle_bracket_url_in_html_comment_not_flagged() {
2819 let config = MD044Config {
2821 names: vec!["Test".to_string()],
2822 ..MD044Config::default()
2823 };
2824 let rule = MD044ProperNames::from_config_struct(config);
2825
2826 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";
2827 let ctx = create_context(content);
2828 let result = rule.check(&ctx).unwrap();
2829
2830 let line8_warnings: Vec<_> = result.iter().filter(|w| w.line == 8).collect();
2838 assert!(
2839 line8_warnings.is_empty(),
2840 "Should not flag names inside angle-bracket URLs in HTML comments: {line8_warnings:?}"
2841 );
2842 }
2843
2844 #[test]
2845 fn test_bare_url_in_html_comment_still_flagged() {
2846 let config = MD044Config {
2848 names: vec!["Test".to_string()],
2849 ..MD044Config::default()
2850 };
2851 let rule = MD044ProperNames::from_config_struct(config);
2852
2853 let content = "<!-- This is a test https://www.example.test -->\n";
2854 let ctx = create_context(content);
2855 let result = rule.check(&ctx).unwrap();
2856
2857 assert!(
2860 !result.is_empty(),
2861 "Should flag 'test' in prose text of HTML comment with bare URL"
2862 );
2863 }
2864
2865 #[test]
2866 fn test_angle_bracket_url_in_regular_markdown_not_flagged() {
2867 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2870
2871 let content = "<https://www.example.test>\n";
2872 let ctx = create_context(content);
2873 let result = rule.check(&ctx).unwrap();
2874
2875 assert!(
2876 result.is_empty(),
2877 "Should not flag names inside angle-bracket URLs in regular markdown: {result:?}"
2878 );
2879 }
2880
2881 #[test]
2882 fn test_multiple_angle_bracket_urls_in_one_comment() {
2883 let config = MD044Config {
2884 names: vec!["Test".to_string()],
2885 ..MD044Config::default()
2886 };
2887 let rule = MD044ProperNames::from_config_struct(config);
2888
2889 let content = "<!-- See <https://test.example.com> and <https://www.example.test> for details -->\n";
2890 let ctx = create_context(content);
2891 let result = rule.check(&ctx).unwrap();
2892
2893 assert!(
2895 result.is_empty(),
2896 "Should not flag names inside multiple angle-bracket URLs: {result:?}"
2897 );
2898 }
2899
2900 #[test]
2901 fn test_angle_bracket_non_url_still_flagged() {
2902 assert!(
2905 !MD044ProperNames::is_in_angle_bracket_url("<test> which is not a URL.", 1),
2906 "is_in_angle_bracket_url should return false for non-URL angle brackets"
2907 );
2908 }
2909
2910 #[test]
2911 fn test_angle_bracket_mailto_url_not_flagged() {
2912 let config = MD044Config {
2913 names: vec!["Test".to_string()],
2914 ..MD044Config::default()
2915 };
2916 let rule = MD044ProperNames::from_config_struct(config);
2917
2918 let content = "<!-- Contact <mailto:test@example.com> for help -->\n";
2919 let ctx = create_context(content);
2920 let result = rule.check(&ctx).unwrap();
2921
2922 assert!(
2923 result.is_empty(),
2924 "Should not flag names inside angle-bracket mailto URLs: {result:?}"
2925 );
2926 }
2927
2928 #[test]
2929 fn test_angle_bracket_ftp_url_not_flagged() {
2930 let config = MD044Config {
2931 names: vec!["Test".to_string()],
2932 ..MD044Config::default()
2933 };
2934 let rule = MD044ProperNames::from_config_struct(config);
2935
2936 let content = "<!-- Download from <ftp://test.example.com/file> -->\n";
2937 let ctx = create_context(content);
2938 let result = rule.check(&ctx).unwrap();
2939
2940 assert!(
2941 result.is_empty(),
2942 "Should not flag names inside angle-bracket FTP URLs: {result:?}"
2943 );
2944 }
2945
2946 #[test]
2947 fn test_angle_bracket_url_fix_preserves_url() {
2948 let config = MD044Config {
2950 names: vec!["Test".to_string()],
2951 ..MD044Config::default()
2952 };
2953 let rule = MD044ProperNames::from_config_struct(config);
2954
2955 let content = "<!-- test text <https://www.example.test> -->\n";
2956 let ctx = create_context(content);
2957 let fixed = rule.fix(&ctx).unwrap();
2958
2959 assert!(
2961 fixed.contains("<https://www.example.test>"),
2962 "Fix should preserve angle-bracket URLs: {fixed}"
2963 );
2964 assert!(
2965 fixed.contains("Test text"),
2966 "Fix should correct prose 'test' to 'Test': {fixed}"
2967 );
2968 }
2969
2970 #[test]
2971 fn test_is_in_angle_bracket_url_helper() {
2972 let line = "text <https://example.test> more text";
2974
2975 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));
2988
2989 assert!(MD044ProperNames::is_in_angle_bracket_url(
2991 "<mailto:test@example.com>",
2992 10
2993 ));
2994
2995 assert!(MD044ProperNames::is_in_angle_bracket_url(
2997 "<ftp://test.example.com>",
2998 10
2999 ));
3000 }
3001
3002 #[test]
3003 fn test_is_in_angle_bracket_url_uppercase_scheme() {
3004 assert!(MD044ProperNames::is_in_angle_bracket_url(
3006 "<HTTPS://test.example.com>",
3007 10
3008 ));
3009 assert!(MD044ProperNames::is_in_angle_bracket_url(
3010 "<Http://test.example.com>",
3011 10
3012 ));
3013 }
3014
3015 #[test]
3016 fn test_is_in_angle_bracket_url_uncommon_schemes() {
3017 assert!(MD044ProperNames::is_in_angle_bracket_url(
3019 "<ssh://test@example.com>",
3020 10
3021 ));
3022 assert!(MD044ProperNames::is_in_angle_bracket_url("<file:///test/path>", 10));
3024 assert!(MD044ProperNames::is_in_angle_bracket_url("<data:text/plain;test>", 10));
3026 }
3027
3028 #[test]
3029 fn test_is_in_angle_bracket_url_unclosed() {
3030 assert!(!MD044ProperNames::is_in_angle_bracket_url(
3032 "<https://test.example.com",
3033 10
3034 ));
3035 }
3036
3037 #[test]
3038 fn test_vale_inline_config_comments_not_flagged() {
3039 let config = MD044Config {
3040 names: vec!["Vale".to_string(), "JavaScript".to_string()],
3041 ..MD044Config::default()
3042 };
3043 let rule = MD044ProperNames::from_config_struct(config);
3044
3045 let content = "\
3046<!-- vale off -->
3047Some javascript text here.
3048<!-- vale on -->
3049<!-- vale Style.Rule = NO -->
3050More javascript text.
3051<!-- vale Style.Rule = YES -->
3052<!-- vale JavaScript.Grammar = NO -->
3053";
3054 let ctx = create_context(content);
3055 let result = rule.check(&ctx).unwrap();
3056
3057 assert_eq!(result.len(), 2, "Should only flag body lines, not Vale config comments");
3059 assert_eq!(result[0].line, 2);
3060 assert_eq!(result[1].line, 5);
3061 }
3062
3063 #[test]
3064 fn test_remark_lint_inline_config_comments_not_flagged() {
3065 let config = MD044Config {
3066 names: vec!["JavaScript".to_string()],
3067 ..MD044Config::default()
3068 };
3069 let rule = MD044ProperNames::from_config_struct(config);
3070
3071 let content = "\
3072<!-- lint disable remark-lint-some-rule -->
3073Some javascript text here.
3074<!-- lint enable remark-lint-some-rule -->
3075<!-- lint ignore remark-lint-some-rule -->
3076More javascript text.
3077";
3078 let ctx = create_context(content);
3079 let result = rule.check(&ctx).unwrap();
3080
3081 assert_eq!(
3082 result.len(),
3083 2,
3084 "Should only flag body lines, not remark-lint config comments"
3085 );
3086 assert_eq!(result[0].line, 2);
3087 assert_eq!(result[1].line, 5);
3088 }
3089
3090 #[test]
3091 fn test_fix_does_not_modify_vale_remark_lint_comments() {
3092 let config = MD044Config {
3093 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3094 ..MD044Config::default()
3095 };
3096 let rule = MD044ProperNames::from_config_struct(config);
3097
3098 let content = "\
3099<!-- vale off -->
3100Some javascript text.
3101<!-- vale on -->
3102<!-- lint disable remark-lint-some-rule -->
3103More javascript text.
3104<!-- lint enable remark-lint-some-rule -->
3105";
3106 let ctx = create_context(content);
3107 let fixed = rule.fix(&ctx).unwrap();
3108
3109 assert!(fixed.contains("<!-- vale off -->"));
3111 assert!(fixed.contains("<!-- vale on -->"));
3112 assert!(fixed.contains("<!-- lint disable remark-lint-some-rule -->"));
3113 assert!(fixed.contains("<!-- lint enable remark-lint-some-rule -->"));
3114 assert!(fixed.contains("Some JavaScript text."));
3116 assert!(fixed.contains("More JavaScript text."));
3117 }
3118
3119 #[test]
3120 fn test_mixed_tool_directives_all_skipped() {
3121 let config = MD044Config {
3122 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3123 ..MD044Config::default()
3124 };
3125 let rule = MD044ProperNames::from_config_struct(config);
3126
3127 let content = "\
3128<!-- rumdl-disable MD044 -->
3129Some javascript text.
3130<!-- markdownlint-disable -->
3131More javascript text.
3132<!-- vale off -->
3133Even more javascript text.
3134<!-- lint disable some-rule -->
3135Final javascript text.
3136<!-- rumdl-enable MD044 -->
3137<!-- markdownlint-enable -->
3138<!-- vale on -->
3139<!-- lint enable some-rule -->
3140";
3141 let ctx = create_context(content);
3142 let result = rule.check(&ctx).unwrap();
3143
3144 assert_eq!(
3146 result.len(),
3147 4,
3148 "Should only flag body lines, not any tool directive comments"
3149 );
3150 assert_eq!(result[0].line, 2);
3151 assert_eq!(result[1].line, 4);
3152 assert_eq!(result[2].line, 6);
3153 assert_eq!(result[3].line, 8);
3154 }
3155
3156 #[test]
3157 fn test_vale_remark_lint_edge_cases_not_matched() {
3158 let config = MD044Config {
3159 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3160 ..MD044Config::default()
3161 };
3162 let rule = MD044ProperNames::from_config_struct(config);
3163
3164 let content = "\
3172<!-- vale -->
3173<!-- vale is a tool for writing -->
3174<!-- valedictorian javascript -->
3175<!-- linting javascript tips -->
3176<!-- vale javascript -->
3177<!-- lint your javascript code -->
3178";
3179 let ctx = create_context(content);
3180 let result = rule.check(&ctx).unwrap();
3181
3182 assert_eq!(
3189 result.len(),
3190 7,
3191 "Should flag proper names in non-directive HTML comments: got {result:?}"
3192 );
3193 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); }
3201
3202 #[test]
3203 fn test_vale_style_directives_skipped() {
3204 let config = MD044Config {
3205 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3206 ..MD044Config::default()
3207 };
3208 let rule = MD044ProperNames::from_config_struct(config);
3209
3210 let content = "\
3212<!-- vale style = MyStyle -->
3213<!-- vale styles = Style1, Style2 -->
3214<!-- vale MyRule.Name = YES -->
3215<!-- vale MyRule.Name = NO -->
3216Some javascript text.
3217";
3218 let ctx = create_context(content);
3219 let result = rule.check(&ctx).unwrap();
3220
3221 assert_eq!(
3223 result.len(),
3224 1,
3225 "Should only flag body lines, not Vale style/rule directives: got {result:?}"
3226 );
3227 assert_eq!(result[0].line, 5);
3228 }
3229
3230 #[test]
3233 fn test_backtick_code_single_backticks() {
3234 let line = "hello `world` bye";
3235 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 7));
3237 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3239 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 14));
3241 }
3242
3243 #[test]
3244 fn test_backtick_code_double_backticks() {
3245 let line = "a ``code`` b";
3246 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3248 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3250 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 11));
3252 }
3253
3254 #[test]
3255 fn test_backtick_code_unclosed() {
3256 let line = "a `code b";
3257 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3259 }
3260
3261 #[test]
3262 fn test_backtick_code_mismatched_count() {
3263 let line = "a `code`` b";
3265 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3268 }
3269
3270 #[test]
3271 fn test_backtick_code_multiple_spans() {
3272 let line = "`first` and `second`";
3273 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
3275 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 8));
3277 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 13));
3279 }
3280
3281 #[test]
3282 fn test_backtick_code_on_backtick_boundary() {
3283 let line = "`code`";
3284 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3286 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 5));
3288 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
3290 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3291 }
3292
3293 #[test]
3299 fn test_double_bracket_link_url_not_flagged() {
3300 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3301 let content = "[[rumdl]](https://github.com/rvben/rumdl)";
3303 let ctx = create_context(content);
3304 let result = rule.check(&ctx).unwrap();
3305 assert!(
3306 result.is_empty(),
3307 "URL inside [[text]](url) must not be flagged, got: {result:?}"
3308 );
3309 }
3310
3311 #[test]
3312 fn test_double_bracket_link_url_not_fixed() {
3313 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3314 let content = "[[rumdl]](https://github.com/rvben/rumdl)\n";
3315 let ctx = create_context(content);
3316 let fixed = rule.fix(&ctx).unwrap();
3317 assert_eq!(
3318 fixed, content,
3319 "fix() must leave the URL inside [[text]](url) unchanged"
3320 );
3321 }
3322
3323 #[test]
3324 fn test_double_bracket_link_text_still_flagged() {
3325 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3326 let content = "[[github]](https://example.com)";
3328 let ctx = create_context(content);
3329 let result = rule.check(&ctx).unwrap();
3330 assert_eq!(
3331 result.len(),
3332 1,
3333 "Incorrect name in [[text]] link text should still be flagged, got: {result:?}"
3334 );
3335 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
3336 }
3337
3338 #[test]
3339 fn test_double_bracket_link_mixed_line() {
3340 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3341 let content = "See [[rumdl]](https://github.com/rvben/rumdl) and github for more.";
3343 let ctx = create_context(content);
3344 let result = rule.check(&ctx).unwrap();
3345 assert_eq!(
3346 result.len(),
3347 1,
3348 "Only the standalone 'github' after the link should be flagged, got: {result:?}"
3349 );
3350 assert!(result[0].message.contains("'github'"));
3351 assert_eq!(
3353 result[0].column, 51,
3354 "Flagged column should be the trailing 'github', not the one in the URL"
3355 );
3356 }
3357
3358 #[test]
3359 fn test_regular_link_url_still_not_flagged() {
3360 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3362 let content = "[rumdl](https://github.com/rvben/rumdl)";
3363 let ctx = create_context(content);
3364 let result = rule.check(&ctx).unwrap();
3365 assert!(
3366 result.is_empty(),
3367 "URL inside regular [text](url) must still not be flagged, got: {result:?}"
3368 );
3369 }
3370
3371 #[test]
3372 fn test_link_like_text_in_code_span_still_flagged_when_code_blocks_enabled() {
3373 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
3378 let content = "`[foo](https://github.com/org/repo)`";
3379 let ctx = create_context(content);
3380 let result = rule.check(&ctx).unwrap();
3381 assert_eq!(
3382 result.len(),
3383 1,
3384 "Proper name inside a code span must be flagged when code-blocks=true, got: {result:?}"
3385 );
3386 assert!(result[0].message.contains("'github'"));
3387 }
3388
3389 #[test]
3390 fn test_malformed_link_not_treated_as_url() {
3391 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3394 let content = "See [rumdl](github repo) for details.";
3395 let ctx = create_context(content);
3396 let result = rule.check(&ctx).unwrap();
3397 assert_eq!(
3398 result.len(),
3399 1,
3400 "Name inside malformed [text](url with spaces) must still be flagged, got: {result:?}"
3401 );
3402 assert!(result[0].message.contains("'github'"));
3403 }
3404
3405 #[test]
3406 fn test_wikilink_followed_by_prose_parens_still_flagged() {
3407 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3411 let content = "[[note]](github repo)";
3412 let ctx = create_context(content);
3413 let result = rule.check(&ctx).unwrap();
3414 assert_eq!(
3415 result.len(),
3416 1,
3417 "Name inside [[wikilink]](prose with spaces) must still be flagged, got: {result:?}"
3418 );
3419 assert!(result[0].message.contains("'github'"));
3420 }
3421
3422 #[test]
3424 fn test_roundtrip_fix_then_check_basic() {
3425 let rule = MD044ProperNames::new(
3426 vec![
3427 "JavaScript".to_string(),
3428 "TypeScript".to_string(),
3429 "Node.js".to_string(),
3430 ],
3431 true,
3432 );
3433 let content = "I love javascript, typescript, and nodejs!";
3434 let ctx = create_context(content);
3435 let fixed = rule.fix(&ctx).unwrap();
3436 let ctx2 = create_context(&fixed);
3437 let warnings = rule.check(&ctx2).unwrap();
3438 assert!(
3439 warnings.is_empty(),
3440 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3441 );
3442 }
3443
3444 #[test]
3446 fn test_roundtrip_fix_then_check_multiline() {
3447 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
3448 let content = "First line with rust.\nSecond line with python.\nThird line with RUST and PYTHON.\n";
3449 let ctx = create_context(content);
3450 let fixed = rule.fix(&ctx).unwrap();
3451 let ctx2 = create_context(&fixed);
3452 let warnings = rule.check(&ctx2).unwrap();
3453 assert!(
3454 warnings.is_empty(),
3455 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3456 );
3457 }
3458
3459 #[test]
3461 fn test_roundtrip_fix_then_check_inline_config() {
3462 let config = MD044Config {
3463 names: vec!["RUMDL".to_string()],
3464 ..MD044Config::default()
3465 };
3466 let rule = MD044ProperNames::from_config_struct(config);
3467 let content =
3468 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
3469 let ctx = create_context(content);
3470 let fixed = rule.fix(&ctx).unwrap();
3471 assert!(
3473 fixed.contains("Some rumdl text.\n"),
3474 "Disabled block text should be preserved"
3475 );
3476 assert!(
3477 fixed.contains("Some RUMDL text outside."),
3478 "Outside text should be fixed"
3479 );
3480 }
3481
3482 #[test]
3484 fn test_roundtrip_fix_then_check_html_comments() {
3485 let config = MD044Config {
3486 names: vec!["JavaScript".to_string()],
3487 ..MD044Config::default()
3488 };
3489 let rule = MD044ProperNames::from_config_struct(config);
3490 let content = "# Guide\n\n<!-- javascript mentioned here -->\n\njavascript outside\n";
3491 let ctx = create_context(content);
3492 let fixed = rule.fix(&ctx).unwrap();
3493 let ctx2 = create_context(&fixed);
3494 let warnings = rule.check(&ctx2).unwrap();
3495 assert!(
3496 warnings.is_empty(),
3497 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3498 );
3499 }
3500
3501 #[test]
3503 fn test_roundtrip_no_op_when_correct() {
3504 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
3505 let content = "This uses JavaScript and TypeScript correctly.\n";
3506 let ctx = create_context(content);
3507 let fixed = rule.fix(&ctx).unwrap();
3508 assert_eq!(fixed, content, "Fix should be a no-op when content is already correct");
3509 }
3510
3511 #[test]
3514 fn test_bare_domain_link_text_not_flagged() {
3515 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3519 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3520 let ctx = create_context(content);
3521 let result = rule.check(&ctx).unwrap();
3522 assert!(
3523 result.is_empty(),
3524 "Should not flag 'github' in a bare-domain link text that matches the link URL: {result:?}"
3525 );
3526 }
3527
3528 #[test]
3529 fn test_bare_domain_link_text_not_fixed() {
3530 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3532 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3533 let ctx = create_context(content);
3534 let fixed = rule.fix(&ctx).unwrap();
3535 assert_eq!(
3536 fixed, content,
3537 "fix() must not alter bare-domain link text that matches the destination URL"
3538 );
3539 }
3540
3541 #[test]
3542 fn test_bare_domain_link_text_with_path_not_flagged() {
3543 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3545 let content = "Visit [ravencentric.github.io](https://ravencentric.github.io/projects).\n";
3546 let ctx = create_context(content);
3547 let result = rule.check(&ctx).unwrap();
3548 assert!(
3549 result.is_empty(),
3550 "Should not flag 'github' when bare-domain text is the hostname of its destination URL: {result:?}"
3551 );
3552 }
3553
3554 #[test]
3555 fn test_bare_domain_link_text_full_path_not_flagged() {
3556 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3558 let content = "See [ravencentric.github.io/blog](https://ravencentric.github.io/blog).\n";
3559 let ctx = create_context(content);
3560 let result = rule.check(&ctx).unwrap();
3561 assert!(
3562 result.is_empty(),
3563 "Should not flag 'github' when link text is the full URL path without scheme: {result:?}"
3564 );
3565 }
3566
3567 #[test]
3568 fn test_github_product_name_in_link_text_still_flagged() {
3569 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3572 let content = "Hosted on [github pages](https://pages.github.com).\n";
3573 let ctx = create_context(content);
3574 let result = rule.check(&ctx).unwrap();
3575 assert!(
3576 !result.is_empty(),
3577 "Should still flag 'github' in descriptive link text that does not match the destination URL"
3578 );
3579 }
3580
3581 #[test]
3582 fn test_protocol_relative_bare_domain_link_text_not_flagged() {
3583 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3585 let content = "See [github.io](//github.io).\n";
3586 let ctx = create_context(content);
3587 let result = rule.check(&ctx).unwrap();
3588 assert!(
3589 result.is_empty(),
3590 "Should not flag 'github' in bare-domain text matching a protocol-relative destination: {result:?}"
3591 );
3592 }
3593
3594 #[test]
3595 fn test_dotted_wikilink_target_still_flagged() {
3596 let rule = MD044ProperNames::new(vec!["Node.js".to_string()], false);
3601 let content = "See [[node.js]] for details.\n";
3602 let ctx = create_context(content);
3603 let result = rule.check(&ctx).unwrap();
3604 assert!(
3605 !result.is_empty(),
3606 "Should flag 'node.js' in a dotted WikiLink target: {result:?}"
3607 );
3608 }
3609
3610 #[test]
3611 fn test_bare_domain_link_text_case_insensitive_url() {
3612 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3615 let content = "See [github.io](HTTPS://github.io).\n";
3616 let ctx = create_context(content);
3617 let result = rule.check(&ctx).unwrap();
3618 assert!(
3619 result.is_empty(),
3620 "Should not flag bare-domain text when destination URL has an uppercase scheme: {result:?}"
3621 );
3622 }
3623
3624 #[test]
3625 fn test_frontmatter_value_span_strips_trailing_comment() {
3626 let line = "link: docs/guide/myapp # canonical path";
3627 let (s, e) = frontmatter_values::value_span(line).unwrap();
3628 assert_eq!(&line[s..e], "docs/guide/myapp");
3629 }
3630
3631 #[test]
3632 fn test_frontmatter_value_span_quoted_keeps_hash_and_spaces() {
3633 let line = "link: 'docs/My App/a#b'";
3634 let (s, e) = frontmatter_values::value_span(line).unwrap();
3635 assert_eq!(&line[s..e], "docs/My App/a#b");
3636 }
3637
3638 #[test]
3639 fn test_frontmatter_value_span_plain_value() {
3640 let line = "title: Heading for myapp";
3641 let (s, e) = frontmatter_values::value_span(line).unwrap();
3642 assert_eq!(&line[s..e], "Heading for myapp");
3643 }
3644
3645 #[test]
3646 fn test_frontmatter_value_span_none_for_key_only() {
3647 assert!(frontmatter_values::value_span("seo:").is_none());
3648 assert!(frontmatter_values::value_span("---").is_none());
3649 }
3650
3651 #[test]
3652 fn test_frontmatter_value_span_quoted_strips_trailing_comment() {
3653 let line = "link: 'docs/guide' # canonical path";
3654 let (s, e) = frontmatter_values::value_span(line).unwrap();
3655 assert_eq!(&line[s..e], "docs/guide");
3656 }
3657
3658 #[test]
3659 fn test_frontmatter_value_span_empty_quoted_value_is_none() {
3660 assert!(frontmatter_values::value_span("key: ''").is_none());
3661 }
3662
3663 #[test]
3664 fn test_frontmatter_value_span_unterminated_quote_strips_leading_quote() {
3665 let line = "link: 'docs/a";
3666 let (s, e) = frontmatter_values::value_span(line).unwrap();
3667 assert_eq!(&line[s..e], "docs/a");
3668 }
3669
3670 fn at(line: &str, needle: &str) -> usize {
3672 line.find(needle).expect("needle present")
3673 }
3674
3675 #[test]
3676 fn test_path_like_exempts_single_token_frontmatter_paths() {
3677 for line in [
3678 "link: this/is/a/link/to/myapp.md",
3679 "link: docs/myapp.md",
3680 "link: /abs/path/myapp.md",
3681 "link: ./myapp.md",
3682 "link: ../shared/myapp.md",
3683 ] {
3684 let span = frontmatter_values::value_span(line).unwrap();
3685 let pos = at(line, "myapp");
3686 assert!(
3687 MD044ProperNames::is_in_path_like_token(line, pos, span),
3688 "should treat as a path: {line}"
3689 );
3690 }
3691 }
3692
3693 #[test]
3694 fn test_path_like_does_not_exempt_slash_conjunction_prose() {
3695 let line = "description: We support github/gitlab/bitbucket imports.";
3698 let span = frontmatter_values::value_span(line).unwrap();
3699 assert!(
3700 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
3701 "slash-separated prose is not a path"
3702 );
3703
3704 let line = "description: The javascript/typescript ecosystem is large.";
3705 let span = frontmatter_values::value_span(line).unwrap();
3706 assert!(!MD044ProperNames::is_in_path_like_token(
3707 line,
3708 at(line, "javascript"),
3709 span
3710 ));
3711 }
3712
3713 #[test]
3714 fn test_path_like_requires_a_slash_so_dotted_names_survive() {
3715 let line = "title: Use nodejs and myapp.md today.";
3716 let span = frontmatter_values::value_span(line).unwrap();
3717 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3718 }
3719
3720 #[test]
3721 fn test_path_like_no_slash_frontmatter_value_still_flagged() {
3722 let line = "slug: myapp-guide";
3727 let span = frontmatter_values::value_span(line).unwrap();
3728 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3729 }
3730
3731 #[test]
3732 fn test_path_like_returns_false_outside_value_span() {
3733 let line = "myapp: docs/guide/myapp";
3736 let span = frontmatter_values::value_span(line).unwrap();
3737 let key_pos = 0;
3738 assert!(!MD044ProperNames::is_in_path_like_token(line, key_pos, span));
3739 }
3740
3741 #[test]
3742 fn test_path_like_three_segments_only_as_sole_frontmatter_value() {
3743 let line = "link: docs/guide/myapp";
3744 let span = frontmatter_values::value_span(line).unwrap();
3745 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3746
3747 let line = "description: We support github/gitlab/bitbucket now";
3748 let span = frontmatter_values::value_span(line).unwrap();
3749 assert!(
3750 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
3751 "multi-token value gets body treatment"
3752 );
3753 }
3754
3755 #[test]
3756 fn test_path_like_quoted_value_with_spaces() {
3757 let line = "link: 'docs/My App/myapp.md'";
3760 let span = frontmatter_values::value_span(line).unwrap();
3761 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3762 }
3763
3764 #[test]
3765 fn test_path_like_quoted_value_with_spaces_no_extension_not_exempt() {
3766 let line = "link: 'docs/My App/myapp'";
3773 let span = frontmatter_values::value_span(line).unwrap();
3774 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3775 }
3776
3777 #[test]
3778 fn test_path_like_trailing_comment_is_still_sole_value() {
3779 let line = "link: docs/guide/myapp # canonical path";
3780 let span = frontmatter_values::value_span(line).unwrap();
3781 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3782 }
3783
3784 #[test]
3785 fn test_path_like_trailing_punctuation_trimmed() {
3786 let line = "link: docs/myapp.md, then leave.";
3787 let span = frontmatter_values::value_span(line).unwrap();
3788 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3789 }
3790
3791 #[test]
3792 fn test_trim_token_bounds_reaches_fixpoint_after_punctuation_exposes_wrapper() {
3793 let line = r#"See "docs/myapp.md", then leave."#;
3794 let raw_start = at(line, "\"docs");
3795 let raw_end = raw_start + r#""docs/myapp.md","#.len();
3796 assert_eq!(&line[raw_start..raw_end], r#""docs/myapp.md","#);
3797 let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
3798 assert_eq!(&line[start..end], "docs/myapp.md");
3799 }
3800
3801 #[test]
3802 fn test_trim_token_bounds_reaches_fixpoint_with_multiple_trailing_wrappers() {
3803 let line = r#"("docs/myapp.md")."#;
3804 let (start, end) = frontmatter_values::trim_token_bounds(line, 0, line.len());
3805 assert_eq!(&line[start..end], "docs/myapp.md");
3806 }
3807
3808 #[test]
3809 fn test_frontmatter_link_path_not_flagged() {
3810 let content = "---\ntitle: Heading for MyApp\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
3811 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3812 let ctx = create_context(content);
3813 let result = rule.check(&ctx).unwrap();
3814 assert!(
3815 result.is_empty(),
3816 "path in a frontmatter value must not be flagged: {result:?}"
3817 );
3818 }
3819
3820 #[test]
3821 fn test_fix_does_not_corrupt_frontmatter_link_path() {
3822 let content = "---\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 assert_eq!(rule.fix(&ctx).unwrap(), content, "fix must not rewrite a path");
3826 }
3827
3828 #[test]
3839 fn test_body_prose_parenthesized_disambiguator_is_case_corrected() {
3840 let content = "See docs/myapp(1).md here.\n";
3841 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3842 let ctx = create_context(content);
3843 let result = rule.check(&ctx).unwrap();
3844 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3845 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp(1).md here.\n");
3846 }
3847
3848 #[test]
3849 fn test_body_prose_bracketed_dynamic_segment_is_case_corrected() {
3850 let content = "See docs/[myapp].md here.\n";
3851 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3852 let ctx = create_context(content);
3853 let result = rule.check(&ctx).unwrap();
3854 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3855 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/[MyApp].md here.\n");
3856 }
3857
3858 #[test]
3859 fn test_body_prose_nextjs_catch_all_segment_is_case_corrected() {
3860 let content = "pages/[[...myapp]].tsx are catch-all routes.\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!(
3868 rule.fix(&ctx).unwrap(),
3869 "pages/[[...MyApp]].tsx are catch-all routes.\n"
3870 );
3871 }
3872
3873 #[test]
3874 fn test_two_adjacent_whitespace_free_links_both_flagged() {
3875 let content = "[myapp](https://a.com)[github](https://b.com)\n";
3879 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitHub".to_string()], false);
3880 let ctx = create_context(content);
3881 let result = rule.check(&ctx).unwrap();
3882 assert_eq!(result.len(), 2, "both link texts must be flagged: {result:?}");
3883 assert!(result.iter().any(|w| w.message.contains("'myapp'")));
3884 assert!(result.iter().any(|w| w.message.contains("'github'")));
3885 }
3886
3887 #[test]
3888 fn test_fix_does_not_corrupt_frontmatter_path_with_route_group_named_after_proper_name() {
3889 let content = "---\nlink: src/(myapp)/page.tsx\n---\n\nBody.\n";
3892 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3893 let ctx = create_context(content);
3894 assert_eq!(
3895 rule.fix(&ctx).unwrap(),
3896 content,
3897 "fix must not rewrite a frontmatter path whose route-group directory name is the proper name"
3898 );
3899 }
3900
3901 #[test]
3902 fn test_quoted_frontmatter_value_slash_conjunction_prose_still_flagged() {
3903 let content = "---\ndescription: \"We support github/gitlab/bitbucket now\"\n---\n\nBody.\n";
3908 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3909 let ctx = create_context(content);
3910 let result = rule.check(&ctx).unwrap();
3911 assert_eq!(
3912 result.len(),
3913 1,
3914 "quoted prose value must still flag 'github': {result:?}"
3915 );
3916 }
3917
3918 #[test]
3919 fn test_quoted_frontmatter_value_single_slash_word_with_unrelated_dot_still_flagged() {
3920 let content = "---\ndescription: \"We use myapp/gitlab and version 1.0 e.g. weekly\"\n---\n\nBody.\n";
3923 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3924 let ctx = create_context(content);
3925 let result = rule.check(&ctx).unwrap();
3926 assert_eq!(
3927 result.len(),
3928 1,
3929 "quoted prose value must still flag 'myapp': {result:?}"
3930 );
3931 }
3932
3933 #[test]
3934 fn test_quoted_toml_frontmatter_value_slash_conjunction_prose_still_flagged() {
3935 let content = "+++\ndescription = \"We support github/gitlab/bitbucket now\"\n+++\n\nBody.\n";
3938 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3939 let ctx = create_context(content);
3940 let result = rule.check(&ctx).unwrap();
3941 assert_eq!(
3942 result.len(),
3943 1,
3944 "TOML quoted prose value must still flag 'github': {result:?}"
3945 );
3946 }
3947
3948 #[test]
3949 fn test_path_like_collapsed_multiword_no_extension_not_exempt() {
3950 for line in [
3957 r#"description: "myapp/gitlab github/bitbucket""#,
3958 r#"description: "and/or this/that myapp/gitlab""#,
3959 r#"description: "he/him she/her myapp/gitlab""#,
3960 ] {
3961 let span = frontmatter_values::value_span(line).unwrap();
3962 for needle in ["myapp", "gitlab"] {
3963 if let Some(byte_pos) = line.find(needle) {
3964 assert!(
3965 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
3966 "collapsed multi-word value must not exempt '{needle}': {line}"
3967 );
3968 }
3969 }
3970 }
3971 }
3972
3973 #[test]
3974 fn test_path_like_collapsed_multiword_no_extension_not_exempt_toml() {
3975 let line = r#"description = "myapp/gitlab github/bitbucket""#;
3976 let span = frontmatter_values::value_span(line).unwrap();
3977 for needle in ["myapp", "gitlab", "github", "bitbucket"] {
3978 let byte_pos = at(line, needle);
3979 assert!(
3980 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
3981 "collapsed multi-word TOML value must not exempt '{needle}'"
3982 );
3983 }
3984 }
3985
3986 #[test]
3987 fn test_frontmatter_collapsed_multiword_names_all_flagged_yaml() {
3988 let content = "---\ndescription: \"myapp/gitlab github/bitbucket\"\n---\n\nBody.\n";
3989 let rule = MD044ProperNames::new(
3990 vec![
3991 "MyApp".to_string(),
3992 "GitLab".to_string(),
3993 "GitHub".to_string(),
3994 "Bitbucket".to_string(),
3995 ],
3996 false,
3997 );
3998 let ctx = create_context(content);
3999 let result = rule.check(&ctx).unwrap();
4000 assert_eq!(
4001 result.len(),
4002 4,
4003 "all four names in the collapsed multi-word value must be flagged: {result:?}"
4004 );
4005 }
4006
4007 #[test]
4008 fn test_frontmatter_collapsed_multiword_names_all_flagged_toml() {
4009 let content = "+++\ndescription = \"myapp/gitlab github/bitbucket\"\n+++\n\nBody.\n";
4010 let rule = MD044ProperNames::new(
4011 vec![
4012 "MyApp".to_string(),
4013 "GitLab".to_string(),
4014 "GitHub".to_string(),
4015 "Bitbucket".to_string(),
4016 ],
4017 false,
4018 );
4019 let ctx = create_context(content);
4020 let result = rule.check(&ctx).unwrap();
4021 assert_eq!(
4022 result.len(),
4023 4,
4024 "all four names in the collapsed multi-word TOML value must be flagged: {result:?}"
4025 );
4026 }
4027
4028 #[test]
4029 fn test_frontmatter_collapsed_multiword_conjunction_pairs_flagged() {
4030 let content = "---\ndescription: \"and/or this/that myapp/gitlab\"\n---\n\nBody.\n";
4031 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4032 let ctx = create_context(content);
4033 let result = rule.check(&ctx).unwrap();
4034 assert_eq!(
4035 result.len(),
4036 2,
4037 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4038 );
4039 }
4040
4041 #[test]
4042 fn test_frontmatter_collapsed_multiword_pronoun_pairs_flagged() {
4043 let content = "---\ndescription: \"he/him she/her myapp/gitlab\"\n---\n\nBody.\n";
4044 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4045 let ctx = create_context(content);
4046 let result = rule.check(&ctx).unwrap();
4047 assert_eq!(
4048 result.len(),
4049 2,
4050 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4051 );
4052 }
4053
4054 #[test]
4060 fn test_body_prose_path_is_flagged_frontmatter_only_scope() {
4061 let content = "See docs/myapp.md for details about myapp.\n";
4062 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4063 let ctx = create_context(content);
4064 let result = rule.check(&ctx).unwrap();
4065 assert_eq!(
4066 result.len(),
4067 2,
4068 "both the path occurrence and the prose occurrence are flagged in body text: {result:?}"
4069 );
4070 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp.md for details about MyApp.\n");
4071 }
4072
4073 #[test]
4074 fn test_slash_conjunction_prose_still_flagged() {
4075 let content = "We support github/gitlab imports.\n";
4076 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4077 let ctx = create_context(content);
4078 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
4079 }
4080}