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