1use crate::utils::fast_hash;
2use crate::utils::regex_cache::{escape_regex, get_cached_regex};
3
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use std::collections::{HashMap, HashSet};
6use std::sync::{Arc, Mutex};
7
8mod md044_config;
9pub use md044_config::MD044Config;
10
11type WarningPosition = (usize, usize, String); fn is_inline_config_comment(trimmed: &str) -> bool {
70 trimmed.starts_with("<!-- rumdl-")
71 || trimmed.starts_with("<!-- markdownlint-")
72 || trimmed.starts_with("<!-- vale off")
73 || trimmed.starts_with("<!-- vale on")
74 || (trimmed.starts_with("<!-- vale ") && trimmed.contains(" = "))
75 || trimmed.starts_with("<!-- vale style")
76 || trimmed.starts_with("<!-- lint disable ")
77 || trimmed.starts_with("<!-- lint enable ")
78 || trimmed.starts_with("<!-- lint ignore ")
79}
80
81#[derive(Clone)]
82pub struct MD044ProperNames {
83 config: MD044Config,
84 combined_pattern: Option<String>,
86 name_variants: Vec<String>,
88 content_cache: Arc<Mutex<HashMap<u64, Vec<WarningPosition>>>>,
90}
91
92impl MD044ProperNames {
93 pub fn new(names: Vec<String>, code_blocks: bool) -> Self {
94 let config = MD044Config {
95 names,
96 code_blocks,
97 html_elements: true, html_comments: true, };
100 let combined_pattern = Self::create_combined_pattern(&config);
101 let name_variants = Self::build_name_variants(&config);
102 Self {
103 config,
104 combined_pattern,
105 name_variants,
106 content_cache: Arc::new(Mutex::new(HashMap::new())),
107 }
108 }
109
110 fn ascii_normalize(s: &str) -> String {
112 s.replace(['é', 'è', 'ê', 'ë'], "e")
113 .replace(['à', 'á', 'â', 'ä', 'ã', 'å'], "a")
114 .replace(['ï', 'î', 'í', 'ì'], "i")
115 .replace(['ü', 'ú', 'ù', 'û'], "u")
116 .replace(['ö', 'ó', 'ò', 'ô', 'õ'], "o")
117 .replace('ñ', "n")
118 .replace('ç', "c")
119 }
120
121 pub fn from_config_struct(config: MD044Config) -> Self {
122 let combined_pattern = Self::create_combined_pattern(&config);
123 let name_variants = Self::build_name_variants(&config);
124 Self {
125 config,
126 combined_pattern,
127 name_variants,
128 content_cache: Arc::new(Mutex::new(HashMap::new())),
129 }
130 }
131
132 fn create_combined_pattern(config: &MD044Config) -> Option<String> {
134 if config.names.is_empty() {
135 return None;
136 }
137
138 let mut patterns: Vec<String> = config
140 .names
141 .iter()
142 .flat_map(|name| {
143 let mut variations = vec![];
144 let lower_name = name.to_lowercase();
145
146 variations.push(escape_regex(&lower_name));
148
149 let lower_name_no_dots = lower_name.replace('.', "");
151 if lower_name != lower_name_no_dots {
152 variations.push(escape_regex(&lower_name_no_dots));
153 }
154
155 let ascii_normalized = Self::ascii_normalize(&lower_name);
157
158 if ascii_normalized != lower_name {
159 variations.push(escape_regex(&ascii_normalized));
160
161 let ascii_no_dots = ascii_normalized.replace('.', "");
163 if ascii_normalized != ascii_no_dots {
164 variations.push(escape_regex(&ascii_no_dots));
165 }
166 }
167
168 variations
169 })
170 .collect();
171
172 patterns.sort_by_key(|b| std::cmp::Reverse(b.len()));
174
175 Some(format!(r"(?i)({})", patterns.join("|")))
178 }
179
180 fn build_name_variants(config: &MD044Config) -> Vec<String> {
181 let mut variants = HashSet::new();
182 for name in &config.names {
183 let lower_name = name.to_lowercase();
184 variants.insert(lower_name.clone());
185
186 let lower_no_dots = lower_name.replace('.', "");
187 if lower_name != lower_no_dots {
188 variants.insert(lower_no_dots);
189 }
190
191 let ascii_normalized = Self::ascii_normalize(&lower_name);
192 if ascii_normalized != lower_name {
193 variants.insert(ascii_normalized.clone());
194
195 let ascii_no_dots = ascii_normalized.replace('.', "");
196 if ascii_normalized != ascii_no_dots {
197 variants.insert(ascii_no_dots);
198 }
199 }
200 }
201
202 variants.into_iter().collect()
203 }
204
205 fn find_name_violations(
208 &self,
209 content: &str,
210 ctx: &crate::lint_context::LintContext,
211 content_lower: &str,
212 ) -> Vec<WarningPosition> {
213 if self.config.names.is_empty() || content.is_empty() || self.combined_pattern.is_none() {
215 return Vec::new();
216 }
217
218 let has_potential_matches = self.name_variants.iter().any(|name| content_lower.contains(name));
220
221 if !has_potential_matches {
222 return Vec::new();
223 }
224
225 let hash = fast_hash(content);
227 {
228 if let Ok(cache) = self.content_cache.lock()
230 && let Some(cached) = cache.get(&hash)
231 {
232 return cached.clone();
233 }
234 }
235
236 let mut violations = Vec::new();
237
238 let combined_regex = match &self.combined_pattern {
240 Some(pattern) => match get_cached_regex(pattern) {
241 Ok(regex) => regex,
242 Err(_) => return Vec::new(),
243 },
244 None => return Vec::new(),
245 };
246
247 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
249 let line_num = line_idx + 1;
250 let line = line_info.content(ctx.content);
251
252 let trimmed = line.trim_start();
254 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
255 continue;
256 }
257
258 if !self.config.code_blocks && line_info.in_code_block {
260 continue;
261 }
262
263 if !self.config.html_elements && line_info.in_html_block {
265 continue;
266 }
267
268 if !self.config.html_comments && line_info.in_html_comment {
270 continue;
271 }
272
273 if line_info.in_jsx_expression || line_info.in_mdx_comment {
275 continue;
276 }
277
278 if line_info.in_obsidian_comment {
280 continue;
281 }
282
283 let fm_value_offset = if line_info.in_front_matter {
286 Self::frontmatter_value_offset(line)
287 } else {
288 0
289 };
290 if fm_value_offset == usize::MAX {
291 continue;
292 }
293
294 if is_inline_config_comment(trimmed) {
296 continue;
297 }
298
299 let line_lower = line.to_lowercase();
301 let has_line_matches = self.name_variants.iter().any(|name| line_lower.contains(name));
302
303 if !has_line_matches {
304 continue;
305 }
306
307 for cap in combined_regex.find_iter(line) {
309 let found_name = &line[cap.start()..cap.end()];
310
311 let start_pos = cap.start();
313 let end_pos = cap.end();
314
315 if start_pos < fm_value_offset {
317 continue;
318 }
319
320 let byte_pos = line_info.byte_offset + start_pos;
322 if ctx.is_in_html_tag(byte_pos) {
323 continue;
324 }
325
326 if !Self::is_at_word_boundary(line, start_pos, true) || !Self::is_at_word_boundary(line, end_pos, false)
327 {
328 continue; }
330
331 if !self.config.code_blocks {
333 if ctx.is_in_code_block_or_span(byte_pos) {
334 continue;
335 }
336 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
340 && Self::is_in_backtick_code_in_line(line, start_pos)
341 {
342 continue;
343 }
344 }
345
346 if Self::is_in_link(ctx, byte_pos) {
348 continue;
349 }
350
351 if Self::is_in_angle_bracket_url(line, start_pos) {
355 continue;
356 }
357
358 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
361 && Self::is_in_markdown_link_url(line, start_pos)
362 {
363 continue;
364 }
365
366 if let Some(proper_name) = self.get_proper_name_for(found_name) {
368 if found_name != proper_name {
370 violations.push((line_num, cap.start() + 1, found_name.to_string()));
371 }
372 }
373 }
374 }
375
376 if let Ok(mut cache) = self.content_cache.lock() {
378 cache.insert(hash, violations.clone());
379 }
380 violations
381 }
382
383 fn is_in_link(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
390 use pulldown_cmark::LinkType;
391
392 let link_idx = ctx.links.partition_point(|link| link.byte_offset <= byte_pos);
394 if link_idx > 0 {
395 let link = &ctx.links[link_idx - 1];
396 if byte_pos < link.byte_end {
397 let text_start = if matches!(link.link_type, LinkType::WikiLink { .. }) {
399 link.byte_offset + 2
400 } else {
401 link.byte_offset + 1
402 };
403 let text_end = text_start + link.text.len();
404
405 if byte_pos >= text_start && byte_pos < text_end {
407 return Self::link_text_is_url(&link.text);
408 }
409 return true;
411 }
412 }
413
414 let image_idx = ctx.images.partition_point(|img| img.byte_offset <= byte_pos);
416 if image_idx > 0 {
417 let image = &ctx.images[image_idx - 1];
418 if byte_pos < image.byte_end {
419 let alt_start = image.byte_offset + 2;
421 let alt_end = alt_start + image.alt_text.len();
422
423 if byte_pos >= alt_start && byte_pos < alt_end {
425 return false;
426 }
427 return true;
429 }
430 }
431
432 ctx.is_in_reference_def(byte_pos)
434 }
435
436 fn link_text_is_url(text: &str) -> bool {
439 let lower = text.trim().to_ascii_lowercase();
440 lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("www.")
441 }
442
443 fn is_in_angle_bracket_url(line: &str, pos: usize) -> bool {
449 let bytes = line.as_bytes();
450 let len = bytes.len();
451 let mut i = 0;
452 while i < len {
453 if bytes[i] == b'<' {
454 let after_open = i + 1;
455 if after_open < len && bytes[after_open].is_ascii_alphabetic() {
459 let mut s = after_open + 1;
460 let scheme_max = (after_open + 32).min(len);
461 while s < scheme_max
462 && (bytes[s].is_ascii_alphanumeric()
463 || bytes[s] == b'+'
464 || bytes[s] == b'-'
465 || bytes[s] == b'.')
466 {
467 s += 1;
468 }
469 if s < len && bytes[s] == b':' {
470 let mut j = s + 1;
472 let mut found_close = false;
473 while j < len {
474 match bytes[j] {
475 b'>' => {
476 found_close = true;
477 break;
478 }
479 b' ' | b'<' => break,
480 _ => j += 1,
481 }
482 }
483 if found_close && pos >= i && pos <= j {
484 return true;
485 }
486 if found_close {
487 i = j + 1;
488 continue;
489 }
490 }
491 }
492 }
493 i += 1;
494 }
495 false
496 }
497
498 fn is_in_markdown_link_url(line: &str, pos: usize) -> bool {
507 let bytes = line.as_bytes();
508 let len = bytes.len();
509 let mut i = 0;
510
511 while i < len {
512 if bytes[i] == b'[' && (i == 0 || bytes[i - 1] != b'\\' || (i >= 2 && bytes[i - 2] == b'\\')) {
514 let mut depth: u32 = 1;
516 let mut j = i + 1;
517 while j < len && depth > 0 {
518 match bytes[j] {
519 b'\\' => {
520 j += 1; }
522 b'[' => depth += 1,
523 b']' => depth -= 1,
524 _ => {}
525 }
526 j += 1;
527 }
528
529 if depth == 0 && j < len {
531 if bytes[j] == b'(' {
532 let url_start = j;
534 let mut paren_depth: u32 = 1;
535 let mut k = j + 1;
536 while k < len && paren_depth > 0 {
537 match bytes[k] {
538 b'\\' => {
539 k += 1; }
541 b'(' => paren_depth += 1,
542 b')' => paren_depth -= 1,
543 _ => {}
544 }
545 k += 1;
546 }
547
548 if paren_depth == 0 {
549 if pos > url_start && pos < k {
550 return true;
551 }
552 i = k;
553 continue;
554 }
555 } else if bytes[j] == b'[' {
556 let ref_start = j;
558 let mut ref_depth: u32 = 1;
559 let mut k = j + 1;
560 while k < len && ref_depth > 0 {
561 match bytes[k] {
562 b'\\' => {
563 k += 1;
564 }
565 b'[' => ref_depth += 1,
566 b']' => ref_depth -= 1,
567 _ => {}
568 }
569 k += 1;
570 }
571
572 if ref_depth == 0 {
573 if pos > ref_start && pos < k {
574 return true;
575 }
576 i = k;
577 continue;
578 }
579 }
580 }
581 }
582 i += 1;
583 }
584 false
585 }
586
587 fn is_in_backtick_code_in_line(line: &str, pos: usize) -> bool {
595 let bytes = line.as_bytes();
596 let len = bytes.len();
597 let mut i = 0;
598 while i < len {
599 if bytes[i] == b'`' {
600 let open_start = i;
602 while i < len && bytes[i] == b'`' {
603 i += 1;
604 }
605 let tick_len = i - open_start;
606
607 while i < len {
609 if bytes[i] == b'`' {
610 let close_start = i;
611 while i < len && bytes[i] == b'`' {
612 i += 1;
613 }
614 if i - close_start == tick_len {
615 let content_start = open_start + tick_len;
619 let content_end = close_start;
620 if pos >= content_start && pos < content_end {
621 return true;
622 }
623 break;
625 }
626 } else {
628 i += 1;
629 }
630 }
631 } else {
632 i += 1;
633 }
634 }
635 false
636 }
637
638 fn is_word_boundary_char(c: char) -> bool {
640 !c.is_alphanumeric()
641 }
642
643 fn is_at_word_boundary(content: &str, pos: usize, is_start: bool) -> bool {
645 if is_start {
646 if pos == 0 {
647 return true;
648 }
649 match content[..pos].chars().next_back() {
650 None => true,
651 Some(c) => Self::is_word_boundary_char(c),
652 }
653 } else {
654 if pos >= content.len() {
655 return true;
656 }
657 match content[pos..].chars().next() {
658 None => true,
659 Some(c) => Self::is_word_boundary_char(c),
660 }
661 }
662 }
663
664 fn frontmatter_value_offset(line: &str) -> usize {
668 let trimmed = line.trim();
669
670 if trimmed == "---" || trimmed == "+++" || trimmed.is_empty() {
672 return usize::MAX;
673 }
674
675 if trimmed.starts_with('#') {
677 return usize::MAX;
678 }
679
680 let stripped = line.trim_start();
682 if let Some(after_dash) = stripped.strip_prefix("- ") {
683 let leading = line.len() - stripped.len();
684 if let Some(result) = Self::kv_value_offset(line, after_dash, leading + 2) {
686 return result;
687 }
688 return leading + 2;
690 }
691 if stripped == "-" {
692 return usize::MAX;
693 }
694
695 if let Some(result) = Self::kv_value_offset(line, stripped, line.len() - stripped.len()) {
697 return result;
698 }
699
700 if let Some(eq_pos) = line.find('=') {
702 let after_eq = eq_pos + 1;
703 if after_eq < line.len() && line.as_bytes()[after_eq] == b' ' {
704 let value_start = after_eq + 1;
705 let value_slice = &line[value_start..];
706 let value_trimmed = value_slice.trim();
707 if value_trimmed.is_empty() {
708 return usize::MAX;
709 }
710 if (value_trimmed.starts_with('"') && value_trimmed.ends_with('"'))
712 || (value_trimmed.starts_with('\'') && value_trimmed.ends_with('\''))
713 {
714 let quote_offset = value_slice.find(['"', '\'']).unwrap_or(0);
715 return value_start + quote_offset + 1;
716 }
717 return value_start;
718 }
719 return usize::MAX;
721 }
722
723 0
725 }
726
727 fn kv_value_offset(line: &str, content: &str, base_offset: usize) -> Option<usize> {
731 let colon_pos = content.find(':')?;
732 let abs_colon = base_offset + colon_pos;
733 let after_colon = abs_colon + 1;
734 if after_colon < line.len() && line.as_bytes()[after_colon] == b' ' {
735 let value_start = after_colon + 1;
736 let value_slice = &line[value_start..];
737 let value_trimmed = value_slice.trim();
738 if value_trimmed.is_empty() {
739 return Some(usize::MAX);
740 }
741 if value_trimmed.starts_with('{') || value_trimmed.starts_with('[') {
743 return Some(usize::MAX);
744 }
745 if (value_trimmed.starts_with('"') && value_trimmed.ends_with('"'))
747 || (value_trimmed.starts_with('\'') && value_trimmed.ends_with('\''))
748 {
749 let quote_offset = value_slice.find(['"', '\'']).unwrap_or(0);
750 return Some(value_start + quote_offset + 1);
751 }
752 return Some(value_start);
753 }
754 Some(usize::MAX)
756 }
757
758 fn get_proper_name_for(&self, found_name: &str) -> Option<String> {
760 let found_lower = found_name.to_lowercase();
761
762 for name in &self.config.names {
764 let lower_name = name.to_lowercase();
765 let lower_name_no_dots = lower_name.replace('.', "");
766
767 if found_lower == lower_name || found_lower == lower_name_no_dots {
769 return Some(name.clone());
770 }
771
772 let ascii_normalized = Self::ascii_normalize(&lower_name);
774
775 let ascii_no_dots = ascii_normalized.replace('.', "");
776
777 if found_lower == ascii_normalized || found_lower == ascii_no_dots {
778 return Some(name.clone());
779 }
780 }
781 None
782 }
783}
784
785impl Rule for MD044ProperNames {
786 fn name(&self) -> &'static str {
787 "MD044"
788 }
789
790 fn description(&self) -> &'static str {
791 "Proper names should have the correct capitalization"
792 }
793
794 fn category(&self) -> RuleCategory {
795 RuleCategory::Other
796 }
797
798 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
799 if self.config.names.is_empty() {
800 return true;
801 }
802 let content_lower = if ctx.content.is_ascii() {
804 ctx.content.to_ascii_lowercase()
805 } else {
806 ctx.content.to_lowercase()
807 };
808 !self.name_variants.iter().any(|name| content_lower.contains(name))
809 }
810
811 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
812 let content = ctx.content;
813 if content.is_empty() || self.config.names.is_empty() || self.combined_pattern.is_none() {
814 return Ok(Vec::new());
815 }
816
817 let content_lower = if content.is_ascii() {
819 content.to_ascii_lowercase()
820 } else {
821 content.to_lowercase()
822 };
823
824 let has_potential_matches = self.name_variants.iter().any(|name| content_lower.contains(name));
826
827 if !has_potential_matches {
828 return Ok(Vec::new());
829 }
830
831 let line_index = &ctx.line_index;
832 let violations = self.find_name_violations(content, ctx, &content_lower);
833
834 let warnings = violations
835 .into_iter()
836 .filter_map(|(line, column, found_name)| {
837 self.get_proper_name_for(&found_name).map(|proper_name| LintWarning {
838 rule_name: Some(self.name().to_string()),
839 line,
840 column,
841 end_line: line,
842 end_column: column + found_name.len(),
843 message: format!("Proper name '{found_name}' should be '{proper_name}'"),
844 severity: Severity::Warning,
845 fix: Some(Fix {
846 range: line_index.line_col_to_byte_range_with_length(line, column, found_name.len()),
847 replacement: proper_name,
848 }),
849 })
850 })
851 .collect();
852
853 Ok(warnings)
854 }
855
856 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
857 let content = ctx.content;
858 if content.is_empty() || self.config.names.is_empty() {
859 return Ok(content.to_string());
860 }
861
862 let content_lower = if content.is_ascii() {
863 content.to_ascii_lowercase()
864 } else {
865 content.to_lowercase()
866 };
867 let violations = self.find_name_violations(content, ctx, &content_lower);
868 if violations.is_empty() {
869 return Ok(content.to_string());
870 }
871
872 let mut fixed_lines = Vec::new();
874
875 let mut violations_by_line: HashMap<usize, Vec<(usize, String)>> = HashMap::new();
877 for (line_num, col_num, found_name) in violations {
878 violations_by_line
879 .entry(line_num)
880 .or_default()
881 .push((col_num, found_name));
882 }
883
884 for violations in violations_by_line.values_mut() {
886 violations.sort_by_key(|b| std::cmp::Reverse(b.0));
887 }
888
889 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
891 let line_num = line_idx + 1;
892
893 if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
895 fixed_lines.push(line_info.content(ctx.content).to_string());
896 continue;
897 }
898
899 if let Some(line_violations) = violations_by_line.get(&line_num) {
900 let mut fixed_line = line_info.content(ctx.content).to_string();
902
903 for (col_num, found_name) in line_violations {
904 if let Some(proper_name) = self.get_proper_name_for(found_name) {
905 let start_col = col_num - 1; let end_col = start_col + found_name.len();
907
908 if end_col <= fixed_line.len()
909 && fixed_line.is_char_boundary(start_col)
910 && fixed_line.is_char_boundary(end_col)
911 {
912 fixed_line.replace_range(start_col..end_col, &proper_name);
913 }
914 }
915 }
916
917 fixed_lines.push(fixed_line);
918 } else {
919 fixed_lines.push(line_info.content(ctx.content).to_string());
921 }
922 }
923
924 let mut result = fixed_lines.join("\n");
926 if content.ends_with('\n') && !result.ends_with('\n') {
927 result.push('\n');
928 }
929 Ok(result)
930 }
931
932 fn as_any(&self) -> &dyn std::any::Any {
933 self
934 }
935
936 fn default_config_section(&self) -> Option<(String, toml::Value)> {
937 let json_value = serde_json::to_value(&self.config).ok()?;
938 Some((
939 self.name().to_string(),
940 crate::rule_config_serde::json_to_toml_value(&json_value)?,
941 ))
942 }
943
944 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
945 where
946 Self: Sized,
947 {
948 let rule_config = crate::rule_config_serde::load_rule_config::<MD044Config>(config);
949 Box::new(Self::from_config_struct(rule_config))
950 }
951}
952
953#[cfg(test)]
954mod tests {
955 use super::*;
956 use crate::lint_context::LintContext;
957
958 fn create_context(content: &str) -> LintContext<'_> {
959 LintContext::new(content, crate::config::MarkdownFlavor::Standard, None)
960 }
961
962 #[test]
963 fn test_correctly_capitalized_names() {
964 let rule = MD044ProperNames::new(
965 vec![
966 "JavaScript".to_string(),
967 "TypeScript".to_string(),
968 "Node.js".to_string(),
969 ],
970 true,
971 );
972
973 let content = "This document uses JavaScript, TypeScript, and Node.js correctly.";
974 let ctx = create_context(content);
975 let result = rule.check(&ctx).unwrap();
976 assert!(result.is_empty(), "Should not flag correctly capitalized names");
977 }
978
979 #[test]
980 fn test_incorrectly_capitalized_names() {
981 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
982
983 let content = "This document uses javascript and typescript incorrectly.";
984 let ctx = create_context(content);
985 let result = rule.check(&ctx).unwrap();
986
987 assert_eq!(result.len(), 2, "Should flag two incorrect capitalizations");
988 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
989 assert_eq!(result[0].line, 1);
990 assert_eq!(result[0].column, 20);
991 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
992 assert_eq!(result[1].line, 1);
993 assert_eq!(result[1].column, 35);
994 }
995
996 #[test]
997 fn test_names_at_beginning_of_sentences() {
998 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "Python".to_string()], true);
999
1000 let content = "javascript is a great language. python is also popular.";
1001 let ctx = create_context(content);
1002 let result = rule.check(&ctx).unwrap();
1003
1004 assert_eq!(result.len(), 2, "Should flag names at beginning of sentences");
1005 assert_eq!(result[0].line, 1);
1006 assert_eq!(result[0].column, 1);
1007 assert_eq!(result[1].line, 1);
1008 assert_eq!(result[1].column, 33);
1009 }
1010
1011 #[test]
1012 fn test_names_in_code_blocks_checked_by_default() {
1013 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1014
1015 let content = r#"Here is some text with JavaScript.
1016
1017```javascript
1018// This javascript should be checked
1019const lang = "javascript";
1020```
1021
1022But this javascript should be flagged."#;
1023
1024 let ctx = create_context(content);
1025 let result = rule.check(&ctx).unwrap();
1026
1027 assert_eq!(result.len(), 3, "Should flag javascript inside and outside code blocks");
1028 assert_eq!(result[0].line, 4);
1029 assert_eq!(result[1].line, 5);
1030 assert_eq!(result[2].line, 8);
1031 }
1032
1033 #[test]
1034 fn test_names_in_code_blocks_ignored_when_disabled() {
1035 let rule = MD044ProperNames::new(
1036 vec!["JavaScript".to_string()],
1037 false, );
1039
1040 let content = r#"```
1041javascript in code block
1042```"#;
1043
1044 let ctx = create_context(content);
1045 let result = rule.check(&ctx).unwrap();
1046
1047 assert_eq!(
1048 result.len(),
1049 0,
1050 "Should not flag javascript in code blocks when code_blocks is false"
1051 );
1052 }
1053
1054 #[test]
1055 fn test_names_in_inline_code_checked_by_default() {
1056 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1057
1058 let content = "This is `javascript` in inline code and javascript outside.";
1059 let ctx = create_context(content);
1060 let result = rule.check(&ctx).unwrap();
1061
1062 assert_eq!(result.len(), 2, "Should flag javascript inside and outside inline code");
1064 assert_eq!(result[0].column, 10); assert_eq!(result[1].column, 41); }
1067
1068 #[test]
1069 fn test_multiple_names_in_same_line() {
1070 let rule = MD044ProperNames::new(
1071 vec!["JavaScript".to_string(), "TypeScript".to_string(), "React".to_string()],
1072 true,
1073 );
1074
1075 let content = "I use javascript, typescript, and react in my projects.";
1076 let ctx = create_context(content);
1077 let result = rule.check(&ctx).unwrap();
1078
1079 assert_eq!(result.len(), 3, "Should flag all three incorrect names");
1080 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1081 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1082 assert_eq!(result[2].message, "Proper name 'react' should be 'React'");
1083 }
1084
1085 #[test]
1086 fn test_case_sensitivity() {
1087 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1088
1089 let content = "JAVASCRIPT, Javascript, javascript, and JavaScript variations.";
1090 let ctx = create_context(content);
1091 let result = rule.check(&ctx).unwrap();
1092
1093 assert_eq!(result.len(), 3, "Should flag all incorrect case variations");
1094 assert!(result.iter().all(|w| w.message.contains("should be 'JavaScript'")));
1096 }
1097
1098 #[test]
1099 fn test_configuration_with_custom_name_list() {
1100 let config = MD044Config {
1101 names: vec!["GitHub".to_string(), "GitLab".to_string(), "DevOps".to_string()],
1102 code_blocks: true,
1103 html_elements: true,
1104 html_comments: true,
1105 };
1106 let rule = MD044ProperNames::from_config_struct(config);
1107
1108 let content = "We use github, gitlab, and devops for our workflow.";
1109 let ctx = create_context(content);
1110 let result = rule.check(&ctx).unwrap();
1111
1112 assert_eq!(result.len(), 3, "Should flag all custom names");
1113 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
1114 assert_eq!(result[1].message, "Proper name 'gitlab' should be 'GitLab'");
1115 assert_eq!(result[2].message, "Proper name 'devops' should be 'DevOps'");
1116 }
1117
1118 #[test]
1119 fn test_empty_configuration() {
1120 let rule = MD044ProperNames::new(vec![], true);
1121
1122 let content = "This has javascript and typescript but no configured names.";
1123 let ctx = create_context(content);
1124 let result = rule.check(&ctx).unwrap();
1125
1126 assert!(result.is_empty(), "Should not flag anything with empty configuration");
1127 }
1128
1129 #[test]
1130 fn test_names_with_special_characters() {
1131 let rule = MD044ProperNames::new(
1132 vec!["Node.js".to_string(), "ASP.NET".to_string(), "C++".to_string()],
1133 true,
1134 );
1135
1136 let content = "We use nodejs, asp.net, ASP.NET, and c++ in our stack.";
1137 let ctx = create_context(content);
1138 let result = rule.check(&ctx).unwrap();
1139
1140 assert_eq!(result.len(), 3, "Should handle special characters correctly");
1145
1146 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
1147 assert!(messages.contains(&"Proper name 'nodejs' should be 'Node.js'"));
1148 assert!(messages.contains(&"Proper name 'asp.net' should be 'ASP.NET'"));
1149 assert!(messages.contains(&"Proper name 'c++' should be 'C++'"));
1150 }
1151
1152 #[test]
1153 fn test_word_boundaries() {
1154 let rule = MD044ProperNames::new(vec!["Java".to_string(), "Script".to_string()], true);
1155
1156 let content = "JavaScript is not java or script, but Java and Script are separate.";
1157 let ctx = create_context(content);
1158 let result = rule.check(&ctx).unwrap();
1159
1160 assert_eq!(result.len(), 2, "Should respect word boundaries");
1162 assert!(result.iter().any(|w| w.column == 19)); assert!(result.iter().any(|w| w.column == 27)); }
1165
1166 #[test]
1167 fn test_fix_method() {
1168 let rule = MD044ProperNames::new(
1169 vec![
1170 "JavaScript".to_string(),
1171 "TypeScript".to_string(),
1172 "Node.js".to_string(),
1173 ],
1174 true,
1175 );
1176
1177 let content = "I love javascript, typescript, and nodejs!";
1178 let ctx = create_context(content);
1179 let fixed = rule.fix(&ctx).unwrap();
1180
1181 assert_eq!(fixed, "I love JavaScript, TypeScript, and Node.js!");
1182 }
1183
1184 #[test]
1185 fn test_fix_multiple_occurrences() {
1186 let rule = MD044ProperNames::new(vec!["Python".to_string()], true);
1187
1188 let content = "python is great. I use python daily. PYTHON is powerful.";
1189 let ctx = create_context(content);
1190 let fixed = rule.fix(&ctx).unwrap();
1191
1192 assert_eq!(fixed, "Python is great. I use Python daily. Python is powerful.");
1193 }
1194
1195 #[test]
1196 fn test_fix_checks_code_blocks_by_default() {
1197 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1198
1199 let content = r#"I love javascript.
1200
1201```
1202const lang = "javascript";
1203```
1204
1205More javascript here."#;
1206
1207 let ctx = create_context(content);
1208 let fixed = rule.fix(&ctx).unwrap();
1209
1210 let expected = r#"I love JavaScript.
1211
1212```
1213const lang = "JavaScript";
1214```
1215
1216More JavaScript here."#;
1217
1218 assert_eq!(fixed, expected);
1219 }
1220
1221 #[test]
1222 fn test_multiline_content() {
1223 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
1224
1225 let content = r#"First line with rust.
1226Second line with python.
1227Third line with RUST and PYTHON."#;
1228
1229 let ctx = create_context(content);
1230 let result = rule.check(&ctx).unwrap();
1231
1232 assert_eq!(result.len(), 4, "Should flag all incorrect occurrences");
1233 assert_eq!(result[0].line, 1);
1234 assert_eq!(result[1].line, 2);
1235 assert_eq!(result[2].line, 3);
1236 assert_eq!(result[3].line, 3);
1237 }
1238
1239 #[test]
1240 fn test_default_config() {
1241 let config = MD044Config::default();
1242 assert!(config.names.is_empty());
1243 assert!(!config.code_blocks);
1244 assert!(config.html_elements);
1245 assert!(config.html_comments);
1246 }
1247
1248 #[test]
1249 fn test_default_config_checks_html_comments() {
1250 let config = MD044Config {
1251 names: vec!["JavaScript".to_string()],
1252 ..MD044Config::default()
1253 };
1254 let rule = MD044ProperNames::from_config_struct(config);
1255
1256 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1257 let ctx = create_context(content);
1258 let result = rule.check(&ctx).unwrap();
1259
1260 assert_eq!(result.len(), 1, "Default config should check HTML comments");
1261 assert_eq!(result[0].line, 3);
1262 }
1263
1264 #[test]
1265 fn test_default_config_skips_code_blocks() {
1266 let config = MD044Config {
1267 names: vec!["JavaScript".to_string()],
1268 ..MD044Config::default()
1269 };
1270 let rule = MD044ProperNames::from_config_struct(config);
1271
1272 let content = "# Guide\n\n```\njavascript in code\n```\n";
1273 let ctx = create_context(content);
1274 let result = rule.check(&ctx).unwrap();
1275
1276 assert_eq!(result.len(), 0, "Default config should skip code blocks");
1277 }
1278
1279 #[test]
1280 fn test_standalone_html_comment_checked() {
1281 let config = MD044Config {
1282 names: vec!["Test".to_string()],
1283 ..MD044Config::default()
1284 };
1285 let rule = MD044ProperNames::from_config_struct(config);
1286
1287 let content = "# Heading\n\n<!-- this is a test example -->\n";
1288 let ctx = create_context(content);
1289 let result = rule.check(&ctx).unwrap();
1290
1291 assert_eq!(result.len(), 1, "Should flag proper name in standalone HTML comment");
1292 assert_eq!(result[0].line, 3);
1293 }
1294
1295 #[test]
1296 fn test_inline_config_comments_not_flagged() {
1297 let config = MD044Config {
1298 names: vec!["RUMDL".to_string()],
1299 ..MD044Config::default()
1300 };
1301 let rule = MD044ProperNames::from_config_struct(config);
1302
1303 let content = "<!-- rumdl-disable MD044 -->\nSome rumdl text here.\n<!-- rumdl-enable MD044 -->\n<!-- markdownlint-disable -->\nMore rumdl text.\n<!-- markdownlint-enable -->\n";
1307 let ctx = create_context(content);
1308 let result = rule.check(&ctx).unwrap();
1309
1310 assert_eq!(result.len(), 2, "Should only flag body lines, not config comments");
1311 assert_eq!(result[0].line, 2);
1312 assert_eq!(result[1].line, 5);
1313 }
1314
1315 #[test]
1316 fn test_html_comment_skipped_when_disabled() {
1317 let config = MD044Config {
1318 names: vec!["Test".to_string()],
1319 code_blocks: true,
1320 html_elements: true,
1321 html_comments: false,
1322 };
1323 let rule = MD044ProperNames::from_config_struct(config);
1324
1325 let content = "# Heading\n\n<!-- this is a test example -->\n\nRegular test here.\n";
1326 let ctx = create_context(content);
1327 let result = rule.check(&ctx).unwrap();
1328
1329 assert_eq!(
1330 result.len(),
1331 1,
1332 "Should only flag 'test' outside HTML comment when html_comments=false"
1333 );
1334 assert_eq!(result[0].line, 5);
1335 }
1336
1337 #[test]
1338 fn test_fix_corrects_html_comment_content() {
1339 let config = MD044Config {
1340 names: vec!["JavaScript".to_string()],
1341 ..MD044Config::default()
1342 };
1343 let rule = MD044ProperNames::from_config_struct(config);
1344
1345 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1346 let ctx = create_context(content);
1347 let fixed = rule.fix(&ctx).unwrap();
1348
1349 assert_eq!(fixed, "# Guide\n\n<!-- JavaScript mentioned here -->\n");
1350 }
1351
1352 #[test]
1353 fn test_fix_does_not_modify_inline_config_comments() {
1354 let config = MD044Config {
1355 names: vec!["RUMDL".to_string()],
1356 ..MD044Config::default()
1357 };
1358 let rule = MD044ProperNames::from_config_struct(config);
1359
1360 let content = "<!-- rumdl-disable -->\nSome rumdl text.\n<!-- rumdl-enable -->\n";
1361 let ctx = create_context(content);
1362 let fixed = rule.fix(&ctx).unwrap();
1363
1364 assert!(fixed.contains("<!-- rumdl-disable -->"));
1366 assert!(fixed.contains("<!-- rumdl-enable -->"));
1367 assert!(
1369 fixed.contains("Some rumdl text."),
1370 "Line inside rumdl-disable block should not be modified by fix()"
1371 );
1372 }
1373
1374 #[test]
1375 fn test_fix_respects_inline_disable_partial() {
1376 let config = MD044Config {
1377 names: vec!["RUMDL".to_string()],
1378 ..MD044Config::default()
1379 };
1380 let rule = MD044ProperNames::from_config_struct(config);
1381
1382 let content =
1383 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
1384 let ctx = create_context(content);
1385 let fixed = rule.fix(&ctx).unwrap();
1386
1387 assert!(
1389 fixed.contains("Some rumdl text.\n<!-- rumdl-enable"),
1390 "Line inside disable block should not be modified"
1391 );
1392 assert!(
1394 fixed.contains("Some RUMDL text outside."),
1395 "Line outside disable block should be fixed"
1396 );
1397 }
1398
1399 #[test]
1400 fn test_performance_with_many_names() {
1401 let mut names = vec![];
1402 for i in 0..50 {
1403 names.push(format!("ProperName{i}"));
1404 }
1405
1406 let rule = MD044ProperNames::new(names, true);
1407
1408 let content = "This has propername0, propername25, and propername49 incorrectly.";
1409 let ctx = create_context(content);
1410 let result = rule.check(&ctx).unwrap();
1411
1412 assert_eq!(result.len(), 3, "Should handle many configured names efficiently");
1413 }
1414
1415 #[test]
1416 fn test_large_name_count_performance() {
1417 let names = (0..1000).map(|i| format!("ProperName{i}")).collect::<Vec<_>>();
1420
1421 let rule = MD044ProperNames::new(names, true);
1422
1423 assert!(rule.combined_pattern.is_some());
1425
1426 let content = "This has propername0 and propername999 in it.";
1428 let ctx = create_context(content);
1429 let result = rule.check(&ctx).unwrap();
1430
1431 assert_eq!(result.len(), 2, "Should handle 1000 names without issues");
1433 }
1434
1435 #[test]
1436 fn test_cache_behavior() {
1437 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1438
1439 let content = "Using javascript here.";
1440 let ctx = create_context(content);
1441
1442 let result1 = rule.check(&ctx).unwrap();
1444 assert_eq!(result1.len(), 1);
1445
1446 let result2 = rule.check(&ctx).unwrap();
1448 assert_eq!(result2.len(), 1);
1449
1450 assert_eq!(result1[0].line, result2[0].line);
1452 assert_eq!(result1[0].column, result2[0].column);
1453 }
1454
1455 #[test]
1456 fn test_html_comments_not_checked_when_disabled() {
1457 let config = MD044Config {
1458 names: vec!["JavaScript".to_string()],
1459 code_blocks: true, html_elements: true, html_comments: false, };
1463 let rule = MD044ProperNames::from_config_struct(config);
1464
1465 let content = r#"Regular javascript here.
1466<!-- This javascript in HTML comment should be ignored -->
1467More javascript outside."#;
1468
1469 let ctx = create_context(content);
1470 let result = rule.check(&ctx).unwrap();
1471
1472 assert_eq!(result.len(), 2, "Should only flag javascript outside HTML comments");
1473 assert_eq!(result[0].line, 1);
1474 assert_eq!(result[1].line, 3);
1475 }
1476
1477 #[test]
1478 fn test_html_comments_checked_when_enabled() {
1479 let config = MD044Config {
1480 names: vec!["JavaScript".to_string()],
1481 code_blocks: true, html_elements: true, html_comments: true, };
1485 let rule = MD044ProperNames::from_config_struct(config);
1486
1487 let content = r#"Regular javascript here.
1488<!-- This javascript in HTML comment should be checked -->
1489More javascript outside."#;
1490
1491 let ctx = create_context(content);
1492 let result = rule.check(&ctx).unwrap();
1493
1494 assert_eq!(
1495 result.len(),
1496 3,
1497 "Should flag all javascript occurrences including in HTML comments"
1498 );
1499 }
1500
1501 #[test]
1502 fn test_multiline_html_comments() {
1503 let config = MD044Config {
1504 names: vec!["Python".to_string(), "JavaScript".to_string()],
1505 code_blocks: true, html_elements: true, html_comments: false, };
1509 let rule = MD044ProperNames::from_config_struct(config);
1510
1511 let content = r#"Regular python here.
1512<!--
1513This is a multiline comment
1514with javascript and python
1515that should be ignored
1516-->
1517More javascript outside."#;
1518
1519 let ctx = create_context(content);
1520 let result = rule.check(&ctx).unwrap();
1521
1522 assert_eq!(result.len(), 2, "Should only flag names outside HTML comments");
1523 assert_eq!(result[0].line, 1); assert_eq!(result[1].line, 7); }
1526
1527 #[test]
1528 fn test_fix_preserves_html_comments_when_disabled() {
1529 let config = MD044Config {
1530 names: vec!["JavaScript".to_string()],
1531 code_blocks: true, html_elements: true, html_comments: false, };
1535 let rule = MD044ProperNames::from_config_struct(config);
1536
1537 let content = r#"javascript here.
1538<!-- javascript in comment -->
1539More javascript."#;
1540
1541 let ctx = create_context(content);
1542 let fixed = rule.fix(&ctx).unwrap();
1543
1544 let expected = r#"JavaScript here.
1545<!-- javascript in comment -->
1546More JavaScript."#;
1547
1548 assert_eq!(
1549 fixed, expected,
1550 "Should not fix names inside HTML comments when disabled"
1551 );
1552 }
1553
1554 #[test]
1555 fn test_proper_names_in_link_text_are_flagged() {
1556 let rule = MD044ProperNames::new(
1557 vec!["JavaScript".to_string(), "Node.js".to_string(), "Python".to_string()],
1558 true,
1559 );
1560
1561 let content = r#"Check this [javascript documentation](https://javascript.info) for info.
1562
1563Visit [node.js homepage](https://nodejs.org) and [python tutorial](https://python.org).
1564
1565Real javascript should be flagged.
1566
1567Also see the [typescript guide][ts-ref] for more.
1568
1569Real python should be flagged too.
1570
1571[ts-ref]: https://typescript.org/handbook"#;
1572
1573 let ctx = create_context(content);
1574 let result = rule.check(&ctx).unwrap();
1575
1576 assert_eq!(result.len(), 5, "Expected 5 warnings: 3 in link text + 2 standalone");
1583
1584 let line_1_warnings: Vec<_> = result.iter().filter(|w| w.line == 1).collect();
1586 assert_eq!(line_1_warnings.len(), 1);
1587 assert!(
1588 line_1_warnings[0]
1589 .message
1590 .contains("'javascript' should be 'JavaScript'")
1591 );
1592
1593 let line_3_warnings: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
1594 assert_eq!(line_3_warnings.len(), 2); assert!(result.iter().any(|w| w.line == 5 && w.message.contains("'javascript'")));
1598 assert!(result.iter().any(|w| w.line == 9 && w.message.contains("'python'")));
1599 }
1600
1601 #[test]
1602 fn test_link_urls_not_flagged() {
1603 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1604
1605 let content = r#"[Link Text](https://javascript.info/guide)"#;
1607
1608 let ctx = create_context(content);
1609 let result = rule.check(&ctx).unwrap();
1610
1611 assert!(result.is_empty(), "URLs should not be checked for proper names");
1613 }
1614
1615 #[test]
1616 fn test_proper_names_in_image_alt_text_are_flagged() {
1617 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1618
1619 let content = r#"Here is a  image.
1620
1621Real javascript should be flagged."#;
1622
1623 let ctx = create_context(content);
1624 let result = rule.check(&ctx).unwrap();
1625
1626 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in alt text + 1 standalone");
1630 assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
1631 assert!(result[0].line == 1); assert!(result[1].message.contains("'javascript' should be 'JavaScript'"));
1633 assert!(result[1].line == 3); }
1635
1636 #[test]
1637 fn test_image_urls_not_flagged() {
1638 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1639
1640 let content = r#""#;
1642
1643 let ctx = create_context(content);
1644 let result = rule.check(&ctx).unwrap();
1645
1646 assert!(result.is_empty(), "Image URLs should not be checked for proper names");
1648 }
1649
1650 #[test]
1651 fn test_reference_link_text_flagged_but_definition_not() {
1652 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1653
1654 let content = r#"Check the [javascript guide][js-ref] for details.
1655
1656Real javascript should be flagged.
1657
1658[js-ref]: https://javascript.info/typescript/guide"#;
1659
1660 let ctx = create_context(content);
1661 let result = rule.check(&ctx).unwrap();
1662
1663 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in link text + 1 standalone");
1668 assert!(result.iter().any(|w| w.line == 1 && w.message.contains("'javascript'")));
1669 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
1670 }
1671
1672 #[test]
1673 fn test_reference_definitions_not_flagged() {
1674 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1675
1676 let content = r#"[js-ref]: https://javascript.info/guide"#;
1678
1679 let ctx = create_context(content);
1680 let result = rule.check(&ctx).unwrap();
1681
1682 assert!(result.is_empty(), "Reference definitions should not be checked");
1684 }
1685
1686 #[test]
1687 fn test_wikilinks_text_is_flagged() {
1688 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1689
1690 let content = r#"[[javascript]]
1692
1693Regular javascript here.
1694
1695[[JavaScript|display text]]"#;
1696
1697 let ctx = create_context(content);
1698 let result = rule.check(&ctx).unwrap();
1699
1700 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in WikiLink + 1 standalone");
1704 assert!(
1705 result
1706 .iter()
1707 .any(|w| w.line == 1 && w.column == 3 && w.message.contains("'javascript'"))
1708 );
1709 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
1710 }
1711
1712 #[test]
1713 fn test_url_link_text_not_flagged() {
1714 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1715
1716 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
1718
1719[http://github.com/org/repo](http://github.com/org/repo)
1720
1721[www.github.com/org/repo](https://www.github.com/org/repo)"#;
1722
1723 let ctx = create_context(content);
1724 let result = rule.check(&ctx).unwrap();
1725
1726 assert!(
1727 result.is_empty(),
1728 "URL-like link text should not be flagged, got: {result:?}"
1729 );
1730 }
1731
1732 #[test]
1733 fn test_url_link_text_with_leading_space_not_flagged() {
1734 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1735
1736 let content = r#"[ https://github.com/org/repo](https://github.com/org/repo)"#;
1738
1739 let ctx = create_context(content);
1740 let result = rule.check(&ctx).unwrap();
1741
1742 assert!(
1743 result.is_empty(),
1744 "URL-like link text with leading space should not be flagged, got: {result:?}"
1745 );
1746 }
1747
1748 #[test]
1749 fn test_url_link_text_uppercase_scheme_not_flagged() {
1750 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1751
1752 let content = r#"[HTTPS://GITHUB.COM/org/repo](https://github.com/org/repo)"#;
1753
1754 let ctx = create_context(content);
1755 let result = rule.check(&ctx).unwrap();
1756
1757 assert!(
1758 result.is_empty(),
1759 "URL-like link text with uppercase scheme should not be flagged, got: {result:?}"
1760 );
1761 }
1762
1763 #[test]
1764 fn test_non_url_link_text_still_flagged() {
1765 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1766
1767 let content = r#"[github.com/org/repo](https://github.com/org/repo)
1769
1770[Visit github](https://github.com/org/repo)
1771
1772[//github.com/org/repo](//github.com/org/repo)
1773
1774[ftp://github.com/org/repo](ftp://github.com/org/repo)"#;
1775
1776 let ctx = create_context(content);
1777 let result = rule.check(&ctx).unwrap();
1778
1779 assert_eq!(result.len(), 4, "Non-URL link text should be flagged, got: {result:?}");
1780 assert!(result.iter().any(|w| w.line == 1)); assert!(result.iter().any(|w| w.line == 3)); assert!(result.iter().any(|w| w.line == 5)); assert!(result.iter().any(|w| w.line == 7)); }
1785
1786 #[test]
1787 fn test_url_link_text_fix_not_applied() {
1788 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1789
1790 let content = "[https://github.com/org/repo](https://github.com/org/repo)\n";
1791
1792 let ctx = create_context(content);
1793 let result = rule.fix(&ctx).unwrap();
1794
1795 assert_eq!(result, content, "Fix should not modify URL-like link text");
1796 }
1797
1798 #[test]
1799 fn test_mixed_url_and_regular_link_text() {
1800 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1801
1802 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
1804
1805Visit [github documentation](https://github.com/docs) for details.
1806
1807[www.github.com/pricing](https://www.github.com/pricing)"#;
1808
1809 let ctx = create_context(content);
1810 let result = rule.check(&ctx).unwrap();
1811
1812 assert_eq!(
1814 result.len(),
1815 1,
1816 "Only non-URL link text should be flagged, got: {result:?}"
1817 );
1818 assert_eq!(result[0].line, 3);
1819 }
1820
1821 #[test]
1822 fn test_html_attribute_values_not_flagged() {
1823 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1826 let content = "# Heading\n\ntest\n\n<img src=\"www.example.test/test_image.png\">\n";
1827 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1828 let result = rule.check(&ctx).unwrap();
1829
1830 let line5_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
1832 assert!(
1833 line5_violations.is_empty(),
1834 "Should not flag anything inside HTML tag attributes: {line5_violations:?}"
1835 );
1836
1837 let line3_violations: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
1839 assert_eq!(line3_violations.len(), 1, "Plain 'test' on line 3 should be flagged");
1840 }
1841
1842 #[test]
1843 fn test_html_text_content_still_flagged() {
1844 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1846 let content = "# Heading\n\n<a href=\"https://example.test/page\">test link</a>\n";
1847 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1848 let result = rule.check(&ctx).unwrap();
1849
1850 assert_eq!(
1853 result.len(),
1854 1,
1855 "Should flag only 'test' in anchor text, not in href: {result:?}"
1856 );
1857 assert_eq!(result[0].column, 37, "Should flag col 37 ('test link' in anchor text)");
1858 }
1859
1860 #[test]
1861 fn test_html_attribute_various_not_flagged() {
1862 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1864 let content = concat!(
1865 "# Heading\n\n",
1866 "<img src=\"test.png\" alt=\"test image\">\n",
1867 "<span class=\"test-class\" data-test=\"value\">test content</span>\n",
1868 );
1869 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1870 let result = rule.check(&ctx).unwrap();
1871
1872 assert_eq!(
1874 result.len(),
1875 1,
1876 "Should flag only 'test content' between tags: {result:?}"
1877 );
1878 assert_eq!(result[0].line, 4);
1879 }
1880
1881 #[test]
1882 fn test_plain_text_underscore_boundary_unchanged() {
1883 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1886 let content = "# Heading\n\ntest_image is here and just_test ends here\n";
1887 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1888 let result = rule.check(&ctx).unwrap();
1889
1890 assert_eq!(
1893 result.len(),
1894 2,
1895 "Should flag 'test' in both 'test_image' and 'just_test': {result:?}"
1896 );
1897 let cols: Vec<usize> = result.iter().map(|w| w.column).collect();
1898 assert!(cols.contains(&1), "Should flag col 1 (test_image): {cols:?}");
1899 assert!(cols.contains(&29), "Should flag col 29 (just_test): {cols:?}");
1900 }
1901
1902 #[test]
1903 fn test_frontmatter_yaml_keys_not_flagged() {
1904 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1907
1908 let content = "---\ntitle: Heading\ntest: Some Test value\n---\n\nTest\n";
1909 let ctx = create_context(content);
1910 let result = rule.check(&ctx).unwrap();
1911
1912 assert!(
1916 result.is_empty(),
1917 "Should not flag YAML keys or correctly capitalized values: {result:?}"
1918 );
1919 }
1920
1921 #[test]
1922 fn test_frontmatter_yaml_values_flagged() {
1923 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1925
1926 let content = "---\ntitle: Heading\nkey: a test value\n---\n\nTest\n";
1927 let ctx = create_context(content);
1928 let result = rule.check(&ctx).unwrap();
1929
1930 assert_eq!(result.len(), 1, "Should flag 'test' in YAML value: {result:?}");
1932 assert_eq!(result[0].line, 3);
1933 assert_eq!(result[0].column, 8); }
1935
1936 #[test]
1937 fn test_frontmatter_key_matches_name_not_flagged() {
1938 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1940
1941 let content = "---\ntest: other value\n---\n\nBody text\n";
1942 let ctx = create_context(content);
1943 let result = rule.check(&ctx).unwrap();
1944
1945 assert!(
1946 result.is_empty(),
1947 "Should not flag YAML key that matches configured name: {result:?}"
1948 );
1949 }
1950
1951 #[test]
1952 fn test_frontmatter_empty_value_not_flagged() {
1953 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1955
1956 let content = "---\ntest:\ntest: \n---\n\nBody text\n";
1957 let ctx = create_context(content);
1958 let result = rule.check(&ctx).unwrap();
1959
1960 assert!(
1961 result.is_empty(),
1962 "Should not flag YAML keys with empty values: {result:?}"
1963 );
1964 }
1965
1966 #[test]
1967 fn test_frontmatter_nested_yaml_key_not_flagged() {
1968 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1970
1971 let content = "---\nparent:\n test: nested value\n---\n\nBody text\n";
1972 let ctx = create_context(content);
1973 let result = rule.check(&ctx).unwrap();
1974
1975 assert!(result.is_empty(), "Should not flag nested YAML keys: {result:?}");
1977 }
1978
1979 #[test]
1980 fn test_frontmatter_list_items_checked() {
1981 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1983
1984 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\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 list item: {result:?}");
1990 assert_eq!(result[0].line, 3);
1991 }
1992
1993 #[test]
1994 fn test_frontmatter_value_with_multiple_colons() {
1995 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1997
1998 let content = "---\ntest: description: a test thing\n---\n\nBody text\n";
1999 let ctx = create_context(content);
2000 let result = rule.check(&ctx).unwrap();
2001
2002 assert_eq!(
2005 result.len(),
2006 1,
2007 "Should flag 'test' in value after first colon: {result:?}"
2008 );
2009 assert_eq!(result[0].line, 2);
2010 assert!(result[0].column > 6, "Violation column should be in value portion");
2011 }
2012
2013 #[test]
2014 fn test_frontmatter_does_not_affect_body() {
2015 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2017
2018 let content = "---\ntitle: Heading\n---\n\ntest should be flagged here\n";
2019 let ctx = create_context(content);
2020 let result = rule.check(&ctx).unwrap();
2021
2022 assert_eq!(result.len(), 1, "Should flag 'test' in body text: {result:?}");
2023 assert_eq!(result[0].line, 5);
2024 }
2025
2026 #[test]
2027 fn test_frontmatter_fix_corrects_values_preserves_keys() {
2028 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2030
2031 let content = "---\ntest: a test value\n---\n\ntest here\n";
2032 let ctx = create_context(content);
2033 let fixed = rule.fix(&ctx).unwrap();
2034
2035 assert_eq!(fixed, "---\ntest: a Test value\n---\n\nTest here\n");
2037 }
2038
2039 #[test]
2040 fn test_frontmatter_multiword_value_flagged() {
2041 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2043
2044 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2045 let ctx = create_context(content);
2046 let result = rule.check(&ctx).unwrap();
2047
2048 assert_eq!(result.len(), 2, "Should flag both names in YAML value: {result:?}");
2049 assert!(result.iter().all(|w| w.line == 2));
2050 }
2051
2052 #[test]
2053 fn test_frontmatter_yaml_comments_not_checked() {
2054 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2056
2057 let content = "---\n# test comment\ntitle: Heading\n---\n\nBody text\n";
2058 let ctx = create_context(content);
2059 let result = rule.check(&ctx).unwrap();
2060
2061 assert!(result.is_empty(), "Should not flag names in YAML comments: {result:?}");
2062 }
2063
2064 #[test]
2065 fn test_frontmatter_delimiters_not_checked() {
2066 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2068
2069 let content = "---\ntitle: Heading\n---\n\ntest here\n";
2070 let ctx = create_context(content);
2071 let result = rule.check(&ctx).unwrap();
2072
2073 assert_eq!(result.len(), 1, "Should only flag body text: {result:?}");
2075 assert_eq!(result[0].line, 5);
2076 }
2077
2078 #[test]
2079 fn test_frontmatter_continuation_lines_checked() {
2080 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2082
2083 let content = "---\ndescription: >\n a test value\n continued here\n---\n\nBody\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 continuation line: {result:?}");
2089 assert_eq!(result[0].line, 3);
2090 }
2091
2092 #[test]
2093 fn test_frontmatter_quoted_values_checked() {
2094 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2096
2097 let content = "---\ntitle: \"a test title\"\n---\n\nBody\n";
2098 let ctx = create_context(content);
2099 let result = rule.check(&ctx).unwrap();
2100
2101 assert_eq!(result.len(), 1, "Should flag 'test' in quoted YAML value: {result:?}");
2102 assert_eq!(result[0].line, 2);
2103 }
2104
2105 #[test]
2106 fn test_frontmatter_single_quoted_values_checked() {
2107 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2109
2110 let content = "---\ntitle: 'a test title'\n---\n\nBody\n";
2111 let ctx = create_context(content);
2112 let result = rule.check(&ctx).unwrap();
2113
2114 assert_eq!(
2115 result.len(),
2116 1,
2117 "Should flag 'test' in single-quoted YAML value: {result:?}"
2118 );
2119 assert_eq!(result[0].line, 2);
2120 }
2121
2122 #[test]
2123 fn test_frontmatter_fix_multiword_values() {
2124 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2126
2127 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2128 let ctx = create_context(content);
2129 let fixed = rule.fix(&ctx).unwrap();
2130
2131 assert_eq!(
2132 fixed,
2133 "---\ndescription: Learn JavaScript and TypeScript\n---\n\nBody\n"
2134 );
2135 }
2136
2137 #[test]
2138 fn test_frontmatter_fix_preserves_yaml_structure() {
2139 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2141
2142 let content = "---\ntags:\n - test\n - other\ntitle: a test doc\n---\n\ntest body\n";
2143 let ctx = create_context(content);
2144 let fixed = rule.fix(&ctx).unwrap();
2145
2146 assert_eq!(
2147 fixed,
2148 "---\ntags:\n - Test\n - other\ntitle: a Test doc\n---\n\nTest body\n"
2149 );
2150 }
2151
2152 #[test]
2153 fn test_frontmatter_toml_delimiters_not_checked() {
2154 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2156
2157 let content = "+++\ntitle = \"a test title\"\n+++\n\ntest body\n";
2158 let ctx = create_context(content);
2159 let result = rule.check(&ctx).unwrap();
2160
2161 assert_eq!(result.len(), 2, "Should flag TOML value and body: {result:?}");
2165 let fm_violations: Vec<_> = result.iter().filter(|w| w.line == 2).collect();
2166 assert_eq!(fm_violations.len(), 1, "Should flag 'test' in TOML value: {result:?}");
2167 let body_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2168 assert_eq!(body_violations.len(), 1, "Should flag body 'test': {result:?}");
2169 }
2170
2171 #[test]
2172 fn test_frontmatter_toml_key_not_flagged() {
2173 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2175
2176 let content = "+++\ntest = \"other value\"\n+++\n\nBody text\n";
2177 let ctx = create_context(content);
2178 let result = rule.check(&ctx).unwrap();
2179
2180 assert!(
2181 result.is_empty(),
2182 "Should not flag TOML key that matches configured name: {result:?}"
2183 );
2184 }
2185
2186 #[test]
2187 fn test_frontmatter_toml_fix_preserves_keys() {
2188 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2190
2191 let content = "+++\ntest = \"a test value\"\n+++\n\ntest here\n";
2192 let ctx = create_context(content);
2193 let fixed = rule.fix(&ctx).unwrap();
2194
2195 assert_eq!(fixed, "+++\ntest = \"a Test value\"\n+++\n\nTest here\n");
2197 }
2198
2199 #[test]
2200 fn test_frontmatter_list_item_mapping_key_not_flagged() {
2201 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2204
2205 let content = "---\nitems:\n - test: nested value\n---\n\nBody text\n";
2206 let ctx = create_context(content);
2207 let result = rule.check(&ctx).unwrap();
2208
2209 assert!(
2210 result.is_empty(),
2211 "Should not flag YAML key in list-item mapping: {result:?}"
2212 );
2213 }
2214
2215 #[test]
2216 fn test_frontmatter_list_item_mapping_value_flagged() {
2217 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2219
2220 let content = "---\nitems:\n - key: a test value\n---\n\nBody text\n";
2221 let ctx = create_context(content);
2222 let result = rule.check(&ctx).unwrap();
2223
2224 assert_eq!(
2225 result.len(),
2226 1,
2227 "Should flag 'test' in list-item mapping value: {result:?}"
2228 );
2229 assert_eq!(result[0].line, 3);
2230 }
2231
2232 #[test]
2233 fn test_frontmatter_bare_list_item_still_flagged() {
2234 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2236
2237 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2238 let ctx = create_context(content);
2239 let result = rule.check(&ctx).unwrap();
2240
2241 assert_eq!(result.len(), 1, "Should flag 'test' in bare list item: {result:?}");
2242 assert_eq!(result[0].line, 3);
2243 }
2244
2245 #[test]
2246 fn test_frontmatter_flow_mapping_not_flagged() {
2247 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2250
2251 let content = "---\nflow_map: {test: value, other: test}\n---\n\nBody text\n";
2252 let ctx = create_context(content);
2253 let result = rule.check(&ctx).unwrap();
2254
2255 assert!(
2256 result.is_empty(),
2257 "Should not flag names inside flow mappings: {result:?}"
2258 );
2259 }
2260
2261 #[test]
2262 fn test_frontmatter_flow_sequence_not_flagged() {
2263 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2265
2266 let content = "---\nitems: [test, other, test]\n---\n\nBody text\n";
2267 let ctx = create_context(content);
2268 let result = rule.check(&ctx).unwrap();
2269
2270 assert!(
2271 result.is_empty(),
2272 "Should not flag names inside flow sequences: {result:?}"
2273 );
2274 }
2275
2276 #[test]
2277 fn test_frontmatter_list_item_mapping_fix_preserves_key() {
2278 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2280
2281 let content = "---\nitems:\n - test: a test value\n---\n\ntest here\n";
2282 let ctx = create_context(content);
2283 let fixed = rule.fix(&ctx).unwrap();
2284
2285 assert_eq!(fixed, "---\nitems:\n - test: a Test value\n---\n\nTest here\n");
2288 }
2289
2290 #[test]
2291 fn test_frontmatter_backtick_code_not_flagged() {
2292 let config = MD044Config {
2294 names: vec!["GoodApplication".to_string()],
2295 code_blocks: false,
2296 ..MD044Config::default()
2297 };
2298 let rule = MD044ProperNames::from_config_struct(config);
2299
2300 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2301 let ctx = create_context(content);
2302 let result = rule.check(&ctx).unwrap();
2303
2304 assert!(
2306 result.is_empty(),
2307 "Should not flag names inside backticks in frontmatter or body: {result:?}"
2308 );
2309 }
2310
2311 #[test]
2312 fn test_frontmatter_unquoted_backtick_code_not_flagged() {
2313 let config = MD044Config {
2315 names: vec!["GoodApplication".to_string()],
2316 code_blocks: false,
2317 ..MD044Config::default()
2318 };
2319 let rule = MD044ProperNames::from_config_struct(config);
2320
2321 let content = "---\ntitle: `goodapplication` CLI\n---\n\nIntroductory `goodapplication` CLI text.\n";
2322 let ctx = create_context(content);
2323 let result = rule.check(&ctx).unwrap();
2324
2325 assert!(
2326 result.is_empty(),
2327 "Should not flag names inside backticks in unquoted YAML frontmatter: {result:?}"
2328 );
2329 }
2330
2331 #[test]
2332 fn test_frontmatter_bare_name_still_flagged_with_backtick_nearby() {
2333 let config = MD044Config {
2335 names: vec!["GoodApplication".to_string()],
2336 code_blocks: false,
2337 ..MD044Config::default()
2338 };
2339 let rule = MD044ProperNames::from_config_struct(config);
2340
2341 let content = "---\ntitle: goodapplication `goodapplication` CLI\n---\n\nBody\n";
2342 let ctx = create_context(content);
2343 let result = rule.check(&ctx).unwrap();
2344
2345 assert_eq!(
2347 result.len(),
2348 1,
2349 "Should flag bare name but not backtick-wrapped name: {result:?}"
2350 );
2351 assert_eq!(result[0].line, 2);
2352 assert_eq!(result[0].column, 8); }
2354
2355 #[test]
2356 fn test_frontmatter_backtick_code_with_code_blocks_true() {
2357 let config = MD044Config {
2359 names: vec!["GoodApplication".to_string()],
2360 code_blocks: true,
2361 ..MD044Config::default()
2362 };
2363 let rule = MD044ProperNames::from_config_struct(config);
2364
2365 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nBody\n";
2366 let ctx = create_context(content);
2367 let result = rule.check(&ctx).unwrap();
2368
2369 assert_eq!(
2371 result.len(),
2372 1,
2373 "Should flag backtick-wrapped name when code_blocks=true: {result:?}"
2374 );
2375 assert_eq!(result[0].line, 2);
2376 }
2377
2378 #[test]
2379 fn test_frontmatter_fix_preserves_backtick_code() {
2380 let config = MD044Config {
2382 names: vec!["GoodApplication".to_string()],
2383 code_blocks: false,
2384 ..MD044Config::default()
2385 };
2386 let rule = MD044ProperNames::from_config_struct(config);
2387
2388 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2389 let ctx = create_context(content);
2390 let fixed = rule.fix(&ctx).unwrap();
2391
2392 assert_eq!(
2394 fixed, content,
2395 "Fix should not modify names inside backticks in frontmatter"
2396 );
2397 }
2398
2399 #[test]
2402 fn test_angle_bracket_url_in_html_comment_not_flagged() {
2403 let config = MD044Config {
2405 names: vec!["Test".to_string()],
2406 ..MD044Config::default()
2407 };
2408 let rule = MD044ProperNames::from_config_struct(config);
2409
2410 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";
2411 let ctx = create_context(content);
2412 let result = rule.check(&ctx).unwrap();
2413
2414 let line8_warnings: Vec<_> = result.iter().filter(|w| w.line == 8).collect();
2422 assert!(
2423 line8_warnings.is_empty(),
2424 "Should not flag names inside angle-bracket URLs in HTML comments: {line8_warnings:?}"
2425 );
2426 }
2427
2428 #[test]
2429 fn test_bare_url_in_html_comment_still_flagged() {
2430 let config = MD044Config {
2432 names: vec!["Test".to_string()],
2433 ..MD044Config::default()
2434 };
2435 let rule = MD044ProperNames::from_config_struct(config);
2436
2437 let content = "<!-- This is a test https://www.example.test -->\n";
2438 let ctx = create_context(content);
2439 let result = rule.check(&ctx).unwrap();
2440
2441 assert!(
2444 !result.is_empty(),
2445 "Should flag 'test' in prose text of HTML comment with bare URL"
2446 );
2447 }
2448
2449 #[test]
2450 fn test_angle_bracket_url_in_regular_markdown_not_flagged() {
2451 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2454
2455 let content = "<https://www.example.test>\n";
2456 let ctx = create_context(content);
2457 let result = rule.check(&ctx).unwrap();
2458
2459 assert!(
2460 result.is_empty(),
2461 "Should not flag names inside angle-bracket URLs in regular markdown: {result:?}"
2462 );
2463 }
2464
2465 #[test]
2466 fn test_multiple_angle_bracket_urls_in_one_comment() {
2467 let config = MD044Config {
2468 names: vec!["Test".to_string()],
2469 ..MD044Config::default()
2470 };
2471 let rule = MD044ProperNames::from_config_struct(config);
2472
2473 let content = "<!-- See <https://test.example.com> and <https://www.example.test> for details -->\n";
2474 let ctx = create_context(content);
2475 let result = rule.check(&ctx).unwrap();
2476
2477 assert!(
2479 result.is_empty(),
2480 "Should not flag names inside multiple angle-bracket URLs: {result:?}"
2481 );
2482 }
2483
2484 #[test]
2485 fn test_angle_bracket_non_url_still_flagged() {
2486 assert!(
2489 !MD044ProperNames::is_in_angle_bracket_url("<test> which is not a URL.", 1),
2490 "is_in_angle_bracket_url should return false for non-URL angle brackets"
2491 );
2492 }
2493
2494 #[test]
2495 fn test_angle_bracket_mailto_url_not_flagged() {
2496 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 = "<!-- Contact <mailto:test@example.com> for help -->\n";
2503 let ctx = create_context(content);
2504 let result = rule.check(&ctx).unwrap();
2505
2506 assert!(
2507 result.is_empty(),
2508 "Should not flag names inside angle-bracket mailto URLs: {result:?}"
2509 );
2510 }
2511
2512 #[test]
2513 fn test_angle_bracket_ftp_url_not_flagged() {
2514 let config = MD044Config {
2515 names: vec!["Test".to_string()],
2516 ..MD044Config::default()
2517 };
2518 let rule = MD044ProperNames::from_config_struct(config);
2519
2520 let content = "<!-- Download from <ftp://test.example.com/file> -->\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 FTP URLs: {result:?}"
2527 );
2528 }
2529
2530 #[test]
2531 fn test_angle_bracket_url_fix_preserves_url() {
2532 let config = MD044Config {
2534 names: vec!["Test".to_string()],
2535 ..MD044Config::default()
2536 };
2537 let rule = MD044ProperNames::from_config_struct(config);
2538
2539 let content = "<!-- test text <https://www.example.test> -->\n";
2540 let ctx = create_context(content);
2541 let fixed = rule.fix(&ctx).unwrap();
2542
2543 assert!(
2545 fixed.contains("<https://www.example.test>"),
2546 "Fix should preserve angle-bracket URLs: {fixed}"
2547 );
2548 assert!(
2549 fixed.contains("Test text"),
2550 "Fix should correct prose 'test' to 'Test': {fixed}"
2551 );
2552 }
2553
2554 #[test]
2555 fn test_is_in_angle_bracket_url_helper() {
2556 let line = "text <https://example.test> more text";
2558
2559 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));
2572
2573 assert!(MD044ProperNames::is_in_angle_bracket_url(
2575 "<mailto:test@example.com>",
2576 10
2577 ));
2578
2579 assert!(MD044ProperNames::is_in_angle_bracket_url(
2581 "<ftp://test.example.com>",
2582 10
2583 ));
2584 }
2585
2586 #[test]
2587 fn test_is_in_angle_bracket_url_uppercase_scheme() {
2588 assert!(MD044ProperNames::is_in_angle_bracket_url(
2590 "<HTTPS://test.example.com>",
2591 10
2592 ));
2593 assert!(MD044ProperNames::is_in_angle_bracket_url(
2594 "<Http://test.example.com>",
2595 10
2596 ));
2597 }
2598
2599 #[test]
2600 fn test_is_in_angle_bracket_url_uncommon_schemes() {
2601 assert!(MD044ProperNames::is_in_angle_bracket_url(
2603 "<ssh://test@example.com>",
2604 10
2605 ));
2606 assert!(MD044ProperNames::is_in_angle_bracket_url("<file:///test/path>", 10));
2608 assert!(MD044ProperNames::is_in_angle_bracket_url("<data:text/plain;test>", 10));
2610 }
2611
2612 #[test]
2613 fn test_is_in_angle_bracket_url_unclosed() {
2614 assert!(!MD044ProperNames::is_in_angle_bracket_url(
2616 "<https://test.example.com",
2617 10
2618 ));
2619 }
2620
2621 #[test]
2622 fn test_vale_inline_config_comments_not_flagged() {
2623 let config = MD044Config {
2624 names: vec!["Vale".to_string(), "JavaScript".to_string()],
2625 ..MD044Config::default()
2626 };
2627 let rule = MD044ProperNames::from_config_struct(config);
2628
2629 let content = "\
2630<!-- vale off -->
2631Some javascript text here.
2632<!-- vale on -->
2633<!-- vale Style.Rule = NO -->
2634More javascript text.
2635<!-- vale Style.Rule = YES -->
2636<!-- vale JavaScript.Grammar = NO -->
2637";
2638 let ctx = create_context(content);
2639 let result = rule.check(&ctx).unwrap();
2640
2641 assert_eq!(result.len(), 2, "Should only flag body lines, not Vale config comments");
2643 assert_eq!(result[0].line, 2);
2644 assert_eq!(result[1].line, 5);
2645 }
2646
2647 #[test]
2648 fn test_remark_lint_inline_config_comments_not_flagged() {
2649 let config = MD044Config {
2650 names: vec!["JavaScript".to_string()],
2651 ..MD044Config::default()
2652 };
2653 let rule = MD044ProperNames::from_config_struct(config);
2654
2655 let content = "\
2656<!-- lint disable remark-lint-some-rule -->
2657Some javascript text here.
2658<!-- lint enable remark-lint-some-rule -->
2659<!-- lint ignore remark-lint-some-rule -->
2660More javascript text.
2661";
2662 let ctx = create_context(content);
2663 let result = rule.check(&ctx).unwrap();
2664
2665 assert_eq!(
2666 result.len(),
2667 2,
2668 "Should only flag body lines, not remark-lint config comments"
2669 );
2670 assert_eq!(result[0].line, 2);
2671 assert_eq!(result[1].line, 5);
2672 }
2673
2674 #[test]
2675 fn test_fix_does_not_modify_vale_remark_lint_comments() {
2676 let config = MD044Config {
2677 names: vec!["JavaScript".to_string(), "Vale".to_string()],
2678 ..MD044Config::default()
2679 };
2680 let rule = MD044ProperNames::from_config_struct(config);
2681
2682 let content = "\
2683<!-- vale off -->
2684Some javascript text.
2685<!-- vale on -->
2686<!-- lint disable remark-lint-some-rule -->
2687More javascript text.
2688<!-- lint enable remark-lint-some-rule -->
2689";
2690 let ctx = create_context(content);
2691 let fixed = rule.fix(&ctx).unwrap();
2692
2693 assert!(fixed.contains("<!-- vale off -->"));
2695 assert!(fixed.contains("<!-- vale on -->"));
2696 assert!(fixed.contains("<!-- lint disable remark-lint-some-rule -->"));
2697 assert!(fixed.contains("<!-- lint enable remark-lint-some-rule -->"));
2698 assert!(fixed.contains("Some JavaScript text."));
2700 assert!(fixed.contains("More JavaScript text."));
2701 }
2702
2703 #[test]
2704 fn test_mixed_tool_directives_all_skipped() {
2705 let config = MD044Config {
2706 names: vec!["JavaScript".to_string(), "Vale".to_string()],
2707 ..MD044Config::default()
2708 };
2709 let rule = MD044ProperNames::from_config_struct(config);
2710
2711 let content = "\
2712<!-- rumdl-disable MD044 -->
2713Some javascript text.
2714<!-- markdownlint-disable -->
2715More javascript text.
2716<!-- vale off -->
2717Even more javascript text.
2718<!-- lint disable some-rule -->
2719Final javascript text.
2720<!-- rumdl-enable MD044 -->
2721<!-- markdownlint-enable -->
2722<!-- vale on -->
2723<!-- lint enable some-rule -->
2724";
2725 let ctx = create_context(content);
2726 let result = rule.check(&ctx).unwrap();
2727
2728 assert_eq!(
2730 result.len(),
2731 4,
2732 "Should only flag body lines, not any tool directive comments"
2733 );
2734 assert_eq!(result[0].line, 2);
2735 assert_eq!(result[1].line, 4);
2736 assert_eq!(result[2].line, 6);
2737 assert_eq!(result[3].line, 8);
2738 }
2739
2740 #[test]
2741 fn test_vale_remark_lint_edge_cases_not_matched() {
2742 let config = MD044Config {
2743 names: vec!["JavaScript".to_string(), "Vale".to_string()],
2744 ..MD044Config::default()
2745 };
2746 let rule = MD044ProperNames::from_config_struct(config);
2747
2748 let content = "\
2756<!-- vale -->
2757<!-- vale is a tool for writing -->
2758<!-- valedictorian javascript -->
2759<!-- linting javascript tips -->
2760<!-- vale javascript -->
2761<!-- lint your javascript code -->
2762";
2763 let ctx = create_context(content);
2764 let result = rule.check(&ctx).unwrap();
2765
2766 assert_eq!(
2773 result.len(),
2774 7,
2775 "Should flag proper names in non-directive HTML comments: got {result:?}"
2776 );
2777 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); }
2785
2786 #[test]
2787 fn test_vale_style_directives_skipped() {
2788 let config = MD044Config {
2789 names: vec!["JavaScript".to_string(), "Vale".to_string()],
2790 ..MD044Config::default()
2791 };
2792 let rule = MD044ProperNames::from_config_struct(config);
2793
2794 let content = "\
2796<!-- vale style = MyStyle -->
2797<!-- vale styles = Style1, Style2 -->
2798<!-- vale MyRule.Name = YES -->
2799<!-- vale MyRule.Name = NO -->
2800Some javascript text.
2801";
2802 let ctx = create_context(content);
2803 let result = rule.check(&ctx).unwrap();
2804
2805 assert_eq!(
2807 result.len(),
2808 1,
2809 "Should only flag body lines, not Vale style/rule directives: got {result:?}"
2810 );
2811 assert_eq!(result[0].line, 5);
2812 }
2813
2814 #[test]
2817 fn test_backtick_code_single_backticks() {
2818 let line = "hello `world` bye";
2819 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 7));
2821 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
2823 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 14));
2825 }
2826
2827 #[test]
2828 fn test_backtick_code_double_backticks() {
2829 let line = "a ``code`` b";
2830 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
2832 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
2834 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 11));
2836 }
2837
2838 #[test]
2839 fn test_backtick_code_unclosed() {
2840 let line = "a `code b";
2841 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
2843 }
2844
2845 #[test]
2846 fn test_backtick_code_mismatched_count() {
2847 let line = "a `code`` b";
2849 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
2852 }
2853
2854 #[test]
2855 fn test_backtick_code_multiple_spans() {
2856 let line = "`first` and `second`";
2857 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
2859 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 8));
2861 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 13));
2863 }
2864
2865 #[test]
2866 fn test_backtick_code_on_backtick_boundary() {
2867 let line = "`code`";
2868 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
2870 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 5));
2872 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
2874 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
2875 }
2876}