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