1use crate::utils::fast_hash;
2use crate::utils::regex_cache::{escape_regex, get_cached_fancy_regex};
3
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
5use fancy_regex::Regex;
6use std::collections::{HashMap, HashSet};
7use std::sync::LazyLock;
8use std::sync::{Arc, Mutex};
9
10mod md044_config;
11use md044_config::MD044Config;
12
13static HTML_COMMENT_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<!--([\s\S]*?)-->").unwrap());
14static REF_DEF_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
16 regex::Regex::new(r#"(?m)^[ ]{0,3}\[([^\]]+)\]:\s*([^\s]+)(?:\s+(?:"([^"]*)"|'([^']*)'))?$"#).unwrap()
17});
18
19type WarningPosition = (usize, usize, String); #[derive(Clone)]
76pub struct MD044ProperNames {
77 config: MD044Config,
78 combined_pattern: Option<String>,
80 name_variants: Vec<String>,
82 content_cache: Arc<Mutex<HashMap<u64, Vec<WarningPosition>>>>,
84}
85
86impl MD044ProperNames {
87 pub fn new(names: Vec<String>, code_blocks: bool) -> Self {
88 let config = MD044Config {
89 names,
90 code_blocks,
91 html_elements: true, html_comments: true, };
94 let combined_pattern = Self::create_combined_pattern(&config);
95 let name_variants = Self::build_name_variants(&config);
96 Self {
97 config,
98 combined_pattern,
99 name_variants,
100 content_cache: Arc::new(Mutex::new(HashMap::new())),
101 }
102 }
103
104 fn ascii_normalize(s: &str) -> String {
106 s.replace(['é', 'è', 'ê', 'ë'], "e")
107 .replace(['à', 'á', 'â', 'ä', 'ã', 'å'], "a")
108 .replace(['ï', 'î', 'í', 'ì'], "i")
109 .replace(['ü', 'ú', 'ù', 'û'], "u")
110 .replace(['ö', 'ó', 'ò', 'ô', 'õ'], "o")
111 .replace('ñ', "n")
112 .replace('ç', "c")
113 }
114
115 pub fn from_config_struct(config: MD044Config) -> Self {
116 let combined_pattern = Self::create_combined_pattern(&config);
117 let name_variants = Self::build_name_variants(&config);
118 Self {
119 config,
120 combined_pattern,
121 name_variants,
122 content_cache: Arc::new(Mutex::new(HashMap::new())),
123 }
124 }
125
126 fn create_combined_pattern(config: &MD044Config) -> Option<String> {
128 if config.names.is_empty() {
129 return None;
130 }
131
132 let mut patterns: Vec<String> = config
134 .names
135 .iter()
136 .flat_map(|name| {
137 let mut variations = vec![];
138 let lower_name = name.to_lowercase();
139
140 variations.push(escape_regex(&lower_name));
142
143 let lower_name_no_dots = lower_name.replace('.', "");
145 if lower_name != lower_name_no_dots {
146 variations.push(escape_regex(&lower_name_no_dots));
147 }
148
149 let ascii_normalized = Self::ascii_normalize(&lower_name);
151
152 if ascii_normalized != lower_name {
153 variations.push(escape_regex(&ascii_normalized));
154
155 let ascii_no_dots = ascii_normalized.replace('.', "");
157 if ascii_normalized != ascii_no_dots {
158 variations.push(escape_regex(&ascii_no_dots));
159 }
160 }
161
162 variations
163 })
164 .collect();
165
166 patterns.sort_by_key(|b| std::cmp::Reverse(b.len()));
168
169 Some(format!(r"(?i)({})", patterns.join("|")))
172 }
173
174 fn build_name_variants(config: &MD044Config) -> Vec<String> {
175 let mut variants = HashSet::new();
176 for name in &config.names {
177 let lower_name = name.to_lowercase();
178 variants.insert(lower_name.clone());
179
180 let lower_no_dots = lower_name.replace('.', "");
181 if lower_name != lower_no_dots {
182 variants.insert(lower_no_dots);
183 }
184
185 let ascii_normalized = Self::ascii_normalize(&lower_name);
186 if ascii_normalized != lower_name {
187 variants.insert(ascii_normalized.clone());
188
189 let ascii_no_dots = ascii_normalized.replace('.', "");
190 if ascii_normalized != ascii_no_dots {
191 variants.insert(ascii_no_dots);
192 }
193 }
194 }
195
196 variants.into_iter().collect()
197 }
198
199 fn find_name_violations(&self, content: &str, ctx: &crate::lint_context::LintContext) -> Vec<WarningPosition> {
201 if self.config.names.is_empty() || content.is_empty() || self.combined_pattern.is_none() {
203 return Vec::new();
204 }
205
206 let content_lower = if content.is_ascii() {
208 content.to_ascii_lowercase()
209 } else {
210 content.to_lowercase()
211 };
212 let has_potential_matches = self.name_variants.iter().any(|name| content_lower.contains(name));
213
214 if !has_potential_matches {
215 return Vec::new();
216 }
217
218 let hash = fast_hash(content);
220 {
221 if let Ok(cache) = self.content_cache.lock()
223 && let Some(cached) = cache.get(&hash)
224 {
225 return cached.clone();
226 }
227 }
228
229 let mut violations = Vec::new();
230
231 let combined_regex = match &self.combined_pattern {
233 Some(pattern) => match get_cached_fancy_regex(pattern) {
234 Ok(regex) => regex,
235 Err(_) => return Vec::new(),
236 },
237 None => return Vec::new(),
238 };
239
240 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
242 let line_num = line_idx + 1;
243 let line = line_info.content(ctx.content);
244
245 let trimmed = line.trim_start();
247 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
248 continue;
249 }
250
251 if !self.config.code_blocks && line_info.in_code_block {
253 continue;
254 }
255
256 if !self.config.html_elements && line_info.in_html_block {
258 continue;
259 }
260
261 let in_html_comment = if !self.config.html_comments {
263 self.is_in_html_comment(content, line_info.byte_offset)
265 } else {
266 false
267 };
268
269 if in_html_comment {
270 continue;
271 }
272
273 if line_info.in_jsx_expression || line_info.in_mdx_comment {
275 continue;
276 }
277
278 let line_lower = line.to_lowercase();
280 let has_line_matches = self.name_variants.iter().any(|name| line_lower.contains(name));
281
282 if !has_line_matches {
283 continue;
284 }
285
286 for cap_result in combined_regex.find_iter(line) {
288 match cap_result {
289 Ok(cap) => {
290 let found_name = &line[cap.start()..cap.end()];
291
292 let start_pos = cap.start();
294 let end_pos = cap.end();
295
296 if !self.is_at_word_boundary(line, start_pos, true)
297 || !self.is_at_word_boundary(line, end_pos, false)
298 {
299 continue; }
301
302 if !self.config.code_blocks {
304 let byte_pos = line_info.byte_offset + cap.start();
305 if ctx.is_in_code_block_or_span(byte_pos) {
306 continue;
307 }
308 }
309
310 let byte_pos = line_info.byte_offset + cap.start();
312 if self.is_in_link(ctx, byte_pos) {
313 continue;
314 }
315
316 if let Some(proper_name) = self.get_proper_name_for(found_name) {
318 if found_name != proper_name {
320 violations.push((line_num, cap.start() + 1, found_name.to_string()));
321 }
322 }
323 }
324 Err(e) => {
325 eprintln!("Regex execution error on line {line_num}: {e}");
326 }
327 }
328 }
329 }
330
331 if let Ok(mut cache) = self.content_cache.lock() {
333 cache.insert(hash, violations.clone());
334 }
335 violations
336 }
337
338 fn is_in_html_comment(&self, content: &str, byte_pos: usize) -> bool {
340 for m in HTML_COMMENT_REGEX.find_iter(content).flatten() {
341 if m.start() <= byte_pos && byte_pos < m.end() {
342 return true;
343 }
344 }
345 false
346 }
347
348 fn is_in_link(&self, ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
350 for link in &ctx.links {
352 if link.byte_offset <= byte_pos && byte_pos < link.byte_end {
353 return true;
354 }
355 }
356
357 for image in &ctx.images {
359 if image.byte_offset <= byte_pos && byte_pos < image.byte_end {
360 return true;
361 }
362 }
363
364 for m in REF_DEF_REGEX.find_iter(ctx.content) {
366 if m.start() <= byte_pos && byte_pos < m.end() {
367 return true;
368 }
369 }
370
371 false
372 }
373
374 fn is_word_boundary_char(c: char) -> bool {
376 !c.is_alphanumeric()
377 }
378
379 fn is_at_word_boundary(&self, content: &str, pos: usize, is_start: bool) -> bool {
381 let chars: Vec<char> = content.chars().collect();
382 let char_indices: Vec<(usize, char)> = content.char_indices().collect();
383
384 let char_pos = char_indices.iter().position(|(idx, _)| *idx == pos);
386 if char_pos.is_none() {
387 return true; }
389 let char_pos = char_pos.unwrap();
390
391 if is_start {
392 if char_pos == 0 {
394 return true; }
396 Self::is_word_boundary_char(chars[char_pos - 1])
397 } else {
398 if char_pos >= chars.len() {
400 return true; }
402 Self::is_word_boundary_char(chars[char_pos])
403 }
404 }
405
406 fn get_proper_name_for(&self, found_name: &str) -> Option<String> {
408 let found_lower = found_name.to_lowercase();
409
410 for name in &self.config.names {
412 let lower_name = name.to_lowercase();
413 let lower_name_no_dots = lower_name.replace('.', "");
414
415 if found_lower == lower_name || found_lower == lower_name_no_dots {
417 return Some(name.clone());
418 }
419
420 let ascii_normalized = Self::ascii_normalize(&lower_name);
422
423 let ascii_no_dots = ascii_normalized.replace('.', "");
424
425 if found_lower == ascii_normalized || found_lower == ascii_no_dots {
426 return Some(name.clone());
427 }
428 }
429 None
430 }
431}
432
433impl Rule for MD044ProperNames {
434 fn name(&self) -> &'static str {
435 "MD044"
436 }
437
438 fn description(&self) -> &'static str {
439 "Proper names should have the correct capitalization"
440 }
441
442 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
443 if self.config.names.is_empty() {
444 return true;
445 }
446 let content_lower = ctx.content.to_lowercase();
448 !self
449 .config
450 .names
451 .iter()
452 .any(|name| content_lower.contains(&name.to_lowercase()))
453 }
454
455 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
456 let content = ctx.content;
457 if content.is_empty() || self.config.names.is_empty() || self.combined_pattern.is_none() {
458 return Ok(Vec::new());
459 }
460
461 let content_lower = content.to_lowercase();
463 let has_potential_matches = self.config.names.iter().any(|name| {
464 let name_lower = name.to_lowercase();
465 let name_no_dots = name_lower.replace('.', "");
466
467 if content_lower.contains(&name_lower) || content_lower.contains(&name_no_dots) {
469 return true;
470 }
471
472 let ascii_normalized = Self::ascii_normalize(&name_lower);
474
475 if ascii_normalized != name_lower {
476 if content_lower.contains(&ascii_normalized) {
477 return true;
478 }
479 let ascii_no_dots = ascii_normalized.replace('.', "");
480 if ascii_normalized != ascii_no_dots && content_lower.contains(&ascii_no_dots) {
481 return true;
482 }
483 }
484
485 false
486 });
487
488 if !has_potential_matches {
489 return Ok(Vec::new());
490 }
491
492 let line_index = &ctx.line_index;
493 let violations = self.find_name_violations(content, ctx);
494
495 let warnings = violations
496 .into_iter()
497 .filter_map(|(line, column, found_name)| {
498 self.get_proper_name_for(&found_name).map(|proper_name| LintWarning {
499 rule_name: Some(self.name().to_string()),
500 line,
501 column,
502 end_line: line,
503 end_column: column + found_name.len(),
504 message: format!("Proper name '{found_name}' should be '{proper_name}'"),
505 severity: Severity::Warning,
506 fix: Some(Fix {
507 range: line_index.line_col_to_byte_range(line, column),
508 replacement: proper_name,
509 }),
510 })
511 })
512 .collect();
513
514 Ok(warnings)
515 }
516
517 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
518 let content = ctx.content;
519 if content.is_empty() || self.config.names.is_empty() {
520 return Ok(content.to_string());
521 }
522
523 let violations = self.find_name_violations(content, ctx);
524 if violations.is_empty() {
525 return Ok(content.to_string());
526 }
527
528 let mut fixed_lines = Vec::new();
530
531 let mut violations_by_line: HashMap<usize, Vec<(usize, String)>> = HashMap::new();
533 for (line_num, col_num, found_name) in violations {
534 violations_by_line
535 .entry(line_num)
536 .or_default()
537 .push((col_num, found_name));
538 }
539
540 for violations in violations_by_line.values_mut() {
542 violations.sort_by_key(|b| std::cmp::Reverse(b.0));
543 }
544
545 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
547 let line_num = line_idx + 1;
548
549 if let Some(line_violations) = violations_by_line.get(&line_num) {
550 let mut fixed_line = line_info.content(ctx.content).to_string();
552
553 for (col_num, found_name) in line_violations {
554 if let Some(proper_name) = self.get_proper_name_for(found_name) {
555 let start_col = col_num - 1; let end_col = start_col + found_name.len();
557
558 if end_col <= fixed_line.len()
559 && fixed_line.is_char_boundary(start_col)
560 && fixed_line.is_char_boundary(end_col)
561 {
562 fixed_line.replace_range(start_col..end_col, &proper_name);
563 }
564 }
565 }
566
567 fixed_lines.push(fixed_line);
568 } else {
569 fixed_lines.push(line_info.content(ctx.content).to_string());
571 }
572 }
573
574 let mut result = fixed_lines.join("\n");
576 if content.ends_with('\n') && !result.ends_with('\n') {
577 result.push('\n');
578 }
579 Ok(result)
580 }
581
582 fn as_any(&self) -> &dyn std::any::Any {
583 self
584 }
585
586 fn default_config_section(&self) -> Option<(String, toml::Value)> {
587 let json_value = serde_json::to_value(&self.config).ok()?;
588 Some((
589 self.name().to_string(),
590 crate::rule_config_serde::json_to_toml_value(&json_value)?,
591 ))
592 }
593
594 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
595 where
596 Self: Sized,
597 {
598 let rule_config = crate::rule_config_serde::load_rule_config::<MD044Config>(config);
599 Box::new(Self::from_config_struct(rule_config))
600 }
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606 use crate::lint_context::LintContext;
607
608 fn create_context(content: &str) -> LintContext<'_> {
609 LintContext::new(content, crate::config::MarkdownFlavor::Standard, None)
610 }
611
612 #[test]
613 fn test_correctly_capitalized_names() {
614 let rule = MD044ProperNames::new(
615 vec![
616 "JavaScript".to_string(),
617 "TypeScript".to_string(),
618 "Node.js".to_string(),
619 ],
620 true,
621 );
622
623 let content = "This document uses JavaScript, TypeScript, and Node.js correctly.";
624 let ctx = create_context(content);
625 let result = rule.check(&ctx).unwrap();
626 assert!(result.is_empty(), "Should not flag correctly capitalized names");
627 }
628
629 #[test]
630 fn test_incorrectly_capitalized_names() {
631 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
632
633 let content = "This document uses javascript and typescript incorrectly.";
634 let ctx = create_context(content);
635 let result = rule.check(&ctx).unwrap();
636
637 assert_eq!(result.len(), 2, "Should flag two incorrect capitalizations");
638 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
639 assert_eq!(result[0].line, 1);
640 assert_eq!(result[0].column, 20);
641 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
642 assert_eq!(result[1].line, 1);
643 assert_eq!(result[1].column, 35);
644 }
645
646 #[test]
647 fn test_names_at_beginning_of_sentences() {
648 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "Python".to_string()], true);
649
650 let content = "javascript is a great language. python is also popular.";
651 let ctx = create_context(content);
652 let result = rule.check(&ctx).unwrap();
653
654 assert_eq!(result.len(), 2, "Should flag names at beginning of sentences");
655 assert_eq!(result[0].line, 1);
656 assert_eq!(result[0].column, 1);
657 assert_eq!(result[1].line, 1);
658 assert_eq!(result[1].column, 33);
659 }
660
661 #[test]
662 fn test_names_in_code_blocks_checked_by_default() {
663 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
664
665 let content = r#"Here is some text with JavaScript.
666
667```javascript
668// This javascript should be checked
669const lang = "javascript";
670```
671
672But this javascript should be flagged."#;
673
674 let ctx = create_context(content);
675 let result = rule.check(&ctx).unwrap();
676
677 assert_eq!(result.len(), 3, "Should flag javascript inside and outside code blocks");
678 assert_eq!(result[0].line, 4);
679 assert_eq!(result[1].line, 5);
680 assert_eq!(result[2].line, 8);
681 }
682
683 #[test]
684 fn test_names_in_code_blocks_ignored_when_disabled() {
685 let rule = MD044ProperNames::new(
686 vec!["JavaScript".to_string()],
687 false, );
689
690 let content = r#"```
691javascript in code block
692```"#;
693
694 let ctx = create_context(content);
695 let result = rule.check(&ctx).unwrap();
696
697 assert_eq!(
698 result.len(),
699 0,
700 "Should not flag javascript in code blocks when code_blocks is false"
701 );
702 }
703
704 #[test]
705 fn test_names_in_inline_code_checked_by_default() {
706 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
707
708 let content = "This is `javascript` in inline code and javascript outside.";
709 let ctx = create_context(content);
710 let result = rule.check(&ctx).unwrap();
711
712 assert_eq!(result.len(), 2, "Should flag javascript inside and outside inline code");
714 assert_eq!(result[0].column, 10); assert_eq!(result[1].column, 41); }
717
718 #[test]
719 fn test_multiple_names_in_same_line() {
720 let rule = MD044ProperNames::new(
721 vec!["JavaScript".to_string(), "TypeScript".to_string(), "React".to_string()],
722 true,
723 );
724
725 let content = "I use javascript, typescript, and react in my projects.";
726 let ctx = create_context(content);
727 let result = rule.check(&ctx).unwrap();
728
729 assert_eq!(result.len(), 3, "Should flag all three incorrect names");
730 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
731 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
732 assert_eq!(result[2].message, "Proper name 'react' should be 'React'");
733 }
734
735 #[test]
736 fn test_case_sensitivity() {
737 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
738
739 let content = "JAVASCRIPT, Javascript, javascript, and JavaScript variations.";
740 let ctx = create_context(content);
741 let result = rule.check(&ctx).unwrap();
742
743 assert_eq!(result.len(), 3, "Should flag all incorrect case variations");
744 assert!(result.iter().all(|w| w.message.contains("should be 'JavaScript'")));
746 }
747
748 #[test]
749 fn test_configuration_with_custom_name_list() {
750 let config = MD044Config {
751 names: vec!["GitHub".to_string(), "GitLab".to_string(), "DevOps".to_string()],
752 code_blocks: true,
753 html_elements: true,
754 html_comments: true,
755 };
756 let rule = MD044ProperNames::from_config_struct(config);
757
758 let content = "We use github, gitlab, and devops for our workflow.";
759 let ctx = create_context(content);
760 let result = rule.check(&ctx).unwrap();
761
762 assert_eq!(result.len(), 3, "Should flag all custom names");
763 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
764 assert_eq!(result[1].message, "Proper name 'gitlab' should be 'GitLab'");
765 assert_eq!(result[2].message, "Proper name 'devops' should be 'DevOps'");
766 }
767
768 #[test]
769 fn test_empty_configuration() {
770 let rule = MD044ProperNames::new(vec![], true);
771
772 let content = "This has javascript and typescript but no configured names.";
773 let ctx = create_context(content);
774 let result = rule.check(&ctx).unwrap();
775
776 assert!(result.is_empty(), "Should not flag anything with empty configuration");
777 }
778
779 #[test]
780 fn test_names_with_special_characters() {
781 let rule = MD044ProperNames::new(
782 vec!["Node.js".to_string(), "ASP.NET".to_string(), "C++".to_string()],
783 true,
784 );
785
786 let content = "We use nodejs, asp.net, ASP.NET, and c++ in our stack.";
787 let ctx = create_context(content);
788 let result = rule.check(&ctx).unwrap();
789
790 assert_eq!(result.len(), 3, "Should handle special characters correctly");
795
796 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
797 assert!(messages.contains(&"Proper name 'nodejs' should be 'Node.js'"));
798 assert!(messages.contains(&"Proper name 'asp.net' should be 'ASP.NET'"));
799 assert!(messages.contains(&"Proper name 'c++' should be 'C++'"));
800 }
801
802 #[test]
803 fn test_word_boundaries() {
804 let rule = MD044ProperNames::new(vec!["Java".to_string(), "Script".to_string()], true);
805
806 let content = "JavaScript is not java or script, but Java and Script are separate.";
807 let ctx = create_context(content);
808 let result = rule.check(&ctx).unwrap();
809
810 assert_eq!(result.len(), 2, "Should respect word boundaries");
812 assert!(result.iter().any(|w| w.column == 19)); assert!(result.iter().any(|w| w.column == 27)); }
815
816 #[test]
817 fn test_fix_method() {
818 let rule = MD044ProperNames::new(
819 vec![
820 "JavaScript".to_string(),
821 "TypeScript".to_string(),
822 "Node.js".to_string(),
823 ],
824 true,
825 );
826
827 let content = "I love javascript, typescript, and nodejs!";
828 let ctx = create_context(content);
829 let fixed = rule.fix(&ctx).unwrap();
830
831 assert_eq!(fixed, "I love JavaScript, TypeScript, and Node.js!");
832 }
833
834 #[test]
835 fn test_fix_multiple_occurrences() {
836 let rule = MD044ProperNames::new(vec!["Python".to_string()], true);
837
838 let content = "python is great. I use python daily. PYTHON is powerful.";
839 let ctx = create_context(content);
840 let fixed = rule.fix(&ctx).unwrap();
841
842 assert_eq!(fixed, "Python is great. I use Python daily. Python is powerful.");
843 }
844
845 #[test]
846 fn test_fix_checks_code_blocks_by_default() {
847 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
848
849 let content = r#"I love javascript.
850
851```
852const lang = "javascript";
853```
854
855More javascript here."#;
856
857 let ctx = create_context(content);
858 let fixed = rule.fix(&ctx).unwrap();
859
860 let expected = r#"I love JavaScript.
861
862```
863const lang = "JavaScript";
864```
865
866More JavaScript here."#;
867
868 assert_eq!(fixed, expected);
869 }
870
871 #[test]
872 fn test_multiline_content() {
873 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
874
875 let content = r#"First line with rust.
876Second line with python.
877Third line with RUST and PYTHON."#;
878
879 let ctx = create_context(content);
880 let result = rule.check(&ctx).unwrap();
881
882 assert_eq!(result.len(), 4, "Should flag all incorrect occurrences");
883 assert_eq!(result[0].line, 1);
884 assert_eq!(result[1].line, 2);
885 assert_eq!(result[2].line, 3);
886 assert_eq!(result[3].line, 3);
887 }
888
889 #[test]
890 fn test_default_config() {
891 let config = MD044Config::default();
892 assert!(config.names.is_empty());
893 assert!(!config.code_blocks); }
895
896 #[test]
897 fn test_performance_with_many_names() {
898 let mut names = vec![];
899 for i in 0..50 {
900 names.push(format!("ProperName{i}"));
901 }
902
903 let rule = MD044ProperNames::new(names, true);
904
905 let content = "This has propername0, propername25, and propername49 incorrectly.";
906 let ctx = create_context(content);
907 let result = rule.check(&ctx).unwrap();
908
909 assert_eq!(result.len(), 3, "Should handle many configured names efficiently");
910 }
911
912 #[test]
913 fn test_large_name_count_performance() {
914 let names = (0..1000).map(|i| format!("ProperName{i}")).collect::<Vec<_>>();
917
918 let rule = MD044ProperNames::new(names, true);
919
920 assert!(rule.combined_pattern.is_some());
922
923 let content = "This has propername0 and propername999 in it.";
925 let ctx = create_context(content);
926 let result = rule.check(&ctx).unwrap();
927
928 assert_eq!(result.len(), 2, "Should handle 1000 names without issues");
930 }
931
932 #[test]
933 fn test_cache_behavior() {
934 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
935
936 let content = "Using javascript here.";
937 let ctx = create_context(content);
938
939 let result1 = rule.check(&ctx).unwrap();
941 assert_eq!(result1.len(), 1);
942
943 let result2 = rule.check(&ctx).unwrap();
945 assert_eq!(result2.len(), 1);
946
947 assert_eq!(result1[0].line, result2[0].line);
949 assert_eq!(result1[0].column, result2[0].column);
950 }
951
952 #[test]
953 fn test_html_comments_not_checked_when_disabled() {
954 let config = MD044Config {
955 names: vec!["JavaScript".to_string()],
956 code_blocks: true, html_elements: true, html_comments: false, };
960 let rule = MD044ProperNames::from_config_struct(config);
961
962 let content = r#"Regular javascript here.
963<!-- This javascript in HTML comment should be ignored -->
964More javascript outside."#;
965
966 let ctx = create_context(content);
967 let result = rule.check(&ctx).unwrap();
968
969 assert_eq!(result.len(), 2, "Should only flag javascript outside HTML comments");
970 assert_eq!(result[0].line, 1);
971 assert_eq!(result[1].line, 3);
972 }
973
974 #[test]
975 fn test_html_comments_checked_when_enabled() {
976 let config = MD044Config {
977 names: vec!["JavaScript".to_string()],
978 code_blocks: true, html_elements: true, html_comments: true, };
982 let rule = MD044ProperNames::from_config_struct(config);
983
984 let content = r#"Regular javascript here.
985<!-- This javascript in HTML comment should be checked -->
986More javascript outside."#;
987
988 let ctx = create_context(content);
989 let result = rule.check(&ctx).unwrap();
990
991 assert_eq!(
992 result.len(),
993 3,
994 "Should flag all javascript occurrences including in HTML comments"
995 );
996 }
997
998 #[test]
999 fn test_multiline_html_comments() {
1000 let config = MD044Config {
1001 names: vec!["Python".to_string(), "JavaScript".to_string()],
1002 code_blocks: true, html_elements: true, html_comments: false, };
1006 let rule = MD044ProperNames::from_config_struct(config);
1007
1008 let content = r#"Regular python here.
1009<!--
1010This is a multiline comment
1011with javascript and python
1012that should be ignored
1013-->
1014More javascript outside."#;
1015
1016 let ctx = create_context(content);
1017 let result = rule.check(&ctx).unwrap();
1018
1019 assert_eq!(result.len(), 2, "Should only flag names outside HTML comments");
1020 assert_eq!(result[0].line, 1); assert_eq!(result[1].line, 7); }
1023
1024 #[test]
1025 fn test_fix_preserves_html_comments_when_disabled() {
1026 let config = MD044Config {
1027 names: vec!["JavaScript".to_string()],
1028 code_blocks: true, html_elements: true, html_comments: false, };
1032 let rule = MD044ProperNames::from_config_struct(config);
1033
1034 let content = r#"javascript here.
1035<!-- javascript in comment -->
1036More javascript."#;
1037
1038 let ctx = create_context(content);
1039 let fixed = rule.fix(&ctx).unwrap();
1040
1041 let expected = r#"JavaScript here.
1042<!-- javascript in comment -->
1043More JavaScript."#;
1044
1045 assert_eq!(
1046 fixed, expected,
1047 "Should not fix names inside HTML comments when disabled"
1048 );
1049 }
1050
1051 #[test]
1052 fn test_proper_names_in_links_not_flagged() {
1053 let rule = MD044ProperNames::new(
1054 vec!["JavaScript".to_string(), "Node.js".to_string(), "Python".to_string()],
1055 true,
1056 );
1057
1058 let content = r#"Check this [javascript documentation](https://javascript.info) for info.
1059
1060Visit [node.js homepage](https://nodejs.org) and [python tutorial](https://python.org).
1061
1062Real javascript should be flagged.
1063
1064Also see the [typescript guide][ts-ref] for more.
1065
1066Real python should be flagged too.
1067
1068[ts-ref]: https://typescript.org/handbook"#;
1069
1070 let ctx = create_context(content);
1071 let result = rule.check(&ctx).unwrap();
1072
1073 assert_eq!(
1075 result.len(),
1076 2,
1077 "Expected exactly 2 warnings for standalone proper names"
1078 );
1079 assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
1080 assert!(result[1].message.contains("'python' should be 'Python'"));
1081 assert!(result[0].line == 5); assert!(result[1].line == 9); }
1085
1086 #[test]
1087 fn test_proper_names_in_images_not_flagged() {
1088 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1089
1090 let content = r#"Here is a  image.
1091
1092Real javascript should be flagged."#;
1093
1094 let ctx = create_context(content);
1095 let result = rule.check(&ctx).unwrap();
1096
1097 assert_eq!(result.len(), 1, "Expected exactly 1 warning for standalone proper name");
1099 assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
1100 assert!(result[0].line == 3); }
1102
1103 #[test]
1104 fn test_proper_names_in_reference_definitions_not_flagged() {
1105 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1106
1107 let content = r#"Check the [javascript guide][js-ref] for details.
1108
1109Real javascript should be flagged.
1110
1111[js-ref]: https://javascript.info/typescript/guide"#;
1112
1113 let ctx = create_context(content);
1114 let result = rule.check(&ctx).unwrap();
1115
1116 assert_eq!(result.len(), 1, "Expected exactly 1 warning for standalone proper name");
1118 assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
1119 assert!(result[0].line == 3); }
1121}