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 let Some(proper_name) = self.get_proper_name_for(found_name) {
385 if found_name != proper_name {
387 violations.push((line_num, cap.start() + 1, found_name.to_string()));
388 }
389 }
390 }
391 }
392
393 if let Ok(mut cache) = self.content_cache.lock() {
395 cache.insert(hash, violations.clone());
396 }
397 violations
398 }
399
400 fn is_in_link(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
407 use pulldown_cmark::LinkType;
408
409 let link_idx = ctx.links.partition_point(|link| link.byte_offset <= byte_pos);
411 if link_idx > 0 {
412 let link = &ctx.links[link_idx - 1];
413 if byte_pos < link.byte_end {
414 let text_start = if matches!(link.link_type, LinkType::WikiLink { .. }) {
416 link.byte_offset + 2
417 } else {
418 link.byte_offset + 1
419 };
420 let text_end = text_start + link.text.len();
421
422 if byte_pos >= text_start && byte_pos < text_end {
426 let is_wikilink = matches!(link.link_type, LinkType::WikiLink { .. });
427 return Self::link_text_is_url(&link.text)
428 || (!is_wikilink && Self::link_text_matches_link_url(&link.text, &link.url));
429 }
430 return true;
432 }
433 }
434
435 let image_idx = ctx.images.partition_point(|img| img.byte_offset <= byte_pos);
437 if image_idx > 0 {
438 let image = &ctx.images[image_idx - 1];
439 if byte_pos < image.byte_end {
440 let alt_start = image.byte_offset + 2;
442 let alt_end = alt_start + image.alt_text.len();
443
444 if byte_pos >= alt_start && byte_pos < alt_end {
446 return false;
447 }
448 return true;
450 }
451 }
452
453 ctx.is_in_reference_def(byte_pos)
455 }
456
457 fn link_text_is_url(text: &str) -> bool {
459 let lower = text.trim().to_ascii_lowercase();
460 lower.starts_with("http://")
461 || lower.starts_with("https://")
462 || lower.starts_with("www.")
463 || lower.starts_with("//")
464 }
465
466 fn link_text_matches_link_url(text: &str, url: &str) -> bool {
478 let text = text.trim();
479 if !text.contains('.') {
481 return false;
482 }
483 let url_lower = url.to_ascii_lowercase();
484 let url_without_scheme = url_lower
485 .strip_prefix("https://")
486 .or_else(|| url_lower.strip_prefix("http://"))
487 .or_else(|| url_lower.strip_prefix("//"))
488 .unwrap_or(&url_lower);
489 let text_lower = text.to_ascii_lowercase();
490 if url_without_scheme == text_lower.as_str() {
492 return true;
493 }
494 url_without_scheme.len() > text_lower.len()
496 && url_without_scheme.starts_with(text_lower.as_str())
497 && matches!(
498 url_without_scheme.as_bytes().get(text_lower.len()),
499 Some(b'/') | Some(b'?') | Some(b'#')
500 )
501 }
502
503 fn is_in_angle_bracket_url(line: &str, pos: usize) -> bool {
509 let bytes = line.as_bytes();
510 let len = bytes.len();
511 let mut i = 0;
512 while i < len {
513 if bytes[i] == b'<' {
514 let after_open = i + 1;
515 if after_open < len && bytes[after_open].is_ascii_alphabetic() {
519 let mut s = after_open + 1;
520 let scheme_max = (after_open + 32).min(len);
521 while s < scheme_max
522 && (bytes[s].is_ascii_alphanumeric()
523 || bytes[s] == b'+'
524 || bytes[s] == b'-'
525 || bytes[s] == b'.')
526 {
527 s += 1;
528 }
529 if s < len && bytes[s] == b':' {
530 let mut j = s + 1;
532 let mut found_close = false;
533 while j < len {
534 match bytes[j] {
535 b'>' => {
536 found_close = true;
537 break;
538 }
539 b' ' | b'<' => break,
540 _ => j += 1,
541 }
542 }
543 if found_close && pos >= i && pos <= j {
544 return true;
545 }
546 if found_close {
547 i = j + 1;
548 continue;
549 }
550 }
551 }
552 }
553 i += 1;
554 }
555 false
556 }
557
558 fn is_in_wikilink_url(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
571 use pulldown_cmark::LinkType;
572 let content = ctx.content.as_bytes();
573
574 let end = ctx.links.partition_point(|l| l.byte_offset <= byte_pos);
577
578 for link in &ctx.links[..end] {
579 if !matches!(link.link_type, LinkType::WikiLink { .. }) {
580 continue;
581 }
582 let wiki_end = link.byte_end;
583 if wiki_end >= byte_pos || wiki_end >= content.len() || content[wiki_end] != b'(' {
585 continue;
586 }
587 let mut depth: u32 = 1;
592 let mut k = wiki_end + 1;
593 let mut valid_destination = true;
594 while k < content.len() && depth > 0 {
595 match content[k] {
596 b'\\' => {
597 k += 1; }
599 b'(' => depth += 1,
600 b')' => depth -= 1,
601 b' ' | b'\t' | b'\n' | b'\r' => {
602 valid_destination = false;
603 break;
604 }
605 _ => {}
606 }
607 k += 1;
608 }
609 if valid_destination && depth == 0 && byte_pos > wiki_end && byte_pos < k {
612 return true;
613 }
614 }
615 false
616 }
617
618 fn is_in_markdown_link_url(line: &str, pos: usize) -> bool {
628 let bytes = line.as_bytes();
629 let len = bytes.len();
630 let mut i = 0;
631
632 while i < len {
633 if bytes[i] == b'[' && (i == 0 || bytes[i - 1] != b'\\' || (i >= 2 && bytes[i - 2] == b'\\')) {
635 let mut depth: u32 = 1;
637 let mut j = i + 1;
638 while j < len && depth > 0 {
639 match bytes[j] {
640 b'\\' => {
641 j += 1; }
643 b'[' => depth += 1,
644 b']' => depth -= 1,
645 _ => {}
646 }
647 j += 1;
648 }
649
650 if depth == 0 && j < len {
652 if bytes[j] == b'(' {
653 let url_start = j;
655 let mut paren_depth: u32 = 1;
656 let mut k = j + 1;
657 while k < len && paren_depth > 0 {
658 match bytes[k] {
659 b'\\' => {
660 k += 1; }
662 b'(' => paren_depth += 1,
663 b')' => paren_depth -= 1,
664 _ => {}
665 }
666 k += 1;
667 }
668
669 if paren_depth == 0 {
670 if pos > url_start && pos < k {
671 return true;
672 }
673 i = k;
674 continue;
675 }
676 } else if bytes[j] == b'[' {
677 let ref_start = j;
679 let mut ref_depth: u32 = 1;
680 let mut k = j + 1;
681 while k < len && ref_depth > 0 {
682 match bytes[k] {
683 b'\\' => {
684 k += 1;
685 }
686 b'[' => ref_depth += 1,
687 b']' => ref_depth -= 1,
688 _ => {}
689 }
690 k += 1;
691 }
692
693 if ref_depth == 0 {
694 if pos > ref_start && pos < k {
695 return true;
696 }
697 i = k;
698 continue;
699 }
700 }
701 }
702 }
703 i += 1;
704 }
705 false
706 }
707
708 fn is_in_backtick_code_in_line(line: &str, pos: usize) -> bool {
716 let bytes = line.as_bytes();
717 let len = bytes.len();
718 let mut i = 0;
719 while i < len {
720 if bytes[i] == b'`' {
721 let open_start = i;
723 while i < len && bytes[i] == b'`' {
724 i += 1;
725 }
726 let tick_len = i - open_start;
727
728 while i < len {
730 if bytes[i] == b'`' {
731 let close_start = i;
732 while i < len && bytes[i] == b'`' {
733 i += 1;
734 }
735 if i - close_start == tick_len {
736 let content_start = open_start + tick_len;
740 let content_end = close_start;
741 if pos >= content_start && pos < content_end {
742 return true;
743 }
744 break;
746 }
747 } else {
749 i += 1;
750 }
751 }
752 } else {
753 i += 1;
754 }
755 }
756 false
757 }
758
759 fn is_word_boundary_char(c: char) -> bool {
761 !c.is_alphanumeric()
762 }
763
764 fn is_at_word_boundary(content: &str, pos: usize, is_start: bool) -> bool {
766 if is_start {
767 if pos == 0 {
768 return true;
769 }
770 match content[..pos].chars().next_back() {
771 None => true,
772 Some(c) => Self::is_word_boundary_char(c),
773 }
774 } else {
775 if pos >= content.len() {
776 return true;
777 }
778 match content[pos..].chars().next() {
779 None => true,
780 Some(c) => Self::is_word_boundary_char(c),
781 }
782 }
783 }
784
785 fn frontmatter_value_offset(line: &str) -> usize {
789 let trimmed = line.trim();
790
791 if trimmed == "---" || trimmed == "+++" || trimmed.is_empty() {
793 return usize::MAX;
794 }
795
796 if trimmed.starts_with('#') {
798 return usize::MAX;
799 }
800
801 let stripped = line.trim_start();
803 if let Some(after_dash) = stripped.strip_prefix("- ") {
804 let leading = line.len() - stripped.len();
805 if let Some(result) = Self::kv_value_offset(line, after_dash, leading + 2) {
807 return result;
808 }
809 return leading + 2;
811 }
812 if stripped == "-" {
813 return usize::MAX;
814 }
815
816 if let Some(result) = Self::kv_value_offset(line, stripped, line.len() - stripped.len()) {
818 return result;
819 }
820
821 if let Some(eq_pos) = line.find('=') {
823 let after_eq = eq_pos + 1;
824 if after_eq < line.len() && line.as_bytes()[after_eq] == b' ' {
825 let value_start = after_eq + 1;
826 let value_slice = &line[value_start..];
827 let value_trimmed = value_slice.trim();
828 if value_trimmed.is_empty() {
829 return usize::MAX;
830 }
831 if (value_trimmed.starts_with('"') && value_trimmed.ends_with('"'))
833 || (value_trimmed.starts_with('\'') && value_trimmed.ends_with('\''))
834 {
835 let quote_offset = value_slice.find(['"', '\'']).unwrap_or(0);
836 return value_start + quote_offset + 1;
837 }
838 return value_start;
839 }
840 return usize::MAX;
842 }
843
844 0
846 }
847
848 fn kv_value_offset(line: &str, content: &str, base_offset: usize) -> Option<usize> {
852 let colon_pos = content.find(':')?;
853 let abs_colon = base_offset + colon_pos;
854 let after_colon = abs_colon + 1;
855 if after_colon < line.len() && line.as_bytes()[after_colon] == b' ' {
856 let value_start = after_colon + 1;
857 let value_slice = &line[value_start..];
858 let value_trimmed = value_slice.trim();
859 if value_trimmed.is_empty() {
860 return Some(usize::MAX);
861 }
862 if value_trimmed.starts_with('{') || value_trimmed.starts_with('[') {
864 return Some(usize::MAX);
865 }
866 if (value_trimmed.starts_with('"') && value_trimmed.ends_with('"'))
868 || (value_trimmed.starts_with('\'') && value_trimmed.ends_with('\''))
869 {
870 let quote_offset = value_slice.find(['"', '\'']).unwrap_or(0);
871 return Some(value_start + quote_offset + 1);
872 }
873 return Some(value_start);
874 }
875 Some(usize::MAX)
877 }
878
879 fn get_proper_name_for(&self, found_name: &str) -> Option<String> {
881 let found_lower = found_name.to_lowercase();
882
883 for name in &self.config.names {
885 let lower_name = name.to_lowercase();
886 let lower_name_no_dots = lower_name.replace('.', "");
887
888 if found_lower == lower_name || found_lower == lower_name_no_dots {
890 return Some(name.clone());
891 }
892
893 let ascii_normalized = Self::ascii_normalize(&lower_name);
895
896 let ascii_no_dots = ascii_normalized.replace('.', "");
897
898 if found_lower == ascii_normalized || found_lower == ascii_no_dots {
899 return Some(name.clone());
900 }
901 }
902 None
903 }
904}
905
906impl Rule for MD044ProperNames {
907 fn name(&self) -> &'static str {
908 "MD044"
909 }
910
911 fn description(&self) -> &'static str {
912 "Proper names should have the correct capitalization"
913 }
914
915 fn category(&self) -> RuleCategory {
916 RuleCategory::Other
917 }
918
919 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
920 if self.config.names.is_empty() {
921 return true;
922 }
923 let content_lower = if ctx.content.is_ascii() {
925 ctx.content.to_ascii_lowercase()
926 } else {
927 ctx.content.to_lowercase()
928 };
929 !self.name_variants.iter().any(|name| content_lower.contains(name))
930 }
931
932 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
933 let content = ctx.content;
934 if content.is_empty() || self.config.names.is_empty() || self.combined_pattern.is_none() {
935 return Ok(Vec::new());
936 }
937
938 let content_lower = if content.is_ascii() {
940 content.to_ascii_lowercase()
941 } else {
942 content.to_lowercase()
943 };
944
945 let has_potential_matches = self.name_variants.iter().any(|name| content_lower.contains(name));
947
948 if !has_potential_matches {
949 return Ok(Vec::new());
950 }
951
952 let line_index = &ctx.line_index;
953 let violations = self.find_name_violations(content, ctx, &content_lower);
954
955 let warnings = violations
956 .into_iter()
957 .filter_map(|(line, column, found_name)| {
958 self.get_proper_name_for(&found_name).map(|proper_name| {
959 let line_start = line_index.get_line_start_byte(line).unwrap_or(0);
964 let byte_start = line_start + (column - 1);
965 let byte_end = byte_start + found_name.len();
966 let line_text = ctx.line_info(line).map_or("", |li| li.content(ctx.content));
969 let char_col = byte_to_char_count(line_text, column - 1);
970 LintWarning {
971 rule_name: Some(self.name().to_string()),
972 line,
973 column: char_col,
974 end_line: line,
975 end_column: char_col + found_name.chars().count(),
976 message: format!("Proper name '{found_name}' should be '{proper_name}'"),
977 severity: Severity::Warning,
978 fix: Some(Fix::new(byte_start..byte_end, proper_name)),
979 }
980 })
981 })
982 .collect();
983
984 Ok(warnings)
985 }
986
987 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
988 if self.should_skip(ctx) {
989 return Ok(ctx.content.to_string());
990 }
991 let warnings = self.check(ctx)?;
992 if warnings.is_empty() {
993 return Ok(ctx.content.to_string());
994 }
995 let warnings =
996 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
997 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
998 .map_err(crate::rule::LintError::InvalidInput)
999 }
1000
1001 fn as_any(&self) -> &dyn std::any::Any {
1002 self
1003 }
1004
1005 crate::impl_rule_config_methods!(MD044Config);
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010 use super::*;
1011 use crate::lint_context::LintContext;
1012
1013 fn create_context(content: &str) -> LintContext<'_> {
1014 LintContext::new(content, crate::config::MarkdownFlavor::Standard, None)
1015 }
1016
1017 #[test]
1018 fn test_correctly_capitalized_names() {
1019 let rule = MD044ProperNames::new(
1020 vec![
1021 "JavaScript".to_string(),
1022 "TypeScript".to_string(),
1023 "Node.js".to_string(),
1024 ],
1025 true,
1026 );
1027
1028 let content = "This document uses JavaScript, TypeScript, and Node.js correctly.";
1029 let ctx = create_context(content);
1030 let result = rule.check(&ctx).unwrap();
1031 assert!(result.is_empty(), "Should not flag correctly capitalized names");
1032 }
1033
1034 #[test]
1035 fn test_incorrectly_capitalized_names() {
1036 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1037
1038 let content = "This document uses javascript and typescript incorrectly.";
1039 let ctx = create_context(content);
1040 let result = rule.check(&ctx).unwrap();
1041
1042 assert_eq!(result.len(), 2, "Should flag two incorrect capitalizations");
1043 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1044 assert_eq!(result[0].line, 1);
1045 assert_eq!(result[0].column, 20);
1046 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1047 assert_eq!(result[1].line, 1);
1048 assert_eq!(result[1].column, 35);
1049 }
1050
1051 #[test]
1052 fn test_names_at_beginning_of_sentences() {
1053 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "Python".to_string()], true);
1054
1055 let content = "javascript is a great language. python is also popular.";
1056 let ctx = create_context(content);
1057 let result = rule.check(&ctx).unwrap();
1058
1059 assert_eq!(result.len(), 2, "Should flag names at beginning of sentences");
1060 assert_eq!(result[0].line, 1);
1061 assert_eq!(result[0].column, 1);
1062 assert_eq!(result[1].line, 1);
1063 assert_eq!(result[1].column, 33);
1064 }
1065
1066 #[test]
1067 fn test_names_in_code_blocks_checked_by_default() {
1068 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1069
1070 let content = r#"Here is some text with JavaScript.
1071
1072```javascript
1073// This javascript should be checked
1074const lang = "javascript";
1075```
1076
1077But this javascript should be flagged."#;
1078
1079 let ctx = create_context(content);
1080 let result = rule.check(&ctx).unwrap();
1081
1082 assert_eq!(result.len(), 3, "Should flag javascript inside and outside code blocks");
1083 assert_eq!(result[0].line, 4);
1084 assert_eq!(result[1].line, 5);
1085 assert_eq!(result[2].line, 8);
1086 }
1087
1088 #[test]
1089 fn test_names_in_code_blocks_ignored_when_disabled() {
1090 let rule = MD044ProperNames::new(
1091 vec!["JavaScript".to_string()],
1092 false, );
1094
1095 let content = r#"```
1096javascript in code block
1097```"#;
1098
1099 let ctx = create_context(content);
1100 let result = rule.check(&ctx).unwrap();
1101
1102 assert_eq!(
1103 result.len(),
1104 0,
1105 "Should not flag javascript in code blocks when code_blocks is false"
1106 );
1107 }
1108
1109 #[test]
1110 fn test_names_in_inline_code_checked_by_default() {
1111 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1112
1113 let content = "This is `javascript` in inline code and javascript outside.";
1114 let ctx = create_context(content);
1115 let result = rule.check(&ctx).unwrap();
1116
1117 assert_eq!(result.len(), 2, "Should flag javascript inside and outside inline code");
1119 assert_eq!(result[0].column, 10); assert_eq!(result[1].column, 41); }
1122
1123 #[test]
1124 fn test_multiple_names_in_same_line() {
1125 let rule = MD044ProperNames::new(
1126 vec!["JavaScript".to_string(), "TypeScript".to_string(), "React".to_string()],
1127 true,
1128 );
1129
1130 let content = "I use javascript, typescript, and react in my projects.";
1131 let ctx = create_context(content);
1132 let result = rule.check(&ctx).unwrap();
1133
1134 assert_eq!(result.len(), 3, "Should flag all three incorrect names");
1135 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1136 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1137 assert_eq!(result[2].message, "Proper name 'react' should be 'React'");
1138 }
1139
1140 #[test]
1141 fn test_case_sensitivity() {
1142 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1143
1144 let content = "JAVASCRIPT, Javascript, javascript, and JavaScript variations.";
1145 let ctx = create_context(content);
1146 let result = rule.check(&ctx).unwrap();
1147
1148 assert_eq!(result.len(), 3, "Should flag all incorrect case variations");
1149 assert!(result.iter().all(|w| w.message.contains("should be 'JavaScript'")));
1151 }
1152
1153 #[test]
1154 fn test_configuration_with_custom_name_list() {
1155 let config = MD044Config {
1156 names: vec!["GitHub".to_string(), "GitLab".to_string(), "DevOps".to_string()],
1157 code_blocks: true,
1158 html_elements: true,
1159 html_comments: true,
1160 };
1161 let rule = MD044ProperNames::from_config_struct(config);
1162
1163 let content = "We use github, gitlab, and devops for our workflow.";
1164 let ctx = create_context(content);
1165 let result = rule.check(&ctx).unwrap();
1166
1167 assert_eq!(result.len(), 3, "Should flag all custom names");
1168 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
1169 assert_eq!(result[1].message, "Proper name 'gitlab' should be 'GitLab'");
1170 assert_eq!(result[2].message, "Proper name 'devops' should be 'DevOps'");
1171 }
1172
1173 #[test]
1174 fn test_empty_configuration() {
1175 let rule = MD044ProperNames::new(vec![], true);
1176
1177 let content = "This has javascript and typescript but no configured names.";
1178 let ctx = create_context(content);
1179 let result = rule.check(&ctx).unwrap();
1180
1181 assert!(result.is_empty(), "Should not flag anything with empty configuration");
1182 }
1183
1184 #[test]
1185 fn test_names_with_special_characters() {
1186 let rule = MD044ProperNames::new(
1187 vec!["Node.js".to_string(), "ASP.NET".to_string(), "C++".to_string()],
1188 true,
1189 );
1190
1191 let content = "We use nodejs, asp.net, ASP.NET, and c++ in our stack.";
1192 let ctx = create_context(content);
1193 let result = rule.check(&ctx).unwrap();
1194
1195 assert_eq!(result.len(), 3, "Should handle special characters correctly");
1200
1201 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
1202 assert!(messages.contains(&"Proper name 'nodejs' should be 'Node.js'"));
1203 assert!(messages.contains(&"Proper name 'asp.net' should be 'ASP.NET'"));
1204 assert!(messages.contains(&"Proper name 'c++' should be 'C++'"));
1205 }
1206
1207 #[test]
1208 fn test_word_boundaries() {
1209 let rule = MD044ProperNames::new(vec!["Java".to_string(), "Script".to_string()], true);
1210
1211 let content = "JavaScript is not java or script, but Java and Script are separate.";
1212 let ctx = create_context(content);
1213 let result = rule.check(&ctx).unwrap();
1214
1215 assert_eq!(result.len(), 2, "Should respect word boundaries");
1217 assert!(result.iter().any(|w| w.column == 19)); assert!(result.iter().any(|w| w.column == 27)); }
1220
1221 #[test]
1222 fn test_fix_method() {
1223 let rule = MD044ProperNames::new(
1224 vec![
1225 "JavaScript".to_string(),
1226 "TypeScript".to_string(),
1227 "Node.js".to_string(),
1228 ],
1229 true,
1230 );
1231
1232 let content = "I love javascript, typescript, and nodejs!";
1233 let ctx = create_context(content);
1234 let fixed = rule.fix(&ctx).unwrap();
1235
1236 assert_eq!(fixed, "I love JavaScript, TypeScript, and Node.js!");
1237 }
1238
1239 #[test]
1240 fn test_fix_multiple_occurrences() {
1241 let rule = MD044ProperNames::new(vec!["Python".to_string()], true);
1242
1243 let content = "python is great. I use python daily. PYTHON is powerful.";
1244 let ctx = create_context(content);
1245 let fixed = rule.fix(&ctx).unwrap();
1246
1247 assert_eq!(fixed, "Python is great. I use Python daily. Python is powerful.");
1248 }
1249
1250 #[test]
1251 fn test_fix_checks_code_blocks_by_default() {
1252 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1253
1254 let content = r#"I love javascript.
1255
1256```
1257const lang = "javascript";
1258```
1259
1260More javascript here."#;
1261
1262 let ctx = create_context(content);
1263 let fixed = rule.fix(&ctx).unwrap();
1264
1265 let expected = r#"I love JavaScript.
1266
1267```
1268const lang = "JavaScript";
1269```
1270
1271More JavaScript here."#;
1272
1273 assert_eq!(fixed, expected);
1274 }
1275
1276 #[test]
1277 fn test_multiline_content() {
1278 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
1279
1280 let content = r#"First line with rust.
1281Second line with python.
1282Third line with RUST and PYTHON."#;
1283
1284 let ctx = create_context(content);
1285 let result = rule.check(&ctx).unwrap();
1286
1287 assert_eq!(result.len(), 4, "Should flag all incorrect occurrences");
1288 assert_eq!(result[0].line, 1);
1289 assert_eq!(result[1].line, 2);
1290 assert_eq!(result[2].line, 3);
1291 assert_eq!(result[3].line, 3);
1292 }
1293
1294 #[test]
1295 fn test_default_config() {
1296 let config = MD044Config::default();
1297 assert!(config.names.is_empty());
1298 assert!(!config.code_blocks);
1299 assert!(config.html_elements);
1300 assert!(config.html_comments);
1301 }
1302
1303 #[test]
1304 fn test_default_config_checks_html_comments() {
1305 let config = MD044Config {
1306 names: vec!["JavaScript".to_string()],
1307 ..MD044Config::default()
1308 };
1309 let rule = MD044ProperNames::from_config_struct(config);
1310
1311 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1312 let ctx = create_context(content);
1313 let result = rule.check(&ctx).unwrap();
1314
1315 assert_eq!(result.len(), 1, "Default config should check HTML comments");
1316 assert_eq!(result[0].line, 3);
1317 }
1318
1319 #[test]
1320 fn test_default_config_skips_code_blocks() {
1321 let config = MD044Config {
1322 names: vec!["JavaScript".to_string()],
1323 ..MD044Config::default()
1324 };
1325 let rule = MD044ProperNames::from_config_struct(config);
1326
1327 let content = "# Guide\n\n```\njavascript in code\n```\n";
1328 let ctx = create_context(content);
1329 let result = rule.check(&ctx).unwrap();
1330
1331 assert_eq!(result.len(), 0, "Default config should skip code blocks");
1332 }
1333
1334 #[test]
1335 fn test_standalone_html_comment_checked() {
1336 let config = MD044Config {
1337 names: vec!["Test".to_string()],
1338 ..MD044Config::default()
1339 };
1340 let rule = MD044ProperNames::from_config_struct(config);
1341
1342 let content = "# Heading\n\n<!-- this is a test example -->\n";
1343 let ctx = create_context(content);
1344 let result = rule.check(&ctx).unwrap();
1345
1346 assert_eq!(result.len(), 1, "Should flag proper name in standalone HTML comment");
1347 assert_eq!(result[0].line, 3);
1348 }
1349
1350 #[test]
1351 fn test_inline_config_comments_not_flagged() {
1352 let config = MD044Config {
1353 names: vec!["RUMDL".to_string()],
1354 ..MD044Config::default()
1355 };
1356 let rule = MD044ProperNames::from_config_struct(config);
1357
1358 let content = "<!-- rumdl-disable MD044 -->\nSome rumdl text here.\n<!-- rumdl-enable MD044 -->\n<!-- markdownlint-disable -->\nMore rumdl text.\n<!-- markdownlint-enable -->\n";
1362 let ctx = create_context(content);
1363 let result = rule.check(&ctx).unwrap();
1364
1365 assert_eq!(result.len(), 2, "Should only flag body lines, not config comments");
1366 assert_eq!(result[0].line, 2);
1367 assert_eq!(result[1].line, 5);
1368 }
1369
1370 #[test]
1371 fn test_html_comment_skipped_when_disabled() {
1372 let config = MD044Config {
1373 names: vec!["Test".to_string()],
1374 code_blocks: true,
1375 html_elements: true,
1376 html_comments: false,
1377 };
1378 let rule = MD044ProperNames::from_config_struct(config);
1379
1380 let content = "# Heading\n\n<!-- this is a test example -->\n\nRegular test here.\n";
1381 let ctx = create_context(content);
1382 let result = rule.check(&ctx).unwrap();
1383
1384 assert_eq!(
1385 result.len(),
1386 1,
1387 "Should only flag 'test' outside HTML comment when html_comments=false"
1388 );
1389 assert_eq!(result[0].line, 5);
1390 }
1391
1392 #[test]
1393 fn test_fix_corrects_html_comment_content() {
1394 let config = MD044Config {
1395 names: vec!["JavaScript".to_string()],
1396 ..MD044Config::default()
1397 };
1398 let rule = MD044ProperNames::from_config_struct(config);
1399
1400 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1401 let ctx = create_context(content);
1402 let fixed = rule.fix(&ctx).unwrap();
1403
1404 assert_eq!(fixed, "# Guide\n\n<!-- JavaScript mentioned here -->\n");
1405 }
1406
1407 #[test]
1408 fn test_fix_does_not_modify_inline_config_comments() {
1409 let config = MD044Config {
1410 names: vec!["RUMDL".to_string()],
1411 ..MD044Config::default()
1412 };
1413 let rule = MD044ProperNames::from_config_struct(config);
1414
1415 let content = "<!-- rumdl-disable -->\nSome rumdl text.\n<!-- rumdl-enable -->\n";
1416 let ctx = create_context(content);
1417 let fixed = rule.fix(&ctx).unwrap();
1418
1419 assert!(fixed.contains("<!-- rumdl-disable -->"));
1421 assert!(fixed.contains("<!-- rumdl-enable -->"));
1422 assert!(
1424 fixed.contains("Some rumdl text."),
1425 "Line inside rumdl-disable block should not be modified by fix()"
1426 );
1427 }
1428
1429 #[test]
1430 fn test_fix_respects_inline_disable_partial() {
1431 let config = MD044Config {
1432 names: vec!["RUMDL".to_string()],
1433 ..MD044Config::default()
1434 };
1435 let rule = MD044ProperNames::from_config_struct(config);
1436
1437 let content =
1438 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
1439 let ctx = create_context(content);
1440 let fixed = rule.fix(&ctx).unwrap();
1441
1442 assert!(
1444 fixed.contains("Some rumdl text.\n<!-- rumdl-enable"),
1445 "Line inside disable block should not be modified"
1446 );
1447 assert!(
1449 fixed.contains("Some RUMDL text outside."),
1450 "Line outside disable block should be fixed"
1451 );
1452 }
1453
1454 #[test]
1455 fn test_performance_with_many_names() {
1456 let mut names = vec![];
1457 for i in 0..50 {
1458 names.push(format!("ProperName{i}"));
1459 }
1460
1461 let rule = MD044ProperNames::new(names, true);
1462
1463 let content = "This has propername0, propername25, and propername49 incorrectly.";
1464 let ctx = create_context(content);
1465 let result = rule.check(&ctx).unwrap();
1466
1467 assert_eq!(result.len(), 3, "Should handle many configured names efficiently");
1468 }
1469
1470 #[test]
1471 fn test_large_name_count_performance() {
1472 let names = (0..1000).map(|i| format!("ProperName{i}")).collect::<Vec<_>>();
1475
1476 let rule = MD044ProperNames::new(names, true);
1477
1478 assert!(rule.combined_pattern.is_some());
1480
1481 let content = "This has propername0 and propername999 in it.";
1483 let ctx = create_context(content);
1484 let result = rule.check(&ctx).unwrap();
1485
1486 assert_eq!(result.len(), 2, "Should handle 1000 names without issues");
1488 }
1489
1490 #[test]
1491 fn test_cache_behavior() {
1492 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1493
1494 let content = "Using javascript here.";
1495 let ctx = create_context(content);
1496
1497 let result1 = rule.check(&ctx).unwrap();
1499 assert_eq!(result1.len(), 1);
1500
1501 let result2 = rule.check(&ctx).unwrap();
1503 assert_eq!(result2.len(), 1);
1504
1505 assert_eq!(result1[0].line, result2[0].line);
1507 assert_eq!(result1[0].column, result2[0].column);
1508 }
1509
1510 #[test]
1511 fn test_html_comments_not_checked_when_disabled() {
1512 let config = MD044Config {
1513 names: vec!["JavaScript".to_string()],
1514 code_blocks: true, html_elements: true, html_comments: false, };
1518 let rule = MD044ProperNames::from_config_struct(config);
1519
1520 let content = r#"Regular javascript here.
1521<!-- This javascript in HTML comment should be ignored -->
1522More javascript outside."#;
1523
1524 let ctx = create_context(content);
1525 let result = rule.check(&ctx).unwrap();
1526
1527 assert_eq!(result.len(), 2, "Should only flag javascript outside HTML comments");
1528 assert_eq!(result[0].line, 1);
1529 assert_eq!(result[1].line, 3);
1530 }
1531
1532 #[test]
1533 fn test_html_comments_checked_when_enabled() {
1534 let config = MD044Config {
1535 names: vec!["JavaScript".to_string()],
1536 code_blocks: true, html_elements: true, html_comments: true, };
1540 let rule = MD044ProperNames::from_config_struct(config);
1541
1542 let content = r#"Regular javascript here.
1543<!-- This javascript in HTML comment should be checked -->
1544More javascript outside."#;
1545
1546 let ctx = create_context(content);
1547 let result = rule.check(&ctx).unwrap();
1548
1549 assert_eq!(
1550 result.len(),
1551 3,
1552 "Should flag all javascript occurrences including in HTML comments"
1553 );
1554 }
1555
1556 #[test]
1557 fn test_multiline_html_comments() {
1558 let config = MD044Config {
1559 names: vec!["Python".to_string(), "JavaScript".to_string()],
1560 code_blocks: true, html_elements: true, html_comments: false, };
1564 let rule = MD044ProperNames::from_config_struct(config);
1565
1566 let content = r#"Regular python here.
1567<!--
1568This is a multiline comment
1569with javascript and python
1570that should be ignored
1571-->
1572More javascript outside."#;
1573
1574 let ctx = create_context(content);
1575 let result = rule.check(&ctx).unwrap();
1576
1577 assert_eq!(result.len(), 2, "Should only flag names outside HTML comments");
1578 assert_eq!(result[0].line, 1); assert_eq!(result[1].line, 7); }
1581
1582 #[test]
1583 fn test_fix_preserves_html_comments_when_disabled() {
1584 let config = MD044Config {
1585 names: vec!["JavaScript".to_string()],
1586 code_blocks: true, html_elements: true, html_comments: false, };
1590 let rule = MD044ProperNames::from_config_struct(config);
1591
1592 let content = r#"javascript here.
1593<!-- javascript in comment -->
1594More javascript."#;
1595
1596 let ctx = create_context(content);
1597 let fixed = rule.fix(&ctx).unwrap();
1598
1599 let expected = r#"JavaScript here.
1600<!-- javascript in comment -->
1601More JavaScript."#;
1602
1603 assert_eq!(
1604 fixed, expected,
1605 "Should not fix names inside HTML comments when disabled"
1606 );
1607 }
1608
1609 #[test]
1610 fn test_proper_names_in_link_text_are_flagged() {
1611 let rule = MD044ProperNames::new(
1612 vec!["JavaScript".to_string(), "Node.js".to_string(), "Python".to_string()],
1613 true,
1614 );
1615
1616 let content = r#"Check this [javascript documentation](https://javascript.info) for info.
1617
1618Visit [node.js homepage](https://nodejs.org) and [python tutorial](https://python.org).
1619
1620Real javascript should be flagged.
1621
1622Also see the [typescript guide][ts-ref] for more.
1623
1624Real python should be flagged too.
1625
1626[ts-ref]: https://typescript.org/handbook"#;
1627
1628 let ctx = create_context(content);
1629 let result = rule.check(&ctx).unwrap();
1630
1631 assert_eq!(result.len(), 5, "Expected 5 warnings: 3 in link text + 2 standalone");
1638
1639 let line_1_warnings: Vec<_> = result.iter().filter(|w| w.line == 1).collect();
1641 assert_eq!(line_1_warnings.len(), 1);
1642 assert!(
1643 line_1_warnings[0]
1644 .message
1645 .contains("'javascript' should be 'JavaScript'")
1646 );
1647
1648 let line_3_warnings: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
1649 assert_eq!(line_3_warnings.len(), 2); assert!(result.iter().any(|w| w.line == 5 && w.message.contains("'javascript'")));
1653 assert!(result.iter().any(|w| w.line == 9 && w.message.contains("'python'")));
1654 }
1655
1656 #[test]
1657 fn test_link_urls_not_flagged() {
1658 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1659
1660 let content = r#"[Link Text](https://javascript.info/guide)"#;
1662
1663 let ctx = create_context(content);
1664 let result = rule.check(&ctx).unwrap();
1665
1666 assert!(result.is_empty(), "URLs should not be checked for proper names");
1668 }
1669
1670 #[test]
1671 fn test_proper_names_in_image_alt_text_are_flagged() {
1672 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1673
1674 let content = r#"Here is a  image.
1675
1676Real javascript should be flagged."#;
1677
1678 let ctx = create_context(content);
1679 let result = rule.check(&ctx).unwrap();
1680
1681 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in alt text + 1 standalone");
1685 assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
1686 assert!(result[0].line == 1); assert!(result[1].message.contains("'javascript' should be 'JavaScript'"));
1688 assert!(result[1].line == 3); }
1690
1691 #[test]
1692 fn test_image_urls_not_flagged() {
1693 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1694
1695 let content = r#""#;
1697
1698 let ctx = create_context(content);
1699 let result = rule.check(&ctx).unwrap();
1700
1701 assert!(result.is_empty(), "Image URLs should not be checked for proper names");
1703 }
1704
1705 #[test]
1706 fn test_reference_link_text_flagged_but_definition_not() {
1707 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1708
1709 let content = r#"Check the [javascript guide][js-ref] for details.
1710
1711Real javascript should be flagged.
1712
1713[js-ref]: https://javascript.info/typescript/guide"#;
1714
1715 let ctx = create_context(content);
1716 let result = rule.check(&ctx).unwrap();
1717
1718 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in link text + 1 standalone");
1723 assert!(result.iter().any(|w| w.line == 1 && w.message.contains("'javascript'")));
1724 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
1725 }
1726
1727 #[test]
1728 fn test_reference_definitions_not_flagged() {
1729 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1730
1731 let content = r#"[js-ref]: https://javascript.info/guide"#;
1733
1734 let ctx = create_context(content);
1735 let result = rule.check(&ctx).unwrap();
1736
1737 assert!(result.is_empty(), "Reference definitions should not be checked");
1739 }
1740
1741 #[test]
1742 fn test_wikilinks_text_is_flagged() {
1743 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1744
1745 let content = r#"[[javascript]]
1747
1748Regular javascript here.
1749
1750[[JavaScript|display text]]"#;
1751
1752 let ctx = create_context(content);
1753 let result = rule.check(&ctx).unwrap();
1754
1755 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in WikiLink + 1 standalone");
1759 assert!(
1760 result
1761 .iter()
1762 .any(|w| w.line == 1 && w.column == 3 && w.message.contains("'javascript'"))
1763 );
1764 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
1765 }
1766
1767 #[test]
1768 fn test_url_link_text_not_flagged() {
1769 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1770
1771 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
1773
1774[http://github.com/org/repo](http://github.com/org/repo)
1775
1776[www.github.com/org/repo](https://www.github.com/org/repo)"#;
1777
1778 let ctx = create_context(content);
1779 let result = rule.check(&ctx).unwrap();
1780
1781 assert!(
1782 result.is_empty(),
1783 "URL-like link text should not be flagged, got: {result:?}"
1784 );
1785 }
1786
1787 #[test]
1788 fn test_url_link_text_with_leading_space_not_flagged() {
1789 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1790
1791 let content = r#"[ https://github.com/org/repo](https://github.com/org/repo)"#;
1793
1794 let ctx = create_context(content);
1795 let result = rule.check(&ctx).unwrap();
1796
1797 assert!(
1798 result.is_empty(),
1799 "URL-like link text with leading space should not be flagged, got: {result:?}"
1800 );
1801 }
1802
1803 #[test]
1804 fn test_url_link_text_uppercase_scheme_not_flagged() {
1805 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1806
1807 let content = r#"[HTTPS://GITHUB.COM/org/repo](https://github.com/org/repo)"#;
1808
1809 let ctx = create_context(content);
1810 let result = rule.check(&ctx).unwrap();
1811
1812 assert!(
1813 result.is_empty(),
1814 "URL-like link text with uppercase scheme should not be flagged, got: {result:?}"
1815 );
1816 }
1817
1818 #[test]
1819 fn test_non_url_link_text_still_flagged() {
1820 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1821
1822 let content = r#"[github.com/org/repo](https://github.com/org/repo)
1826
1827[Visit github](https://github.com/org/repo)
1828
1829[//github.com/org/repo](//github.com/org/repo)
1830
1831[ftp://github.com/org/repo](ftp://github.com/org/repo)"#;
1832
1833 let ctx = create_context(content);
1834 let result = rule.check(&ctx).unwrap();
1835
1836 assert_eq!(
1841 result.len(),
1842 1,
1843 "Only prose link text should be flagged, got: {result:?}"
1844 );
1845 assert!(
1846 result.iter().any(|w| w.line == 3),
1847 "Expected 'Visit github' on line 3 to be flagged"
1848 );
1849 }
1850
1851 #[test]
1852 fn test_url_link_text_fix_not_applied() {
1853 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1854
1855 let content = "[https://github.com/org/repo](https://github.com/org/repo)\n";
1856
1857 let ctx = create_context(content);
1858 let result = rule.fix(&ctx).unwrap();
1859
1860 assert_eq!(result, content, "Fix should not modify URL-like link text");
1861 }
1862
1863 #[test]
1864 fn test_mixed_url_and_regular_link_text() {
1865 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1866
1867 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
1869
1870Visit [github documentation](https://github.com/docs) for details.
1871
1872[www.github.com/pricing](https://www.github.com/pricing)"#;
1873
1874 let ctx = create_context(content);
1875 let result = rule.check(&ctx).unwrap();
1876
1877 assert_eq!(
1879 result.len(),
1880 1,
1881 "Only non-URL link text should be flagged, got: {result:?}"
1882 );
1883 assert_eq!(result[0].line, 3);
1884 }
1885
1886 #[test]
1887 fn test_html_attribute_values_not_flagged() {
1888 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1891 let content = "# Heading\n\ntest\n\n<img src=\"www.example.test/test_image.png\">\n";
1892 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1893 let result = rule.check(&ctx).unwrap();
1894
1895 let line5_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
1897 assert!(
1898 line5_violations.is_empty(),
1899 "Should not flag anything inside HTML tag attributes: {line5_violations:?}"
1900 );
1901
1902 let line3_violations: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
1904 assert_eq!(line3_violations.len(), 1, "Plain 'test' on line 3 should be flagged");
1905 }
1906
1907 #[test]
1908 fn test_html_text_content_still_flagged() {
1909 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1911 let content = "# Heading\n\n<a href=\"https://example.test/page\">test link</a>\n";
1912 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1913 let result = rule.check(&ctx).unwrap();
1914
1915 assert_eq!(
1918 result.len(),
1919 1,
1920 "Should flag only 'test' in anchor text, not in href: {result:?}"
1921 );
1922 assert_eq!(result[0].column, 37, "Should flag col 37 ('test link' in anchor text)");
1923 }
1924
1925 #[test]
1926 fn test_html_attribute_various_not_flagged() {
1927 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1929 let content = concat!(
1930 "# Heading\n\n",
1931 "<img src=\"test.png\" alt=\"test image\">\n",
1932 "<span class=\"test-class\" data-test=\"value\">test content</span>\n",
1933 );
1934 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1935 let result = rule.check(&ctx).unwrap();
1936
1937 assert_eq!(
1939 result.len(),
1940 1,
1941 "Should flag only 'test content' between tags: {result:?}"
1942 );
1943 assert_eq!(result[0].line, 4);
1944 }
1945
1946 #[test]
1947 fn test_plain_text_underscore_boundary_unchanged() {
1948 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1951 let content = "# Heading\n\ntest_image is here and just_test ends here\n";
1952 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1953 let result = rule.check(&ctx).unwrap();
1954
1955 assert_eq!(
1958 result.len(),
1959 2,
1960 "Should flag 'test' in both 'test_image' and 'just_test': {result:?}"
1961 );
1962 let cols: Vec<usize> = result.iter().map(|w| w.column).collect();
1963 assert!(cols.contains(&1), "Should flag col 1 (test_image): {cols:?}");
1964 assert!(cols.contains(&29), "Should flag col 29 (just_test): {cols:?}");
1965 }
1966
1967 #[test]
1968 fn test_frontmatter_yaml_keys_not_flagged() {
1969 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1972
1973 let content = "---\ntitle: Heading\ntest: Some Test value\n---\n\nTest\n";
1974 let ctx = create_context(content);
1975 let result = rule.check(&ctx).unwrap();
1976
1977 assert!(
1981 result.is_empty(),
1982 "Should not flag YAML keys or correctly capitalized values: {result:?}"
1983 );
1984 }
1985
1986 #[test]
1987 fn test_frontmatter_yaml_values_flagged() {
1988 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1990
1991 let content = "---\ntitle: Heading\nkey: a test value\n---\n\nTest\n";
1992 let ctx = create_context(content);
1993 let result = rule.check(&ctx).unwrap();
1994
1995 assert_eq!(result.len(), 1, "Should flag 'test' in YAML value: {result:?}");
1997 assert_eq!(result[0].line, 3);
1998 assert_eq!(result[0].column, 8); }
2000
2001 #[test]
2002 fn test_frontmatter_key_matches_name_not_flagged() {
2003 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2005
2006 let content = "---\ntest: other value\n---\n\nBody text\n";
2007 let ctx = create_context(content);
2008 let result = rule.check(&ctx).unwrap();
2009
2010 assert!(
2011 result.is_empty(),
2012 "Should not flag YAML key that matches configured name: {result:?}"
2013 );
2014 }
2015
2016 #[test]
2017 fn test_frontmatter_empty_value_not_flagged() {
2018 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2020
2021 let content = "---\ntest:\ntest: \n---\n\nBody text\n";
2022 let ctx = create_context(content);
2023 let result = rule.check(&ctx).unwrap();
2024
2025 assert!(
2026 result.is_empty(),
2027 "Should not flag YAML keys with empty values: {result:?}"
2028 );
2029 }
2030
2031 #[test]
2032 fn test_frontmatter_nested_yaml_key_not_flagged() {
2033 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2035
2036 let content = "---\nparent:\n test: nested value\n---\n\nBody text\n";
2037 let ctx = create_context(content);
2038 let result = rule.check(&ctx).unwrap();
2039
2040 assert!(result.is_empty(), "Should not flag nested YAML keys: {result:?}");
2042 }
2043
2044 #[test]
2045 fn test_frontmatter_list_items_checked() {
2046 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2048
2049 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2050 let ctx = create_context(content);
2051 let result = rule.check(&ctx).unwrap();
2052
2053 assert_eq!(result.len(), 1, "Should flag 'test' in YAML list item: {result:?}");
2055 assert_eq!(result[0].line, 3);
2056 }
2057
2058 #[test]
2059 fn test_frontmatter_value_with_multiple_colons() {
2060 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2062
2063 let content = "---\ntest: description: a test thing\n---\n\nBody text\n";
2064 let ctx = create_context(content);
2065 let result = rule.check(&ctx).unwrap();
2066
2067 assert_eq!(
2070 result.len(),
2071 1,
2072 "Should flag 'test' in value after first colon: {result:?}"
2073 );
2074 assert_eq!(result[0].line, 2);
2075 assert!(result[0].column > 6, "Violation column should be in value portion");
2076 }
2077
2078 #[test]
2079 fn test_frontmatter_does_not_affect_body() {
2080 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2082
2083 let content = "---\ntitle: Heading\n---\n\ntest should be flagged here\n";
2084 let ctx = create_context(content);
2085 let result = rule.check(&ctx).unwrap();
2086
2087 assert_eq!(result.len(), 1, "Should flag 'test' in body text: {result:?}");
2088 assert_eq!(result[0].line, 5);
2089 }
2090
2091 #[test]
2092 fn test_frontmatter_fix_corrects_values_preserves_keys() {
2093 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2095
2096 let content = "---\ntest: a test value\n---\n\ntest here\n";
2097 let ctx = create_context(content);
2098 let fixed = rule.fix(&ctx).unwrap();
2099
2100 assert_eq!(fixed, "---\ntest: a Test value\n---\n\nTest here\n");
2102 }
2103
2104 #[test]
2105 fn test_frontmatter_multiword_value_flagged() {
2106 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2108
2109 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2110 let ctx = create_context(content);
2111 let result = rule.check(&ctx).unwrap();
2112
2113 assert_eq!(result.len(), 2, "Should flag both names in YAML value: {result:?}");
2114 assert!(result.iter().all(|w| w.line == 2));
2115 }
2116
2117 #[test]
2118 fn test_frontmatter_yaml_comments_not_checked() {
2119 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2121
2122 let content = "---\n# test comment\ntitle: Heading\n---\n\nBody text\n";
2123 let ctx = create_context(content);
2124 let result = rule.check(&ctx).unwrap();
2125
2126 assert!(result.is_empty(), "Should not flag names in YAML comments: {result:?}");
2127 }
2128
2129 #[test]
2130 fn test_frontmatter_delimiters_not_checked() {
2131 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2133
2134 let content = "---\ntitle: Heading\n---\n\ntest here\n";
2135 let ctx = create_context(content);
2136 let result = rule.check(&ctx).unwrap();
2137
2138 assert_eq!(result.len(), 1, "Should only flag body text: {result:?}");
2140 assert_eq!(result[0].line, 5);
2141 }
2142
2143 #[test]
2144 fn test_frontmatter_continuation_lines_checked() {
2145 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2147
2148 let content = "---\ndescription: >\n a test value\n continued here\n---\n\nBody\n";
2149 let ctx = create_context(content);
2150 let result = rule.check(&ctx).unwrap();
2151
2152 assert_eq!(result.len(), 1, "Should flag 'test' in continuation line: {result:?}");
2154 assert_eq!(result[0].line, 3);
2155 }
2156
2157 #[test]
2158 fn test_frontmatter_quoted_values_checked() {
2159 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2161
2162 let content = "---\ntitle: \"a test title\"\n---\n\nBody\n";
2163 let ctx = create_context(content);
2164 let result = rule.check(&ctx).unwrap();
2165
2166 assert_eq!(result.len(), 1, "Should flag 'test' in quoted YAML value: {result:?}");
2167 assert_eq!(result[0].line, 2);
2168 }
2169
2170 #[test]
2171 fn test_frontmatter_single_quoted_values_checked() {
2172 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2174
2175 let content = "---\ntitle: 'a test title'\n---\n\nBody\n";
2176 let ctx = create_context(content);
2177 let result = rule.check(&ctx).unwrap();
2178
2179 assert_eq!(
2180 result.len(),
2181 1,
2182 "Should flag 'test' in single-quoted YAML value: {result:?}"
2183 );
2184 assert_eq!(result[0].line, 2);
2185 }
2186
2187 #[test]
2188 fn test_frontmatter_fix_multiword_values() {
2189 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2191
2192 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2193 let ctx = create_context(content);
2194 let fixed = rule.fix(&ctx).unwrap();
2195
2196 assert_eq!(
2197 fixed,
2198 "---\ndescription: Learn JavaScript and TypeScript\n---\n\nBody\n"
2199 );
2200 }
2201
2202 #[test]
2203 fn test_frontmatter_fix_preserves_yaml_structure() {
2204 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2206
2207 let content = "---\ntags:\n - test\n - other\ntitle: a test doc\n---\n\ntest body\n";
2208 let ctx = create_context(content);
2209 let fixed = rule.fix(&ctx).unwrap();
2210
2211 assert_eq!(
2212 fixed,
2213 "---\ntags:\n - Test\n - other\ntitle: a Test doc\n---\n\nTest body\n"
2214 );
2215 }
2216
2217 #[test]
2218 fn test_frontmatter_toml_delimiters_not_checked() {
2219 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2221
2222 let content = "+++\ntitle = \"a test title\"\n+++\n\ntest body\n";
2223 let ctx = create_context(content);
2224 let result = rule.check(&ctx).unwrap();
2225
2226 assert_eq!(result.len(), 2, "Should flag TOML value and body: {result:?}");
2230 let fm_violations: Vec<_> = result.iter().filter(|w| w.line == 2).collect();
2231 assert_eq!(fm_violations.len(), 1, "Should flag 'test' in TOML value: {result:?}");
2232 let body_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2233 assert_eq!(body_violations.len(), 1, "Should flag body 'test': {result:?}");
2234 }
2235
2236 #[test]
2237 fn test_frontmatter_toml_key_not_flagged() {
2238 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2240
2241 let content = "+++\ntest = \"other value\"\n+++\n\nBody text\n";
2242 let ctx = create_context(content);
2243 let result = rule.check(&ctx).unwrap();
2244
2245 assert!(
2246 result.is_empty(),
2247 "Should not flag TOML key that matches configured name: {result:?}"
2248 );
2249 }
2250
2251 #[test]
2252 fn test_frontmatter_toml_fix_preserves_keys() {
2253 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2255
2256 let content = "+++\ntest = \"a test value\"\n+++\n\ntest here\n";
2257 let ctx = create_context(content);
2258 let fixed = rule.fix(&ctx).unwrap();
2259
2260 assert_eq!(fixed, "+++\ntest = \"a Test value\"\n+++\n\nTest here\n");
2262 }
2263
2264 #[test]
2265 fn test_frontmatter_list_item_mapping_key_not_flagged() {
2266 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2269
2270 let content = "---\nitems:\n - test: nested value\n---\n\nBody text\n";
2271 let ctx = create_context(content);
2272 let result = rule.check(&ctx).unwrap();
2273
2274 assert!(
2275 result.is_empty(),
2276 "Should not flag YAML key in list-item mapping: {result:?}"
2277 );
2278 }
2279
2280 #[test]
2281 fn test_frontmatter_list_item_mapping_value_flagged() {
2282 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2284
2285 let content = "---\nitems:\n - key: a test value\n---\n\nBody text\n";
2286 let ctx = create_context(content);
2287 let result = rule.check(&ctx).unwrap();
2288
2289 assert_eq!(
2290 result.len(),
2291 1,
2292 "Should flag 'test' in list-item mapping value: {result:?}"
2293 );
2294 assert_eq!(result[0].line, 3);
2295 }
2296
2297 #[test]
2298 fn test_frontmatter_bare_list_item_still_flagged() {
2299 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2301
2302 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2303 let ctx = create_context(content);
2304 let result = rule.check(&ctx).unwrap();
2305
2306 assert_eq!(result.len(), 1, "Should flag 'test' in bare list item: {result:?}");
2307 assert_eq!(result[0].line, 3);
2308 }
2309
2310 #[test]
2311 fn test_frontmatter_flow_mapping_not_flagged() {
2312 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2315
2316 let content = "---\nflow_map: {test: value, other: test}\n---\n\nBody text\n";
2317 let ctx = create_context(content);
2318 let result = rule.check(&ctx).unwrap();
2319
2320 assert!(
2321 result.is_empty(),
2322 "Should not flag names inside flow mappings: {result:?}"
2323 );
2324 }
2325
2326 #[test]
2327 fn test_frontmatter_flow_sequence_not_flagged() {
2328 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2330
2331 let content = "---\nitems: [test, other, test]\n---\n\nBody text\n";
2332 let ctx = create_context(content);
2333 let result = rule.check(&ctx).unwrap();
2334
2335 assert!(
2336 result.is_empty(),
2337 "Should not flag names inside flow sequences: {result:?}"
2338 );
2339 }
2340
2341 #[test]
2342 fn test_frontmatter_list_item_mapping_fix_preserves_key() {
2343 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2345
2346 let content = "---\nitems:\n - test: a test value\n---\n\ntest here\n";
2347 let ctx = create_context(content);
2348 let fixed = rule.fix(&ctx).unwrap();
2349
2350 assert_eq!(fixed, "---\nitems:\n - test: a Test value\n---\n\nTest here\n");
2353 }
2354
2355 #[test]
2356 fn test_frontmatter_backtick_code_not_flagged() {
2357 let config = MD044Config {
2359 names: vec!["GoodApplication".to_string()],
2360 code_blocks: false,
2361 ..MD044Config::default()
2362 };
2363 let rule = MD044ProperNames::from_config_struct(config);
2364
2365 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2366 let ctx = create_context(content);
2367 let result = rule.check(&ctx).unwrap();
2368
2369 assert!(
2371 result.is_empty(),
2372 "Should not flag names inside backticks in frontmatter or body: {result:?}"
2373 );
2374 }
2375
2376 #[test]
2377 fn test_frontmatter_unquoted_backtick_code_not_flagged() {
2378 let config = MD044Config {
2380 names: vec!["GoodApplication".to_string()],
2381 code_blocks: false,
2382 ..MD044Config::default()
2383 };
2384 let rule = MD044ProperNames::from_config_struct(config);
2385
2386 let content = "---\ntitle: `goodapplication` CLI\n---\n\nIntroductory `goodapplication` CLI 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 backticks in unquoted YAML frontmatter: {result:?}"
2393 );
2394 }
2395
2396 #[test]
2397 fn test_frontmatter_bare_name_still_flagged_with_backtick_nearby() {
2398 let config = MD044Config {
2400 names: vec!["GoodApplication".to_string()],
2401 code_blocks: false,
2402 ..MD044Config::default()
2403 };
2404 let rule = MD044ProperNames::from_config_struct(config);
2405
2406 let content = "---\ntitle: goodapplication `goodapplication` CLI\n---\n\nBody\n";
2407 let ctx = create_context(content);
2408 let result = rule.check(&ctx).unwrap();
2409
2410 assert_eq!(
2412 result.len(),
2413 1,
2414 "Should flag bare name but not backtick-wrapped name: {result:?}"
2415 );
2416 assert_eq!(result[0].line, 2);
2417 assert_eq!(result[0].column, 8); }
2419
2420 #[test]
2421 fn test_frontmatter_backtick_code_with_code_blocks_true() {
2422 let config = MD044Config {
2424 names: vec!["GoodApplication".to_string()],
2425 code_blocks: true,
2426 ..MD044Config::default()
2427 };
2428 let rule = MD044ProperNames::from_config_struct(config);
2429
2430 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nBody\n";
2431 let ctx = create_context(content);
2432 let result = rule.check(&ctx).unwrap();
2433
2434 assert_eq!(
2436 result.len(),
2437 1,
2438 "Should flag backtick-wrapped name when code_blocks=true: {result:?}"
2439 );
2440 assert_eq!(result[0].line, 2);
2441 }
2442
2443 #[test]
2444 fn test_frontmatter_fix_preserves_backtick_code() {
2445 let config = MD044Config {
2447 names: vec!["GoodApplication".to_string()],
2448 code_blocks: false,
2449 ..MD044Config::default()
2450 };
2451 let rule = MD044ProperNames::from_config_struct(config);
2452
2453 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2454 let ctx = create_context(content);
2455 let fixed = rule.fix(&ctx).unwrap();
2456
2457 assert_eq!(
2459 fixed, content,
2460 "Fix should not modify names inside backticks in frontmatter"
2461 );
2462 }
2463
2464 #[test]
2467 fn test_angle_bracket_url_in_html_comment_not_flagged() {
2468 let config = MD044Config {
2470 names: vec!["Test".to_string()],
2471 ..MD044Config::default()
2472 };
2473 let rule = MD044ProperNames::from_config_struct(config);
2474
2475 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";
2476 let ctx = create_context(content);
2477 let result = rule.check(&ctx).unwrap();
2478
2479 let line8_warnings: Vec<_> = result.iter().filter(|w| w.line == 8).collect();
2487 assert!(
2488 line8_warnings.is_empty(),
2489 "Should not flag names inside angle-bracket URLs in HTML comments: {line8_warnings:?}"
2490 );
2491 }
2492
2493 #[test]
2494 fn test_bare_url_in_html_comment_still_flagged() {
2495 let config = MD044Config {
2497 names: vec!["Test".to_string()],
2498 ..MD044Config::default()
2499 };
2500 let rule = MD044ProperNames::from_config_struct(config);
2501
2502 let content = "<!-- This is a test https://www.example.test -->\n";
2503 let ctx = create_context(content);
2504 let result = rule.check(&ctx).unwrap();
2505
2506 assert!(
2509 !result.is_empty(),
2510 "Should flag 'test' in prose text of HTML comment with bare URL"
2511 );
2512 }
2513
2514 #[test]
2515 fn test_angle_bracket_url_in_regular_markdown_not_flagged() {
2516 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2519
2520 let content = "<https://www.example.test>\n";
2521 let ctx = create_context(content);
2522 let result = rule.check(&ctx).unwrap();
2523
2524 assert!(
2525 result.is_empty(),
2526 "Should not flag names inside angle-bracket URLs in regular markdown: {result:?}"
2527 );
2528 }
2529
2530 #[test]
2531 fn test_multiple_angle_bracket_urls_in_one_comment() {
2532 let config = MD044Config {
2533 names: vec!["Test".to_string()],
2534 ..MD044Config::default()
2535 };
2536 let rule = MD044ProperNames::from_config_struct(config);
2537
2538 let content = "<!-- See <https://test.example.com> and <https://www.example.test> for details -->\n";
2539 let ctx = create_context(content);
2540 let result = rule.check(&ctx).unwrap();
2541
2542 assert!(
2544 result.is_empty(),
2545 "Should not flag names inside multiple angle-bracket URLs: {result:?}"
2546 );
2547 }
2548
2549 #[test]
2550 fn test_angle_bracket_non_url_still_flagged() {
2551 assert!(
2554 !MD044ProperNames::is_in_angle_bracket_url("<test> which is not a URL.", 1),
2555 "is_in_angle_bracket_url should return false for non-URL angle brackets"
2556 );
2557 }
2558
2559 #[test]
2560 fn test_angle_bracket_mailto_url_not_flagged() {
2561 let config = MD044Config {
2562 names: vec!["Test".to_string()],
2563 ..MD044Config::default()
2564 };
2565 let rule = MD044ProperNames::from_config_struct(config);
2566
2567 let content = "<!-- Contact <mailto:test@example.com> for help -->\n";
2568 let ctx = create_context(content);
2569 let result = rule.check(&ctx).unwrap();
2570
2571 assert!(
2572 result.is_empty(),
2573 "Should not flag names inside angle-bracket mailto URLs: {result:?}"
2574 );
2575 }
2576
2577 #[test]
2578 fn test_angle_bracket_ftp_url_not_flagged() {
2579 let config = MD044Config {
2580 names: vec!["Test".to_string()],
2581 ..MD044Config::default()
2582 };
2583 let rule = MD044ProperNames::from_config_struct(config);
2584
2585 let content = "<!-- Download from <ftp://test.example.com/file> -->\n";
2586 let ctx = create_context(content);
2587 let result = rule.check(&ctx).unwrap();
2588
2589 assert!(
2590 result.is_empty(),
2591 "Should not flag names inside angle-bracket FTP URLs: {result:?}"
2592 );
2593 }
2594
2595 #[test]
2596 fn test_angle_bracket_url_fix_preserves_url() {
2597 let config = MD044Config {
2599 names: vec!["Test".to_string()],
2600 ..MD044Config::default()
2601 };
2602 let rule = MD044ProperNames::from_config_struct(config);
2603
2604 let content = "<!-- test text <https://www.example.test> -->\n";
2605 let ctx = create_context(content);
2606 let fixed = rule.fix(&ctx).unwrap();
2607
2608 assert!(
2610 fixed.contains("<https://www.example.test>"),
2611 "Fix should preserve angle-bracket URLs: {fixed}"
2612 );
2613 assert!(
2614 fixed.contains("Test text"),
2615 "Fix should correct prose 'test' to 'Test': {fixed}"
2616 );
2617 }
2618
2619 #[test]
2620 fn test_is_in_angle_bracket_url_helper() {
2621 let line = "text <https://example.test> more text";
2623
2624 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));
2637
2638 assert!(MD044ProperNames::is_in_angle_bracket_url(
2640 "<mailto:test@example.com>",
2641 10
2642 ));
2643
2644 assert!(MD044ProperNames::is_in_angle_bracket_url(
2646 "<ftp://test.example.com>",
2647 10
2648 ));
2649 }
2650
2651 #[test]
2652 fn test_is_in_angle_bracket_url_uppercase_scheme() {
2653 assert!(MD044ProperNames::is_in_angle_bracket_url(
2655 "<HTTPS://test.example.com>",
2656 10
2657 ));
2658 assert!(MD044ProperNames::is_in_angle_bracket_url(
2659 "<Http://test.example.com>",
2660 10
2661 ));
2662 }
2663
2664 #[test]
2665 fn test_is_in_angle_bracket_url_uncommon_schemes() {
2666 assert!(MD044ProperNames::is_in_angle_bracket_url(
2668 "<ssh://test@example.com>",
2669 10
2670 ));
2671 assert!(MD044ProperNames::is_in_angle_bracket_url("<file:///test/path>", 10));
2673 assert!(MD044ProperNames::is_in_angle_bracket_url("<data:text/plain;test>", 10));
2675 }
2676
2677 #[test]
2678 fn test_is_in_angle_bracket_url_unclosed() {
2679 assert!(!MD044ProperNames::is_in_angle_bracket_url(
2681 "<https://test.example.com",
2682 10
2683 ));
2684 }
2685
2686 #[test]
2687 fn test_vale_inline_config_comments_not_flagged() {
2688 let config = MD044Config {
2689 names: vec!["Vale".to_string(), "JavaScript".to_string()],
2690 ..MD044Config::default()
2691 };
2692 let rule = MD044ProperNames::from_config_struct(config);
2693
2694 let content = "\
2695<!-- vale off -->
2696Some javascript text here.
2697<!-- vale on -->
2698<!-- vale Style.Rule = NO -->
2699More javascript text.
2700<!-- vale Style.Rule = YES -->
2701<!-- vale JavaScript.Grammar = NO -->
2702";
2703 let ctx = create_context(content);
2704 let result = rule.check(&ctx).unwrap();
2705
2706 assert_eq!(result.len(), 2, "Should only flag body lines, not Vale config comments");
2708 assert_eq!(result[0].line, 2);
2709 assert_eq!(result[1].line, 5);
2710 }
2711
2712 #[test]
2713 fn test_remark_lint_inline_config_comments_not_flagged() {
2714 let config = MD044Config {
2715 names: vec!["JavaScript".to_string()],
2716 ..MD044Config::default()
2717 };
2718 let rule = MD044ProperNames::from_config_struct(config);
2719
2720 let content = "\
2721<!-- lint disable remark-lint-some-rule -->
2722Some javascript text here.
2723<!-- lint enable remark-lint-some-rule -->
2724<!-- lint ignore remark-lint-some-rule -->
2725More javascript text.
2726";
2727 let ctx = create_context(content);
2728 let result = rule.check(&ctx).unwrap();
2729
2730 assert_eq!(
2731 result.len(),
2732 2,
2733 "Should only flag body lines, not remark-lint config comments"
2734 );
2735 assert_eq!(result[0].line, 2);
2736 assert_eq!(result[1].line, 5);
2737 }
2738
2739 #[test]
2740 fn test_fix_does_not_modify_vale_remark_lint_comments() {
2741 let config = MD044Config {
2742 names: vec!["JavaScript".to_string(), "Vale".to_string()],
2743 ..MD044Config::default()
2744 };
2745 let rule = MD044ProperNames::from_config_struct(config);
2746
2747 let content = "\
2748<!-- vale off -->
2749Some javascript text.
2750<!-- vale on -->
2751<!-- lint disable remark-lint-some-rule -->
2752More javascript text.
2753<!-- lint enable remark-lint-some-rule -->
2754";
2755 let ctx = create_context(content);
2756 let fixed = rule.fix(&ctx).unwrap();
2757
2758 assert!(fixed.contains("<!-- vale off -->"));
2760 assert!(fixed.contains("<!-- vale on -->"));
2761 assert!(fixed.contains("<!-- lint disable remark-lint-some-rule -->"));
2762 assert!(fixed.contains("<!-- lint enable remark-lint-some-rule -->"));
2763 assert!(fixed.contains("Some JavaScript text."));
2765 assert!(fixed.contains("More JavaScript text."));
2766 }
2767
2768 #[test]
2769 fn test_mixed_tool_directives_all_skipped() {
2770 let config = MD044Config {
2771 names: vec!["JavaScript".to_string(), "Vale".to_string()],
2772 ..MD044Config::default()
2773 };
2774 let rule = MD044ProperNames::from_config_struct(config);
2775
2776 let content = "\
2777<!-- rumdl-disable MD044 -->
2778Some javascript text.
2779<!-- markdownlint-disable -->
2780More javascript text.
2781<!-- vale off -->
2782Even more javascript text.
2783<!-- lint disable some-rule -->
2784Final javascript text.
2785<!-- rumdl-enable MD044 -->
2786<!-- markdownlint-enable -->
2787<!-- vale on -->
2788<!-- lint enable some-rule -->
2789";
2790 let ctx = create_context(content);
2791 let result = rule.check(&ctx).unwrap();
2792
2793 assert_eq!(
2795 result.len(),
2796 4,
2797 "Should only flag body lines, not any tool directive comments"
2798 );
2799 assert_eq!(result[0].line, 2);
2800 assert_eq!(result[1].line, 4);
2801 assert_eq!(result[2].line, 6);
2802 assert_eq!(result[3].line, 8);
2803 }
2804
2805 #[test]
2806 fn test_vale_remark_lint_edge_cases_not_matched() {
2807 let config = MD044Config {
2808 names: vec!["JavaScript".to_string(), "Vale".to_string()],
2809 ..MD044Config::default()
2810 };
2811 let rule = MD044ProperNames::from_config_struct(config);
2812
2813 let content = "\
2821<!-- vale -->
2822<!-- vale is a tool for writing -->
2823<!-- valedictorian javascript -->
2824<!-- linting javascript tips -->
2825<!-- vale javascript -->
2826<!-- lint your javascript code -->
2827";
2828 let ctx = create_context(content);
2829 let result = rule.check(&ctx).unwrap();
2830
2831 assert_eq!(
2838 result.len(),
2839 7,
2840 "Should flag proper names in non-directive HTML comments: got {result:?}"
2841 );
2842 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); }
2850
2851 #[test]
2852 fn test_vale_style_directives_skipped() {
2853 let config = MD044Config {
2854 names: vec!["JavaScript".to_string(), "Vale".to_string()],
2855 ..MD044Config::default()
2856 };
2857 let rule = MD044ProperNames::from_config_struct(config);
2858
2859 let content = "\
2861<!-- vale style = MyStyle -->
2862<!-- vale styles = Style1, Style2 -->
2863<!-- vale MyRule.Name = YES -->
2864<!-- vale MyRule.Name = NO -->
2865Some javascript text.
2866";
2867 let ctx = create_context(content);
2868 let result = rule.check(&ctx).unwrap();
2869
2870 assert_eq!(
2872 result.len(),
2873 1,
2874 "Should only flag body lines, not Vale style/rule directives: got {result:?}"
2875 );
2876 assert_eq!(result[0].line, 5);
2877 }
2878
2879 #[test]
2882 fn test_backtick_code_single_backticks() {
2883 let line = "hello `world` bye";
2884 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 7));
2886 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
2888 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 14));
2890 }
2891
2892 #[test]
2893 fn test_backtick_code_double_backticks() {
2894 let line = "a ``code`` b";
2895 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
2897 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
2899 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 11));
2901 }
2902
2903 #[test]
2904 fn test_backtick_code_unclosed() {
2905 let line = "a `code b";
2906 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
2908 }
2909
2910 #[test]
2911 fn test_backtick_code_mismatched_count() {
2912 let line = "a `code`` b";
2914 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
2917 }
2918
2919 #[test]
2920 fn test_backtick_code_multiple_spans() {
2921 let line = "`first` and `second`";
2922 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
2924 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 8));
2926 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 13));
2928 }
2929
2930 #[test]
2931 fn test_backtick_code_on_backtick_boundary() {
2932 let line = "`code`";
2933 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
2935 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 5));
2937 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
2939 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
2940 }
2941
2942 #[test]
2948 fn test_double_bracket_link_url_not_flagged() {
2949 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
2950 let content = "[[rumdl]](https://github.com/rvben/rumdl)";
2952 let ctx = create_context(content);
2953 let result = rule.check(&ctx).unwrap();
2954 assert!(
2955 result.is_empty(),
2956 "URL inside [[text]](url) must not be flagged, got: {result:?}"
2957 );
2958 }
2959
2960 #[test]
2961 fn test_double_bracket_link_url_not_fixed() {
2962 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
2963 let content = "[[rumdl]](https://github.com/rvben/rumdl)\n";
2964 let ctx = create_context(content);
2965 let fixed = rule.fix(&ctx).unwrap();
2966 assert_eq!(
2967 fixed, content,
2968 "fix() must leave the URL inside [[text]](url) unchanged"
2969 );
2970 }
2971
2972 #[test]
2973 fn test_double_bracket_link_text_still_flagged() {
2974 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
2975 let content = "[[github]](https://example.com)";
2977 let ctx = create_context(content);
2978 let result = rule.check(&ctx).unwrap();
2979 assert_eq!(
2980 result.len(),
2981 1,
2982 "Incorrect name in [[text]] link text should still be flagged, got: {result:?}"
2983 );
2984 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
2985 }
2986
2987 #[test]
2988 fn test_double_bracket_link_mixed_line() {
2989 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
2990 let content = "See [[rumdl]](https://github.com/rvben/rumdl) and github for more.";
2992 let ctx = create_context(content);
2993 let result = rule.check(&ctx).unwrap();
2994 assert_eq!(
2995 result.len(),
2996 1,
2997 "Only the standalone 'github' after the link should be flagged, got: {result:?}"
2998 );
2999 assert!(result[0].message.contains("'github'"));
3000 assert_eq!(
3002 result[0].column, 51,
3003 "Flagged column should be the trailing 'github', not the one in the URL"
3004 );
3005 }
3006
3007 #[test]
3008 fn test_regular_link_url_still_not_flagged() {
3009 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3011 let content = "[rumdl](https://github.com/rvben/rumdl)";
3012 let ctx = create_context(content);
3013 let result = rule.check(&ctx).unwrap();
3014 assert!(
3015 result.is_empty(),
3016 "URL inside regular [text](url) must still not be flagged, got: {result:?}"
3017 );
3018 }
3019
3020 #[test]
3021 fn test_link_like_text_in_code_span_still_flagged_when_code_blocks_enabled() {
3022 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
3027 let content = "`[foo](https://github.com/org/repo)`";
3028 let ctx = create_context(content);
3029 let result = rule.check(&ctx).unwrap();
3030 assert_eq!(
3031 result.len(),
3032 1,
3033 "Proper name inside a code span must be flagged when code-blocks=true, got: {result:?}"
3034 );
3035 assert!(result[0].message.contains("'github'"));
3036 }
3037
3038 #[test]
3039 fn test_malformed_link_not_treated_as_url() {
3040 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3043 let content = "See [rumdl](github repo) for details.";
3044 let ctx = create_context(content);
3045 let result = rule.check(&ctx).unwrap();
3046 assert_eq!(
3047 result.len(),
3048 1,
3049 "Name inside malformed [text](url with spaces) must still be flagged, got: {result:?}"
3050 );
3051 assert!(result[0].message.contains("'github'"));
3052 }
3053
3054 #[test]
3055 fn test_wikilink_followed_by_prose_parens_still_flagged() {
3056 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3060 let content = "[[note]](github repo)";
3061 let ctx = create_context(content);
3062 let result = rule.check(&ctx).unwrap();
3063 assert_eq!(
3064 result.len(),
3065 1,
3066 "Name inside [[wikilink]](prose with spaces) must still be flagged, got: {result:?}"
3067 );
3068 assert!(result[0].message.contains("'github'"));
3069 }
3070
3071 #[test]
3073 fn test_roundtrip_fix_then_check_basic() {
3074 let rule = MD044ProperNames::new(
3075 vec![
3076 "JavaScript".to_string(),
3077 "TypeScript".to_string(),
3078 "Node.js".to_string(),
3079 ],
3080 true,
3081 );
3082 let content = "I love javascript, typescript, and nodejs!";
3083 let ctx = create_context(content);
3084 let fixed = rule.fix(&ctx).unwrap();
3085 let ctx2 = create_context(&fixed);
3086 let warnings = rule.check(&ctx2).unwrap();
3087 assert!(
3088 warnings.is_empty(),
3089 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3090 );
3091 }
3092
3093 #[test]
3095 fn test_roundtrip_fix_then_check_multiline() {
3096 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
3097 let content = "First line with rust.\nSecond line with python.\nThird line with RUST and PYTHON.\n";
3098 let ctx = create_context(content);
3099 let fixed = rule.fix(&ctx).unwrap();
3100 let ctx2 = create_context(&fixed);
3101 let warnings = rule.check(&ctx2).unwrap();
3102 assert!(
3103 warnings.is_empty(),
3104 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3105 );
3106 }
3107
3108 #[test]
3110 fn test_roundtrip_fix_then_check_inline_config() {
3111 let config = MD044Config {
3112 names: vec!["RUMDL".to_string()],
3113 ..MD044Config::default()
3114 };
3115 let rule = MD044ProperNames::from_config_struct(config);
3116 let content =
3117 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
3118 let ctx = create_context(content);
3119 let fixed = rule.fix(&ctx).unwrap();
3120 assert!(
3122 fixed.contains("Some rumdl text.\n"),
3123 "Disabled block text should be preserved"
3124 );
3125 assert!(
3126 fixed.contains("Some RUMDL text outside."),
3127 "Outside text should be fixed"
3128 );
3129 }
3130
3131 #[test]
3133 fn test_roundtrip_fix_then_check_html_comments() {
3134 let config = MD044Config {
3135 names: vec!["JavaScript".to_string()],
3136 ..MD044Config::default()
3137 };
3138 let rule = MD044ProperNames::from_config_struct(config);
3139 let content = "# Guide\n\n<!-- javascript mentioned here -->\n\njavascript outside\n";
3140 let ctx = create_context(content);
3141 let fixed = rule.fix(&ctx).unwrap();
3142 let ctx2 = create_context(&fixed);
3143 let warnings = rule.check(&ctx2).unwrap();
3144 assert!(
3145 warnings.is_empty(),
3146 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3147 );
3148 }
3149
3150 #[test]
3152 fn test_roundtrip_no_op_when_correct() {
3153 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
3154 let content = "This uses JavaScript and TypeScript correctly.\n";
3155 let ctx = create_context(content);
3156 let fixed = rule.fix(&ctx).unwrap();
3157 assert_eq!(fixed, content, "Fix should be a no-op when content is already correct");
3158 }
3159
3160 #[test]
3163 fn test_bare_domain_link_text_not_flagged() {
3164 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3168 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3169 let ctx = create_context(content);
3170 let result = rule.check(&ctx).unwrap();
3171 assert!(
3172 result.is_empty(),
3173 "Should not flag 'github' in a bare-domain link text that matches the link URL: {result:?}"
3174 );
3175 }
3176
3177 #[test]
3178 fn test_bare_domain_link_text_not_fixed() {
3179 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3181 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3182 let ctx = create_context(content);
3183 let fixed = rule.fix(&ctx).unwrap();
3184 assert_eq!(
3185 fixed, content,
3186 "fix() must not alter bare-domain link text that matches the destination URL"
3187 );
3188 }
3189
3190 #[test]
3191 fn test_bare_domain_link_text_with_path_not_flagged() {
3192 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3194 let content = "Visit [ravencentric.github.io](https://ravencentric.github.io/projects).\n";
3195 let ctx = create_context(content);
3196 let result = rule.check(&ctx).unwrap();
3197 assert!(
3198 result.is_empty(),
3199 "Should not flag 'github' when bare-domain text is the hostname of its destination URL: {result:?}"
3200 );
3201 }
3202
3203 #[test]
3204 fn test_bare_domain_link_text_full_path_not_flagged() {
3205 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3207 let content = "See [ravencentric.github.io/blog](https://ravencentric.github.io/blog).\n";
3208 let ctx = create_context(content);
3209 let result = rule.check(&ctx).unwrap();
3210 assert!(
3211 result.is_empty(),
3212 "Should not flag 'github' when link text is the full URL path without scheme: {result:?}"
3213 );
3214 }
3215
3216 #[test]
3217 fn test_github_product_name_in_link_text_still_flagged() {
3218 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3221 let content = "Hosted on [github pages](https://pages.github.com).\n";
3222 let ctx = create_context(content);
3223 let result = rule.check(&ctx).unwrap();
3224 assert!(
3225 !result.is_empty(),
3226 "Should still flag 'github' in descriptive link text that does not match the destination URL"
3227 );
3228 }
3229
3230 #[test]
3231 fn test_protocol_relative_bare_domain_link_text_not_flagged() {
3232 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3234 let content = "See [github.io](//github.io).\n";
3235 let ctx = create_context(content);
3236 let result = rule.check(&ctx).unwrap();
3237 assert!(
3238 result.is_empty(),
3239 "Should not flag 'github' in bare-domain text matching a protocol-relative destination: {result:?}"
3240 );
3241 }
3242
3243 #[test]
3244 fn test_dotted_wikilink_target_still_flagged() {
3245 let rule = MD044ProperNames::new(vec!["Node.js".to_string()], false);
3250 let content = "See [[node.js]] for details.\n";
3251 let ctx = create_context(content);
3252 let result = rule.check(&ctx).unwrap();
3253 assert!(
3254 !result.is_empty(),
3255 "Should flag 'node.js' in a dotted WikiLink target: {result:?}"
3256 );
3257 }
3258
3259 #[test]
3260 fn test_bare_domain_link_text_case_insensitive_url() {
3261 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3264 let content = "See [github.io](HTTPS://github.io).\n";
3265 let ctx = create_context(content);
3266 let result = rule.check(&ctx).unwrap();
3267 assert!(
3268 result.is_empty(),
3269 "Should not flag bare-domain text when destination URL has an uppercase scheme: {result:?}"
3270 );
3271 }
3272}