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::range_utils::byte_to_char_count;
6use std::collections::{HashMap, HashSet};
7use std::sync::{Arc, Mutex};
8
9mod md044_config;
10pub(super) use md044_config::MD044Config;
11
12type WarningPosition = (usize, usize, String); fn is_inline_config_comment(trimmed: &str) -> bool {
71 trimmed.starts_with("<!-- rumdl-")
72 || trimmed.starts_with("<!-- markdownlint-")
73 || trimmed.starts_with("<!-- vale off")
74 || trimmed.starts_with("<!-- vale on")
75 || (trimmed.starts_with("<!-- vale ") && trimmed.contains(" = "))
76 || trimmed.starts_with("<!-- vale style")
77 || trimmed.starts_with("<!-- lint disable ")
78 || trimmed.starts_with("<!-- lint enable ")
79 || trimmed.starts_with("<!-- lint ignore ")
80}
81
82#[derive(Clone)]
83pub struct MD044ProperNames {
84 config: MD044Config,
85 combined_pattern: Option<String>,
87 name_variants: Vec<String>,
89 content_cache: Arc<Mutex<HashMap<u64, Vec<WarningPosition>>>>,
98}
99
100impl MD044ProperNames {
101 pub fn new(names: Vec<String>, code_blocks: bool) -> Self {
102 let config = MD044Config {
103 names,
104 code_blocks,
105 html_elements: true, html_comments: true, };
108 let combined_pattern = Self::create_combined_pattern(&config);
109 let name_variants = Self::build_name_variants(&config);
110 Self {
111 config,
112 combined_pattern,
113 name_variants,
114 content_cache: Arc::new(Mutex::new(HashMap::new())),
115 }
116 }
117
118 fn ascii_normalize(s: &str) -> String {
120 s.replace(['é', 'è', 'ê', 'ë'], "e")
121 .replace(['à', 'á', 'â', 'ä', 'ã', 'å'], "a")
122 .replace(['ï', 'î', 'í', 'ì'], "i")
123 .replace(['ü', 'ú', 'ù', 'û'], "u")
124 .replace(['ö', 'ó', 'ò', 'ô', 'õ'], "o")
125 .replace('ñ', "n")
126 .replace('ç', "c")
127 }
128
129 pub fn from_config_struct(config: MD044Config) -> Self {
130 let combined_pattern = Self::create_combined_pattern(&config);
131 let name_variants = Self::build_name_variants(&config);
132 Self {
133 config,
134 combined_pattern,
135 name_variants,
136 content_cache: Arc::new(Mutex::new(HashMap::new())),
137 }
138 }
139
140 fn create_combined_pattern(config: &MD044Config) -> Option<String> {
142 if config.names.is_empty() {
143 return None;
144 }
145
146 let mut patterns: Vec<String> = config
148 .names
149 .iter()
150 .flat_map(|name| {
151 let mut variations = vec![];
152 let lower_name = name.to_lowercase();
153
154 variations.push(escape_regex(&lower_name));
156
157 let lower_name_no_dots = lower_name.replace('.', "");
159 if lower_name != lower_name_no_dots {
160 variations.push(escape_regex(&lower_name_no_dots));
161 }
162
163 let ascii_normalized = Self::ascii_normalize(&lower_name);
165
166 if ascii_normalized != lower_name {
167 variations.push(escape_regex(&ascii_normalized));
168
169 let ascii_no_dots = ascii_normalized.replace('.', "");
171 if ascii_normalized != ascii_no_dots {
172 variations.push(escape_regex(&ascii_no_dots));
173 }
174 }
175
176 variations
177 })
178 .collect();
179
180 patterns.sort_by_key(|b| std::cmp::Reverse(b.len()));
182
183 Some(format!(r"(?i)({})", patterns.join("|")))
186 }
187
188 fn build_name_variants(config: &MD044Config) -> Vec<String> {
189 let mut variants = HashSet::new();
190 for name in &config.names {
191 let lower_name = name.to_lowercase();
192 variants.insert(lower_name.clone());
193
194 let lower_no_dots = lower_name.replace('.', "");
195 if lower_name != lower_no_dots {
196 variants.insert(lower_no_dots);
197 }
198
199 let ascii_normalized = Self::ascii_normalize(&lower_name);
200 if ascii_normalized != lower_name {
201 variants.insert(ascii_normalized.clone());
202
203 let ascii_no_dots = ascii_normalized.replace('.', "");
204 if ascii_normalized != ascii_no_dots {
205 variants.insert(ascii_no_dots);
206 }
207 }
208 }
209
210 variants.into_iter().collect()
211 }
212
213 fn find_name_violations(
216 &self,
217 content: &str,
218 ctx: &crate::lint_context::LintContext,
219 content_lower: &str,
220 ) -> Vec<WarningPosition> {
221 if self.config.names.is_empty() || content.is_empty() || self.combined_pattern.is_none() {
223 return Vec::new();
224 }
225
226 let has_potential_matches = self.name_variants.iter().any(|name| content_lower.contains(name));
228
229 if !has_potential_matches {
230 return Vec::new();
231 }
232
233 let hash = fast_hash(content);
235 {
236 if let Ok(cache) = self.content_cache.lock()
238 && let Some(cached) = cache.get(&hash)
239 {
240 return cached.clone();
241 }
242 }
243
244 let mut violations = Vec::new();
245
246 let combined_regex = match &self.combined_pattern {
248 Some(pattern) => match get_cached_regex(pattern) {
249 Ok(regex) => regex,
250 Err(_) => return Vec::new(),
251 },
252 None => return Vec::new(),
253 };
254
255 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
257 let line_num = line_idx + 1;
258 let line = line_info.content(ctx.content);
259
260 let trimmed = line.trim_start();
262 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
263 continue;
264 }
265
266 if !self.config.code_blocks && line_info.in_code_block {
268 continue;
269 }
270
271 if !self.config.html_elements && line_info.in_html_block {
273 continue;
274 }
275
276 if !self.config.html_comments && line_info.in_html_comment {
278 continue;
279 }
280
281 if line_info.in_jsx_expression || line_info.in_mdx_comment {
283 continue;
284 }
285
286 if line_info.in_obsidian_comment {
288 continue;
289 }
290
291 let fm_value_offset = if line_info.in_front_matter {
294 Self::frontmatter_value_offset(line)
295 } else {
296 0
297 };
298 if fm_value_offset == usize::MAX {
299 continue;
300 }
301
302 if is_inline_config_comment(trimmed) {
304 continue;
305 }
306
307 let line_lower = line.to_lowercase();
309 let has_line_matches = self.name_variants.iter().any(|name| line_lower.contains(name));
310
311 if !has_line_matches {
312 continue;
313 }
314
315 for cap in combined_regex.find_iter(line) {
317 let found_name = &line[cap.start()..cap.end()];
318
319 let start_pos = cap.start();
321 let end_pos = cap.end();
322
323 if start_pos < fm_value_offset {
325 continue;
326 }
327
328 let byte_pos = line_info.byte_offset + start_pos;
330 if ctx.is_in_html_tag(byte_pos) {
331 continue;
332 }
333
334 if !Self::is_at_word_boundary(line, start_pos, true) || !Self::is_at_word_boundary(line, end_pos, false)
335 {
336 continue; }
338
339 if !self.config.code_blocks {
341 if ctx.is_in_code_block_or_span(byte_pos) {
342 continue;
343 }
344 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
348 && Self::is_in_backtick_code_in_line(line, start_pos)
349 {
350 continue;
351 }
352 }
353
354 if Self::is_in_link(ctx, byte_pos) {
356 continue;
357 }
358
359 if Self::is_in_angle_bracket_url(line, start_pos) {
363 continue;
364 }
365
366 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
370 && Self::is_in_markdown_link_url(line, start_pos)
371 {
372 continue;
373 }
374
375 if Self::is_in_wikilink_url(ctx, byte_pos) {
380 continue;
381 }
382
383 if Self::is_in_bare_url(ctx, byte_pos) {
389 continue;
390 }
391
392 if let Some(proper_name) = self.get_proper_name_for(found_name) {
394 if found_name != proper_name {
396 violations.push((line_num, cap.start() + 1, found_name.to_string()));
397 }
398 }
399 }
400 }
401
402 if let Ok(mut cache) = self.content_cache.lock() {
404 cache.insert(hash, violations.clone());
405 }
406 violations
407 }
408
409 fn is_in_bare_url(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
412 let bare_urls = ctx.bare_urls();
413 let idx = bare_urls.partition_point(|url| url.byte_offset <= byte_pos);
415 idx > 0 && byte_pos < bare_urls[idx - 1].byte_end
416 }
417
418 fn is_in_link(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
425 use pulldown_cmark::LinkType;
426
427 let link_idx = ctx.links.partition_point(|link| link.byte_offset <= byte_pos);
429 if link_idx > 0 {
430 let link = &ctx.links[link_idx - 1];
431 if byte_pos < link.byte_end {
432 let text_start = if matches!(link.link_type, LinkType::WikiLink { .. }) {
434 link.byte_offset + 2
435 } else {
436 link.byte_offset + 1
437 };
438 let text_end = text_start + link.text.len();
439
440 if byte_pos >= text_start && byte_pos < text_end {
444 let is_wikilink = matches!(link.link_type, LinkType::WikiLink { .. });
445 return Self::link_text_is_url(&link.text)
446 || (!is_wikilink && Self::link_text_matches_link_url(&link.text, &link.url));
447 }
448 return true;
450 }
451 }
452
453 let image_idx = ctx.images.partition_point(|img| img.byte_offset <= byte_pos);
455 if image_idx > 0 {
456 let image = &ctx.images[image_idx - 1];
457 if byte_pos < image.byte_end {
458 let alt_start = image.byte_offset + 2;
460 let alt_end = alt_start + image.alt_text.len();
461
462 if byte_pos >= alt_start && byte_pos < alt_end {
464 return false;
465 }
466 return true;
468 }
469 }
470
471 ctx.is_in_reference_def(byte_pos)
473 }
474
475 fn link_text_is_url(text: &str) -> bool {
477 let lower = text.trim().to_ascii_lowercase();
478 lower.starts_with("http://")
479 || lower.starts_with("https://")
480 || lower.starts_with("www.")
481 || lower.starts_with("//")
482 }
483
484 fn link_text_matches_link_url(text: &str, url: &str) -> bool {
496 let text = text.trim();
497 if !text.contains('.') {
499 return false;
500 }
501 let url_lower = url.to_ascii_lowercase();
502 let url_without_scheme = url_lower
503 .strip_prefix("https://")
504 .or_else(|| url_lower.strip_prefix("http://"))
505 .or_else(|| url_lower.strip_prefix("//"))
506 .unwrap_or(&url_lower);
507 let text_lower = text.to_ascii_lowercase();
508 if url_without_scheme == text_lower.as_str() {
510 return true;
511 }
512 url_without_scheme.len() > text_lower.len()
514 && url_without_scheme.starts_with(text_lower.as_str())
515 && matches!(
516 url_without_scheme.as_bytes().get(text_lower.len()),
517 Some(b'/') | Some(b'?') | Some(b'#')
518 )
519 }
520
521 fn is_in_angle_bracket_url(line: &str, pos: usize) -> bool {
527 let bytes = line.as_bytes();
528 let len = bytes.len();
529 let mut i = 0;
530 while i < len {
531 if bytes[i] == b'<' {
532 let after_open = i + 1;
533 if after_open < len && bytes[after_open].is_ascii_alphabetic() {
537 let mut s = after_open + 1;
538 let scheme_max = (after_open + 32).min(len);
539 while s < scheme_max
540 && (bytes[s].is_ascii_alphanumeric()
541 || bytes[s] == b'+'
542 || bytes[s] == b'-'
543 || bytes[s] == b'.')
544 {
545 s += 1;
546 }
547 if s < len && bytes[s] == b':' {
548 let mut j = s + 1;
550 let mut found_close = false;
551 while j < len {
552 match bytes[j] {
553 b'>' => {
554 found_close = true;
555 break;
556 }
557 b' ' | b'<' => break,
558 _ => j += 1,
559 }
560 }
561 if found_close && pos >= i && pos <= j {
562 return true;
563 }
564 if found_close {
565 i = j + 1;
566 continue;
567 }
568 }
569 }
570 }
571 i += 1;
572 }
573 false
574 }
575
576 fn is_in_wikilink_url(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
589 use pulldown_cmark::LinkType;
590 let content = ctx.content.as_bytes();
591
592 let end = ctx.links.partition_point(|l| l.byte_offset <= byte_pos);
595
596 for link in &ctx.links[..end] {
597 if !matches!(link.link_type, LinkType::WikiLink { .. }) {
598 continue;
599 }
600 let wiki_end = link.byte_end;
601 if wiki_end >= byte_pos || wiki_end >= content.len() || content[wiki_end] != b'(' {
603 continue;
604 }
605 let mut depth: u32 = 1;
610 let mut k = wiki_end + 1;
611 let mut valid_destination = true;
612 while k < content.len() && depth > 0 {
613 match content[k] {
614 b'\\' => {
615 k += 1; }
617 b'(' => depth += 1,
618 b')' => depth -= 1,
619 b' ' | b'\t' | b'\n' | b'\r' => {
620 valid_destination = false;
621 break;
622 }
623 _ => {}
624 }
625 k += 1;
626 }
627 if valid_destination && depth == 0 && byte_pos > wiki_end && byte_pos < k {
630 return true;
631 }
632 }
633 false
634 }
635
636 fn is_in_markdown_link_url(line: &str, pos: usize) -> bool {
646 let bytes = line.as_bytes();
647 let len = bytes.len();
648 let mut i = 0;
649
650 while i < len {
651 if bytes[i] == b'[' && (i == 0 || bytes[i - 1] != b'\\' || (i >= 2 && bytes[i - 2] == b'\\')) {
653 let mut depth: u32 = 1;
655 let mut j = i + 1;
656 while j < len && depth > 0 {
657 match bytes[j] {
658 b'\\' => {
659 j += 1; }
661 b'[' => depth += 1,
662 b']' => depth -= 1,
663 _ => {}
664 }
665 j += 1;
666 }
667
668 if depth == 0 && j < len {
670 if bytes[j] == b'(' {
671 let url_start = j;
673 let mut paren_depth: u32 = 1;
674 let mut k = j + 1;
675 while k < len && paren_depth > 0 {
676 match bytes[k] {
677 b'\\' => {
678 k += 1; }
680 b'(' => paren_depth += 1,
681 b')' => paren_depth -= 1,
682 _ => {}
683 }
684 k += 1;
685 }
686
687 if paren_depth == 0 {
688 if pos > url_start && pos < k {
689 return true;
690 }
691 i = k;
692 continue;
693 }
694 } else if bytes[j] == b'[' {
695 let ref_start = j;
697 let mut ref_depth: u32 = 1;
698 let mut k = j + 1;
699 while k < len && ref_depth > 0 {
700 match bytes[k] {
701 b'\\' => {
702 k += 1;
703 }
704 b'[' => ref_depth += 1,
705 b']' => ref_depth -= 1,
706 _ => {}
707 }
708 k += 1;
709 }
710
711 if ref_depth == 0 {
712 if pos > ref_start && pos < k {
713 return true;
714 }
715 i = k;
716 continue;
717 }
718 }
719 }
720 }
721 i += 1;
722 }
723 false
724 }
725
726 fn is_in_backtick_code_in_line(line: &str, pos: usize) -> bool {
734 let bytes = line.as_bytes();
735 let len = bytes.len();
736 let mut i = 0;
737 while i < len {
738 if bytes[i] == b'`' {
739 let open_start = i;
741 while i < len && bytes[i] == b'`' {
742 i += 1;
743 }
744 let tick_len = i - open_start;
745
746 while i < len {
748 if bytes[i] == b'`' {
749 let close_start = i;
750 while i < len && bytes[i] == b'`' {
751 i += 1;
752 }
753 if i - close_start == tick_len {
754 let content_start = open_start + tick_len;
758 let content_end = close_start;
759 if pos >= content_start && pos < content_end {
760 return true;
761 }
762 break;
764 }
765 } else {
767 i += 1;
768 }
769 }
770 } else {
771 i += 1;
772 }
773 }
774 false
775 }
776
777 fn is_word_boundary_char(c: char) -> bool {
779 !c.is_alphanumeric()
780 }
781
782 fn is_at_word_boundary(content: &str, pos: usize, is_start: bool) -> bool {
784 if is_start {
785 if pos == 0 {
786 return true;
787 }
788 match content[..pos].chars().next_back() {
789 None => true,
790 Some(c) => Self::is_word_boundary_char(c),
791 }
792 } else {
793 if pos >= content.len() {
794 return true;
795 }
796 match content[pos..].chars().next() {
797 None => true,
798 Some(c) => Self::is_word_boundary_char(c),
799 }
800 }
801 }
802
803 fn frontmatter_value_offset(line: &str) -> usize {
807 let trimmed = line.trim();
808
809 if trimmed == "---" || trimmed == "+++" || trimmed.is_empty() {
811 return usize::MAX;
812 }
813
814 if trimmed.starts_with('#') {
816 return usize::MAX;
817 }
818
819 let stripped = line.trim_start();
821 if let Some(after_dash) = stripped.strip_prefix("- ") {
822 let leading = line.len() - stripped.len();
823 if let Some(result) = Self::kv_value_offset(line, after_dash, leading + 2) {
825 return result;
826 }
827 return leading + 2;
829 }
830 if stripped == "-" {
831 return usize::MAX;
832 }
833
834 if let Some(result) = Self::kv_value_offset(line, stripped, line.len() - stripped.len()) {
836 return result;
837 }
838
839 if let Some(eq_pos) = line.find('=') {
841 let after_eq = eq_pos + 1;
842 if after_eq < line.len() && line.as_bytes()[after_eq] == b' ' {
843 let value_start = after_eq + 1;
844 let value_slice = &line[value_start..];
845 let value_trimmed = value_slice.trim();
846 if value_trimmed.is_empty() {
847 return usize::MAX;
848 }
849 if (value_trimmed.starts_with('"') && value_trimmed.ends_with('"'))
851 || (value_trimmed.starts_with('\'') && value_trimmed.ends_with('\''))
852 {
853 let quote_offset = value_slice.find(['"', '\'']).unwrap_or(0);
854 return value_start + quote_offset + 1;
855 }
856 return value_start;
857 }
858 return usize::MAX;
860 }
861
862 0
864 }
865
866 fn kv_value_offset(line: &str, content: &str, base_offset: usize) -> Option<usize> {
870 let colon_pos = content.find(':')?;
871 let abs_colon = base_offset + colon_pos;
872 let after_colon = abs_colon + 1;
873 if after_colon < line.len() && line.as_bytes()[after_colon] == b' ' {
874 let value_start = after_colon + 1;
875 let value_slice = &line[value_start..];
876 let value_trimmed = value_slice.trim();
877 if value_trimmed.is_empty() {
878 return Some(usize::MAX);
879 }
880 if value_trimmed.starts_with('{') || value_trimmed.starts_with('[') {
882 return Some(usize::MAX);
883 }
884 if (value_trimmed.starts_with('"') && value_trimmed.ends_with('"'))
886 || (value_trimmed.starts_with('\'') && value_trimmed.ends_with('\''))
887 {
888 let quote_offset = value_slice.find(['"', '\'']).unwrap_or(0);
889 return Some(value_start + quote_offset + 1);
890 }
891 return Some(value_start);
892 }
893 Some(usize::MAX)
895 }
896
897 fn get_proper_name_for(&self, found_name: &str) -> Option<String> {
899 let found_lower = found_name.to_lowercase();
900
901 for name in &self.config.names {
903 let lower_name = name.to_lowercase();
904 let lower_name_no_dots = lower_name.replace('.', "");
905
906 if found_lower == lower_name || found_lower == lower_name_no_dots {
908 return Some(name.clone());
909 }
910
911 let ascii_normalized = Self::ascii_normalize(&lower_name);
913
914 let ascii_no_dots = ascii_normalized.replace('.', "");
915
916 if found_lower == ascii_normalized || found_lower == ascii_no_dots {
917 return Some(name.clone());
918 }
919 }
920 None
921 }
922}
923
924impl Rule for MD044ProperNames {
925 fn name(&self) -> &'static str {
926 "MD044"
927 }
928
929 fn description(&self) -> &'static str {
930 "Proper names should have the correct capitalization"
931 }
932
933 fn category(&self) -> RuleCategory {
934 RuleCategory::Other
935 }
936
937 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
938 if self.config.names.is_empty() {
939 return true;
940 }
941 let content_lower = if ctx.content.is_ascii() {
943 ctx.content.to_ascii_lowercase()
944 } else {
945 ctx.content.to_lowercase()
946 };
947 !self.name_variants.iter().any(|name| content_lower.contains(name))
948 }
949
950 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
951 let content = ctx.content;
952 if content.is_empty() || self.config.names.is_empty() || self.combined_pattern.is_none() {
953 return Ok(Vec::new());
954 }
955
956 let content_lower = if content.is_ascii() {
958 content.to_ascii_lowercase()
959 } else {
960 content.to_lowercase()
961 };
962
963 let has_potential_matches = self.name_variants.iter().any(|name| content_lower.contains(name));
965
966 if !has_potential_matches {
967 return Ok(Vec::new());
968 }
969
970 let line_index = &ctx.line_index;
971 let violations = self.find_name_violations(content, ctx, &content_lower);
972
973 let warnings = violations
974 .into_iter()
975 .filter_map(|(line, column, found_name)| {
976 self.get_proper_name_for(&found_name).map(|proper_name| {
977 let line_start = line_index.get_line_start_byte(line).unwrap_or(0);
982 let byte_start = line_start + (column - 1);
983 let byte_end = byte_start + found_name.len();
984 let line_text = ctx.line_info(line).map_or("", |li| li.content(ctx.content));
987 let char_col = byte_to_char_count(line_text, column - 1);
988 LintWarning {
989 rule_name: Some(self.name().to_string()),
990 line,
991 column: char_col,
992 end_line: line,
993 end_column: char_col + found_name.chars().count(),
994 message: format!("Proper name '{found_name}' should be '{proper_name}'"),
995 severity: Severity::Warning,
996 fix: Some(Fix::new(byte_start..byte_end, proper_name)),
997 }
998 })
999 })
1000 .collect();
1001
1002 Ok(warnings)
1003 }
1004
1005 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1006 if self.should_skip(ctx) {
1007 return Ok(ctx.content.to_string());
1008 }
1009 let warnings = self.check(ctx)?;
1010 if warnings.is_empty() {
1011 return Ok(ctx.content.to_string());
1012 }
1013 let warnings =
1014 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1015 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
1016 .map_err(crate::rule::LintError::InvalidInput)
1017 }
1018
1019 fn as_any(&self) -> &dyn std::any::Any {
1020 self
1021 }
1022
1023 crate::impl_rule_config_methods!(MD044Config);
1024}
1025
1026#[cfg(test)]
1027mod tests {
1028 use super::*;
1029 use crate::lint_context::LintContext;
1030
1031 fn create_context(content: &str) -> LintContext<'_> {
1032 LintContext::new(content, crate::config::MarkdownFlavor::Standard, None)
1033 }
1034
1035 #[test]
1036 fn test_correctly_capitalized_names() {
1037 let rule = MD044ProperNames::new(
1038 vec![
1039 "JavaScript".to_string(),
1040 "TypeScript".to_string(),
1041 "Node.js".to_string(),
1042 ],
1043 true,
1044 );
1045
1046 let content = "This document uses JavaScript, TypeScript, and Node.js correctly.";
1047 let ctx = create_context(content);
1048 let result = rule.check(&ctx).unwrap();
1049 assert!(result.is_empty(), "Should not flag correctly capitalized names");
1050 }
1051
1052 #[test]
1053 fn test_incorrectly_capitalized_names() {
1054 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1055
1056 let content = "This document uses javascript and typescript incorrectly.";
1057 let ctx = create_context(content);
1058 let result = rule.check(&ctx).unwrap();
1059
1060 assert_eq!(result.len(), 2, "Should flag two incorrect capitalizations");
1061 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1062 assert_eq!(result[0].line, 1);
1063 assert_eq!(result[0].column, 20);
1064 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1065 assert_eq!(result[1].line, 1);
1066 assert_eq!(result[1].column, 35);
1067 }
1068
1069 #[test]
1070 fn test_names_at_beginning_of_sentences() {
1071 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "Python".to_string()], true);
1072
1073 let content = "javascript is a great language. python is also popular.";
1074 let ctx = create_context(content);
1075 let result = rule.check(&ctx).unwrap();
1076
1077 assert_eq!(result.len(), 2, "Should flag names at beginning of sentences");
1078 assert_eq!(result[0].line, 1);
1079 assert_eq!(result[0].column, 1);
1080 assert_eq!(result[1].line, 1);
1081 assert_eq!(result[1].column, 33);
1082 }
1083
1084 #[test]
1085 fn test_names_in_code_blocks_checked_by_default() {
1086 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1087
1088 let content = r#"Here is some text with JavaScript.
1089
1090```javascript
1091// This javascript should be checked
1092const lang = "javascript";
1093```
1094
1095But this javascript should be flagged."#;
1096
1097 let ctx = create_context(content);
1098 let result = rule.check(&ctx).unwrap();
1099
1100 assert_eq!(result.len(), 3, "Should flag javascript inside and outside code blocks");
1101 assert_eq!(result[0].line, 4);
1102 assert_eq!(result[1].line, 5);
1103 assert_eq!(result[2].line, 8);
1104 }
1105
1106 #[test]
1107 fn test_names_in_code_blocks_ignored_when_disabled() {
1108 let rule = MD044ProperNames::new(
1109 vec!["JavaScript".to_string()],
1110 false, );
1112
1113 let content = r#"```
1114javascript in code block
1115```"#;
1116
1117 let ctx = create_context(content);
1118 let result = rule.check(&ctx).unwrap();
1119
1120 assert_eq!(
1121 result.len(),
1122 0,
1123 "Should not flag javascript in code blocks when code_blocks is false"
1124 );
1125 }
1126
1127 #[test]
1128 fn test_names_in_inline_code_checked_by_default() {
1129 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1130
1131 let content = "This is `javascript` in inline code and javascript outside.";
1132 let ctx = create_context(content);
1133 let result = rule.check(&ctx).unwrap();
1134
1135 assert_eq!(result.len(), 2, "Should flag javascript inside and outside inline code");
1137 assert_eq!(result[0].column, 10); assert_eq!(result[1].column, 41); }
1140
1141 #[test]
1142 fn test_multiple_names_in_same_line() {
1143 let rule = MD044ProperNames::new(
1144 vec!["JavaScript".to_string(), "TypeScript".to_string(), "React".to_string()],
1145 true,
1146 );
1147
1148 let content = "I use javascript, typescript, and react in my projects.";
1149 let ctx = create_context(content);
1150 let result = rule.check(&ctx).unwrap();
1151
1152 assert_eq!(result.len(), 3, "Should flag all three incorrect names");
1153 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1154 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1155 assert_eq!(result[2].message, "Proper name 'react' should be 'React'");
1156 }
1157
1158 #[test]
1159 fn test_case_sensitivity() {
1160 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1161
1162 let content = "JAVASCRIPT, Javascript, javascript, and JavaScript variations.";
1163 let ctx = create_context(content);
1164 let result = rule.check(&ctx).unwrap();
1165
1166 assert_eq!(result.len(), 3, "Should flag all incorrect case variations");
1167 assert!(result.iter().all(|w| w.message.contains("should be 'JavaScript'")));
1169 }
1170
1171 #[test]
1172 fn test_configuration_with_custom_name_list() {
1173 let config = MD044Config {
1174 names: vec!["GitHub".to_string(), "GitLab".to_string(), "DevOps".to_string()],
1175 code_blocks: true,
1176 html_elements: true,
1177 html_comments: true,
1178 };
1179 let rule = MD044ProperNames::from_config_struct(config);
1180
1181 let content = "We use github, gitlab, and devops for our workflow.";
1182 let ctx = create_context(content);
1183 let result = rule.check(&ctx).unwrap();
1184
1185 assert_eq!(result.len(), 3, "Should flag all custom names");
1186 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
1187 assert_eq!(result[1].message, "Proper name 'gitlab' should be 'GitLab'");
1188 assert_eq!(result[2].message, "Proper name 'devops' should be 'DevOps'");
1189 }
1190
1191 #[test]
1192 fn test_empty_configuration() {
1193 let rule = MD044ProperNames::new(vec![], true);
1194
1195 let content = "This has javascript and typescript but no configured names.";
1196 let ctx = create_context(content);
1197 let result = rule.check(&ctx).unwrap();
1198
1199 assert!(result.is_empty(), "Should not flag anything with empty configuration");
1200 }
1201
1202 #[test]
1203 fn test_names_with_special_characters() {
1204 let rule = MD044ProperNames::new(
1205 vec!["Node.js".to_string(), "ASP.NET".to_string(), "C++".to_string()],
1206 true,
1207 );
1208
1209 let content = "We use nodejs, asp.net, ASP.NET, and c++ in our stack.";
1210 let ctx = create_context(content);
1211 let result = rule.check(&ctx).unwrap();
1212
1213 assert_eq!(result.len(), 3, "Should handle special characters correctly");
1218
1219 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
1220 assert!(messages.contains(&"Proper name 'nodejs' should be 'Node.js'"));
1221 assert!(messages.contains(&"Proper name 'asp.net' should be 'ASP.NET'"));
1222 assert!(messages.contains(&"Proper name 'c++' should be 'C++'"));
1223 }
1224
1225 #[test]
1226 fn test_word_boundaries() {
1227 let rule = MD044ProperNames::new(vec!["Java".to_string(), "Script".to_string()], true);
1228
1229 let content = "JavaScript is not java or script, but Java and Script are separate.";
1230 let ctx = create_context(content);
1231 let result = rule.check(&ctx).unwrap();
1232
1233 assert_eq!(result.len(), 2, "Should respect word boundaries");
1235 assert!(result.iter().any(|w| w.column == 19)); assert!(result.iter().any(|w| w.column == 27)); }
1238
1239 #[test]
1240 fn test_fix_method() {
1241 let rule = MD044ProperNames::new(
1242 vec![
1243 "JavaScript".to_string(),
1244 "TypeScript".to_string(),
1245 "Node.js".to_string(),
1246 ],
1247 true,
1248 );
1249
1250 let content = "I love javascript, typescript, and nodejs!";
1251 let ctx = create_context(content);
1252 let fixed = rule.fix(&ctx).unwrap();
1253
1254 assert_eq!(fixed, "I love JavaScript, TypeScript, and Node.js!");
1255 }
1256
1257 #[test]
1258 fn test_fix_multiple_occurrences() {
1259 let rule = MD044ProperNames::new(vec!["Python".to_string()], true);
1260
1261 let content = "python is great. I use python daily. PYTHON is powerful.";
1262 let ctx = create_context(content);
1263 let fixed = rule.fix(&ctx).unwrap();
1264
1265 assert_eq!(fixed, "Python is great. I use Python daily. Python is powerful.");
1266 }
1267
1268 #[test]
1269 fn test_fix_checks_code_blocks_by_default() {
1270 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1271
1272 let content = r#"I love javascript.
1273
1274```
1275const lang = "javascript";
1276```
1277
1278More javascript here."#;
1279
1280 let ctx = create_context(content);
1281 let fixed = rule.fix(&ctx).unwrap();
1282
1283 let expected = r#"I love JavaScript.
1284
1285```
1286const lang = "JavaScript";
1287```
1288
1289More JavaScript here."#;
1290
1291 assert_eq!(fixed, expected);
1292 }
1293
1294 #[test]
1295 fn test_multiline_content() {
1296 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
1297
1298 let content = r#"First line with rust.
1299Second line with python.
1300Third line with RUST and PYTHON."#;
1301
1302 let ctx = create_context(content);
1303 let result = rule.check(&ctx).unwrap();
1304
1305 assert_eq!(result.len(), 4, "Should flag all incorrect occurrences");
1306 assert_eq!(result[0].line, 1);
1307 assert_eq!(result[1].line, 2);
1308 assert_eq!(result[2].line, 3);
1309 assert_eq!(result[3].line, 3);
1310 }
1311
1312 #[test]
1313 fn test_default_config() {
1314 let config = MD044Config::default();
1315 assert!(config.names.is_empty());
1316 assert!(!config.code_blocks);
1317 assert!(config.html_elements);
1318 assert!(config.html_comments);
1319 }
1320
1321 #[test]
1322 fn test_default_config_checks_html_comments() {
1323 let config = MD044Config {
1324 names: vec!["JavaScript".to_string()],
1325 ..MD044Config::default()
1326 };
1327 let rule = MD044ProperNames::from_config_struct(config);
1328
1329 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1330 let ctx = create_context(content);
1331 let result = rule.check(&ctx).unwrap();
1332
1333 assert_eq!(result.len(), 1, "Default config should check HTML comments");
1334 assert_eq!(result[0].line, 3);
1335 }
1336
1337 #[test]
1338 fn test_default_config_skips_code_blocks() {
1339 let config = MD044Config {
1340 names: vec!["JavaScript".to_string()],
1341 ..MD044Config::default()
1342 };
1343 let rule = MD044ProperNames::from_config_struct(config);
1344
1345 let content = "# Guide\n\n```\njavascript in code\n```\n";
1346 let ctx = create_context(content);
1347 let result = rule.check(&ctx).unwrap();
1348
1349 assert_eq!(result.len(), 0, "Default config should skip code blocks");
1350 }
1351
1352 #[test]
1353 fn test_standalone_html_comment_checked() {
1354 let config = MD044Config {
1355 names: vec!["Test".to_string()],
1356 ..MD044Config::default()
1357 };
1358 let rule = MD044ProperNames::from_config_struct(config);
1359
1360 let content = "# Heading\n\n<!-- this is a test example -->\n";
1361 let ctx = create_context(content);
1362 let result = rule.check(&ctx).unwrap();
1363
1364 assert_eq!(result.len(), 1, "Should flag proper name in standalone HTML comment");
1365 assert_eq!(result[0].line, 3);
1366 }
1367
1368 #[test]
1369 fn test_inline_config_comments_not_flagged() {
1370 let config = MD044Config {
1371 names: vec!["RUMDL".to_string()],
1372 ..MD044Config::default()
1373 };
1374 let rule = MD044ProperNames::from_config_struct(config);
1375
1376 let content = "<!-- rumdl-disable MD044 -->\nSome rumdl text here.\n<!-- rumdl-enable MD044 -->\n<!-- markdownlint-disable -->\nMore rumdl text.\n<!-- markdownlint-enable -->\n";
1380 let ctx = create_context(content);
1381 let result = rule.check(&ctx).unwrap();
1382
1383 assert_eq!(result.len(), 2, "Should only flag body lines, not config comments");
1384 assert_eq!(result[0].line, 2);
1385 assert_eq!(result[1].line, 5);
1386 }
1387
1388 #[test]
1389 fn test_html_comment_skipped_when_disabled() {
1390 let config = MD044Config {
1391 names: vec!["Test".to_string()],
1392 code_blocks: true,
1393 html_elements: true,
1394 html_comments: false,
1395 };
1396 let rule = MD044ProperNames::from_config_struct(config);
1397
1398 let content = "# Heading\n\n<!-- this is a test example -->\n\nRegular test here.\n";
1399 let ctx = create_context(content);
1400 let result = rule.check(&ctx).unwrap();
1401
1402 assert_eq!(
1403 result.len(),
1404 1,
1405 "Should only flag 'test' outside HTML comment when html_comments=false"
1406 );
1407 assert_eq!(result[0].line, 5);
1408 }
1409
1410 #[test]
1411 fn test_fix_corrects_html_comment_content() {
1412 let config = MD044Config {
1413 names: vec!["JavaScript".to_string()],
1414 ..MD044Config::default()
1415 };
1416 let rule = MD044ProperNames::from_config_struct(config);
1417
1418 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1419 let ctx = create_context(content);
1420 let fixed = rule.fix(&ctx).unwrap();
1421
1422 assert_eq!(fixed, "# Guide\n\n<!-- JavaScript mentioned here -->\n");
1423 }
1424
1425 #[test]
1426 fn test_fix_does_not_modify_inline_config_comments() {
1427 let config = MD044Config {
1428 names: vec!["RUMDL".to_string()],
1429 ..MD044Config::default()
1430 };
1431 let rule = MD044ProperNames::from_config_struct(config);
1432
1433 let content = "<!-- rumdl-disable -->\nSome rumdl text.\n<!-- rumdl-enable -->\n";
1434 let ctx = create_context(content);
1435 let fixed = rule.fix(&ctx).unwrap();
1436
1437 assert!(fixed.contains("<!-- rumdl-disable -->"));
1439 assert!(fixed.contains("<!-- rumdl-enable -->"));
1440 assert!(
1442 fixed.contains("Some rumdl text."),
1443 "Line inside rumdl-disable block should not be modified by fix()"
1444 );
1445 }
1446
1447 #[test]
1448 fn test_fix_respects_inline_disable_partial() {
1449 let config = MD044Config {
1450 names: vec!["RUMDL".to_string()],
1451 ..MD044Config::default()
1452 };
1453 let rule = MD044ProperNames::from_config_struct(config);
1454
1455 let content =
1456 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
1457 let ctx = create_context(content);
1458 let fixed = rule.fix(&ctx).unwrap();
1459
1460 assert!(
1462 fixed.contains("Some rumdl text.\n<!-- rumdl-enable"),
1463 "Line inside disable block should not be modified"
1464 );
1465 assert!(
1467 fixed.contains("Some RUMDL text outside."),
1468 "Line outside disable block should be fixed"
1469 );
1470 }
1471
1472 #[test]
1473 fn test_performance_with_many_names() {
1474 let mut names = vec![];
1475 for i in 0..50 {
1476 names.push(format!("ProperName{i}"));
1477 }
1478
1479 let rule = MD044ProperNames::new(names, true);
1480
1481 let content = "This has propername0, propername25, and propername49 incorrectly.";
1482 let ctx = create_context(content);
1483 let result = rule.check(&ctx).unwrap();
1484
1485 assert_eq!(result.len(), 3, "Should handle many configured names efficiently");
1486 }
1487
1488 #[test]
1489 fn test_large_name_count_performance() {
1490 let names = (0..1000).map(|i| format!("ProperName{i}")).collect::<Vec<_>>();
1493
1494 let rule = MD044ProperNames::new(names, true);
1495
1496 assert!(rule.combined_pattern.is_some());
1498
1499 let content = "This has propername0 and propername999 in it.";
1501 let ctx = create_context(content);
1502 let result = rule.check(&ctx).unwrap();
1503
1504 assert_eq!(result.len(), 2, "Should handle 1000 names without issues");
1506 }
1507
1508 #[test]
1509 fn test_cache_behavior() {
1510 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1511
1512 let content = "Using javascript here.";
1513 let ctx = create_context(content);
1514
1515 let result1 = rule.check(&ctx).unwrap();
1517 assert_eq!(result1.len(), 1);
1518
1519 let result2 = rule.check(&ctx).unwrap();
1521 assert_eq!(result2.len(), 1);
1522
1523 assert_eq!(result1[0].line, result2[0].line);
1525 assert_eq!(result1[0].column, result2[0].column);
1526 }
1527
1528 #[test]
1529 fn test_html_comments_not_checked_when_disabled() {
1530 let config = MD044Config {
1531 names: vec!["JavaScript".to_string()],
1532 code_blocks: true, html_elements: true, html_comments: false, };
1536 let rule = MD044ProperNames::from_config_struct(config);
1537
1538 let content = r#"Regular javascript here.
1539<!-- This javascript in HTML comment should be ignored -->
1540More javascript outside."#;
1541
1542 let ctx = create_context(content);
1543 let result = rule.check(&ctx).unwrap();
1544
1545 assert_eq!(result.len(), 2, "Should only flag javascript outside HTML comments");
1546 assert_eq!(result[0].line, 1);
1547 assert_eq!(result[1].line, 3);
1548 }
1549
1550 #[test]
1551 fn test_html_comments_checked_when_enabled() {
1552 let config = MD044Config {
1553 names: vec!["JavaScript".to_string()],
1554 code_blocks: true, html_elements: true, html_comments: true, };
1558 let rule = MD044ProperNames::from_config_struct(config);
1559
1560 let content = r#"Regular javascript here.
1561<!-- This javascript in HTML comment should be checked -->
1562More javascript outside."#;
1563
1564 let ctx = create_context(content);
1565 let result = rule.check(&ctx).unwrap();
1566
1567 assert_eq!(
1568 result.len(),
1569 3,
1570 "Should flag all javascript occurrences including in HTML comments"
1571 );
1572 }
1573
1574 #[test]
1575 fn test_multiline_html_comments() {
1576 let config = MD044Config {
1577 names: vec!["Python".to_string(), "JavaScript".to_string()],
1578 code_blocks: true, html_elements: true, html_comments: false, };
1582 let rule = MD044ProperNames::from_config_struct(config);
1583
1584 let content = r#"Regular python here.
1585<!--
1586This is a multiline comment
1587with javascript and python
1588that should be ignored
1589-->
1590More javascript outside."#;
1591
1592 let ctx = create_context(content);
1593 let result = rule.check(&ctx).unwrap();
1594
1595 assert_eq!(result.len(), 2, "Should only flag names outside HTML comments");
1596 assert_eq!(result[0].line, 1); assert_eq!(result[1].line, 7); }
1599
1600 #[test]
1601 fn test_fix_preserves_html_comments_when_disabled() {
1602 let config = MD044Config {
1603 names: vec!["JavaScript".to_string()],
1604 code_blocks: true, html_elements: true, html_comments: false, };
1608 let rule = MD044ProperNames::from_config_struct(config);
1609
1610 let content = r#"javascript here.
1611<!-- javascript in comment -->
1612More javascript."#;
1613
1614 let ctx = create_context(content);
1615 let fixed = rule.fix(&ctx).unwrap();
1616
1617 let expected = r#"JavaScript here.
1618<!-- javascript in comment -->
1619More JavaScript."#;
1620
1621 assert_eq!(
1622 fixed, expected,
1623 "Should not fix names inside HTML comments when disabled"
1624 );
1625 }
1626
1627 #[test]
1628 fn test_proper_names_in_link_text_are_flagged() {
1629 let rule = MD044ProperNames::new(
1630 vec!["JavaScript".to_string(), "Node.js".to_string(), "Python".to_string()],
1631 true,
1632 );
1633
1634 let content = r#"Check this [javascript documentation](https://javascript.info) for info.
1635
1636Visit [node.js homepage](https://nodejs.org) and [python tutorial](https://python.org).
1637
1638Real javascript should be flagged.
1639
1640Also see the [typescript guide][ts-ref] for more.
1641
1642Real python should be flagged too.
1643
1644[ts-ref]: https://typescript.org/handbook"#;
1645
1646 let ctx = create_context(content);
1647 let result = rule.check(&ctx).unwrap();
1648
1649 assert_eq!(result.len(), 5, "Expected 5 warnings: 3 in link text + 2 standalone");
1656
1657 let line_1_warnings: Vec<_> = result.iter().filter(|w| w.line == 1).collect();
1659 assert_eq!(line_1_warnings.len(), 1);
1660 assert!(
1661 line_1_warnings[0]
1662 .message
1663 .contains("'javascript' should be 'JavaScript'")
1664 );
1665
1666 let line_3_warnings: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
1667 assert_eq!(line_3_warnings.len(), 2); assert!(result.iter().any(|w| w.line == 5 && w.message.contains("'javascript'")));
1671 assert!(result.iter().any(|w| w.line == 9 && w.message.contains("'python'")));
1672 }
1673
1674 #[test]
1675 fn test_link_urls_not_flagged() {
1676 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1677
1678 let content = r#"[Link Text](https://javascript.info/guide)"#;
1680
1681 let ctx = create_context(content);
1682 let result = rule.check(&ctx).unwrap();
1683
1684 assert!(result.is_empty(), "URLs should not be checked for proper names");
1686 }
1687
1688 #[test]
1689 fn test_bare_urls_not_flagged() {
1690 let rule = MD044ProperNames::new(vec!["Foo".to_string(), "JavaScript".to_string()], true);
1691
1692 let content =
1695 "https://foo.com\n\nSee https://javascript.info/foo/guide for details.\n\nMail foo@foo.com about it.\n";
1696
1697 let ctx = create_context(content);
1698 let result = rule.check(&ctx).unwrap();
1699
1700 assert!(
1701 result.is_empty(),
1702 "Bare URLs and emails should not be checked for proper names: {result:?}"
1703 );
1704 }
1705
1706 #[test]
1707 fn test_prose_around_bare_url_still_flagged() {
1708 let rule = MD044ProperNames::new(vec!["Foo".to_string()], true);
1709
1710 let content = "Use foo at https://foo.com because foo is great.\n";
1713
1714 let ctx = create_context(content);
1715 let result = rule.check(&ctx).unwrap();
1716
1717 assert_eq!(
1718 result.len(),
1719 2,
1720 "Prose occurrences around a bare URL must still be flagged: {result:?}"
1721 );
1722 assert!(result.iter().all(|w| w.message.contains("'foo' should be 'Foo'")));
1723 }
1724
1725 #[test]
1726 fn test_proper_names_in_image_alt_text_are_flagged() {
1727 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1728
1729 let content = r#"Here is a  image.
1730
1731Real javascript should be flagged."#;
1732
1733 let ctx = create_context(content);
1734 let result = rule.check(&ctx).unwrap();
1735
1736 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in alt text + 1 standalone");
1740 assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
1741 assert!(result[0].line == 1); assert!(result[1].message.contains("'javascript' should be 'JavaScript'"));
1743 assert!(result[1].line == 3); }
1745
1746 #[test]
1747 fn test_image_urls_not_flagged() {
1748 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1749
1750 let content = r#""#;
1752
1753 let ctx = create_context(content);
1754 let result = rule.check(&ctx).unwrap();
1755
1756 assert!(result.is_empty(), "Image URLs should not be checked for proper names");
1758 }
1759
1760 #[test]
1761 fn test_reference_link_text_flagged_but_definition_not() {
1762 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1763
1764 let content = r#"Check the [javascript guide][js-ref] for details.
1765
1766Real javascript should be flagged.
1767
1768[js-ref]: https://javascript.info/typescript/guide"#;
1769
1770 let ctx = create_context(content);
1771 let result = rule.check(&ctx).unwrap();
1772
1773 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in link text + 1 standalone");
1778 assert!(result.iter().any(|w| w.line == 1 && w.message.contains("'javascript'")));
1779 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
1780 }
1781
1782 #[test]
1783 fn test_reference_definitions_not_flagged() {
1784 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1785
1786 let content = r#"[js-ref]: https://javascript.info/guide"#;
1788
1789 let ctx = create_context(content);
1790 let result = rule.check(&ctx).unwrap();
1791
1792 assert!(result.is_empty(), "Reference definitions should not be checked");
1794 }
1795
1796 #[test]
1797 fn test_wikilinks_text_is_flagged() {
1798 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1799
1800 let content = r#"[[javascript]]
1802
1803Regular javascript here.
1804
1805[[JavaScript|display text]]"#;
1806
1807 let ctx = create_context(content);
1808 let result = rule.check(&ctx).unwrap();
1809
1810 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in WikiLink + 1 standalone");
1814 assert!(
1815 result
1816 .iter()
1817 .any(|w| w.line == 1 && w.column == 3 && w.message.contains("'javascript'"))
1818 );
1819 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
1820 }
1821
1822 #[test]
1823 fn test_url_link_text_not_flagged() {
1824 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1825
1826 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
1828
1829[http://github.com/org/repo](http://github.com/org/repo)
1830
1831[www.github.com/org/repo](https://www.github.com/org/repo)"#;
1832
1833 let ctx = create_context(content);
1834 let result = rule.check(&ctx).unwrap();
1835
1836 assert!(
1837 result.is_empty(),
1838 "URL-like link text should not be flagged, got: {result:?}"
1839 );
1840 }
1841
1842 #[test]
1843 fn test_url_link_text_with_leading_space_not_flagged() {
1844 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1845
1846 let content = r#"[ https://github.com/org/repo](https://github.com/org/repo)"#;
1848
1849 let ctx = create_context(content);
1850 let result = rule.check(&ctx).unwrap();
1851
1852 assert!(
1853 result.is_empty(),
1854 "URL-like link text with leading space should not be flagged, got: {result:?}"
1855 );
1856 }
1857
1858 #[test]
1859 fn test_url_link_text_uppercase_scheme_not_flagged() {
1860 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1861
1862 let content = r#"[HTTPS://GITHUB.COM/org/repo](https://github.com/org/repo)"#;
1863
1864 let ctx = create_context(content);
1865 let result = rule.check(&ctx).unwrap();
1866
1867 assert!(
1868 result.is_empty(),
1869 "URL-like link text with uppercase scheme should not be flagged, got: {result:?}"
1870 );
1871 }
1872
1873 #[test]
1874 fn test_non_url_link_text_still_flagged() {
1875 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1876
1877 let content = r#"[github.com/org/repo](https://github.com/org/repo)
1881
1882[Visit github](https://github.com/org/repo)
1883
1884[//github.com/org/repo](//github.com/org/repo)
1885
1886[ftp://github.com/org/repo](ftp://github.com/org/repo)"#;
1887
1888 let ctx = create_context(content);
1889 let result = rule.check(&ctx).unwrap();
1890
1891 assert_eq!(
1896 result.len(),
1897 1,
1898 "Only prose link text should be flagged, got: {result:?}"
1899 );
1900 assert!(
1901 result.iter().any(|w| w.line == 3),
1902 "Expected 'Visit github' on line 3 to be flagged"
1903 );
1904 }
1905
1906 #[test]
1907 fn test_url_link_text_fix_not_applied() {
1908 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1909
1910 let content = "[https://github.com/org/repo](https://github.com/org/repo)\n";
1911
1912 let ctx = create_context(content);
1913 let result = rule.fix(&ctx).unwrap();
1914
1915 assert_eq!(result, content, "Fix should not modify URL-like link text");
1916 }
1917
1918 #[test]
1919 fn test_mixed_url_and_regular_link_text() {
1920 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1921
1922 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
1924
1925Visit [github documentation](https://github.com/docs) for details.
1926
1927[www.github.com/pricing](https://www.github.com/pricing)"#;
1928
1929 let ctx = create_context(content);
1930 let result = rule.check(&ctx).unwrap();
1931
1932 assert_eq!(
1934 result.len(),
1935 1,
1936 "Only non-URL link text should be flagged, got: {result:?}"
1937 );
1938 assert_eq!(result[0].line, 3);
1939 }
1940
1941 #[test]
1942 fn test_html_attribute_values_not_flagged() {
1943 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1946 let content = "# Heading\n\ntest\n\n<img src=\"www.example.test/test_image.png\">\n";
1947 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1948 let result = rule.check(&ctx).unwrap();
1949
1950 let line5_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
1952 assert!(
1953 line5_violations.is_empty(),
1954 "Should not flag anything inside HTML tag attributes: {line5_violations:?}"
1955 );
1956
1957 let line3_violations: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
1959 assert_eq!(line3_violations.len(), 1, "Plain 'test' on line 3 should be flagged");
1960 }
1961
1962 #[test]
1963 fn test_html_text_content_still_flagged() {
1964 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1966 let content = "# Heading\n\n<a href=\"https://example.test/page\">test link</a>\n";
1967 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1968 let result = rule.check(&ctx).unwrap();
1969
1970 assert_eq!(
1973 result.len(),
1974 1,
1975 "Should flag only 'test' in anchor text, not in href: {result:?}"
1976 );
1977 assert_eq!(result[0].column, 37, "Should flag col 37 ('test link' in anchor text)");
1978 }
1979
1980 #[test]
1981 fn test_html_attribute_various_not_flagged() {
1982 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1984 let content = concat!(
1985 "# Heading\n\n",
1986 "<img src=\"test.png\" alt=\"test image\">\n",
1987 "<span class=\"test-class\" data-test=\"value\">test content</span>\n",
1988 );
1989 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1990 let result = rule.check(&ctx).unwrap();
1991
1992 assert_eq!(
1994 result.len(),
1995 1,
1996 "Should flag only 'test content' between tags: {result:?}"
1997 );
1998 assert_eq!(result[0].line, 4);
1999 }
2000
2001 #[test]
2002 fn test_plain_text_underscore_boundary_unchanged() {
2003 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2006 let content = "# Heading\n\ntest_image is here and just_test ends here\n";
2007 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2008 let result = rule.check(&ctx).unwrap();
2009
2010 assert_eq!(
2013 result.len(),
2014 2,
2015 "Should flag 'test' in both 'test_image' and 'just_test': {result:?}"
2016 );
2017 let cols: Vec<usize> = result.iter().map(|w| w.column).collect();
2018 assert!(cols.contains(&1), "Should flag col 1 (test_image): {cols:?}");
2019 assert!(cols.contains(&29), "Should flag col 29 (just_test): {cols:?}");
2020 }
2021
2022 #[test]
2023 fn test_frontmatter_yaml_keys_not_flagged() {
2024 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2027
2028 let content = "---\ntitle: Heading\ntest: Some Test value\n---\n\nTest\n";
2029 let ctx = create_context(content);
2030 let result = rule.check(&ctx).unwrap();
2031
2032 assert!(
2036 result.is_empty(),
2037 "Should not flag YAML keys or correctly capitalized values: {result:?}"
2038 );
2039 }
2040
2041 #[test]
2042 fn test_frontmatter_yaml_values_flagged() {
2043 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2045
2046 let content = "---\ntitle: Heading\nkey: a test value\n---\n\nTest\n";
2047 let ctx = create_context(content);
2048 let result = rule.check(&ctx).unwrap();
2049
2050 assert_eq!(result.len(), 1, "Should flag 'test' in YAML value: {result:?}");
2052 assert_eq!(result[0].line, 3);
2053 assert_eq!(result[0].column, 8); }
2055
2056 #[test]
2057 fn test_frontmatter_key_matches_name_not_flagged() {
2058 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2060
2061 let content = "---\ntest: other value\n---\n\nBody text\n";
2062 let ctx = create_context(content);
2063 let result = rule.check(&ctx).unwrap();
2064
2065 assert!(
2066 result.is_empty(),
2067 "Should not flag YAML key that matches configured name: {result:?}"
2068 );
2069 }
2070
2071 #[test]
2072 fn test_frontmatter_empty_value_not_flagged() {
2073 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2075
2076 let content = "---\ntest:\ntest: \n---\n\nBody text\n";
2077 let ctx = create_context(content);
2078 let result = rule.check(&ctx).unwrap();
2079
2080 assert!(
2081 result.is_empty(),
2082 "Should not flag YAML keys with empty values: {result:?}"
2083 );
2084 }
2085
2086 #[test]
2087 fn test_frontmatter_nested_yaml_key_not_flagged() {
2088 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2090
2091 let content = "---\nparent:\n test: nested value\n---\n\nBody text\n";
2092 let ctx = create_context(content);
2093 let result = rule.check(&ctx).unwrap();
2094
2095 assert!(result.is_empty(), "Should not flag nested YAML keys: {result:?}");
2097 }
2098
2099 #[test]
2100 fn test_frontmatter_list_items_checked() {
2101 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2103
2104 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2105 let ctx = create_context(content);
2106 let result = rule.check(&ctx).unwrap();
2107
2108 assert_eq!(result.len(), 1, "Should flag 'test' in YAML list item: {result:?}");
2110 assert_eq!(result[0].line, 3);
2111 }
2112
2113 #[test]
2114 fn test_frontmatter_value_with_multiple_colons() {
2115 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2117
2118 let content = "---\ntest: description: a test thing\n---\n\nBody text\n";
2119 let ctx = create_context(content);
2120 let result = rule.check(&ctx).unwrap();
2121
2122 assert_eq!(
2125 result.len(),
2126 1,
2127 "Should flag 'test' in value after first colon: {result:?}"
2128 );
2129 assert_eq!(result[0].line, 2);
2130 assert!(result[0].column > 6, "Violation column should be in value portion");
2131 }
2132
2133 #[test]
2134 fn test_frontmatter_does_not_affect_body() {
2135 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2137
2138 let content = "---\ntitle: Heading\n---\n\ntest should be flagged here\n";
2139 let ctx = create_context(content);
2140 let result = rule.check(&ctx).unwrap();
2141
2142 assert_eq!(result.len(), 1, "Should flag 'test' in body text: {result:?}");
2143 assert_eq!(result[0].line, 5);
2144 }
2145
2146 #[test]
2147 fn test_frontmatter_fix_corrects_values_preserves_keys() {
2148 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2150
2151 let content = "---\ntest: a test value\n---\n\ntest here\n";
2152 let ctx = create_context(content);
2153 let fixed = rule.fix(&ctx).unwrap();
2154
2155 assert_eq!(fixed, "---\ntest: a Test value\n---\n\nTest here\n");
2157 }
2158
2159 #[test]
2160 fn test_frontmatter_multiword_value_flagged() {
2161 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2163
2164 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2165 let ctx = create_context(content);
2166 let result = rule.check(&ctx).unwrap();
2167
2168 assert_eq!(result.len(), 2, "Should flag both names in YAML value: {result:?}");
2169 assert!(result.iter().all(|w| w.line == 2));
2170 }
2171
2172 #[test]
2173 fn test_frontmatter_yaml_comments_not_checked() {
2174 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2176
2177 let content = "---\n# test comment\ntitle: Heading\n---\n\nBody text\n";
2178 let ctx = create_context(content);
2179 let result = rule.check(&ctx).unwrap();
2180
2181 assert!(result.is_empty(), "Should not flag names in YAML comments: {result:?}");
2182 }
2183
2184 #[test]
2185 fn test_frontmatter_delimiters_not_checked() {
2186 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2188
2189 let content = "---\ntitle: Heading\n---\n\ntest here\n";
2190 let ctx = create_context(content);
2191 let result = rule.check(&ctx).unwrap();
2192
2193 assert_eq!(result.len(), 1, "Should only flag body text: {result:?}");
2195 assert_eq!(result[0].line, 5);
2196 }
2197
2198 #[test]
2199 fn test_frontmatter_continuation_lines_checked() {
2200 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2202
2203 let content = "---\ndescription: >\n a test value\n continued here\n---\n\nBody\n";
2204 let ctx = create_context(content);
2205 let result = rule.check(&ctx).unwrap();
2206
2207 assert_eq!(result.len(), 1, "Should flag 'test' in continuation line: {result:?}");
2209 assert_eq!(result[0].line, 3);
2210 }
2211
2212 #[test]
2213 fn test_frontmatter_quoted_values_checked() {
2214 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2216
2217 let content = "---\ntitle: \"a test title\"\n---\n\nBody\n";
2218 let ctx = create_context(content);
2219 let result = rule.check(&ctx).unwrap();
2220
2221 assert_eq!(result.len(), 1, "Should flag 'test' in quoted YAML value: {result:?}");
2222 assert_eq!(result[0].line, 2);
2223 }
2224
2225 #[test]
2226 fn test_frontmatter_single_quoted_values_checked() {
2227 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2229
2230 let content = "---\ntitle: 'a test title'\n---\n\nBody\n";
2231 let ctx = create_context(content);
2232 let result = rule.check(&ctx).unwrap();
2233
2234 assert_eq!(
2235 result.len(),
2236 1,
2237 "Should flag 'test' in single-quoted YAML value: {result:?}"
2238 );
2239 assert_eq!(result[0].line, 2);
2240 }
2241
2242 #[test]
2243 fn test_frontmatter_fix_multiword_values() {
2244 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2246
2247 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2248 let ctx = create_context(content);
2249 let fixed = rule.fix(&ctx).unwrap();
2250
2251 assert_eq!(
2252 fixed,
2253 "---\ndescription: Learn JavaScript and TypeScript\n---\n\nBody\n"
2254 );
2255 }
2256
2257 #[test]
2258 fn test_frontmatter_fix_preserves_yaml_structure() {
2259 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2261
2262 let content = "---\ntags:\n - test\n - other\ntitle: a test doc\n---\n\ntest body\n";
2263 let ctx = create_context(content);
2264 let fixed = rule.fix(&ctx).unwrap();
2265
2266 assert_eq!(
2267 fixed,
2268 "---\ntags:\n - Test\n - other\ntitle: a Test doc\n---\n\nTest body\n"
2269 );
2270 }
2271
2272 #[test]
2273 fn test_frontmatter_toml_delimiters_not_checked() {
2274 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2276
2277 let content = "+++\ntitle = \"a test title\"\n+++\n\ntest body\n";
2278 let ctx = create_context(content);
2279 let result = rule.check(&ctx).unwrap();
2280
2281 assert_eq!(result.len(), 2, "Should flag TOML value and body: {result:?}");
2285 let fm_violations: Vec<_> = result.iter().filter(|w| w.line == 2).collect();
2286 assert_eq!(fm_violations.len(), 1, "Should flag 'test' in TOML value: {result:?}");
2287 let body_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2288 assert_eq!(body_violations.len(), 1, "Should flag body 'test': {result:?}");
2289 }
2290
2291 #[test]
2292 fn test_frontmatter_toml_key_not_flagged() {
2293 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2295
2296 let content = "+++\ntest = \"other value\"\n+++\n\nBody text\n";
2297 let ctx = create_context(content);
2298 let result = rule.check(&ctx).unwrap();
2299
2300 assert!(
2301 result.is_empty(),
2302 "Should not flag TOML key that matches configured name: {result:?}"
2303 );
2304 }
2305
2306 #[test]
2307 fn test_frontmatter_toml_fix_preserves_keys() {
2308 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2310
2311 let content = "+++\ntest = \"a test value\"\n+++\n\ntest here\n";
2312 let ctx = create_context(content);
2313 let fixed = rule.fix(&ctx).unwrap();
2314
2315 assert_eq!(fixed, "+++\ntest = \"a Test value\"\n+++\n\nTest here\n");
2317 }
2318
2319 #[test]
2320 fn test_frontmatter_list_item_mapping_key_not_flagged() {
2321 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2324
2325 let content = "---\nitems:\n - test: nested value\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 key in list-item mapping: {result:?}"
2332 );
2333 }
2334
2335 #[test]
2336 fn test_frontmatter_list_item_mapping_value_flagged() {
2337 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2339
2340 let content = "---\nitems:\n - key: a test value\n---\n\nBody text\n";
2341 let ctx = create_context(content);
2342 let result = rule.check(&ctx).unwrap();
2343
2344 assert_eq!(
2345 result.len(),
2346 1,
2347 "Should flag 'test' in list-item mapping value: {result:?}"
2348 );
2349 assert_eq!(result[0].line, 3);
2350 }
2351
2352 #[test]
2353 fn test_frontmatter_bare_list_item_still_flagged() {
2354 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2356
2357 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2358 let ctx = create_context(content);
2359 let result = rule.check(&ctx).unwrap();
2360
2361 assert_eq!(result.len(), 1, "Should flag 'test' in bare list item: {result:?}");
2362 assert_eq!(result[0].line, 3);
2363 }
2364
2365 #[test]
2366 fn test_frontmatter_flow_mapping_not_flagged() {
2367 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2370
2371 let content = "---\nflow_map: {test: value, other: test}\n---\n\nBody text\n";
2372 let ctx = create_context(content);
2373 let result = rule.check(&ctx).unwrap();
2374
2375 assert!(
2376 result.is_empty(),
2377 "Should not flag names inside flow mappings: {result:?}"
2378 );
2379 }
2380
2381 #[test]
2382 fn test_frontmatter_flow_sequence_not_flagged() {
2383 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2385
2386 let content = "---\nitems: [test, other, test]\n---\n\nBody text\n";
2387 let ctx = create_context(content);
2388 let result = rule.check(&ctx).unwrap();
2389
2390 assert!(
2391 result.is_empty(),
2392 "Should not flag names inside flow sequences: {result:?}"
2393 );
2394 }
2395
2396 #[test]
2397 fn test_frontmatter_list_item_mapping_fix_preserves_key() {
2398 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2400
2401 let content = "---\nitems:\n - test: a test value\n---\n\ntest here\n";
2402 let ctx = create_context(content);
2403 let fixed = rule.fix(&ctx).unwrap();
2404
2405 assert_eq!(fixed, "---\nitems:\n - test: a Test value\n---\n\nTest here\n");
2408 }
2409
2410 #[test]
2411 fn test_frontmatter_backtick_code_not_flagged() {
2412 let config = MD044Config {
2414 names: vec!["GoodApplication".to_string()],
2415 code_blocks: false,
2416 ..MD044Config::default()
2417 };
2418 let rule = MD044ProperNames::from_config_struct(config);
2419
2420 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2421 let ctx = create_context(content);
2422 let result = rule.check(&ctx).unwrap();
2423
2424 assert!(
2426 result.is_empty(),
2427 "Should not flag names inside backticks in frontmatter or body: {result:?}"
2428 );
2429 }
2430
2431 #[test]
2432 fn test_frontmatter_unquoted_backtick_code_not_flagged() {
2433 let config = MD044Config {
2435 names: vec!["GoodApplication".to_string()],
2436 code_blocks: false,
2437 ..MD044Config::default()
2438 };
2439 let rule = MD044ProperNames::from_config_struct(config);
2440
2441 let content = "---\ntitle: `goodapplication` CLI\n---\n\nIntroductory `goodapplication` CLI text.\n";
2442 let ctx = create_context(content);
2443 let result = rule.check(&ctx).unwrap();
2444
2445 assert!(
2446 result.is_empty(),
2447 "Should not flag names inside backticks in unquoted YAML frontmatter: {result:?}"
2448 );
2449 }
2450
2451 #[test]
2452 fn test_frontmatter_bare_name_still_flagged_with_backtick_nearby() {
2453 let config = MD044Config {
2455 names: vec!["GoodApplication".to_string()],
2456 code_blocks: false,
2457 ..MD044Config::default()
2458 };
2459 let rule = MD044ProperNames::from_config_struct(config);
2460
2461 let content = "---\ntitle: goodapplication `goodapplication` CLI\n---\n\nBody\n";
2462 let ctx = create_context(content);
2463 let result = rule.check(&ctx).unwrap();
2464
2465 assert_eq!(
2467 result.len(),
2468 1,
2469 "Should flag bare name but not backtick-wrapped name: {result:?}"
2470 );
2471 assert_eq!(result[0].line, 2);
2472 assert_eq!(result[0].column, 8); }
2474
2475 #[test]
2476 fn test_frontmatter_backtick_code_with_code_blocks_true() {
2477 let config = MD044Config {
2479 names: vec!["GoodApplication".to_string()],
2480 code_blocks: true,
2481 ..MD044Config::default()
2482 };
2483 let rule = MD044ProperNames::from_config_struct(config);
2484
2485 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nBody\n";
2486 let ctx = create_context(content);
2487 let result = rule.check(&ctx).unwrap();
2488
2489 assert_eq!(
2491 result.len(),
2492 1,
2493 "Should flag backtick-wrapped name when code_blocks=true: {result:?}"
2494 );
2495 assert_eq!(result[0].line, 2);
2496 }
2497
2498 #[test]
2499 fn test_frontmatter_fix_preserves_backtick_code() {
2500 let config = MD044Config {
2502 names: vec!["GoodApplication".to_string()],
2503 code_blocks: false,
2504 ..MD044Config::default()
2505 };
2506 let rule = MD044ProperNames::from_config_struct(config);
2507
2508 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2509 let ctx = create_context(content);
2510 let fixed = rule.fix(&ctx).unwrap();
2511
2512 assert_eq!(
2514 fixed, content,
2515 "Fix should not modify names inside backticks in frontmatter"
2516 );
2517 }
2518
2519 #[test]
2522 fn test_angle_bracket_url_in_html_comment_not_flagged() {
2523 let config = MD044Config {
2525 names: vec!["Test".to_string()],
2526 ..MD044Config::default()
2527 };
2528 let rule = MD044ProperNames::from_config_struct(config);
2529
2530 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";
2531 let ctx = create_context(content);
2532 let result = rule.check(&ctx).unwrap();
2533
2534 let line8_warnings: Vec<_> = result.iter().filter(|w| w.line == 8).collect();
2542 assert!(
2543 line8_warnings.is_empty(),
2544 "Should not flag names inside angle-bracket URLs in HTML comments: {line8_warnings:?}"
2545 );
2546 }
2547
2548 #[test]
2549 fn test_bare_url_in_html_comment_still_flagged() {
2550 let config = MD044Config {
2552 names: vec!["Test".to_string()],
2553 ..MD044Config::default()
2554 };
2555 let rule = MD044ProperNames::from_config_struct(config);
2556
2557 let content = "<!-- This is a test https://www.example.test -->\n";
2558 let ctx = create_context(content);
2559 let result = rule.check(&ctx).unwrap();
2560
2561 assert!(
2564 !result.is_empty(),
2565 "Should flag 'test' in prose text of HTML comment with bare URL"
2566 );
2567 }
2568
2569 #[test]
2570 fn test_angle_bracket_url_in_regular_markdown_not_flagged() {
2571 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2574
2575 let content = "<https://www.example.test>\n";
2576 let ctx = create_context(content);
2577 let result = rule.check(&ctx).unwrap();
2578
2579 assert!(
2580 result.is_empty(),
2581 "Should not flag names inside angle-bracket URLs in regular markdown: {result:?}"
2582 );
2583 }
2584
2585 #[test]
2586 fn test_multiple_angle_bracket_urls_in_one_comment() {
2587 let config = MD044Config {
2588 names: vec!["Test".to_string()],
2589 ..MD044Config::default()
2590 };
2591 let rule = MD044ProperNames::from_config_struct(config);
2592
2593 let content = "<!-- See <https://test.example.com> and <https://www.example.test> for details -->\n";
2594 let ctx = create_context(content);
2595 let result = rule.check(&ctx).unwrap();
2596
2597 assert!(
2599 result.is_empty(),
2600 "Should not flag names inside multiple angle-bracket URLs: {result:?}"
2601 );
2602 }
2603
2604 #[test]
2605 fn test_angle_bracket_non_url_still_flagged() {
2606 assert!(
2609 !MD044ProperNames::is_in_angle_bracket_url("<test> which is not a URL.", 1),
2610 "is_in_angle_bracket_url should return false for non-URL angle brackets"
2611 );
2612 }
2613
2614 #[test]
2615 fn test_angle_bracket_mailto_url_not_flagged() {
2616 let config = MD044Config {
2617 names: vec!["Test".to_string()],
2618 ..MD044Config::default()
2619 };
2620 let rule = MD044ProperNames::from_config_struct(config);
2621
2622 let content = "<!-- Contact <mailto:test@example.com> for help -->\n";
2623 let ctx = create_context(content);
2624 let result = rule.check(&ctx).unwrap();
2625
2626 assert!(
2627 result.is_empty(),
2628 "Should not flag names inside angle-bracket mailto URLs: {result:?}"
2629 );
2630 }
2631
2632 #[test]
2633 fn test_angle_bracket_ftp_url_not_flagged() {
2634 let config = MD044Config {
2635 names: vec!["Test".to_string()],
2636 ..MD044Config::default()
2637 };
2638 let rule = MD044ProperNames::from_config_struct(config);
2639
2640 let content = "<!-- Download from <ftp://test.example.com/file> -->\n";
2641 let ctx = create_context(content);
2642 let result = rule.check(&ctx).unwrap();
2643
2644 assert!(
2645 result.is_empty(),
2646 "Should not flag names inside angle-bracket FTP URLs: {result:?}"
2647 );
2648 }
2649
2650 #[test]
2651 fn test_angle_bracket_url_fix_preserves_url() {
2652 let config = MD044Config {
2654 names: vec!["Test".to_string()],
2655 ..MD044Config::default()
2656 };
2657 let rule = MD044ProperNames::from_config_struct(config);
2658
2659 let content = "<!-- test text <https://www.example.test> -->\n";
2660 let ctx = create_context(content);
2661 let fixed = rule.fix(&ctx).unwrap();
2662
2663 assert!(
2665 fixed.contains("<https://www.example.test>"),
2666 "Fix should preserve angle-bracket URLs: {fixed}"
2667 );
2668 assert!(
2669 fixed.contains("Test text"),
2670 "Fix should correct prose 'test' to 'Test': {fixed}"
2671 );
2672 }
2673
2674 #[test]
2675 fn test_is_in_angle_bracket_url_helper() {
2676 let line = "text <https://example.test> more text";
2678
2679 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));
2692
2693 assert!(MD044ProperNames::is_in_angle_bracket_url(
2695 "<mailto:test@example.com>",
2696 10
2697 ));
2698
2699 assert!(MD044ProperNames::is_in_angle_bracket_url(
2701 "<ftp://test.example.com>",
2702 10
2703 ));
2704 }
2705
2706 #[test]
2707 fn test_is_in_angle_bracket_url_uppercase_scheme() {
2708 assert!(MD044ProperNames::is_in_angle_bracket_url(
2710 "<HTTPS://test.example.com>",
2711 10
2712 ));
2713 assert!(MD044ProperNames::is_in_angle_bracket_url(
2714 "<Http://test.example.com>",
2715 10
2716 ));
2717 }
2718
2719 #[test]
2720 fn test_is_in_angle_bracket_url_uncommon_schemes() {
2721 assert!(MD044ProperNames::is_in_angle_bracket_url(
2723 "<ssh://test@example.com>",
2724 10
2725 ));
2726 assert!(MD044ProperNames::is_in_angle_bracket_url("<file:///test/path>", 10));
2728 assert!(MD044ProperNames::is_in_angle_bracket_url("<data:text/plain;test>", 10));
2730 }
2731
2732 #[test]
2733 fn test_is_in_angle_bracket_url_unclosed() {
2734 assert!(!MD044ProperNames::is_in_angle_bracket_url(
2736 "<https://test.example.com",
2737 10
2738 ));
2739 }
2740
2741 #[test]
2742 fn test_vale_inline_config_comments_not_flagged() {
2743 let config = MD044Config {
2744 names: vec!["Vale".to_string(), "JavaScript".to_string()],
2745 ..MD044Config::default()
2746 };
2747 let rule = MD044ProperNames::from_config_struct(config);
2748
2749 let content = "\
2750<!-- vale off -->
2751Some javascript text here.
2752<!-- vale on -->
2753<!-- vale Style.Rule = NO -->
2754More javascript text.
2755<!-- vale Style.Rule = YES -->
2756<!-- vale JavaScript.Grammar = NO -->
2757";
2758 let ctx = create_context(content);
2759 let result = rule.check(&ctx).unwrap();
2760
2761 assert_eq!(result.len(), 2, "Should only flag body lines, not Vale config comments");
2763 assert_eq!(result[0].line, 2);
2764 assert_eq!(result[1].line, 5);
2765 }
2766
2767 #[test]
2768 fn test_remark_lint_inline_config_comments_not_flagged() {
2769 let config = MD044Config {
2770 names: vec!["JavaScript".to_string()],
2771 ..MD044Config::default()
2772 };
2773 let rule = MD044ProperNames::from_config_struct(config);
2774
2775 let content = "\
2776<!-- lint disable remark-lint-some-rule -->
2777Some javascript text here.
2778<!-- lint enable remark-lint-some-rule -->
2779<!-- lint ignore remark-lint-some-rule -->
2780More javascript text.
2781";
2782 let ctx = create_context(content);
2783 let result = rule.check(&ctx).unwrap();
2784
2785 assert_eq!(
2786 result.len(),
2787 2,
2788 "Should only flag body lines, not remark-lint config comments"
2789 );
2790 assert_eq!(result[0].line, 2);
2791 assert_eq!(result[1].line, 5);
2792 }
2793
2794 #[test]
2795 fn test_fix_does_not_modify_vale_remark_lint_comments() {
2796 let config = MD044Config {
2797 names: vec!["JavaScript".to_string(), "Vale".to_string()],
2798 ..MD044Config::default()
2799 };
2800 let rule = MD044ProperNames::from_config_struct(config);
2801
2802 let content = "\
2803<!-- vale off -->
2804Some javascript text.
2805<!-- vale on -->
2806<!-- lint disable remark-lint-some-rule -->
2807More javascript text.
2808<!-- lint enable remark-lint-some-rule -->
2809";
2810 let ctx = create_context(content);
2811 let fixed = rule.fix(&ctx).unwrap();
2812
2813 assert!(fixed.contains("<!-- vale off -->"));
2815 assert!(fixed.contains("<!-- vale on -->"));
2816 assert!(fixed.contains("<!-- lint disable remark-lint-some-rule -->"));
2817 assert!(fixed.contains("<!-- lint enable remark-lint-some-rule -->"));
2818 assert!(fixed.contains("Some JavaScript text."));
2820 assert!(fixed.contains("More JavaScript text."));
2821 }
2822
2823 #[test]
2824 fn test_mixed_tool_directives_all_skipped() {
2825 let config = MD044Config {
2826 names: vec!["JavaScript".to_string(), "Vale".to_string()],
2827 ..MD044Config::default()
2828 };
2829 let rule = MD044ProperNames::from_config_struct(config);
2830
2831 let content = "\
2832<!-- rumdl-disable MD044 -->
2833Some javascript text.
2834<!-- markdownlint-disable -->
2835More javascript text.
2836<!-- vale off -->
2837Even more javascript text.
2838<!-- lint disable some-rule -->
2839Final javascript text.
2840<!-- rumdl-enable MD044 -->
2841<!-- markdownlint-enable -->
2842<!-- vale on -->
2843<!-- lint enable some-rule -->
2844";
2845 let ctx = create_context(content);
2846 let result = rule.check(&ctx).unwrap();
2847
2848 assert_eq!(
2850 result.len(),
2851 4,
2852 "Should only flag body lines, not any tool directive comments"
2853 );
2854 assert_eq!(result[0].line, 2);
2855 assert_eq!(result[1].line, 4);
2856 assert_eq!(result[2].line, 6);
2857 assert_eq!(result[3].line, 8);
2858 }
2859
2860 #[test]
2861 fn test_vale_remark_lint_edge_cases_not_matched() {
2862 let config = MD044Config {
2863 names: vec!["JavaScript".to_string(), "Vale".to_string()],
2864 ..MD044Config::default()
2865 };
2866 let rule = MD044ProperNames::from_config_struct(config);
2867
2868 let content = "\
2876<!-- vale -->
2877<!-- vale is a tool for writing -->
2878<!-- valedictorian javascript -->
2879<!-- linting javascript tips -->
2880<!-- vale javascript -->
2881<!-- lint your javascript code -->
2882";
2883 let ctx = create_context(content);
2884 let result = rule.check(&ctx).unwrap();
2885
2886 assert_eq!(
2893 result.len(),
2894 7,
2895 "Should flag proper names in non-directive HTML comments: got {result:?}"
2896 );
2897 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); }
2905
2906 #[test]
2907 fn test_vale_style_directives_skipped() {
2908 let config = MD044Config {
2909 names: vec!["JavaScript".to_string(), "Vale".to_string()],
2910 ..MD044Config::default()
2911 };
2912 let rule = MD044ProperNames::from_config_struct(config);
2913
2914 let content = "\
2916<!-- vale style = MyStyle -->
2917<!-- vale styles = Style1, Style2 -->
2918<!-- vale MyRule.Name = YES -->
2919<!-- vale MyRule.Name = NO -->
2920Some javascript text.
2921";
2922 let ctx = create_context(content);
2923 let result = rule.check(&ctx).unwrap();
2924
2925 assert_eq!(
2927 result.len(),
2928 1,
2929 "Should only flag body lines, not Vale style/rule directives: got {result:?}"
2930 );
2931 assert_eq!(result[0].line, 5);
2932 }
2933
2934 #[test]
2937 fn test_backtick_code_single_backticks() {
2938 let line = "hello `world` bye";
2939 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 7));
2941 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
2943 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 14));
2945 }
2946
2947 #[test]
2948 fn test_backtick_code_double_backticks() {
2949 let line = "a ``code`` b";
2950 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
2952 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
2954 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 11));
2956 }
2957
2958 #[test]
2959 fn test_backtick_code_unclosed() {
2960 let line = "a `code b";
2961 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
2963 }
2964
2965 #[test]
2966 fn test_backtick_code_mismatched_count() {
2967 let line = "a `code`` b";
2969 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
2972 }
2973
2974 #[test]
2975 fn test_backtick_code_multiple_spans() {
2976 let line = "`first` and `second`";
2977 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
2979 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 8));
2981 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 13));
2983 }
2984
2985 #[test]
2986 fn test_backtick_code_on_backtick_boundary() {
2987 let line = "`code`";
2988 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
2990 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 5));
2992 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
2994 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
2995 }
2996
2997 #[test]
3003 fn test_double_bracket_link_url_not_flagged() {
3004 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3005 let content = "[[rumdl]](https://github.com/rvben/rumdl)";
3007 let ctx = create_context(content);
3008 let result = rule.check(&ctx).unwrap();
3009 assert!(
3010 result.is_empty(),
3011 "URL inside [[text]](url) must not be flagged, got: {result:?}"
3012 );
3013 }
3014
3015 #[test]
3016 fn test_double_bracket_link_url_not_fixed() {
3017 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3018 let content = "[[rumdl]](https://github.com/rvben/rumdl)\n";
3019 let ctx = create_context(content);
3020 let fixed = rule.fix(&ctx).unwrap();
3021 assert_eq!(
3022 fixed, content,
3023 "fix() must leave the URL inside [[text]](url) unchanged"
3024 );
3025 }
3026
3027 #[test]
3028 fn test_double_bracket_link_text_still_flagged() {
3029 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3030 let content = "[[github]](https://example.com)";
3032 let ctx = create_context(content);
3033 let result = rule.check(&ctx).unwrap();
3034 assert_eq!(
3035 result.len(),
3036 1,
3037 "Incorrect name in [[text]] link text should still be flagged, got: {result:?}"
3038 );
3039 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
3040 }
3041
3042 #[test]
3043 fn test_double_bracket_link_mixed_line() {
3044 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3045 let content = "See [[rumdl]](https://github.com/rvben/rumdl) and github for more.";
3047 let ctx = create_context(content);
3048 let result = rule.check(&ctx).unwrap();
3049 assert_eq!(
3050 result.len(),
3051 1,
3052 "Only the standalone 'github' after the link should be flagged, got: {result:?}"
3053 );
3054 assert!(result[0].message.contains("'github'"));
3055 assert_eq!(
3057 result[0].column, 51,
3058 "Flagged column should be the trailing 'github', not the one in the URL"
3059 );
3060 }
3061
3062 #[test]
3063 fn test_regular_link_url_still_not_flagged() {
3064 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3066 let content = "[rumdl](https://github.com/rvben/rumdl)";
3067 let ctx = create_context(content);
3068 let result = rule.check(&ctx).unwrap();
3069 assert!(
3070 result.is_empty(),
3071 "URL inside regular [text](url) must still not be flagged, got: {result:?}"
3072 );
3073 }
3074
3075 #[test]
3076 fn test_link_like_text_in_code_span_still_flagged_when_code_blocks_enabled() {
3077 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
3082 let content = "`[foo](https://github.com/org/repo)`";
3083 let ctx = create_context(content);
3084 let result = rule.check(&ctx).unwrap();
3085 assert_eq!(
3086 result.len(),
3087 1,
3088 "Proper name inside a code span must be flagged when code-blocks=true, got: {result:?}"
3089 );
3090 assert!(result[0].message.contains("'github'"));
3091 }
3092
3093 #[test]
3094 fn test_malformed_link_not_treated_as_url() {
3095 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3098 let content = "See [rumdl](github repo) for details.";
3099 let ctx = create_context(content);
3100 let result = rule.check(&ctx).unwrap();
3101 assert_eq!(
3102 result.len(),
3103 1,
3104 "Name inside malformed [text](url with spaces) must still be flagged, got: {result:?}"
3105 );
3106 assert!(result[0].message.contains("'github'"));
3107 }
3108
3109 #[test]
3110 fn test_wikilink_followed_by_prose_parens_still_flagged() {
3111 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3115 let content = "[[note]](github repo)";
3116 let ctx = create_context(content);
3117 let result = rule.check(&ctx).unwrap();
3118 assert_eq!(
3119 result.len(),
3120 1,
3121 "Name inside [[wikilink]](prose with spaces) must still be flagged, got: {result:?}"
3122 );
3123 assert!(result[0].message.contains("'github'"));
3124 }
3125
3126 #[test]
3128 fn test_roundtrip_fix_then_check_basic() {
3129 let rule = MD044ProperNames::new(
3130 vec![
3131 "JavaScript".to_string(),
3132 "TypeScript".to_string(),
3133 "Node.js".to_string(),
3134 ],
3135 true,
3136 );
3137 let content = "I love javascript, typescript, and nodejs!";
3138 let ctx = create_context(content);
3139 let fixed = rule.fix(&ctx).unwrap();
3140 let ctx2 = create_context(&fixed);
3141 let warnings = rule.check(&ctx2).unwrap();
3142 assert!(
3143 warnings.is_empty(),
3144 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3145 );
3146 }
3147
3148 #[test]
3150 fn test_roundtrip_fix_then_check_multiline() {
3151 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
3152 let content = "First line with rust.\nSecond line with python.\nThird line with RUST and PYTHON.\n";
3153 let ctx = create_context(content);
3154 let fixed = rule.fix(&ctx).unwrap();
3155 let ctx2 = create_context(&fixed);
3156 let warnings = rule.check(&ctx2).unwrap();
3157 assert!(
3158 warnings.is_empty(),
3159 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3160 );
3161 }
3162
3163 #[test]
3165 fn test_roundtrip_fix_then_check_inline_config() {
3166 let config = MD044Config {
3167 names: vec!["RUMDL".to_string()],
3168 ..MD044Config::default()
3169 };
3170 let rule = MD044ProperNames::from_config_struct(config);
3171 let content =
3172 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
3173 let ctx = create_context(content);
3174 let fixed = rule.fix(&ctx).unwrap();
3175 assert!(
3177 fixed.contains("Some rumdl text.\n"),
3178 "Disabled block text should be preserved"
3179 );
3180 assert!(
3181 fixed.contains("Some RUMDL text outside."),
3182 "Outside text should be fixed"
3183 );
3184 }
3185
3186 #[test]
3188 fn test_roundtrip_fix_then_check_html_comments() {
3189 let config = MD044Config {
3190 names: vec!["JavaScript".to_string()],
3191 ..MD044Config::default()
3192 };
3193 let rule = MD044ProperNames::from_config_struct(config);
3194 let content = "# Guide\n\n<!-- javascript mentioned here -->\n\njavascript outside\n";
3195 let ctx = create_context(content);
3196 let fixed = rule.fix(&ctx).unwrap();
3197 let ctx2 = create_context(&fixed);
3198 let warnings = rule.check(&ctx2).unwrap();
3199 assert!(
3200 warnings.is_empty(),
3201 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3202 );
3203 }
3204
3205 #[test]
3207 fn test_roundtrip_no_op_when_correct() {
3208 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
3209 let content = "This uses JavaScript and TypeScript correctly.\n";
3210 let ctx = create_context(content);
3211 let fixed = rule.fix(&ctx).unwrap();
3212 assert_eq!(fixed, content, "Fix should be a no-op when content is already correct");
3213 }
3214
3215 #[test]
3218 fn test_bare_domain_link_text_not_flagged() {
3219 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3223 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3224 let ctx = create_context(content);
3225 let result = rule.check(&ctx).unwrap();
3226 assert!(
3227 result.is_empty(),
3228 "Should not flag 'github' in a bare-domain link text that matches the link URL: {result:?}"
3229 );
3230 }
3231
3232 #[test]
3233 fn test_bare_domain_link_text_not_fixed() {
3234 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3236 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3237 let ctx = create_context(content);
3238 let fixed = rule.fix(&ctx).unwrap();
3239 assert_eq!(
3240 fixed, content,
3241 "fix() must not alter bare-domain link text that matches the destination URL"
3242 );
3243 }
3244
3245 #[test]
3246 fn test_bare_domain_link_text_with_path_not_flagged() {
3247 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3249 let content = "Visit [ravencentric.github.io](https://ravencentric.github.io/projects).\n";
3250 let ctx = create_context(content);
3251 let result = rule.check(&ctx).unwrap();
3252 assert!(
3253 result.is_empty(),
3254 "Should not flag 'github' when bare-domain text is the hostname of its destination URL: {result:?}"
3255 );
3256 }
3257
3258 #[test]
3259 fn test_bare_domain_link_text_full_path_not_flagged() {
3260 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3262 let content = "See [ravencentric.github.io/blog](https://ravencentric.github.io/blog).\n";
3263 let ctx = create_context(content);
3264 let result = rule.check(&ctx).unwrap();
3265 assert!(
3266 result.is_empty(),
3267 "Should not flag 'github' when link text is the full URL path without scheme: {result:?}"
3268 );
3269 }
3270
3271 #[test]
3272 fn test_github_product_name_in_link_text_still_flagged() {
3273 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3276 let content = "Hosted on [github pages](https://pages.github.com).\n";
3277 let ctx = create_context(content);
3278 let result = rule.check(&ctx).unwrap();
3279 assert!(
3280 !result.is_empty(),
3281 "Should still flag 'github' in descriptive link text that does not match the destination URL"
3282 );
3283 }
3284
3285 #[test]
3286 fn test_protocol_relative_bare_domain_link_text_not_flagged() {
3287 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3289 let content = "See [github.io](//github.io).\n";
3290 let ctx = create_context(content);
3291 let result = rule.check(&ctx).unwrap();
3292 assert!(
3293 result.is_empty(),
3294 "Should not flag 'github' in bare-domain text matching a protocol-relative destination: {result:?}"
3295 );
3296 }
3297
3298 #[test]
3299 fn test_dotted_wikilink_target_still_flagged() {
3300 let rule = MD044ProperNames::new(vec!["Node.js".to_string()], false);
3305 let content = "See [[node.js]] for details.\n";
3306 let ctx = create_context(content);
3307 let result = rule.check(&ctx).unwrap();
3308 assert!(
3309 !result.is_empty(),
3310 "Should flag 'node.js' in a dotted WikiLink target: {result:?}"
3311 );
3312 }
3313
3314 #[test]
3315 fn test_bare_domain_link_text_case_insensitive_url() {
3316 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3319 let content = "See [github.io](HTTPS://github.io).\n";
3320 let ctx = create_context(content);
3321 let result = rule.check(&ctx).unwrap();
3322 assert!(
3323 result.is_empty(),
3324 "Should not flag bare-domain text when destination URL has an uppercase scheme: {result:?}"
3325 );
3326 }
3327}