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 std::collections::{HashMap, HashSet};
6use std::sync::{Arc, Mutex};
7
8mod md044_config;
9pub(super) use md044_config::MD044Config;
10
11type WarningPosition = (usize, usize, String); fn is_inline_config_comment(trimmed: &str) -> bool {
70 trimmed.starts_with("<!-- rumdl-")
71 || trimmed.starts_with("<!-- markdownlint-")
72 || trimmed.starts_with("<!-- vale off")
73 || trimmed.starts_with("<!-- vale on")
74 || (trimmed.starts_with("<!-- vale ") && trimmed.contains(" = "))
75 || trimmed.starts_with("<!-- vale style")
76 || trimmed.starts_with("<!-- lint disable ")
77 || trimmed.starts_with("<!-- lint enable ")
78 || trimmed.starts_with("<!-- lint ignore ")
79}
80
81#[derive(Clone)]
82pub struct MD044ProperNames {
83 config: MD044Config,
84 combined_pattern: Option<String>,
86 name_variants: Vec<String>,
88 content_cache: Arc<Mutex<HashMap<u64, Vec<WarningPosition>>>>,
90}
91
92impl MD044ProperNames {
93 pub fn new(names: Vec<String>, code_blocks: bool) -> Self {
94 let config = MD044Config {
95 names,
96 code_blocks,
97 html_elements: true, html_comments: true, };
100 let combined_pattern = Self::create_combined_pattern(&config);
101 let name_variants = Self::build_name_variants(&config);
102 Self {
103 config,
104 combined_pattern,
105 name_variants,
106 content_cache: Arc::new(Mutex::new(HashMap::new())),
107 }
108 }
109
110 fn ascii_normalize(s: &str) -> String {
112 s.replace(['é', 'è', 'ê', 'ë'], "e")
113 .replace(['à', 'á', 'â', 'ä', 'ã', 'å'], "a")
114 .replace(['ï', 'î', 'í', 'ì'], "i")
115 .replace(['ü', 'ú', 'ù', 'û'], "u")
116 .replace(['ö', 'ó', 'ò', 'ô', 'õ'], "o")
117 .replace('ñ', "n")
118 .replace('ç', "c")
119 }
120
121 pub fn from_config_struct(config: MD044Config) -> Self {
122 let combined_pattern = Self::create_combined_pattern(&config);
123 let name_variants = Self::build_name_variants(&config);
124 Self {
125 config,
126 combined_pattern,
127 name_variants,
128 content_cache: Arc::new(Mutex::new(HashMap::new())),
129 }
130 }
131
132 fn create_combined_pattern(config: &MD044Config) -> Option<String> {
134 if config.names.is_empty() {
135 return None;
136 }
137
138 let mut patterns: Vec<String> = config
140 .names
141 .iter()
142 .flat_map(|name| {
143 let mut variations = vec![];
144 let lower_name = name.to_lowercase();
145
146 variations.push(escape_regex(&lower_name));
148
149 let lower_name_no_dots = lower_name.replace('.', "");
151 if lower_name != lower_name_no_dots {
152 variations.push(escape_regex(&lower_name_no_dots));
153 }
154
155 let ascii_normalized = Self::ascii_normalize(&lower_name);
157
158 if ascii_normalized != lower_name {
159 variations.push(escape_regex(&ascii_normalized));
160
161 let ascii_no_dots = ascii_normalized.replace('.', "");
163 if ascii_normalized != ascii_no_dots {
164 variations.push(escape_regex(&ascii_no_dots));
165 }
166 }
167
168 variations
169 })
170 .collect();
171
172 patterns.sort_by_key(|b| std::cmp::Reverse(b.len()));
174
175 Some(format!(r"(?i)({})", patterns.join("|")))
178 }
179
180 fn build_name_variants(config: &MD044Config) -> Vec<String> {
181 let mut variants = HashSet::new();
182 for name in &config.names {
183 let lower_name = name.to_lowercase();
184 variants.insert(lower_name.clone());
185
186 let lower_no_dots = lower_name.replace('.', "");
187 if lower_name != lower_no_dots {
188 variants.insert(lower_no_dots);
189 }
190
191 let ascii_normalized = Self::ascii_normalize(&lower_name);
192 if ascii_normalized != lower_name {
193 variants.insert(ascii_normalized.clone());
194
195 let ascii_no_dots = ascii_normalized.replace('.', "");
196 if ascii_normalized != ascii_no_dots {
197 variants.insert(ascii_no_dots);
198 }
199 }
200 }
201
202 variants.into_iter().collect()
203 }
204
205 fn find_name_violations(
208 &self,
209 content: &str,
210 ctx: &crate::lint_context::LintContext,
211 content_lower: &str,
212 ) -> Vec<WarningPosition> {
213 if self.config.names.is_empty() || content.is_empty() || self.combined_pattern.is_none() {
215 return Vec::new();
216 }
217
218 let has_potential_matches = self.name_variants.iter().any(|name| content_lower.contains(name));
220
221 if !has_potential_matches {
222 return Vec::new();
223 }
224
225 let hash = fast_hash(content);
227 {
228 if let Ok(cache) = self.content_cache.lock()
230 && let Some(cached) = cache.get(&hash)
231 {
232 return cached.clone();
233 }
234 }
235
236 let mut violations = Vec::new();
237
238 let combined_regex = match &self.combined_pattern {
240 Some(pattern) => match get_cached_regex(pattern) {
241 Ok(regex) => regex,
242 Err(_) => return Vec::new(),
243 },
244 None => return Vec::new(),
245 };
246
247 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
249 let line_num = line_idx + 1;
250 let line = line_info.content(ctx.content);
251
252 let trimmed = line.trim_start();
254 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
255 continue;
256 }
257
258 if !self.config.code_blocks && line_info.in_code_block {
260 continue;
261 }
262
263 if !self.config.html_elements && line_info.in_html_block {
265 continue;
266 }
267
268 if !self.config.html_comments && line_info.in_html_comment {
270 continue;
271 }
272
273 if line_info.in_jsx_expression || line_info.in_mdx_comment {
275 continue;
276 }
277
278 if line_info.in_obsidian_comment {
280 continue;
281 }
282
283 let fm_value_offset = if line_info.in_front_matter {
286 Self::frontmatter_value_offset(line)
287 } else {
288 0
289 };
290 if fm_value_offset == usize::MAX {
291 continue;
292 }
293
294 if is_inline_config_comment(trimmed) {
296 continue;
297 }
298
299 let line_lower = line.to_lowercase();
301 let has_line_matches = self.name_variants.iter().any(|name| line_lower.contains(name));
302
303 if !has_line_matches {
304 continue;
305 }
306
307 for cap in combined_regex.find_iter(line) {
309 let found_name = &line[cap.start()..cap.end()];
310
311 let start_pos = cap.start();
313 let end_pos = cap.end();
314
315 if start_pos < fm_value_offset {
317 continue;
318 }
319
320 let byte_pos = line_info.byte_offset + start_pos;
322 if ctx.is_in_html_tag(byte_pos) {
323 continue;
324 }
325
326 if !Self::is_at_word_boundary(line, start_pos, true) || !Self::is_at_word_boundary(line, end_pos, false)
327 {
328 continue; }
330
331 if !self.config.code_blocks {
333 if ctx.is_in_code_block_or_span(byte_pos) {
334 continue;
335 }
336 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
340 && Self::is_in_backtick_code_in_line(line, start_pos)
341 {
342 continue;
343 }
344 }
345
346 if Self::is_in_link(ctx, byte_pos) {
348 continue;
349 }
350
351 if Self::is_in_angle_bracket_url(line, start_pos) {
355 continue;
356 }
357
358 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
362 && Self::is_in_markdown_link_url(line, start_pos)
363 {
364 continue;
365 }
366
367 if Self::is_in_wikilink_url(ctx, byte_pos) {
372 continue;
373 }
374
375 if let Some(proper_name) = self.get_proper_name_for(found_name) {
377 if found_name != proper_name {
379 violations.push((line_num, cap.start() + 1, found_name.to_string()));
380 }
381 }
382 }
383 }
384
385 if let Ok(mut cache) = self.content_cache.lock() {
387 cache.insert(hash, violations.clone());
388 }
389 violations
390 }
391
392 fn is_in_link(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
399 use pulldown_cmark::LinkType;
400
401 let link_idx = ctx.links.partition_point(|link| link.byte_offset <= byte_pos);
403 if link_idx > 0 {
404 let link = &ctx.links[link_idx - 1];
405 if byte_pos < link.byte_end {
406 let text_start = if matches!(link.link_type, LinkType::WikiLink { .. }) {
408 link.byte_offset + 2
409 } else {
410 link.byte_offset + 1
411 };
412 let text_end = text_start + link.text.len();
413
414 if byte_pos >= text_start && byte_pos < text_end {
418 let is_wikilink = matches!(link.link_type, LinkType::WikiLink { .. });
419 return Self::link_text_is_url(&link.text)
420 || (!is_wikilink && Self::link_text_matches_link_url(&link.text, &link.url));
421 }
422 return true;
424 }
425 }
426
427 let image_idx = ctx.images.partition_point(|img| img.byte_offset <= byte_pos);
429 if image_idx > 0 {
430 let image = &ctx.images[image_idx - 1];
431 if byte_pos < image.byte_end {
432 let alt_start = image.byte_offset + 2;
434 let alt_end = alt_start + image.alt_text.len();
435
436 if byte_pos >= alt_start && byte_pos < alt_end {
438 return false;
439 }
440 return true;
442 }
443 }
444
445 ctx.is_in_reference_def(byte_pos)
447 }
448
449 fn link_text_is_url(text: &str) -> bool {
451 let lower = text.trim().to_ascii_lowercase();
452 lower.starts_with("http://")
453 || lower.starts_with("https://")
454 || lower.starts_with("www.")
455 || lower.starts_with("//")
456 }
457
458 fn link_text_matches_link_url(text: &str, url: &str) -> bool {
470 let text = text.trim();
471 if !text.contains('.') {
473 return false;
474 }
475 let url_lower = url.to_ascii_lowercase();
476 let url_without_scheme = url_lower
477 .strip_prefix("https://")
478 .or_else(|| url_lower.strip_prefix("http://"))
479 .or_else(|| url_lower.strip_prefix("//"))
480 .unwrap_or(&url_lower);
481 let text_lower = text.to_ascii_lowercase();
482 if url_without_scheme == text_lower.as_str() {
484 return true;
485 }
486 url_without_scheme.len() > text_lower.len()
488 && url_without_scheme.starts_with(text_lower.as_str())
489 && matches!(
490 url_without_scheme.as_bytes().get(text_lower.len()),
491 Some(b'/') | Some(b'?') | Some(b'#')
492 )
493 }
494
495 fn is_in_angle_bracket_url(line: &str, pos: usize) -> bool {
501 let bytes = line.as_bytes();
502 let len = bytes.len();
503 let mut i = 0;
504 while i < len {
505 if bytes[i] == b'<' {
506 let after_open = i + 1;
507 if after_open < len && bytes[after_open].is_ascii_alphabetic() {
511 let mut s = after_open + 1;
512 let scheme_max = (after_open + 32).min(len);
513 while s < scheme_max
514 && (bytes[s].is_ascii_alphanumeric()
515 || bytes[s] == b'+'
516 || bytes[s] == b'-'
517 || bytes[s] == b'.')
518 {
519 s += 1;
520 }
521 if s < len && bytes[s] == b':' {
522 let mut j = s + 1;
524 let mut found_close = false;
525 while j < len {
526 match bytes[j] {
527 b'>' => {
528 found_close = true;
529 break;
530 }
531 b' ' | b'<' => break,
532 _ => j += 1,
533 }
534 }
535 if found_close && pos >= i && pos <= j {
536 return true;
537 }
538 if found_close {
539 i = j + 1;
540 continue;
541 }
542 }
543 }
544 }
545 i += 1;
546 }
547 false
548 }
549
550 fn is_in_wikilink_url(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
563 use pulldown_cmark::LinkType;
564 let content = ctx.content.as_bytes();
565
566 let end = ctx.links.partition_point(|l| l.byte_offset <= byte_pos);
569
570 for link in &ctx.links[..end] {
571 if !matches!(link.link_type, LinkType::WikiLink { .. }) {
572 continue;
573 }
574 let wiki_end = link.byte_end;
575 if wiki_end >= byte_pos || wiki_end >= content.len() || content[wiki_end] != b'(' {
577 continue;
578 }
579 let mut depth: u32 = 1;
584 let mut k = wiki_end + 1;
585 let mut valid_destination = true;
586 while k < content.len() && depth > 0 {
587 match content[k] {
588 b'\\' => {
589 k += 1; }
591 b'(' => depth += 1,
592 b')' => depth -= 1,
593 b' ' | b'\t' | b'\n' | b'\r' => {
594 valid_destination = false;
595 break;
596 }
597 _ => {}
598 }
599 k += 1;
600 }
601 if valid_destination && depth == 0 && byte_pos > wiki_end && byte_pos < k {
604 return true;
605 }
606 }
607 false
608 }
609
610 fn is_in_markdown_link_url(line: &str, pos: usize) -> bool {
620 let bytes = line.as_bytes();
621 let len = bytes.len();
622 let mut i = 0;
623
624 while i < len {
625 if bytes[i] == b'[' && (i == 0 || bytes[i - 1] != b'\\' || (i >= 2 && bytes[i - 2] == b'\\')) {
627 let mut depth: u32 = 1;
629 let mut j = i + 1;
630 while j < len && depth > 0 {
631 match bytes[j] {
632 b'\\' => {
633 j += 1; }
635 b'[' => depth += 1,
636 b']' => depth -= 1,
637 _ => {}
638 }
639 j += 1;
640 }
641
642 if depth == 0 && j < len {
644 if bytes[j] == b'(' {
645 let url_start = j;
647 let mut paren_depth: u32 = 1;
648 let mut k = j + 1;
649 while k < len && paren_depth > 0 {
650 match bytes[k] {
651 b'\\' => {
652 k += 1; }
654 b'(' => paren_depth += 1,
655 b')' => paren_depth -= 1,
656 _ => {}
657 }
658 k += 1;
659 }
660
661 if paren_depth == 0 {
662 if pos > url_start && pos < k {
663 return true;
664 }
665 i = k;
666 continue;
667 }
668 } else if bytes[j] == b'[' {
669 let ref_start = j;
671 let mut ref_depth: u32 = 1;
672 let mut k = j + 1;
673 while k < len && ref_depth > 0 {
674 match bytes[k] {
675 b'\\' => {
676 k += 1;
677 }
678 b'[' => ref_depth += 1,
679 b']' => ref_depth -= 1,
680 _ => {}
681 }
682 k += 1;
683 }
684
685 if ref_depth == 0 {
686 if pos > ref_start && pos < k {
687 return true;
688 }
689 i = k;
690 continue;
691 }
692 }
693 }
694 }
695 i += 1;
696 }
697 false
698 }
699
700 fn is_in_backtick_code_in_line(line: &str, pos: usize) -> bool {
708 let bytes = line.as_bytes();
709 let len = bytes.len();
710 let mut i = 0;
711 while i < len {
712 if bytes[i] == b'`' {
713 let open_start = i;
715 while i < len && bytes[i] == b'`' {
716 i += 1;
717 }
718 let tick_len = i - open_start;
719
720 while i < len {
722 if bytes[i] == b'`' {
723 let close_start = i;
724 while i < len && bytes[i] == b'`' {
725 i += 1;
726 }
727 if i - close_start == tick_len {
728 let content_start = open_start + tick_len;
732 let content_end = close_start;
733 if pos >= content_start && pos < content_end {
734 return true;
735 }
736 break;
738 }
739 } else {
741 i += 1;
742 }
743 }
744 } else {
745 i += 1;
746 }
747 }
748 false
749 }
750
751 fn is_word_boundary_char(c: char) -> bool {
753 !c.is_alphanumeric()
754 }
755
756 fn is_at_word_boundary(content: &str, pos: usize, is_start: bool) -> bool {
758 if is_start {
759 if pos == 0 {
760 return true;
761 }
762 match content[..pos].chars().next_back() {
763 None => true,
764 Some(c) => Self::is_word_boundary_char(c),
765 }
766 } else {
767 if pos >= content.len() {
768 return true;
769 }
770 match content[pos..].chars().next() {
771 None => true,
772 Some(c) => Self::is_word_boundary_char(c),
773 }
774 }
775 }
776
777 fn frontmatter_value_offset(line: &str) -> usize {
781 let trimmed = line.trim();
782
783 if trimmed == "---" || trimmed == "+++" || trimmed.is_empty() {
785 return usize::MAX;
786 }
787
788 if trimmed.starts_with('#') {
790 return usize::MAX;
791 }
792
793 let stripped = line.trim_start();
795 if let Some(after_dash) = stripped.strip_prefix("- ") {
796 let leading = line.len() - stripped.len();
797 if let Some(result) = Self::kv_value_offset(line, after_dash, leading + 2) {
799 return result;
800 }
801 return leading + 2;
803 }
804 if stripped == "-" {
805 return usize::MAX;
806 }
807
808 if let Some(result) = Self::kv_value_offset(line, stripped, line.len() - stripped.len()) {
810 return result;
811 }
812
813 if let Some(eq_pos) = line.find('=') {
815 let after_eq = eq_pos + 1;
816 if after_eq < line.len() && line.as_bytes()[after_eq] == b' ' {
817 let value_start = after_eq + 1;
818 let value_slice = &line[value_start..];
819 let value_trimmed = value_slice.trim();
820 if value_trimmed.is_empty() {
821 return usize::MAX;
822 }
823 if (value_trimmed.starts_with('"') && value_trimmed.ends_with('"'))
825 || (value_trimmed.starts_with('\'') && value_trimmed.ends_with('\''))
826 {
827 let quote_offset = value_slice.find(['"', '\'']).unwrap_or(0);
828 return value_start + quote_offset + 1;
829 }
830 return value_start;
831 }
832 return usize::MAX;
834 }
835
836 0
838 }
839
840 fn kv_value_offset(line: &str, content: &str, base_offset: usize) -> Option<usize> {
844 let colon_pos = content.find(':')?;
845 let abs_colon = base_offset + colon_pos;
846 let after_colon = abs_colon + 1;
847 if after_colon < line.len() && line.as_bytes()[after_colon] == b' ' {
848 let value_start = after_colon + 1;
849 let value_slice = &line[value_start..];
850 let value_trimmed = value_slice.trim();
851 if value_trimmed.is_empty() {
852 return Some(usize::MAX);
853 }
854 if value_trimmed.starts_with('{') || value_trimmed.starts_with('[') {
856 return Some(usize::MAX);
857 }
858 if (value_trimmed.starts_with('"') && value_trimmed.ends_with('"'))
860 || (value_trimmed.starts_with('\'') && value_trimmed.ends_with('\''))
861 {
862 let quote_offset = value_slice.find(['"', '\'']).unwrap_or(0);
863 return Some(value_start + quote_offset + 1);
864 }
865 return Some(value_start);
866 }
867 Some(usize::MAX)
869 }
870
871 fn get_proper_name_for(&self, found_name: &str) -> Option<String> {
873 let found_lower = found_name.to_lowercase();
874
875 for name in &self.config.names {
877 let lower_name = name.to_lowercase();
878 let lower_name_no_dots = lower_name.replace('.', "");
879
880 if found_lower == lower_name || found_lower == lower_name_no_dots {
882 return Some(name.clone());
883 }
884
885 let ascii_normalized = Self::ascii_normalize(&lower_name);
887
888 let ascii_no_dots = ascii_normalized.replace('.', "");
889
890 if found_lower == ascii_normalized || found_lower == ascii_no_dots {
891 return Some(name.clone());
892 }
893 }
894 None
895 }
896}
897
898impl Rule for MD044ProperNames {
899 fn name(&self) -> &'static str {
900 "MD044"
901 }
902
903 fn description(&self) -> &'static str {
904 "Proper names should have the correct capitalization"
905 }
906
907 fn category(&self) -> RuleCategory {
908 RuleCategory::Other
909 }
910
911 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
912 if self.config.names.is_empty() {
913 return true;
914 }
915 let content_lower = if ctx.content.is_ascii() {
917 ctx.content.to_ascii_lowercase()
918 } else {
919 ctx.content.to_lowercase()
920 };
921 !self.name_variants.iter().any(|name| content_lower.contains(name))
922 }
923
924 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
925 let content = ctx.content;
926 if content.is_empty() || self.config.names.is_empty() || self.combined_pattern.is_none() {
927 return Ok(Vec::new());
928 }
929
930 let content_lower = if content.is_ascii() {
932 content.to_ascii_lowercase()
933 } else {
934 content.to_lowercase()
935 };
936
937 let has_potential_matches = self.name_variants.iter().any(|name| content_lower.contains(name));
939
940 if !has_potential_matches {
941 return Ok(Vec::new());
942 }
943
944 let line_index = &ctx.line_index;
945 let violations = self.find_name_violations(content, ctx, &content_lower);
946
947 let warnings = violations
948 .into_iter()
949 .filter_map(|(line, column, found_name)| {
950 self.get_proper_name_for(&found_name).map(|proper_name| {
951 let line_start = line_index.get_line_start_byte(line).unwrap_or(0);
956 let byte_start = line_start + (column - 1);
957 let byte_end = byte_start + found_name.len();
958 LintWarning {
959 rule_name: Some(self.name().to_string()),
960 line,
961 column,
962 end_line: line,
963 end_column: column + found_name.len(),
964 message: format!("Proper name '{found_name}' should be '{proper_name}'"),
965 severity: Severity::Warning,
966 fix: Some(Fix::new(byte_start..byte_end, proper_name)),
967 }
968 })
969 })
970 .collect();
971
972 Ok(warnings)
973 }
974
975 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
976 if self.should_skip(ctx) {
977 return Ok(ctx.content.to_string());
978 }
979 let warnings = self.check(ctx)?;
980 if warnings.is_empty() {
981 return Ok(ctx.content.to_string());
982 }
983 let warnings =
984 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
985 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
986 .map_err(crate::rule::LintError::InvalidInput)
987 }
988
989 fn as_any(&self) -> &dyn std::any::Any {
990 self
991 }
992
993 fn default_config_section(&self) -> Option<(String, toml::Value)> {
994 let json_value = serde_json::to_value(&self.config).ok()?;
995 Some((
996 self.name().to_string(),
997 crate::rule_config_serde::json_to_toml_value(&json_value)?,
998 ))
999 }
1000
1001 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1002 where
1003 Self: Sized,
1004 {
1005 let rule_config = crate::rule_config_serde::load_rule_config::<MD044Config>(config);
1006 Box::new(Self::from_config_struct(rule_config))
1007 }
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012 use super::*;
1013 use crate::lint_context::LintContext;
1014
1015 fn create_context(content: &str) -> LintContext<'_> {
1016 LintContext::new(content, crate::config::MarkdownFlavor::Standard, None)
1017 }
1018
1019 #[test]
1020 fn test_correctly_capitalized_names() {
1021 let rule = MD044ProperNames::new(
1022 vec![
1023 "JavaScript".to_string(),
1024 "TypeScript".to_string(),
1025 "Node.js".to_string(),
1026 ],
1027 true,
1028 );
1029
1030 let content = "This document uses JavaScript, TypeScript, and Node.js correctly.";
1031 let ctx = create_context(content);
1032 let result = rule.check(&ctx).unwrap();
1033 assert!(result.is_empty(), "Should not flag correctly capitalized names");
1034 }
1035
1036 #[test]
1037 fn test_incorrectly_capitalized_names() {
1038 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1039
1040 let content = "This document uses javascript and typescript incorrectly.";
1041 let ctx = create_context(content);
1042 let result = rule.check(&ctx).unwrap();
1043
1044 assert_eq!(result.len(), 2, "Should flag two incorrect capitalizations");
1045 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1046 assert_eq!(result[0].line, 1);
1047 assert_eq!(result[0].column, 20);
1048 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1049 assert_eq!(result[1].line, 1);
1050 assert_eq!(result[1].column, 35);
1051 }
1052
1053 #[test]
1054 fn test_names_at_beginning_of_sentences() {
1055 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "Python".to_string()], true);
1056
1057 let content = "javascript is a great language. python is also popular.";
1058 let ctx = create_context(content);
1059 let result = rule.check(&ctx).unwrap();
1060
1061 assert_eq!(result.len(), 2, "Should flag names at beginning of sentences");
1062 assert_eq!(result[0].line, 1);
1063 assert_eq!(result[0].column, 1);
1064 assert_eq!(result[1].line, 1);
1065 assert_eq!(result[1].column, 33);
1066 }
1067
1068 #[test]
1069 fn test_names_in_code_blocks_checked_by_default() {
1070 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1071
1072 let content = r#"Here is some text with JavaScript.
1073
1074```javascript
1075// This javascript should be checked
1076const lang = "javascript";
1077```
1078
1079But this javascript should be flagged."#;
1080
1081 let ctx = create_context(content);
1082 let result = rule.check(&ctx).unwrap();
1083
1084 assert_eq!(result.len(), 3, "Should flag javascript inside and outside code blocks");
1085 assert_eq!(result[0].line, 4);
1086 assert_eq!(result[1].line, 5);
1087 assert_eq!(result[2].line, 8);
1088 }
1089
1090 #[test]
1091 fn test_names_in_code_blocks_ignored_when_disabled() {
1092 let rule = MD044ProperNames::new(
1093 vec!["JavaScript".to_string()],
1094 false, );
1096
1097 let content = r#"```
1098javascript in code block
1099```"#;
1100
1101 let ctx = create_context(content);
1102 let result = rule.check(&ctx).unwrap();
1103
1104 assert_eq!(
1105 result.len(),
1106 0,
1107 "Should not flag javascript in code blocks when code_blocks is false"
1108 );
1109 }
1110
1111 #[test]
1112 fn test_names_in_inline_code_checked_by_default() {
1113 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1114
1115 let content = "This is `javascript` in inline code and javascript outside.";
1116 let ctx = create_context(content);
1117 let result = rule.check(&ctx).unwrap();
1118
1119 assert_eq!(result.len(), 2, "Should flag javascript inside and outside inline code");
1121 assert_eq!(result[0].column, 10); assert_eq!(result[1].column, 41); }
1124
1125 #[test]
1126 fn test_multiple_names_in_same_line() {
1127 let rule = MD044ProperNames::new(
1128 vec!["JavaScript".to_string(), "TypeScript".to_string(), "React".to_string()],
1129 true,
1130 );
1131
1132 let content = "I use javascript, typescript, and react in my projects.";
1133 let ctx = create_context(content);
1134 let result = rule.check(&ctx).unwrap();
1135
1136 assert_eq!(result.len(), 3, "Should flag all three incorrect names");
1137 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1138 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1139 assert_eq!(result[2].message, "Proper name 'react' should be 'React'");
1140 }
1141
1142 #[test]
1143 fn test_case_sensitivity() {
1144 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1145
1146 let content = "JAVASCRIPT, Javascript, javascript, and JavaScript variations.";
1147 let ctx = create_context(content);
1148 let result = rule.check(&ctx).unwrap();
1149
1150 assert_eq!(result.len(), 3, "Should flag all incorrect case variations");
1151 assert!(result.iter().all(|w| w.message.contains("should be 'JavaScript'")));
1153 }
1154
1155 #[test]
1156 fn test_configuration_with_custom_name_list() {
1157 let config = MD044Config {
1158 names: vec!["GitHub".to_string(), "GitLab".to_string(), "DevOps".to_string()],
1159 code_blocks: true,
1160 html_elements: true,
1161 html_comments: true,
1162 };
1163 let rule = MD044ProperNames::from_config_struct(config);
1164
1165 let content = "We use github, gitlab, and devops for our workflow.";
1166 let ctx = create_context(content);
1167 let result = rule.check(&ctx).unwrap();
1168
1169 assert_eq!(result.len(), 3, "Should flag all custom names");
1170 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
1171 assert_eq!(result[1].message, "Proper name 'gitlab' should be 'GitLab'");
1172 assert_eq!(result[2].message, "Proper name 'devops' should be 'DevOps'");
1173 }
1174
1175 #[test]
1176 fn test_empty_configuration() {
1177 let rule = MD044ProperNames::new(vec![], true);
1178
1179 let content = "This has javascript and typescript but no configured names.";
1180 let ctx = create_context(content);
1181 let result = rule.check(&ctx).unwrap();
1182
1183 assert!(result.is_empty(), "Should not flag anything with empty configuration");
1184 }
1185
1186 #[test]
1187 fn test_names_with_special_characters() {
1188 let rule = MD044ProperNames::new(
1189 vec!["Node.js".to_string(), "ASP.NET".to_string(), "C++".to_string()],
1190 true,
1191 );
1192
1193 let content = "We use nodejs, asp.net, ASP.NET, and c++ in our stack.";
1194 let ctx = create_context(content);
1195 let result = rule.check(&ctx).unwrap();
1196
1197 assert_eq!(result.len(), 3, "Should handle special characters correctly");
1202
1203 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
1204 assert!(messages.contains(&"Proper name 'nodejs' should be 'Node.js'"));
1205 assert!(messages.contains(&"Proper name 'asp.net' should be 'ASP.NET'"));
1206 assert!(messages.contains(&"Proper name 'c++' should be 'C++'"));
1207 }
1208
1209 #[test]
1210 fn test_word_boundaries() {
1211 let rule = MD044ProperNames::new(vec!["Java".to_string(), "Script".to_string()], true);
1212
1213 let content = "JavaScript is not java or script, but Java and Script are separate.";
1214 let ctx = create_context(content);
1215 let result = rule.check(&ctx).unwrap();
1216
1217 assert_eq!(result.len(), 2, "Should respect word boundaries");
1219 assert!(result.iter().any(|w| w.column == 19)); assert!(result.iter().any(|w| w.column == 27)); }
1222
1223 #[test]
1224 fn test_fix_method() {
1225 let rule = MD044ProperNames::new(
1226 vec![
1227 "JavaScript".to_string(),
1228 "TypeScript".to_string(),
1229 "Node.js".to_string(),
1230 ],
1231 true,
1232 );
1233
1234 let content = "I love javascript, typescript, and nodejs!";
1235 let ctx = create_context(content);
1236 let fixed = rule.fix(&ctx).unwrap();
1237
1238 assert_eq!(fixed, "I love JavaScript, TypeScript, and Node.js!");
1239 }
1240
1241 #[test]
1242 fn test_fix_multiple_occurrences() {
1243 let rule = MD044ProperNames::new(vec!["Python".to_string()], true);
1244
1245 let content = "python is great. I use python daily. PYTHON is powerful.";
1246 let ctx = create_context(content);
1247 let fixed = rule.fix(&ctx).unwrap();
1248
1249 assert_eq!(fixed, "Python is great. I use Python daily. Python is powerful.");
1250 }
1251
1252 #[test]
1253 fn test_fix_checks_code_blocks_by_default() {
1254 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1255
1256 let content = r#"I love javascript.
1257
1258```
1259const lang = "javascript";
1260```
1261
1262More javascript here."#;
1263
1264 let ctx = create_context(content);
1265 let fixed = rule.fix(&ctx).unwrap();
1266
1267 let expected = r#"I love JavaScript.
1268
1269```
1270const lang = "JavaScript";
1271```
1272
1273More JavaScript here."#;
1274
1275 assert_eq!(fixed, expected);
1276 }
1277
1278 #[test]
1279 fn test_multiline_content() {
1280 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
1281
1282 let content = r#"First line with rust.
1283Second line with python.
1284Third line with RUST and PYTHON."#;
1285
1286 let ctx = create_context(content);
1287 let result = rule.check(&ctx).unwrap();
1288
1289 assert_eq!(result.len(), 4, "Should flag all incorrect occurrences");
1290 assert_eq!(result[0].line, 1);
1291 assert_eq!(result[1].line, 2);
1292 assert_eq!(result[2].line, 3);
1293 assert_eq!(result[3].line, 3);
1294 }
1295
1296 #[test]
1297 fn test_default_config() {
1298 let config = MD044Config::default();
1299 assert!(config.names.is_empty());
1300 assert!(!config.code_blocks);
1301 assert!(config.html_elements);
1302 assert!(config.html_comments);
1303 }
1304
1305 #[test]
1306 fn test_default_config_checks_html_comments() {
1307 let config = MD044Config {
1308 names: vec!["JavaScript".to_string()],
1309 ..MD044Config::default()
1310 };
1311 let rule = MD044ProperNames::from_config_struct(config);
1312
1313 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1314 let ctx = create_context(content);
1315 let result = rule.check(&ctx).unwrap();
1316
1317 assert_eq!(result.len(), 1, "Default config should check HTML comments");
1318 assert_eq!(result[0].line, 3);
1319 }
1320
1321 #[test]
1322 fn test_default_config_skips_code_blocks() {
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```\njavascript in code\n```\n";
1330 let ctx = create_context(content);
1331 let result = rule.check(&ctx).unwrap();
1332
1333 assert_eq!(result.len(), 0, "Default config should skip code blocks");
1334 }
1335
1336 #[test]
1337 fn test_standalone_html_comment_checked() {
1338 let config = MD044Config {
1339 names: vec!["Test".to_string()],
1340 ..MD044Config::default()
1341 };
1342 let rule = MD044ProperNames::from_config_struct(config);
1343
1344 let content = "# Heading\n\n<!-- this is a test example -->\n";
1345 let ctx = create_context(content);
1346 let result = rule.check(&ctx).unwrap();
1347
1348 assert_eq!(result.len(), 1, "Should flag proper name in standalone HTML comment");
1349 assert_eq!(result[0].line, 3);
1350 }
1351
1352 #[test]
1353 fn test_inline_config_comments_not_flagged() {
1354 let config = MD044Config {
1355 names: vec!["RUMDL".to_string()],
1356 ..MD044Config::default()
1357 };
1358 let rule = MD044ProperNames::from_config_struct(config);
1359
1360 let content = "<!-- rumdl-disable MD044 -->\nSome rumdl text here.\n<!-- rumdl-enable MD044 -->\n<!-- markdownlint-disable -->\nMore rumdl text.\n<!-- markdownlint-enable -->\n";
1364 let ctx = create_context(content);
1365 let result = rule.check(&ctx).unwrap();
1366
1367 assert_eq!(result.len(), 2, "Should only flag body lines, not config comments");
1368 assert_eq!(result[0].line, 2);
1369 assert_eq!(result[1].line, 5);
1370 }
1371
1372 #[test]
1373 fn test_html_comment_skipped_when_disabled() {
1374 let config = MD044Config {
1375 names: vec!["Test".to_string()],
1376 code_blocks: true,
1377 html_elements: true,
1378 html_comments: false,
1379 };
1380 let rule = MD044ProperNames::from_config_struct(config);
1381
1382 let content = "# Heading\n\n<!-- this is a test example -->\n\nRegular test here.\n";
1383 let ctx = create_context(content);
1384 let result = rule.check(&ctx).unwrap();
1385
1386 assert_eq!(
1387 result.len(),
1388 1,
1389 "Should only flag 'test' outside HTML comment when html_comments=false"
1390 );
1391 assert_eq!(result[0].line, 5);
1392 }
1393
1394 #[test]
1395 fn test_fix_corrects_html_comment_content() {
1396 let config = MD044Config {
1397 names: vec!["JavaScript".to_string()],
1398 ..MD044Config::default()
1399 };
1400 let rule = MD044ProperNames::from_config_struct(config);
1401
1402 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1403 let ctx = create_context(content);
1404 let fixed = rule.fix(&ctx).unwrap();
1405
1406 assert_eq!(fixed, "# Guide\n\n<!-- JavaScript mentioned here -->\n");
1407 }
1408
1409 #[test]
1410 fn test_fix_does_not_modify_inline_config_comments() {
1411 let config = MD044Config {
1412 names: vec!["RUMDL".to_string()],
1413 ..MD044Config::default()
1414 };
1415 let rule = MD044ProperNames::from_config_struct(config);
1416
1417 let content = "<!-- rumdl-disable -->\nSome rumdl text.\n<!-- rumdl-enable -->\n";
1418 let ctx = create_context(content);
1419 let fixed = rule.fix(&ctx).unwrap();
1420
1421 assert!(fixed.contains("<!-- rumdl-disable -->"));
1423 assert!(fixed.contains("<!-- rumdl-enable -->"));
1424 assert!(
1426 fixed.contains("Some rumdl text."),
1427 "Line inside rumdl-disable block should not be modified by fix()"
1428 );
1429 }
1430
1431 #[test]
1432 fn test_fix_respects_inline_disable_partial() {
1433 let config = MD044Config {
1434 names: vec!["RUMDL".to_string()],
1435 ..MD044Config::default()
1436 };
1437 let rule = MD044ProperNames::from_config_struct(config);
1438
1439 let content =
1440 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
1441 let ctx = create_context(content);
1442 let fixed = rule.fix(&ctx).unwrap();
1443
1444 assert!(
1446 fixed.contains("Some rumdl text.\n<!-- rumdl-enable"),
1447 "Line inside disable block should not be modified"
1448 );
1449 assert!(
1451 fixed.contains("Some RUMDL text outside."),
1452 "Line outside disable block should be fixed"
1453 );
1454 }
1455
1456 #[test]
1457 fn test_performance_with_many_names() {
1458 let mut names = vec![];
1459 for i in 0..50 {
1460 names.push(format!("ProperName{i}"));
1461 }
1462
1463 let rule = MD044ProperNames::new(names, true);
1464
1465 let content = "This has propername0, propername25, and propername49 incorrectly.";
1466 let ctx = create_context(content);
1467 let result = rule.check(&ctx).unwrap();
1468
1469 assert_eq!(result.len(), 3, "Should handle many configured names efficiently");
1470 }
1471
1472 #[test]
1473 fn test_large_name_count_performance() {
1474 let names = (0..1000).map(|i| format!("ProperName{i}")).collect::<Vec<_>>();
1477
1478 let rule = MD044ProperNames::new(names, true);
1479
1480 assert!(rule.combined_pattern.is_some());
1482
1483 let content = "This has propername0 and propername999 in it.";
1485 let ctx = create_context(content);
1486 let result = rule.check(&ctx).unwrap();
1487
1488 assert_eq!(result.len(), 2, "Should handle 1000 names without issues");
1490 }
1491
1492 #[test]
1493 fn test_cache_behavior() {
1494 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1495
1496 let content = "Using javascript here.";
1497 let ctx = create_context(content);
1498
1499 let result1 = rule.check(&ctx).unwrap();
1501 assert_eq!(result1.len(), 1);
1502
1503 let result2 = rule.check(&ctx).unwrap();
1505 assert_eq!(result2.len(), 1);
1506
1507 assert_eq!(result1[0].line, result2[0].line);
1509 assert_eq!(result1[0].column, result2[0].column);
1510 }
1511
1512 #[test]
1513 fn test_html_comments_not_checked_when_disabled() {
1514 let config = MD044Config {
1515 names: vec!["JavaScript".to_string()],
1516 code_blocks: true, html_elements: true, html_comments: false, };
1520 let rule = MD044ProperNames::from_config_struct(config);
1521
1522 let content = r#"Regular javascript here.
1523<!-- This javascript in HTML comment should be ignored -->
1524More javascript outside."#;
1525
1526 let ctx = create_context(content);
1527 let result = rule.check(&ctx).unwrap();
1528
1529 assert_eq!(result.len(), 2, "Should only flag javascript outside HTML comments");
1530 assert_eq!(result[0].line, 1);
1531 assert_eq!(result[1].line, 3);
1532 }
1533
1534 #[test]
1535 fn test_html_comments_checked_when_enabled() {
1536 let config = MD044Config {
1537 names: vec!["JavaScript".to_string()],
1538 code_blocks: true, html_elements: true, html_comments: true, };
1542 let rule = MD044ProperNames::from_config_struct(config);
1543
1544 let content = r#"Regular javascript here.
1545<!-- This javascript in HTML comment should be checked -->
1546More javascript outside."#;
1547
1548 let ctx = create_context(content);
1549 let result = rule.check(&ctx).unwrap();
1550
1551 assert_eq!(
1552 result.len(),
1553 3,
1554 "Should flag all javascript occurrences including in HTML comments"
1555 );
1556 }
1557
1558 #[test]
1559 fn test_multiline_html_comments() {
1560 let config = MD044Config {
1561 names: vec!["Python".to_string(), "JavaScript".to_string()],
1562 code_blocks: true, html_elements: true, html_comments: false, };
1566 let rule = MD044ProperNames::from_config_struct(config);
1567
1568 let content = r#"Regular python here.
1569<!--
1570This is a multiline comment
1571with javascript and python
1572that should be ignored
1573-->
1574More javascript outside."#;
1575
1576 let ctx = create_context(content);
1577 let result = rule.check(&ctx).unwrap();
1578
1579 assert_eq!(result.len(), 2, "Should only flag names outside HTML comments");
1580 assert_eq!(result[0].line, 1); assert_eq!(result[1].line, 7); }
1583
1584 #[test]
1585 fn test_fix_preserves_html_comments_when_disabled() {
1586 let config = MD044Config {
1587 names: vec!["JavaScript".to_string()],
1588 code_blocks: true, html_elements: true, html_comments: false, };
1592 let rule = MD044ProperNames::from_config_struct(config);
1593
1594 let content = r#"javascript here.
1595<!-- javascript in comment -->
1596More javascript."#;
1597
1598 let ctx = create_context(content);
1599 let fixed = rule.fix(&ctx).unwrap();
1600
1601 let expected = r#"JavaScript here.
1602<!-- javascript in comment -->
1603More JavaScript."#;
1604
1605 assert_eq!(
1606 fixed, expected,
1607 "Should not fix names inside HTML comments when disabled"
1608 );
1609 }
1610
1611 #[test]
1612 fn test_proper_names_in_link_text_are_flagged() {
1613 let rule = MD044ProperNames::new(
1614 vec!["JavaScript".to_string(), "Node.js".to_string(), "Python".to_string()],
1615 true,
1616 );
1617
1618 let content = r#"Check this [javascript documentation](https://javascript.info) for info.
1619
1620Visit [node.js homepage](https://nodejs.org) and [python tutorial](https://python.org).
1621
1622Real javascript should be flagged.
1623
1624Also see the [typescript guide][ts-ref] for more.
1625
1626Real python should be flagged too.
1627
1628[ts-ref]: https://typescript.org/handbook"#;
1629
1630 let ctx = create_context(content);
1631 let result = rule.check(&ctx).unwrap();
1632
1633 assert_eq!(result.len(), 5, "Expected 5 warnings: 3 in link text + 2 standalone");
1640
1641 let line_1_warnings: Vec<_> = result.iter().filter(|w| w.line == 1).collect();
1643 assert_eq!(line_1_warnings.len(), 1);
1644 assert!(
1645 line_1_warnings[0]
1646 .message
1647 .contains("'javascript' should be 'JavaScript'")
1648 );
1649
1650 let line_3_warnings: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
1651 assert_eq!(line_3_warnings.len(), 2); assert!(result.iter().any(|w| w.line == 5 && w.message.contains("'javascript'")));
1655 assert!(result.iter().any(|w| w.line == 9 && w.message.contains("'python'")));
1656 }
1657
1658 #[test]
1659 fn test_link_urls_not_flagged() {
1660 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1661
1662 let content = r#"[Link Text](https://javascript.info/guide)"#;
1664
1665 let ctx = create_context(content);
1666 let result = rule.check(&ctx).unwrap();
1667
1668 assert!(result.is_empty(), "URLs should not be checked for proper names");
1670 }
1671
1672 #[test]
1673 fn test_proper_names_in_image_alt_text_are_flagged() {
1674 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1675
1676 let content = r#"Here is a  image.
1677
1678Real javascript should be flagged."#;
1679
1680 let ctx = create_context(content);
1681 let result = rule.check(&ctx).unwrap();
1682
1683 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in alt text + 1 standalone");
1687 assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
1688 assert!(result[0].line == 1); assert!(result[1].message.contains("'javascript' should be 'JavaScript'"));
1690 assert!(result[1].line == 3); }
1692
1693 #[test]
1694 fn test_image_urls_not_flagged() {
1695 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1696
1697 let content = r#""#;
1699
1700 let ctx = create_context(content);
1701 let result = rule.check(&ctx).unwrap();
1702
1703 assert!(result.is_empty(), "Image URLs should not be checked for proper names");
1705 }
1706
1707 #[test]
1708 fn test_reference_link_text_flagged_but_definition_not() {
1709 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1710
1711 let content = r#"Check the [javascript guide][js-ref] for details.
1712
1713Real javascript should be flagged.
1714
1715[js-ref]: https://javascript.info/typescript/guide"#;
1716
1717 let ctx = create_context(content);
1718 let result = rule.check(&ctx).unwrap();
1719
1720 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in link text + 1 standalone");
1725 assert!(result.iter().any(|w| w.line == 1 && w.message.contains("'javascript'")));
1726 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
1727 }
1728
1729 #[test]
1730 fn test_reference_definitions_not_flagged() {
1731 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1732
1733 let content = r#"[js-ref]: https://javascript.info/guide"#;
1735
1736 let ctx = create_context(content);
1737 let result = rule.check(&ctx).unwrap();
1738
1739 assert!(result.is_empty(), "Reference definitions should not be checked");
1741 }
1742
1743 #[test]
1744 fn test_wikilinks_text_is_flagged() {
1745 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1746
1747 let content = r#"[[javascript]]
1749
1750Regular javascript here.
1751
1752[[JavaScript|display text]]"#;
1753
1754 let ctx = create_context(content);
1755 let result = rule.check(&ctx).unwrap();
1756
1757 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in WikiLink + 1 standalone");
1761 assert!(
1762 result
1763 .iter()
1764 .any(|w| w.line == 1 && w.column == 3 && w.message.contains("'javascript'"))
1765 );
1766 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
1767 }
1768
1769 #[test]
1770 fn test_url_link_text_not_flagged() {
1771 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1772
1773 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
1775
1776[http://github.com/org/repo](http://github.com/org/repo)
1777
1778[www.github.com/org/repo](https://www.github.com/org/repo)"#;
1779
1780 let ctx = create_context(content);
1781 let result = rule.check(&ctx).unwrap();
1782
1783 assert!(
1784 result.is_empty(),
1785 "URL-like link text should not be flagged, got: {result:?}"
1786 );
1787 }
1788
1789 #[test]
1790 fn test_url_link_text_with_leading_space_not_flagged() {
1791 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1792
1793 let content = r#"[ https://github.com/org/repo](https://github.com/org/repo)"#;
1795
1796 let ctx = create_context(content);
1797 let result = rule.check(&ctx).unwrap();
1798
1799 assert!(
1800 result.is_empty(),
1801 "URL-like link text with leading space should not be flagged, got: {result:?}"
1802 );
1803 }
1804
1805 #[test]
1806 fn test_url_link_text_uppercase_scheme_not_flagged() {
1807 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1808
1809 let content = r#"[HTTPS://GITHUB.COM/org/repo](https://github.com/org/repo)"#;
1810
1811 let ctx = create_context(content);
1812 let result = rule.check(&ctx).unwrap();
1813
1814 assert!(
1815 result.is_empty(),
1816 "URL-like link text with uppercase scheme should not be flagged, got: {result:?}"
1817 );
1818 }
1819
1820 #[test]
1821 fn test_non_url_link_text_still_flagged() {
1822 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1823
1824 let content = r#"[github.com/org/repo](https://github.com/org/repo)
1828
1829[Visit github](https://github.com/org/repo)
1830
1831[//github.com/org/repo](//github.com/org/repo)
1832
1833[ftp://github.com/org/repo](ftp://github.com/org/repo)"#;
1834
1835 let ctx = create_context(content);
1836 let result = rule.check(&ctx).unwrap();
1837
1838 assert_eq!(
1843 result.len(),
1844 1,
1845 "Only prose link text should be flagged, got: {result:?}"
1846 );
1847 assert!(
1848 result.iter().any(|w| w.line == 3),
1849 "Expected 'Visit github' on line 3 to be flagged"
1850 );
1851 }
1852
1853 #[test]
1854 fn test_url_link_text_fix_not_applied() {
1855 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1856
1857 let content = "[https://github.com/org/repo](https://github.com/org/repo)\n";
1858
1859 let ctx = create_context(content);
1860 let result = rule.fix(&ctx).unwrap();
1861
1862 assert_eq!(result, content, "Fix should not modify URL-like link text");
1863 }
1864
1865 #[test]
1866 fn test_mixed_url_and_regular_link_text() {
1867 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1868
1869 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
1871
1872Visit [github documentation](https://github.com/docs) for details.
1873
1874[www.github.com/pricing](https://www.github.com/pricing)"#;
1875
1876 let ctx = create_context(content);
1877 let result = rule.check(&ctx).unwrap();
1878
1879 assert_eq!(
1881 result.len(),
1882 1,
1883 "Only non-URL link text should be flagged, got: {result:?}"
1884 );
1885 assert_eq!(result[0].line, 3);
1886 }
1887
1888 #[test]
1889 fn test_html_attribute_values_not_flagged() {
1890 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1893 let content = "# Heading\n\ntest\n\n<img src=\"www.example.test/test_image.png\">\n";
1894 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1895 let result = rule.check(&ctx).unwrap();
1896
1897 let line5_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
1899 assert!(
1900 line5_violations.is_empty(),
1901 "Should not flag anything inside HTML tag attributes: {line5_violations:?}"
1902 );
1903
1904 let line3_violations: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
1906 assert_eq!(line3_violations.len(), 1, "Plain 'test' on line 3 should be flagged");
1907 }
1908
1909 #[test]
1910 fn test_html_text_content_still_flagged() {
1911 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1913 let content = "# Heading\n\n<a href=\"https://example.test/page\">test link</a>\n";
1914 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1915 let result = rule.check(&ctx).unwrap();
1916
1917 assert_eq!(
1920 result.len(),
1921 1,
1922 "Should flag only 'test' in anchor text, not in href: {result:?}"
1923 );
1924 assert_eq!(result[0].column, 37, "Should flag col 37 ('test link' in anchor text)");
1925 }
1926
1927 #[test]
1928 fn test_html_attribute_various_not_flagged() {
1929 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1931 let content = concat!(
1932 "# Heading\n\n",
1933 "<img src=\"test.png\" alt=\"test image\">\n",
1934 "<span class=\"test-class\" data-test=\"value\">test content</span>\n",
1935 );
1936 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1937 let result = rule.check(&ctx).unwrap();
1938
1939 assert_eq!(
1941 result.len(),
1942 1,
1943 "Should flag only 'test content' between tags: {result:?}"
1944 );
1945 assert_eq!(result[0].line, 4);
1946 }
1947
1948 #[test]
1949 fn test_plain_text_underscore_boundary_unchanged() {
1950 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1953 let content = "# Heading\n\ntest_image is here and just_test ends here\n";
1954 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1955 let result = rule.check(&ctx).unwrap();
1956
1957 assert_eq!(
1960 result.len(),
1961 2,
1962 "Should flag 'test' in both 'test_image' and 'just_test': {result:?}"
1963 );
1964 let cols: Vec<usize> = result.iter().map(|w| w.column).collect();
1965 assert!(cols.contains(&1), "Should flag col 1 (test_image): {cols:?}");
1966 assert!(cols.contains(&29), "Should flag col 29 (just_test): {cols:?}");
1967 }
1968
1969 #[test]
1970 fn test_frontmatter_yaml_keys_not_flagged() {
1971 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1974
1975 let content = "---\ntitle: Heading\ntest: Some Test value\n---\n\nTest\n";
1976 let ctx = create_context(content);
1977 let result = rule.check(&ctx).unwrap();
1978
1979 assert!(
1983 result.is_empty(),
1984 "Should not flag YAML keys or correctly capitalized values: {result:?}"
1985 );
1986 }
1987
1988 #[test]
1989 fn test_frontmatter_yaml_values_flagged() {
1990 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1992
1993 let content = "---\ntitle: Heading\nkey: a test value\n---\n\nTest\n";
1994 let ctx = create_context(content);
1995 let result = rule.check(&ctx).unwrap();
1996
1997 assert_eq!(result.len(), 1, "Should flag 'test' in YAML value: {result:?}");
1999 assert_eq!(result[0].line, 3);
2000 assert_eq!(result[0].column, 8); }
2002
2003 #[test]
2004 fn test_frontmatter_key_matches_name_not_flagged() {
2005 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2007
2008 let content = "---\ntest: other value\n---\n\nBody text\n";
2009 let ctx = create_context(content);
2010 let result = rule.check(&ctx).unwrap();
2011
2012 assert!(
2013 result.is_empty(),
2014 "Should not flag YAML key that matches configured name: {result:?}"
2015 );
2016 }
2017
2018 #[test]
2019 fn test_frontmatter_empty_value_not_flagged() {
2020 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2022
2023 let content = "---\ntest:\ntest: \n---\n\nBody text\n";
2024 let ctx = create_context(content);
2025 let result = rule.check(&ctx).unwrap();
2026
2027 assert!(
2028 result.is_empty(),
2029 "Should not flag YAML keys with empty values: {result:?}"
2030 );
2031 }
2032
2033 #[test]
2034 fn test_frontmatter_nested_yaml_key_not_flagged() {
2035 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2037
2038 let content = "---\nparent:\n test: nested value\n---\n\nBody text\n";
2039 let ctx = create_context(content);
2040 let result = rule.check(&ctx).unwrap();
2041
2042 assert!(result.is_empty(), "Should not flag nested YAML keys: {result:?}");
2044 }
2045
2046 #[test]
2047 fn test_frontmatter_list_items_checked() {
2048 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2050
2051 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2052 let ctx = create_context(content);
2053 let result = rule.check(&ctx).unwrap();
2054
2055 assert_eq!(result.len(), 1, "Should flag 'test' in YAML list item: {result:?}");
2057 assert_eq!(result[0].line, 3);
2058 }
2059
2060 #[test]
2061 fn test_frontmatter_value_with_multiple_colons() {
2062 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2064
2065 let content = "---\ntest: description: a test thing\n---\n\nBody text\n";
2066 let ctx = create_context(content);
2067 let result = rule.check(&ctx).unwrap();
2068
2069 assert_eq!(
2072 result.len(),
2073 1,
2074 "Should flag 'test' in value after first colon: {result:?}"
2075 );
2076 assert_eq!(result[0].line, 2);
2077 assert!(result[0].column > 6, "Violation column should be in value portion");
2078 }
2079
2080 #[test]
2081 fn test_frontmatter_does_not_affect_body() {
2082 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2084
2085 let content = "---\ntitle: Heading\n---\n\ntest should be flagged here\n";
2086 let ctx = create_context(content);
2087 let result = rule.check(&ctx).unwrap();
2088
2089 assert_eq!(result.len(), 1, "Should flag 'test' in body text: {result:?}");
2090 assert_eq!(result[0].line, 5);
2091 }
2092
2093 #[test]
2094 fn test_frontmatter_fix_corrects_values_preserves_keys() {
2095 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2097
2098 let content = "---\ntest: a test value\n---\n\ntest here\n";
2099 let ctx = create_context(content);
2100 let fixed = rule.fix(&ctx).unwrap();
2101
2102 assert_eq!(fixed, "---\ntest: a Test value\n---\n\nTest here\n");
2104 }
2105
2106 #[test]
2107 fn test_frontmatter_multiword_value_flagged() {
2108 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2110
2111 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2112 let ctx = create_context(content);
2113 let result = rule.check(&ctx).unwrap();
2114
2115 assert_eq!(result.len(), 2, "Should flag both names in YAML value: {result:?}");
2116 assert!(result.iter().all(|w| w.line == 2));
2117 }
2118
2119 #[test]
2120 fn test_frontmatter_yaml_comments_not_checked() {
2121 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2123
2124 let content = "---\n# test comment\ntitle: Heading\n---\n\nBody text\n";
2125 let ctx = create_context(content);
2126 let result = rule.check(&ctx).unwrap();
2127
2128 assert!(result.is_empty(), "Should not flag names in YAML comments: {result:?}");
2129 }
2130
2131 #[test]
2132 fn test_frontmatter_delimiters_not_checked() {
2133 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2135
2136 let content = "---\ntitle: Heading\n---\n\ntest here\n";
2137 let ctx = create_context(content);
2138 let result = rule.check(&ctx).unwrap();
2139
2140 assert_eq!(result.len(), 1, "Should only flag body text: {result:?}");
2142 assert_eq!(result[0].line, 5);
2143 }
2144
2145 #[test]
2146 fn test_frontmatter_continuation_lines_checked() {
2147 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2149
2150 let content = "---\ndescription: >\n a test value\n continued here\n---\n\nBody\n";
2151 let ctx = create_context(content);
2152 let result = rule.check(&ctx).unwrap();
2153
2154 assert_eq!(result.len(), 1, "Should flag 'test' in continuation line: {result:?}");
2156 assert_eq!(result[0].line, 3);
2157 }
2158
2159 #[test]
2160 fn test_frontmatter_quoted_values_checked() {
2161 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2163
2164 let content = "---\ntitle: \"a test title\"\n---\n\nBody\n";
2165 let ctx = create_context(content);
2166 let result = rule.check(&ctx).unwrap();
2167
2168 assert_eq!(result.len(), 1, "Should flag 'test' in quoted YAML value: {result:?}");
2169 assert_eq!(result[0].line, 2);
2170 }
2171
2172 #[test]
2173 fn test_frontmatter_single_quoted_values_checked() {
2174 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2176
2177 let content = "---\ntitle: 'a test title'\n---\n\nBody\n";
2178 let ctx = create_context(content);
2179 let result = rule.check(&ctx).unwrap();
2180
2181 assert_eq!(
2182 result.len(),
2183 1,
2184 "Should flag 'test' in single-quoted YAML value: {result:?}"
2185 );
2186 assert_eq!(result[0].line, 2);
2187 }
2188
2189 #[test]
2190 fn test_frontmatter_fix_multiword_values() {
2191 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2193
2194 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2195 let ctx = create_context(content);
2196 let fixed = rule.fix(&ctx).unwrap();
2197
2198 assert_eq!(
2199 fixed,
2200 "---\ndescription: Learn JavaScript and TypeScript\n---\n\nBody\n"
2201 );
2202 }
2203
2204 #[test]
2205 fn test_frontmatter_fix_preserves_yaml_structure() {
2206 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2208
2209 let content = "---\ntags:\n - test\n - other\ntitle: a test doc\n---\n\ntest body\n";
2210 let ctx = create_context(content);
2211 let fixed = rule.fix(&ctx).unwrap();
2212
2213 assert_eq!(
2214 fixed,
2215 "---\ntags:\n - Test\n - other\ntitle: a Test doc\n---\n\nTest body\n"
2216 );
2217 }
2218
2219 #[test]
2220 fn test_frontmatter_toml_delimiters_not_checked() {
2221 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2223
2224 let content = "+++\ntitle = \"a test title\"\n+++\n\ntest body\n";
2225 let ctx = create_context(content);
2226 let result = rule.check(&ctx).unwrap();
2227
2228 assert_eq!(result.len(), 2, "Should flag TOML value and body: {result:?}");
2232 let fm_violations: Vec<_> = result.iter().filter(|w| w.line == 2).collect();
2233 assert_eq!(fm_violations.len(), 1, "Should flag 'test' in TOML value: {result:?}");
2234 let body_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2235 assert_eq!(body_violations.len(), 1, "Should flag body 'test': {result:?}");
2236 }
2237
2238 #[test]
2239 fn test_frontmatter_toml_key_not_flagged() {
2240 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2242
2243 let content = "+++\ntest = \"other value\"\n+++\n\nBody text\n";
2244 let ctx = create_context(content);
2245 let result = rule.check(&ctx).unwrap();
2246
2247 assert!(
2248 result.is_empty(),
2249 "Should not flag TOML key that matches configured name: {result:?}"
2250 );
2251 }
2252
2253 #[test]
2254 fn test_frontmatter_toml_fix_preserves_keys() {
2255 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2257
2258 let content = "+++\ntest = \"a test value\"\n+++\n\ntest here\n";
2259 let ctx = create_context(content);
2260 let fixed = rule.fix(&ctx).unwrap();
2261
2262 assert_eq!(fixed, "+++\ntest = \"a Test value\"\n+++\n\nTest here\n");
2264 }
2265
2266 #[test]
2267 fn test_frontmatter_list_item_mapping_key_not_flagged() {
2268 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2271
2272 let content = "---\nitems:\n - test: nested value\n---\n\nBody text\n";
2273 let ctx = create_context(content);
2274 let result = rule.check(&ctx).unwrap();
2275
2276 assert!(
2277 result.is_empty(),
2278 "Should not flag YAML key in list-item mapping: {result:?}"
2279 );
2280 }
2281
2282 #[test]
2283 fn test_frontmatter_list_item_mapping_value_flagged() {
2284 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2286
2287 let content = "---\nitems:\n - key: a test value\n---\n\nBody text\n";
2288 let ctx = create_context(content);
2289 let result = rule.check(&ctx).unwrap();
2290
2291 assert_eq!(
2292 result.len(),
2293 1,
2294 "Should flag 'test' in list-item mapping value: {result:?}"
2295 );
2296 assert_eq!(result[0].line, 3);
2297 }
2298
2299 #[test]
2300 fn test_frontmatter_bare_list_item_still_flagged() {
2301 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2303
2304 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2305 let ctx = create_context(content);
2306 let result = rule.check(&ctx).unwrap();
2307
2308 assert_eq!(result.len(), 1, "Should flag 'test' in bare list item: {result:?}");
2309 assert_eq!(result[0].line, 3);
2310 }
2311
2312 #[test]
2313 fn test_frontmatter_flow_mapping_not_flagged() {
2314 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2317
2318 let content = "---\nflow_map: {test: value, other: test}\n---\n\nBody text\n";
2319 let ctx = create_context(content);
2320 let result = rule.check(&ctx).unwrap();
2321
2322 assert!(
2323 result.is_empty(),
2324 "Should not flag names inside flow mappings: {result:?}"
2325 );
2326 }
2327
2328 #[test]
2329 fn test_frontmatter_flow_sequence_not_flagged() {
2330 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2332
2333 let content = "---\nitems: [test, other, test]\n---\n\nBody text\n";
2334 let ctx = create_context(content);
2335 let result = rule.check(&ctx).unwrap();
2336
2337 assert!(
2338 result.is_empty(),
2339 "Should not flag names inside flow sequences: {result:?}"
2340 );
2341 }
2342
2343 #[test]
2344 fn test_frontmatter_list_item_mapping_fix_preserves_key() {
2345 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2347
2348 let content = "---\nitems:\n - test: a test value\n---\n\ntest here\n";
2349 let ctx = create_context(content);
2350 let fixed = rule.fix(&ctx).unwrap();
2351
2352 assert_eq!(fixed, "---\nitems:\n - test: a Test value\n---\n\nTest here\n");
2355 }
2356
2357 #[test]
2358 fn test_frontmatter_backtick_code_not_flagged() {
2359 let config = MD044Config {
2361 names: vec!["GoodApplication".to_string()],
2362 code_blocks: false,
2363 ..MD044Config::default()
2364 };
2365 let rule = MD044ProperNames::from_config_struct(config);
2366
2367 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2368 let ctx = create_context(content);
2369 let result = rule.check(&ctx).unwrap();
2370
2371 assert!(
2373 result.is_empty(),
2374 "Should not flag names inside backticks in frontmatter or body: {result:?}"
2375 );
2376 }
2377
2378 #[test]
2379 fn test_frontmatter_unquoted_backtick_code_not_flagged() {
2380 let config = MD044Config {
2382 names: vec!["GoodApplication".to_string()],
2383 code_blocks: false,
2384 ..MD044Config::default()
2385 };
2386 let rule = MD044ProperNames::from_config_struct(config);
2387
2388 let content = "---\ntitle: `goodapplication` CLI\n---\n\nIntroductory `goodapplication` CLI text.\n";
2389 let ctx = create_context(content);
2390 let result = rule.check(&ctx).unwrap();
2391
2392 assert!(
2393 result.is_empty(),
2394 "Should not flag names inside backticks in unquoted YAML frontmatter: {result:?}"
2395 );
2396 }
2397
2398 #[test]
2399 fn test_frontmatter_bare_name_still_flagged_with_backtick_nearby() {
2400 let config = MD044Config {
2402 names: vec!["GoodApplication".to_string()],
2403 code_blocks: false,
2404 ..MD044Config::default()
2405 };
2406 let rule = MD044ProperNames::from_config_struct(config);
2407
2408 let content = "---\ntitle: goodapplication `goodapplication` CLI\n---\n\nBody\n";
2409 let ctx = create_context(content);
2410 let result = rule.check(&ctx).unwrap();
2411
2412 assert_eq!(
2414 result.len(),
2415 1,
2416 "Should flag bare name but not backtick-wrapped name: {result:?}"
2417 );
2418 assert_eq!(result[0].line, 2);
2419 assert_eq!(result[0].column, 8); }
2421
2422 #[test]
2423 fn test_frontmatter_backtick_code_with_code_blocks_true() {
2424 let config = MD044Config {
2426 names: vec!["GoodApplication".to_string()],
2427 code_blocks: true,
2428 ..MD044Config::default()
2429 };
2430 let rule = MD044ProperNames::from_config_struct(config);
2431
2432 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nBody\n";
2433 let ctx = create_context(content);
2434 let result = rule.check(&ctx).unwrap();
2435
2436 assert_eq!(
2438 result.len(),
2439 1,
2440 "Should flag backtick-wrapped name when code_blocks=true: {result:?}"
2441 );
2442 assert_eq!(result[0].line, 2);
2443 }
2444
2445 #[test]
2446 fn test_frontmatter_fix_preserves_backtick_code() {
2447 let config = MD044Config {
2449 names: vec!["GoodApplication".to_string()],
2450 code_blocks: false,
2451 ..MD044Config::default()
2452 };
2453 let rule = MD044ProperNames::from_config_struct(config);
2454
2455 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2456 let ctx = create_context(content);
2457 let fixed = rule.fix(&ctx).unwrap();
2458
2459 assert_eq!(
2461 fixed, content,
2462 "Fix should not modify names inside backticks in frontmatter"
2463 );
2464 }
2465
2466 #[test]
2469 fn test_angle_bracket_url_in_html_comment_not_flagged() {
2470 let config = MD044Config {
2472 names: vec!["Test".to_string()],
2473 ..MD044Config::default()
2474 };
2475 let rule = MD044ProperNames::from_config_struct(config);
2476
2477 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";
2478 let ctx = create_context(content);
2479 let result = rule.check(&ctx).unwrap();
2480
2481 let line8_warnings: Vec<_> = result.iter().filter(|w| w.line == 8).collect();
2489 assert!(
2490 line8_warnings.is_empty(),
2491 "Should not flag names inside angle-bracket URLs in HTML comments: {line8_warnings:?}"
2492 );
2493 }
2494
2495 #[test]
2496 fn test_bare_url_in_html_comment_still_flagged() {
2497 let config = MD044Config {
2499 names: vec!["Test".to_string()],
2500 ..MD044Config::default()
2501 };
2502 let rule = MD044ProperNames::from_config_struct(config);
2503
2504 let content = "<!-- This is a test https://www.example.test -->\n";
2505 let ctx = create_context(content);
2506 let result = rule.check(&ctx).unwrap();
2507
2508 assert!(
2511 !result.is_empty(),
2512 "Should flag 'test' in prose text of HTML comment with bare URL"
2513 );
2514 }
2515
2516 #[test]
2517 fn test_angle_bracket_url_in_regular_markdown_not_flagged() {
2518 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2521
2522 let content = "<https://www.example.test>\n";
2523 let ctx = create_context(content);
2524 let result = rule.check(&ctx).unwrap();
2525
2526 assert!(
2527 result.is_empty(),
2528 "Should not flag names inside angle-bracket URLs in regular markdown: {result:?}"
2529 );
2530 }
2531
2532 #[test]
2533 fn test_multiple_angle_bracket_urls_in_one_comment() {
2534 let config = MD044Config {
2535 names: vec!["Test".to_string()],
2536 ..MD044Config::default()
2537 };
2538 let rule = MD044ProperNames::from_config_struct(config);
2539
2540 let content = "<!-- See <https://test.example.com> and <https://www.example.test> for details -->\n";
2541 let ctx = create_context(content);
2542 let result = rule.check(&ctx).unwrap();
2543
2544 assert!(
2546 result.is_empty(),
2547 "Should not flag names inside multiple angle-bracket URLs: {result:?}"
2548 );
2549 }
2550
2551 #[test]
2552 fn test_angle_bracket_non_url_still_flagged() {
2553 assert!(
2556 !MD044ProperNames::is_in_angle_bracket_url("<test> which is not a URL.", 1),
2557 "is_in_angle_bracket_url should return false for non-URL angle brackets"
2558 );
2559 }
2560
2561 #[test]
2562 fn test_angle_bracket_mailto_url_not_flagged() {
2563 let config = MD044Config {
2564 names: vec!["Test".to_string()],
2565 ..MD044Config::default()
2566 };
2567 let rule = MD044ProperNames::from_config_struct(config);
2568
2569 let content = "<!-- Contact <mailto:test@example.com> for help -->\n";
2570 let ctx = create_context(content);
2571 let result = rule.check(&ctx).unwrap();
2572
2573 assert!(
2574 result.is_empty(),
2575 "Should not flag names inside angle-bracket mailto URLs: {result:?}"
2576 );
2577 }
2578
2579 #[test]
2580 fn test_angle_bracket_ftp_url_not_flagged() {
2581 let config = MD044Config {
2582 names: vec!["Test".to_string()],
2583 ..MD044Config::default()
2584 };
2585 let rule = MD044ProperNames::from_config_struct(config);
2586
2587 let content = "<!-- Download from <ftp://test.example.com/file> -->\n";
2588 let ctx = create_context(content);
2589 let result = rule.check(&ctx).unwrap();
2590
2591 assert!(
2592 result.is_empty(),
2593 "Should not flag names inside angle-bracket FTP URLs: {result:?}"
2594 );
2595 }
2596
2597 #[test]
2598 fn test_angle_bracket_url_fix_preserves_url() {
2599 let config = MD044Config {
2601 names: vec!["Test".to_string()],
2602 ..MD044Config::default()
2603 };
2604 let rule = MD044ProperNames::from_config_struct(config);
2605
2606 let content = "<!-- test text <https://www.example.test> -->\n";
2607 let ctx = create_context(content);
2608 let fixed = rule.fix(&ctx).unwrap();
2609
2610 assert!(
2612 fixed.contains("<https://www.example.test>"),
2613 "Fix should preserve angle-bracket URLs: {fixed}"
2614 );
2615 assert!(
2616 fixed.contains("Test text"),
2617 "Fix should correct prose 'test' to 'Test': {fixed}"
2618 );
2619 }
2620
2621 #[test]
2622 fn test_is_in_angle_bracket_url_helper() {
2623 let line = "text <https://example.test> more text";
2625
2626 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));
2639
2640 assert!(MD044ProperNames::is_in_angle_bracket_url(
2642 "<mailto:test@example.com>",
2643 10
2644 ));
2645
2646 assert!(MD044ProperNames::is_in_angle_bracket_url(
2648 "<ftp://test.example.com>",
2649 10
2650 ));
2651 }
2652
2653 #[test]
2654 fn test_is_in_angle_bracket_url_uppercase_scheme() {
2655 assert!(MD044ProperNames::is_in_angle_bracket_url(
2657 "<HTTPS://test.example.com>",
2658 10
2659 ));
2660 assert!(MD044ProperNames::is_in_angle_bracket_url(
2661 "<Http://test.example.com>",
2662 10
2663 ));
2664 }
2665
2666 #[test]
2667 fn test_is_in_angle_bracket_url_uncommon_schemes() {
2668 assert!(MD044ProperNames::is_in_angle_bracket_url(
2670 "<ssh://test@example.com>",
2671 10
2672 ));
2673 assert!(MD044ProperNames::is_in_angle_bracket_url("<file:///test/path>", 10));
2675 assert!(MD044ProperNames::is_in_angle_bracket_url("<data:text/plain;test>", 10));
2677 }
2678
2679 #[test]
2680 fn test_is_in_angle_bracket_url_unclosed() {
2681 assert!(!MD044ProperNames::is_in_angle_bracket_url(
2683 "<https://test.example.com",
2684 10
2685 ));
2686 }
2687
2688 #[test]
2689 fn test_vale_inline_config_comments_not_flagged() {
2690 let config = MD044Config {
2691 names: vec!["Vale".to_string(), "JavaScript".to_string()],
2692 ..MD044Config::default()
2693 };
2694 let rule = MD044ProperNames::from_config_struct(config);
2695
2696 let content = "\
2697<!-- vale off -->
2698Some javascript text here.
2699<!-- vale on -->
2700<!-- vale Style.Rule = NO -->
2701More javascript text.
2702<!-- vale Style.Rule = YES -->
2703<!-- vale JavaScript.Grammar = NO -->
2704";
2705 let ctx = create_context(content);
2706 let result = rule.check(&ctx).unwrap();
2707
2708 assert_eq!(result.len(), 2, "Should only flag body lines, not Vale config comments");
2710 assert_eq!(result[0].line, 2);
2711 assert_eq!(result[1].line, 5);
2712 }
2713
2714 #[test]
2715 fn test_remark_lint_inline_config_comments_not_flagged() {
2716 let config = MD044Config {
2717 names: vec!["JavaScript".to_string()],
2718 ..MD044Config::default()
2719 };
2720 let rule = MD044ProperNames::from_config_struct(config);
2721
2722 let content = "\
2723<!-- lint disable remark-lint-some-rule -->
2724Some javascript text here.
2725<!-- lint enable remark-lint-some-rule -->
2726<!-- lint ignore remark-lint-some-rule -->
2727More javascript text.
2728";
2729 let ctx = create_context(content);
2730 let result = rule.check(&ctx).unwrap();
2731
2732 assert_eq!(
2733 result.len(),
2734 2,
2735 "Should only flag body lines, not remark-lint config comments"
2736 );
2737 assert_eq!(result[0].line, 2);
2738 assert_eq!(result[1].line, 5);
2739 }
2740
2741 #[test]
2742 fn test_fix_does_not_modify_vale_remark_lint_comments() {
2743 let config = MD044Config {
2744 names: vec!["JavaScript".to_string(), "Vale".to_string()],
2745 ..MD044Config::default()
2746 };
2747 let rule = MD044ProperNames::from_config_struct(config);
2748
2749 let content = "\
2750<!-- vale off -->
2751Some javascript text.
2752<!-- vale on -->
2753<!-- lint disable remark-lint-some-rule -->
2754More javascript text.
2755<!-- lint enable remark-lint-some-rule -->
2756";
2757 let ctx = create_context(content);
2758 let fixed = rule.fix(&ctx).unwrap();
2759
2760 assert!(fixed.contains("<!-- vale off -->"));
2762 assert!(fixed.contains("<!-- vale on -->"));
2763 assert!(fixed.contains("<!-- lint disable remark-lint-some-rule -->"));
2764 assert!(fixed.contains("<!-- lint enable remark-lint-some-rule -->"));
2765 assert!(fixed.contains("Some JavaScript text."));
2767 assert!(fixed.contains("More JavaScript text."));
2768 }
2769
2770 #[test]
2771 fn test_mixed_tool_directives_all_skipped() {
2772 let config = MD044Config {
2773 names: vec!["JavaScript".to_string(), "Vale".to_string()],
2774 ..MD044Config::default()
2775 };
2776 let rule = MD044ProperNames::from_config_struct(config);
2777
2778 let content = "\
2779<!-- rumdl-disable MD044 -->
2780Some javascript text.
2781<!-- markdownlint-disable -->
2782More javascript text.
2783<!-- vale off -->
2784Even more javascript text.
2785<!-- lint disable some-rule -->
2786Final javascript text.
2787<!-- rumdl-enable MD044 -->
2788<!-- markdownlint-enable -->
2789<!-- vale on -->
2790<!-- lint enable some-rule -->
2791";
2792 let ctx = create_context(content);
2793 let result = rule.check(&ctx).unwrap();
2794
2795 assert_eq!(
2797 result.len(),
2798 4,
2799 "Should only flag body lines, not any tool directive comments"
2800 );
2801 assert_eq!(result[0].line, 2);
2802 assert_eq!(result[1].line, 4);
2803 assert_eq!(result[2].line, 6);
2804 assert_eq!(result[3].line, 8);
2805 }
2806
2807 #[test]
2808 fn test_vale_remark_lint_edge_cases_not_matched() {
2809 let config = MD044Config {
2810 names: vec!["JavaScript".to_string(), "Vale".to_string()],
2811 ..MD044Config::default()
2812 };
2813 let rule = MD044ProperNames::from_config_struct(config);
2814
2815 let content = "\
2823<!-- vale -->
2824<!-- vale is a tool for writing -->
2825<!-- valedictorian javascript -->
2826<!-- linting javascript tips -->
2827<!-- vale javascript -->
2828<!-- lint your javascript code -->
2829";
2830 let ctx = create_context(content);
2831 let result = rule.check(&ctx).unwrap();
2832
2833 assert_eq!(
2840 result.len(),
2841 7,
2842 "Should flag proper names in non-directive HTML comments: got {result:?}"
2843 );
2844 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); }
2852
2853 #[test]
2854 fn test_vale_style_directives_skipped() {
2855 let config = MD044Config {
2856 names: vec!["JavaScript".to_string(), "Vale".to_string()],
2857 ..MD044Config::default()
2858 };
2859 let rule = MD044ProperNames::from_config_struct(config);
2860
2861 let content = "\
2863<!-- vale style = MyStyle -->
2864<!-- vale styles = Style1, Style2 -->
2865<!-- vale MyRule.Name = YES -->
2866<!-- vale MyRule.Name = NO -->
2867Some javascript text.
2868";
2869 let ctx = create_context(content);
2870 let result = rule.check(&ctx).unwrap();
2871
2872 assert_eq!(
2874 result.len(),
2875 1,
2876 "Should only flag body lines, not Vale style/rule directives: got {result:?}"
2877 );
2878 assert_eq!(result[0].line, 5);
2879 }
2880
2881 #[test]
2884 fn test_backtick_code_single_backticks() {
2885 let line = "hello `world` bye";
2886 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 7));
2888 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
2890 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 14));
2892 }
2893
2894 #[test]
2895 fn test_backtick_code_double_backticks() {
2896 let line = "a ``code`` b";
2897 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
2899 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
2901 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 11));
2903 }
2904
2905 #[test]
2906 fn test_backtick_code_unclosed() {
2907 let line = "a `code b";
2908 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
2910 }
2911
2912 #[test]
2913 fn test_backtick_code_mismatched_count() {
2914 let line = "a `code`` b";
2916 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
2919 }
2920
2921 #[test]
2922 fn test_backtick_code_multiple_spans() {
2923 let line = "`first` and `second`";
2924 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
2926 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 8));
2928 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 13));
2930 }
2931
2932 #[test]
2933 fn test_backtick_code_on_backtick_boundary() {
2934 let line = "`code`";
2935 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
2937 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 5));
2939 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
2941 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
2942 }
2943
2944 #[test]
2950 fn test_double_bracket_link_url_not_flagged() {
2951 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
2952 let content = "[[rumdl]](https://github.com/rvben/rumdl)";
2954 let ctx = create_context(content);
2955 let result = rule.check(&ctx).unwrap();
2956 assert!(
2957 result.is_empty(),
2958 "URL inside [[text]](url) must not be flagged, got: {result:?}"
2959 );
2960 }
2961
2962 #[test]
2963 fn test_double_bracket_link_url_not_fixed() {
2964 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
2965 let content = "[[rumdl]](https://github.com/rvben/rumdl)\n";
2966 let ctx = create_context(content);
2967 let fixed = rule.fix(&ctx).unwrap();
2968 assert_eq!(
2969 fixed, content,
2970 "fix() must leave the URL inside [[text]](url) unchanged"
2971 );
2972 }
2973
2974 #[test]
2975 fn test_double_bracket_link_text_still_flagged() {
2976 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
2977 let content = "[[github]](https://example.com)";
2979 let ctx = create_context(content);
2980 let result = rule.check(&ctx).unwrap();
2981 assert_eq!(
2982 result.len(),
2983 1,
2984 "Incorrect name in [[text]] link text should still be flagged, got: {result:?}"
2985 );
2986 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
2987 }
2988
2989 #[test]
2990 fn test_double_bracket_link_mixed_line() {
2991 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
2992 let content = "See [[rumdl]](https://github.com/rvben/rumdl) and github for more.";
2994 let ctx = create_context(content);
2995 let result = rule.check(&ctx).unwrap();
2996 assert_eq!(
2997 result.len(),
2998 1,
2999 "Only the standalone 'github' after the link should be flagged, got: {result:?}"
3000 );
3001 assert!(result[0].message.contains("'github'"));
3002 assert_eq!(
3004 result[0].column, 51,
3005 "Flagged column should be the trailing 'github', not the one in the URL"
3006 );
3007 }
3008
3009 #[test]
3010 fn test_regular_link_url_still_not_flagged() {
3011 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3013 let content = "[rumdl](https://github.com/rvben/rumdl)";
3014 let ctx = create_context(content);
3015 let result = rule.check(&ctx).unwrap();
3016 assert!(
3017 result.is_empty(),
3018 "URL inside regular [text](url) must still not be flagged, got: {result:?}"
3019 );
3020 }
3021
3022 #[test]
3023 fn test_link_like_text_in_code_span_still_flagged_when_code_blocks_enabled() {
3024 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
3029 let content = "`[foo](https://github.com/org/repo)`";
3030 let ctx = create_context(content);
3031 let result = rule.check(&ctx).unwrap();
3032 assert_eq!(
3033 result.len(),
3034 1,
3035 "Proper name inside a code span must be flagged when code-blocks=true, got: {result:?}"
3036 );
3037 assert!(result[0].message.contains("'github'"));
3038 }
3039
3040 #[test]
3041 fn test_malformed_link_not_treated_as_url() {
3042 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3045 let content = "See [rumdl](github repo) for details.";
3046 let ctx = create_context(content);
3047 let result = rule.check(&ctx).unwrap();
3048 assert_eq!(
3049 result.len(),
3050 1,
3051 "Name inside malformed [text](url with spaces) must still be flagged, got: {result:?}"
3052 );
3053 assert!(result[0].message.contains("'github'"));
3054 }
3055
3056 #[test]
3057 fn test_wikilink_followed_by_prose_parens_still_flagged() {
3058 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3062 let content = "[[note]](github repo)";
3063 let ctx = create_context(content);
3064 let result = rule.check(&ctx).unwrap();
3065 assert_eq!(
3066 result.len(),
3067 1,
3068 "Name inside [[wikilink]](prose with spaces) must still be flagged, got: {result:?}"
3069 );
3070 assert!(result[0].message.contains("'github'"));
3071 }
3072
3073 #[test]
3075 fn test_roundtrip_fix_then_check_basic() {
3076 let rule = MD044ProperNames::new(
3077 vec![
3078 "JavaScript".to_string(),
3079 "TypeScript".to_string(),
3080 "Node.js".to_string(),
3081 ],
3082 true,
3083 );
3084 let content = "I love javascript, typescript, and nodejs!";
3085 let ctx = create_context(content);
3086 let fixed = rule.fix(&ctx).unwrap();
3087 let ctx2 = create_context(&fixed);
3088 let warnings = rule.check(&ctx2).unwrap();
3089 assert!(
3090 warnings.is_empty(),
3091 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3092 );
3093 }
3094
3095 #[test]
3097 fn test_roundtrip_fix_then_check_multiline() {
3098 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
3099 let content = "First line with rust.\nSecond line with python.\nThird line with RUST and PYTHON.\n";
3100 let ctx = create_context(content);
3101 let fixed = rule.fix(&ctx).unwrap();
3102 let ctx2 = create_context(&fixed);
3103 let warnings = rule.check(&ctx2).unwrap();
3104 assert!(
3105 warnings.is_empty(),
3106 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3107 );
3108 }
3109
3110 #[test]
3112 fn test_roundtrip_fix_then_check_inline_config() {
3113 let config = MD044Config {
3114 names: vec!["RUMDL".to_string()],
3115 ..MD044Config::default()
3116 };
3117 let rule = MD044ProperNames::from_config_struct(config);
3118 let content =
3119 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
3120 let ctx = create_context(content);
3121 let fixed = rule.fix(&ctx).unwrap();
3122 assert!(
3124 fixed.contains("Some rumdl text.\n"),
3125 "Disabled block text should be preserved"
3126 );
3127 assert!(
3128 fixed.contains("Some RUMDL text outside."),
3129 "Outside text should be fixed"
3130 );
3131 }
3132
3133 #[test]
3135 fn test_roundtrip_fix_then_check_html_comments() {
3136 let config = MD044Config {
3137 names: vec!["JavaScript".to_string()],
3138 ..MD044Config::default()
3139 };
3140 let rule = MD044ProperNames::from_config_struct(config);
3141 let content = "# Guide\n\n<!-- javascript mentioned here -->\n\njavascript outside\n";
3142 let ctx = create_context(content);
3143 let fixed = rule.fix(&ctx).unwrap();
3144 let ctx2 = create_context(&fixed);
3145 let warnings = rule.check(&ctx2).unwrap();
3146 assert!(
3147 warnings.is_empty(),
3148 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3149 );
3150 }
3151
3152 #[test]
3154 fn test_roundtrip_no_op_when_correct() {
3155 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
3156 let content = "This uses JavaScript and TypeScript correctly.\n";
3157 let ctx = create_context(content);
3158 let fixed = rule.fix(&ctx).unwrap();
3159 assert_eq!(fixed, content, "Fix should be a no-op when content is already correct");
3160 }
3161
3162 #[test]
3165 fn test_bare_domain_link_text_not_flagged() {
3166 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3170 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3171 let ctx = create_context(content);
3172 let result = rule.check(&ctx).unwrap();
3173 assert!(
3174 result.is_empty(),
3175 "Should not flag 'github' in a bare-domain link text that matches the link URL: {result:?}"
3176 );
3177 }
3178
3179 #[test]
3180 fn test_bare_domain_link_text_not_fixed() {
3181 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3183 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3184 let ctx = create_context(content);
3185 let fixed = rule.fix(&ctx).unwrap();
3186 assert_eq!(
3187 fixed, content,
3188 "fix() must not alter bare-domain link text that matches the destination URL"
3189 );
3190 }
3191
3192 #[test]
3193 fn test_bare_domain_link_text_with_path_not_flagged() {
3194 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3196 let content = "Visit [ravencentric.github.io](https://ravencentric.github.io/projects).\n";
3197 let ctx = create_context(content);
3198 let result = rule.check(&ctx).unwrap();
3199 assert!(
3200 result.is_empty(),
3201 "Should not flag 'github' when bare-domain text is the hostname of its destination URL: {result:?}"
3202 );
3203 }
3204
3205 #[test]
3206 fn test_bare_domain_link_text_full_path_not_flagged() {
3207 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3209 let content = "See [ravencentric.github.io/blog](https://ravencentric.github.io/blog).\n";
3210 let ctx = create_context(content);
3211 let result = rule.check(&ctx).unwrap();
3212 assert!(
3213 result.is_empty(),
3214 "Should not flag 'github' when link text is the full URL path without scheme: {result:?}"
3215 );
3216 }
3217
3218 #[test]
3219 fn test_github_product_name_in_link_text_still_flagged() {
3220 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3223 let content = "Hosted on [github pages](https://pages.github.com).\n";
3224 let ctx = create_context(content);
3225 let result = rule.check(&ctx).unwrap();
3226 assert!(
3227 !result.is_empty(),
3228 "Should still flag 'github' in descriptive link text that does not match the destination URL"
3229 );
3230 }
3231
3232 #[test]
3233 fn test_protocol_relative_bare_domain_link_text_not_flagged() {
3234 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3236 let content = "See [github.io](//github.io).\n";
3237 let ctx = create_context(content);
3238 let result = rule.check(&ctx).unwrap();
3239 assert!(
3240 result.is_empty(),
3241 "Should not flag 'github' in bare-domain text matching a protocol-relative destination: {result:?}"
3242 );
3243 }
3244
3245 #[test]
3246 fn test_dotted_wikilink_target_still_flagged() {
3247 let rule = MD044ProperNames::new(vec!["Node.js".to_string()], false);
3252 let content = "See [[node.js]] for details.\n";
3253 let ctx = create_context(content);
3254 let result = rule.check(&ctx).unwrap();
3255 assert!(
3256 !result.is_empty(),
3257 "Should flag 'node.js' in a dotted WikiLink target: {result:?}"
3258 );
3259 }
3260
3261 #[test]
3262 fn test_bare_domain_link_text_case_insensitive_url() {
3263 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3266 let content = "See [github.io](HTTPS://github.io).\n";
3267 let ctx = create_context(content);
3268 let result = rule.check(&ctx).unwrap();
3269 assert!(
3270 result.is_empty(),
3271 "Should not flag bare-domain text when destination URL has an uppercase scheme: {result:?}"
3272 );
3273 }
3274}