1use crate::utils::fast_hash;
2use crate::utils::regex_cache::{escape_regex, get_cached_regex};
3
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::frontmatter_values;
6use crate::utils::range_utils::byte_to_char_count;
7use std::collections::{HashMap, HashSet};
8use std::sync::{Arc, Mutex};
9
10mod md044_config;
11pub(super) use md044_config::MD044Config;
12
13type WarningPosition = (usize, usize, String, usize); fn is_inline_config_comment(trimmed: &str) -> bool {
73 trimmed.starts_with("<!-- rumdl-")
74 || trimmed.starts_with("<!-- markdownlint-")
75 || trimmed.starts_with("<!-- vale off")
76 || trimmed.starts_with("<!-- vale on")
77 || (trimmed.starts_with("<!-- vale ") && trimmed.contains(" = "))
78 || trimmed.starts_with("<!-- vale style")
79 || trimmed.starts_with("<!-- lint disable ")
80 || trimmed.starts_with("<!-- lint enable ")
81 || trimmed.starts_with("<!-- lint ignore ")
82}
83
84#[derive(Clone)]
85pub struct MD044ProperNames {
86 config: MD044Config,
87 combined_pattern: Option<String>,
89 name_variants: HashMap<String, usize>,
91 ignore_fields: HashSet<String>,
93 content_cache: Arc<Mutex<HashMap<u64, Vec<WarningPosition>>>>,
102}
103
104impl MD044ProperNames {
105 pub fn new(names: Vec<String>, code_blocks: bool) -> Self {
106 let config = MD044Config {
107 names,
108 code_blocks,
109 ..Default::default()
110 };
111 Self::from_config_struct(config)
112 }
113
114 fn ascii_normalize(s: &str) -> String {
116 s.replace(['é', 'è', 'ê', 'ë'], "e")
117 .replace(['à', 'á', 'â', 'ä', 'ã', 'å'], "a")
118 .replace(['ï', 'î', 'í', 'ì'], "i")
119 .replace(['ü', 'ú', 'ù', 'û'], "u")
120 .replace(['ö', 'ó', 'ò', 'ô', 'õ'], "o")
121 .replace('ñ', "n")
122 .replace('ç', "c")
123 }
124
125 pub fn from_config_struct(config: MD044Config) -> Self {
126 let combined_pattern = Self::create_combined_pattern(&config);
127 let name_variants = Self::build_name_variants(&config);
128 let ignore_fields = config
129 .ignore_frontmatter_fields
130 .iter()
131 .flatten()
132 .map(|f| f.to_lowercase())
133 .collect();
134 Self {
135 config,
136 combined_pattern,
137 name_variants,
138 ignore_fields,
139 content_cache: Arc::new(Mutex::new(HashMap::new())),
140 }
141 }
142
143 fn create_combined_pattern(config: &MD044Config) -> Option<String> {
145 if config.names.is_empty() {
146 return None;
147 }
148
149 let mut patterns: Vec<String> = config
151 .names
152 .iter()
153 .flat_map(|name| {
154 let mut variations = vec![];
155 let lower_name = name.to_lowercase();
156
157 variations.push(escape_regex(&lower_name));
159
160 let lower_name_no_dots = lower_name.replace('.', "");
162 if lower_name != lower_name_no_dots {
163 variations.push(escape_regex(&lower_name_no_dots));
164 }
165
166 let ascii_normalized = Self::ascii_normalize(&lower_name);
168
169 if ascii_normalized != lower_name {
170 variations.push(escape_regex(&ascii_normalized));
171
172 let ascii_no_dots = ascii_normalized.replace('.', "");
174 if ascii_normalized != ascii_no_dots {
175 variations.push(escape_regex(&ascii_no_dots));
176 }
177 }
178
179 variations
180 })
181 .collect();
182
183 patterns.sort_by_key(|b| std::cmp::Reverse(b.len()));
185
186 Some(format!(r"(?i)({})", patterns.join("|")))
189 }
190
191 fn build_name_variants(config: &MD044Config) -> HashMap<String, usize> {
192 let mut variants = HashMap::new();
193 for (index, name) in config.names.iter().enumerate() {
194 let lower_name = name.to_lowercase();
195 variants.entry(lower_name.clone()).or_insert(index);
196
197 let lower_no_dots = lower_name.replace('.', "");
198 if lower_name != lower_no_dots {
199 variants.entry(lower_no_dots).or_insert(index);
200 }
201
202 let ascii_normalized = Self::ascii_normalize(&lower_name);
203 if ascii_normalized != lower_name {
204 variants.entry(ascii_normalized.clone()).or_insert(index);
205
206 let ascii_no_dots = ascii_normalized.replace('.', "");
207 if ascii_normalized != ascii_no_dots {
208 variants.entry(ascii_no_dots).or_insert(index);
209 }
210 }
211 }
212
213 variants
214 }
215
216 fn find_name_violations(&self, ctx: &crate::lint_context::LintContext) -> Vec<WarningPosition> {
218 let content = ctx.content;
219 let hash = fast_hash(content);
221 {
222 if let Ok(cache) = self.content_cache.lock()
224 && let Some(cached) = cache.get(&hash)
225 {
226 return cached.clone();
227 }
228 }
229
230 let mut violations = Vec::new();
231
232 let combined_regex = match &self.combined_pattern {
234 Some(pattern) => match get_cached_regex(pattern) {
235 Ok(regex) => regex,
236 Err(_) => return Vec::new(),
237 },
238 None => return Vec::new(),
239 };
240
241 let field_map = if self.ignore_fields.is_empty() {
243 Vec::new()
244 } else {
245 frontmatter_values::field_map(ctx)
246 };
247
248 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
250 let line_num = line_idx + 1;
251 let line = line_info.content(ctx.content);
252
253 let trimmed = line.trim_start();
255 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
256 continue;
257 }
258
259 if !self.config.code_blocks && line_info.in_code_block {
261 continue;
262 }
263
264 if !self.config.html_elements && line_info.in_html_block {
266 continue;
267 }
268
269 if !self.config.html_comments && line_info.in_html_comment {
271 continue;
272 }
273
274 if line_info.in_jsx_expression || line_info.in_mdx_comment {
276 continue;
277 }
278
279 if line_info.in_obsidian_comment {
281 continue;
282 }
283
284 let fm_value_offset = if line_info.in_front_matter {
287 frontmatter_values::value_offset(line)
288 } else {
289 0
290 };
291 if fm_value_offset == usize::MAX {
292 continue;
293 }
294 if line_info.in_front_matter
295 && let Some(Some(field)) = field_map.get(line_idx)
296 && self.ignore_fields.contains(field)
297 {
298 continue;
299 }
300 let fm_value_span = if line_info.in_front_matter {
301 frontmatter_values::value_span(line)
302 } else {
303 None
304 };
305
306 if is_inline_config_comment(trimmed) {
308 continue;
309 }
310
311 let line_lower = line.to_lowercase();
313 let has_line_matches = self.name_variants.keys().any(|name| line_lower.contains(name));
314
315 if !has_line_matches {
316 continue;
317 }
318
319 for cap in combined_regex.find_iter(line) {
321 let found_name = &line[cap.start()..cap.end()];
322
323 let start_pos = cap.start();
325 let end_pos = cap.end();
326
327 if start_pos < fm_value_offset {
329 continue;
330 }
331
332 let byte_pos = line_info.byte_offset + start_pos;
334 if ctx.is_in_html_tag(byte_pos) {
335 continue;
336 }
337
338 if ctx.is_in_shortcode(byte_pos) {
345 continue;
346 }
347
348 if !Self::is_at_word_boundary(line, start_pos, true) || !Self::is_at_word_boundary(line, end_pos, false)
349 {
350 continue; }
352
353 if self.config.whole_words
354 && ((Self::is_joined_identifier_boundary(line, start_pos, true)
355 && !Self::is_underscore_emphasis_boundary(ctx, byte_pos, true))
356 || (Self::is_joined_identifier_boundary(line, end_pos, false)
357 && !Self::is_underscore_emphasis_boundary(ctx, byte_pos + found_name.len(), false)))
358 {
359 continue;
360 }
361
362 if !self.config.code_blocks {
364 if ctx.is_in_code_block_or_span(byte_pos) {
365 continue;
366 }
367 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
371 && Self::is_in_backtick_code_in_line(line, start_pos)
372 {
373 continue;
374 }
375 }
376
377 if Self::is_in_link(ctx, byte_pos) {
379 continue;
380 }
381
382 if Self::is_in_angle_bracket_url(line, start_pos) {
386 continue;
387 }
388
389 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
393 && Self::is_in_markdown_link_url(line, start_pos)
394 {
395 continue;
396 }
397
398 if Self::is_in_wikilink_url(ctx, byte_pos) {
403 continue;
404 }
405
406 if ctx.is_in_bare_url(byte_pos) {
412 continue;
413 }
414
415 if let Some(fm_value) = fm_value_span
422 && Self::is_in_path_like_token(line, start_pos, fm_value)
423 {
424 continue;
425 }
426
427 if let Some(&proper_name_index) = self.name_variants.get(&found_name.to_lowercase()) {
429 if found_name != self.config.names[proper_name_index] {
431 violations.push((line_num, cap.start() + 1, found_name.to_string(), proper_name_index));
432 }
433 }
434 }
435 }
436
437 if let Ok(mut cache) = self.content_cache.lock() {
439 cache.insert(hash, violations.clone());
440 }
441 violations
442 }
443
444 fn is_in_link(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
452 use pulldown_cmark::LinkType;
453
454 if let Some(link) = ctx.link_containing(byte_pos) {
455 let (text_start, text_end) = if matches!(link.link_type, LinkType::WikiLink { .. }) {
457 let span = &ctx.content[link.byte_offset..link.byte_end];
464 let start = match span.find('|') {
465 Some(pipe) => link.byte_offset + pipe + 1,
466 None => link.byte_offset + 2,
467 };
468 (start, link.byte_end.saturating_sub(2))
469 } else {
470 let start = link.byte_offset + 1;
471 (start, start + link.text.len())
472 };
473
474 if byte_pos >= text_start && byte_pos < text_end {
478 let is_wikilink = matches!(link.link_type, LinkType::WikiLink { .. });
479 if Self::link_text_is_url(&link.text)
480 || (!is_wikilink && Self::link_text_matches_link_url(&link.text, &link.url))
481 {
482 return true;
483 }
484 return Self::image_verdict(ctx, byte_pos).unwrap_or(false);
490 }
491 return true;
493 }
494
495 if let Some(verdict) = Self::image_verdict(ctx, byte_pos) {
496 return verdict;
497 }
498
499 ctx.is_in_reference_def(byte_pos)
501 }
502
503 fn image_verdict(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> Option<bool> {
508 let image = ctx.image_containing(byte_pos)?;
509
510 let alt_start = image.byte_offset + 2;
512 let alt_end = alt_start + image.alt_text.len();
513
514 Some(!(byte_pos >= alt_start && byte_pos < alt_end))
516 }
517
518 fn link_text_is_url(text: &str) -> bool {
520 let lower = text.trim().to_ascii_lowercase();
521 lower.starts_with("http://")
522 || lower.starts_with("https://")
523 || lower.starts_with("www.")
524 || lower.starts_with("//")
525 }
526
527 fn link_text_matches_link_url(text: &str, url: &str) -> bool {
539 let text = text.trim();
540 if !text.contains('.') {
542 return false;
543 }
544 let url_lower = url.to_ascii_lowercase();
545 let url_without_scheme = url_lower
546 .strip_prefix("https://")
547 .or_else(|| url_lower.strip_prefix("http://"))
548 .or_else(|| url_lower.strip_prefix("//"))
549 .unwrap_or(&url_lower);
550 let text_lower = text.to_ascii_lowercase();
551 if url_without_scheme == text_lower.as_str() {
553 return true;
554 }
555 url_without_scheme.len() > text_lower.len()
557 && url_without_scheme.starts_with(text_lower.as_str())
558 && matches!(
559 url_without_scheme.as_bytes().get(text_lower.len()),
560 Some(b'/') | Some(b'?') | Some(b'#')
561 )
562 }
563
564 fn is_in_angle_bracket_url(line: &str, pos: usize) -> bool {
570 let bytes = line.as_bytes();
571 let len = bytes.len();
572 let mut i = 0;
573 while i < len {
574 if bytes[i] == b'<' {
575 let after_open = i + 1;
576 if after_open < len && bytes[after_open].is_ascii_alphabetic() {
580 let mut s = after_open + 1;
581 let scheme_max = (after_open + 32).min(len);
582 while s < scheme_max
583 && (bytes[s].is_ascii_alphanumeric()
584 || bytes[s] == b'+'
585 || bytes[s] == b'-'
586 || bytes[s] == b'.')
587 {
588 s += 1;
589 }
590 if s < len && bytes[s] == b':' {
591 let mut j = s + 1;
593 let mut found_close = false;
594 while j < len {
595 match bytes[j] {
596 b'>' => {
597 found_close = true;
598 break;
599 }
600 b' ' | b'<' => break,
601 _ => j += 1,
602 }
603 }
604 if found_close && pos >= i && pos <= j {
605 return true;
606 }
607 if found_close {
608 i = j + 1;
609 continue;
610 }
611 }
612 }
613 }
614 i += 1;
615 }
616 false
617 }
618
619 fn is_in_wikilink_url(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
632 use pulldown_cmark::LinkType;
633 let content = ctx.content.as_bytes();
634
635 for link in ctx.links_starting_before_or_at(byte_pos) {
636 if !matches!(link.link_type, LinkType::WikiLink { .. }) {
637 continue;
638 }
639 let wiki_end = link.byte_end;
640 if wiki_end >= byte_pos || wiki_end >= content.len() || content[wiki_end] != b'(' {
642 continue;
643 }
644 let mut depth: u32 = 1;
649 let mut k = wiki_end + 1;
650 let mut valid_destination = true;
651 while k < content.len() && depth > 0 {
652 match content[k] {
653 b'\\' => {
654 k += 1; }
656 b'(' => depth += 1,
657 b')' => depth -= 1,
658 b' ' | b'\t' | b'\n' | b'\r' => {
659 valid_destination = false;
660 break;
661 }
662 _ => {}
663 }
664 k += 1;
665 }
666 if valid_destination && depth == 0 && byte_pos > wiki_end && byte_pos < k {
669 return true;
670 }
671 }
672 false
673 }
674
675 fn is_in_markdown_link_url(line: &str, pos: usize) -> bool {
685 let bytes = line.as_bytes();
686 let len = bytes.len();
687 let mut i = 0;
688
689 while i < len {
690 if bytes[i] == b'[' && (i == 0 || bytes[i - 1] != b'\\' || (i >= 2 && bytes[i - 2] == b'\\')) {
692 let mut depth: u32 = 1;
694 let mut j = i + 1;
695 while j < len && depth > 0 {
696 match bytes[j] {
697 b'\\' => {
698 j += 1; }
700 b'[' => depth += 1,
701 b']' => depth -= 1,
702 _ => {}
703 }
704 j += 1;
705 }
706
707 if depth == 0 && j < len {
709 if bytes[j] == b'(' {
710 let url_start = j;
712 let mut paren_depth: u32 = 1;
713 let mut k = j + 1;
714 while k < len && paren_depth > 0 {
715 match bytes[k] {
716 b'\\' => {
717 k += 1; }
719 b'(' => paren_depth += 1,
720 b')' => paren_depth -= 1,
721 _ => {}
722 }
723 k += 1;
724 }
725
726 if paren_depth == 0 {
727 if pos > url_start && pos < k {
728 return true;
729 }
730 i = k;
731 continue;
732 }
733 } else if bytes[j] == b'[' {
734 let ref_start = j;
736 let mut ref_depth: u32 = 1;
737 let mut k = j + 1;
738 while k < len && ref_depth > 0 {
739 match bytes[k] {
740 b'\\' => {
741 k += 1;
742 }
743 b'[' => ref_depth += 1,
744 b']' => ref_depth -= 1,
745 _ => {}
746 }
747 k += 1;
748 }
749
750 if ref_depth == 0 {
751 if pos > ref_start && pos < k {
752 return true;
753 }
754 i = k;
755 continue;
756 }
757 }
758 }
759 }
760 i += 1;
761 }
762 false
763 }
764
765 fn is_in_backtick_code_in_line(line: &str, pos: usize) -> bool {
773 let bytes = line.as_bytes();
774 let len = bytes.len();
775 let mut i = 0;
776 while i < len {
777 if bytes[i] == b'`' {
778 let open_start = i;
780 while i < len && bytes[i] == b'`' {
781 i += 1;
782 }
783 let tick_len = i - open_start;
784
785 while i < len {
787 if bytes[i] == b'`' {
788 let close_start = i;
789 while i < len && bytes[i] == b'`' {
790 i += 1;
791 }
792 if i - close_start == tick_len {
793 let content_start = open_start + tick_len;
797 let content_end = close_start;
798 if pos >= content_start && pos < content_end {
799 return true;
800 }
801 break;
803 }
804 } else {
806 i += 1;
807 }
808 }
809 } else {
810 i += 1;
811 }
812 }
813 false
814 }
815
816 fn is_word_boundary_char(c: char) -> bool {
818 !c.is_alphanumeric()
819 }
820
821 fn is_at_word_boundary(content: &str, pos: usize, is_start: bool) -> bool {
823 if is_start {
824 if pos == 0 {
825 return true;
826 }
827 match content[..pos].chars().next_back() {
828 None => true,
829 Some(c) => Self::is_word_boundary_char(c),
830 }
831 } else {
832 if pos >= content.len() {
833 return true;
834 }
835 match content[pos..].chars().next() {
836 None => true,
837 Some(c) => Self::is_word_boundary_char(c),
838 }
839 }
840 }
841
842 fn is_joined_identifier_boundary(content: &str, pos: usize, is_start: bool) -> bool {
850 if is_start {
851 let mut chars = content[..pos].char_indices().rev();
852 let mut saw_separator = false;
853 for (_, character) in &mut chars {
854 if character == '-' || character == '_' {
855 saw_separator = true;
856 } else {
857 return saw_separator && character.is_alphanumeric();
858 }
859 }
860 false
861 } else {
862 let mut saw_separator = false;
863 for character in content[pos..].chars() {
864 if character == '-' || character == '_' {
865 saw_separator = true;
866 } else {
867 return saw_separator && character.is_alphanumeric();
868 }
869 }
870 false
871 }
872 }
873
874 fn is_underscore_emphasis_boundary(ctx: &crate::lint_context::LintContext, pos: usize, is_start: bool) -> bool {
879 ctx.emphasis_spans().iter().any(|span| {
880 if span.marker != '_' {
881 return false;
882 }
883 let width = if span.is_strong { 2 } else { 1 };
884 if is_start {
885 span.byte_offset + width == pos
886 } else {
887 span.byte_end.checked_sub(width) == Some(pos)
888 }
889 })
890 }
891
892 fn is_in_path_like_token(line: &str, match_start: usize, fm_value: (usize, usize)) -> bool {
920 let (value_start, value_end) = fm_value;
921 if match_start < value_start || match_start >= value_end {
922 return false;
923 }
924
925 let quoted_words: Vec<&str> = if frontmatter_values::value_is_quoted(line, value_start) {
933 line[value_start..value_end].split_whitespace().collect()
934 } else {
935 Vec::new()
936 };
937 let is_single_quoted_path = !quoted_words.is_empty() && quoted_words.iter().all(|word| word.contains('/'));
938 let is_multi_word_collapse = is_single_quoted_path && quoted_words.len() > 1;
946
947 let (raw_start, raw_end) = if is_single_quoted_path {
948 (value_start, value_end)
949 } else {
950 frontmatter_values::token_bounds(line, match_start, value_start, value_end)
951 };
952
953 let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
954 if match_start < start || match_start >= end {
955 return false;
956 }
957
958 let token = &line[start..end];
959 if !token.contains('/') {
960 return false;
961 }
962 if token.starts_with('/') || token.starts_with("./") || token.starts_with("../") || token.starts_with("~/") {
963 return true;
964 }
965 if token.rsplit('/').next().is_some_and(|seg| seg.contains('.')) {
966 return true;
967 }
968
969 if is_multi_word_collapse {
970 return false;
971 }
972
973 let sole_value = {
977 let (ts, te) = frontmatter_values::trim_token_bounds(line, value_start, value_end);
978 ts == start && te == end
979 };
980 sole_value && token.split('/').filter(|s| !s.is_empty()).count() >= 3
981 }
982}
983
984impl Rule for MD044ProperNames {
985 fn name(&self) -> &'static str {
986 "MD044"
987 }
988
989 fn description(&self) -> &'static str {
990 "Proper names should have the correct capitalization"
991 }
992
993 fn category(&self) -> RuleCategory {
994 RuleCategory::Other
995 }
996
997 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
998 if self.config.names.is_empty() {
999 return true;
1000 }
1001 let content_lower = if ctx.content.is_ascii() {
1003 ctx.content.to_ascii_lowercase()
1004 } else {
1005 ctx.content.to_lowercase()
1006 };
1007 !self.name_variants.keys().any(|name| content_lower.contains(name))
1008 }
1009
1010 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1011 let content = ctx.content;
1012 if content.is_empty() || self.config.names.is_empty() || self.combined_pattern.is_none() {
1013 return Ok(Vec::new());
1014 }
1015
1016 let content_lower = if content.is_ascii() {
1018 content.to_ascii_lowercase()
1019 } else {
1020 content.to_lowercase()
1021 };
1022
1023 let has_potential_matches = self.name_variants.keys().any(|name| content_lower.contains(name));
1025
1026 if !has_potential_matches {
1027 return Ok(Vec::new());
1028 }
1029 let violations = self.find_name_violations(ctx);
1030
1031 let warnings = violations
1032 .into_iter()
1033 .map(|(line, column, found_name, proper_name_index)| {
1034 let proper_name = &self.config.names[proper_name_index];
1035 let line_start = ctx.line_start_byte(line).unwrap_or(0);
1040 let byte_start = line_start + (column - 1);
1041 let byte_end = byte_start + found_name.len();
1042 let line_text = ctx.line_info(line).map_or("", |li| li.content(ctx.content));
1045 let char_col = byte_to_char_count(line_text, column - 1);
1046 LintWarning {
1047 rule_name: Some(self.name().to_string()),
1048 line,
1049 column: char_col,
1050 end_line: line,
1051 end_column: char_col + found_name.chars().count(),
1052 message: format!("Proper name '{found_name}' should be '{proper_name}'"),
1053 severity: Severity::Warning,
1054 fix: Some(Fix::new(byte_start..byte_end, proper_name.clone())),
1055 }
1056 })
1057 .collect();
1058
1059 Ok(warnings)
1060 }
1061
1062 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1063 if self.should_skip(ctx) {
1064 return Ok(ctx.content.to_string());
1065 }
1066 let warnings = self.check(ctx)?;
1067 if warnings.is_empty() {
1068 return Ok(ctx.content.to_string());
1069 }
1070 let warnings =
1071 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1072 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
1073 .map_err(crate::rule::LintError::InvalidInput)
1074 }
1075
1076 fn as_any(&self) -> &dyn std::any::Any {
1077 self
1078 }
1079
1080 crate::impl_rule_config_methods!(MD044Config);
1081}
1082
1083#[cfg(test)]
1084mod tests {
1085 use super::*;
1086 use crate::lint_context::LintContext;
1087
1088 fn create_context(content: &str) -> LintContext<'_> {
1089 LintContext::new(content, crate::config::MarkdownFlavor::Standard, None)
1090 }
1091
1092 fn field_map_for(content: &str) -> Vec<Option<String>> {
1093 let ctx = create_context(content);
1094 frontmatter_values::field_map(&ctx)
1095 }
1096
1097 fn whole_word_rule(names: &[&str]) -> MD044ProperNames {
1098 MD044ProperNames::from_config_struct(MD044Config {
1099 names: names.iter().map(|name| (*name).to_string()).collect(),
1100 whole_words: true,
1101 ..Default::default()
1102 })
1103 }
1104
1105 #[test]
1106 fn whole_words_preserves_compound_identifiers_but_checks_standalone_names() {
1107 let rule = whole_word_rule(&["ABC"]);
1108 let content = "123-abc-def\nfoo_abc_bar\nabc\n(abc) [abc]\n";
1109 let ctx = create_context(content);
1110
1111 let warnings = rule.check(&ctx).unwrap();
1112 assert_eq!(warnings.len(), 3);
1113 assert_eq!(
1114 warnings.iter().map(|warning| warning.line).collect::<Vec<_>>(),
1115 [3, 4, 4]
1116 );
1117 assert_eq!(rule.fix(&ctx).unwrap(), "123-abc-def\nfoo_abc_bar\nABC\n(ABC) [ABC]\n");
1118 }
1119
1120 #[test]
1121 fn whole_words_preserves_identifiers_inside_emphasis() {
1122 let rule = whole_word_rule(&["ABC"]);
1123 let content = "_123-abc-def_ _foo_abc_bar_ __foo__abc__bar__\n\
1124 _abc_def_ _foo_abc_\n\
1125 _abc_ __abc__ ___abc___ foo-__abc__-bar\n";
1126 let ctx = create_context(content);
1127 let warnings = rule.check(&ctx).unwrap();
1128 assert_eq!(warnings.len(), 4);
1129 assert!(warnings.iter().all(|warning| warning.line == 3));
1130 let fixed = rule.fix(&ctx).unwrap();
1131 assert_eq!(
1132 fixed,
1133 "_123-abc-def_ _foo_abc_bar_ __foo__abc__bar__\n\
1134 _abc_def_ _foo_abc_\n\
1135 _ABC_ __ABC__ ___ABC___ foo-__ABC__-bar\n"
1136 );
1137 assert!(rule.check(&create_context(&fixed)).unwrap().is_empty());
1138 assert_eq!(rule.fix(&create_context(&fixed)).unwrap(), fixed);
1139 }
1140
1141 #[test]
1142 fn whole_words_distinguishes_unicode_compounds_from_markdown_emphasis() {
1143 let rule = whole_word_rule(&["ABC"]);
1144 let content = "café_abc_menu 東京-abc-駅 foo__abc__bar _abc_ __abc__ foo-__abc__-bar\n";
1145 let ctx = create_context(content);
1146
1147 assert_eq!(rule.check(&ctx).unwrap().len(), 3);
1148 let fixed = rule.fix(&ctx).unwrap();
1149 assert_eq!(
1150 fixed,
1151 "café_abc_menu 東京-abc-駅 foo__abc__bar _ABC_ __ABC__ foo-__ABC__-bar\n"
1152 );
1153 assert_eq!(rule.fix(&create_context(&fixed)).unwrap(), fixed);
1154 }
1155
1156 #[test]
1157 fn repeated_intraword_underscores_keep_legacy_behavior_without_whole_words() {
1158 let rule = MD044ProperNames::new(vec!["ABC".to_string()], false);
1159 let content = "foo__abc__bar\n";
1160 let ctx = create_context(content);
1161
1162 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1163 assert_eq!(rule.fix(&ctx).unwrap(), "foo__ABC__bar\n");
1164 }
1165
1166 #[test]
1167 fn whole_words_respects_code_and_link_exclusions_and_multiword_names() {
1168 let rule = whole_word_rule(&["ABC", "My App"]);
1169 let content = "`abc` [label](https://example.test/abc) my app x-my app-y\n";
1170 let ctx = create_context(content);
1171
1172 let warnings = rule.check(&ctx).unwrap();
1173 assert_eq!(warnings.len(), 1);
1174 assert_eq!(warnings[0].message, "Proper name 'my app' should be 'My App'");
1175 let fixed = rule.fix(&ctx).unwrap();
1176 assert_eq!(fixed, "`abc` [label](https://example.test/abc) My App x-my app-y\n");
1177 assert_eq!(rule.fix(&create_context(&fixed)).unwrap(), fixed);
1178 }
1179
1180 #[test]
1181 fn first_configured_spelling_wins_for_colliding_variants() {
1182 for (names, content, expected) in [
1183 (["Node.js", "Nodejs"], "nodejs", "Node.js"),
1184 (["Nodejs", "Node.js"], "nodejs", "Nodejs"),
1185 (["Café", "Cafe"], "cafe", "Café"),
1186 (["Cafe", "Café"], "cafe", "Cafe"),
1187 (["Node.js", "NODE.JS"], "node.js", "Node.js"),
1188 ] {
1189 let rule = MD044ProperNames::new(names.map(str::to_string).to_vec(), false);
1190 let ctx = create_context(content);
1191 let warnings = rule.check(&ctx).unwrap();
1192 assert_eq!(warnings.len(), 1);
1193 assert_eq!(warnings[0].fix.as_ref().unwrap().replacement, expected);
1194 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1195 assert_eq!(rule.clone().check(&ctx).unwrap(), warnings);
1196 }
1197 }
1198
1199 #[test]
1200 fn test_field_map_nested_lines_inherit_top_level_key() {
1201 let map = field_map_for("---\nseo:\n canonical: docs/a.md\n keywords:\n - myapp\ntitle: x\n---\n");
1202 assert_eq!(map[2].as_deref(), Some("seo"));
1203 assert_eq!(map[4].as_deref(), Some("seo"));
1204 assert_eq!(map[5].as_deref(), Some("title"));
1205 }
1206
1207 #[test]
1208 fn test_field_map_block_scalar_bracket_does_not_swallow_next_key() {
1209 let map = field_map_for("---\ndescription: |\n [myapp\ntitle: myapp\n---\n");
1210 assert_eq!(map[2].as_deref(), Some("description"));
1211 assert_eq!(
1212 map[3].as_deref(),
1213 Some("title"),
1214 "an indent-0 key always starts a new key"
1215 );
1216 }
1217
1218 #[test]
1219 fn test_field_map_quoted_key_with_colon() {
1220 let map = field_map_for("---\n\"og:title\": myapp\n---\n");
1221 assert_eq!(map[1].as_deref(), Some("og:title"));
1222 }
1223
1224 #[test]
1225 fn test_field_map_top_level_sequence_clears_attribution() {
1226 let map = field_map_for("---\n- myapp\n---\n");
1227 assert_eq!(map[1], None);
1228 }
1229
1230 #[test]
1231 fn test_field_map_toml_table_body_belongs_to_table_root() {
1232 let map = field_map_for("+++\n[seo]\ncanonical = \"docs/a.md\"\n\n[[authors]]\nname = \"myapp\"\n+++\n");
1233 assert_eq!(map[2].as_deref(), Some("seo"));
1234 assert_eq!(map[5].as_deref(), Some("authors"));
1235 }
1236
1237 #[test]
1238 fn test_field_map_toml_dotted_assignment_uses_root() {
1239 let map = field_map_for("+++\nseo.canonical = \"docs/a.md\"\n+++\n");
1240 assert_eq!(map[1].as_deref(), Some("seo"));
1241 }
1242
1243 #[test]
1244 fn test_field_map_toml_array_continuation_inherits() {
1245 let map = field_map_for("+++\nseo = [\n\"docs/guide/myapp\"\n]\n+++\n");
1246 assert_eq!(map[2].as_deref(), Some("seo"));
1247 }
1248
1249 #[test]
1250 fn test_field_map_indent_zero_flow_continuation_is_not_attributed_to_parent() {
1251 let map = field_map_for("---\nseo: [\n{name: myapp}\n]\n---\n");
1254 assert_eq!(map[2].as_deref(), Some("{name"));
1255 }
1256
1257 #[test]
1258 fn test_field_map_toml_nested_array_inherits_and_title_not_corrupted() {
1259 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\ntitle = \"x\"\n+++\n");
1260 assert_eq!(map[2].as_deref(), Some("matrix"), "nested array line inherits matrix");
1261 assert_eq!(map[3].as_deref(), Some("matrix"), "nested array line inherits matrix");
1262 assert_eq!(
1263 map[5].as_deref(),
1264 Some("title"),
1265 "title must not inherit stale attribution from a closed nested array"
1266 );
1267 }
1268
1269 #[test]
1270 fn test_field_map_toml_nested_array_last_element_without_trailing_comma() {
1271 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [2]\n]\ntitle = \"x\"\n+++\n");
1272 assert_eq!(
1273 map[3].as_deref(),
1274 Some("matrix"),
1275 "last element without a trailing comma still inherits matrix"
1276 );
1277 assert_eq!(
1278 map[5].as_deref(),
1279 Some("title"),
1280 "title must not inherit stale attribution from a closed nested array"
1281 );
1282 }
1283
1284 #[test]
1285 fn test_field_map_toml_nested_array_then_real_table_header_non_regression_guard() {
1286 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\n\n[seo]\ncanonical = \"docs/a.md\"\n+++\n");
1293 assert_eq!(map[6].as_deref(), Some("seo"), "real table header after a closed array");
1294 assert_eq!(
1295 map[7].as_deref(),
1296 Some("seo"),
1297 "table body still attributes to the table"
1298 );
1299 }
1300
1301 #[test]
1302 fn test_field_map_toml_unclosed_array_resyncs_on_next_assignment() {
1303 let map = field_map_for("+++\nmatrix = [\n [1, 2],\ntitle = \"x\"\n+++\n");
1309 assert_eq!(
1310 map[3].as_deref(),
1311 Some("title"),
1312 "title must resync even though the array was never closed"
1313 );
1314 }
1315
1316 #[test]
1317 fn test_field_map_toml_column_zero_array_elements_inherit_and_title_not_corrupted() {
1318 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[3, 4],\n]\ntitle = \"x\"\n+++\n");
1324 assert_eq!(
1325 map[2].as_deref(),
1326 Some("matrix"),
1327 "column-0 array element inherits matrix"
1328 );
1329 assert_eq!(
1330 map[3].as_deref(),
1331 Some("matrix"),
1332 "column-0 array element inherits matrix"
1333 );
1334 assert_eq!(
1335 map[5].as_deref(),
1336 Some("title"),
1337 "title must not inherit stale attribution from a misread array element"
1338 );
1339 }
1340
1341 #[test]
1342 fn test_field_map_toml_column_zero_array_last_element_without_trailing_comma() {
1343 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[2]\n]\ntitle = \"x\"\n+++\n");
1344 assert_eq!(
1345 map[3].as_deref(),
1346 Some("matrix"),
1347 "column-0 last element without a trailing comma still inherits matrix"
1348 );
1349 assert_eq!(
1350 map[5].as_deref(),
1351 Some("title"),
1352 "title must not inherit stale attribution from a misread array element"
1353 );
1354 }
1355
1356 #[test]
1357 fn test_correctly_capitalized_names() {
1358 let rule = MD044ProperNames::new(
1359 vec![
1360 "JavaScript".to_string(),
1361 "TypeScript".to_string(),
1362 "Node.js".to_string(),
1363 ],
1364 true,
1365 );
1366
1367 let content = "This document uses JavaScript, TypeScript, and Node.js correctly.";
1368 let ctx = create_context(content);
1369 let result = rule.check(&ctx).unwrap();
1370 assert!(result.is_empty(), "Should not flag correctly capitalized names");
1371 }
1372
1373 #[test]
1374 fn test_incorrectly_capitalized_names() {
1375 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1376
1377 let content = "This document uses javascript and typescript incorrectly.";
1378 let ctx = create_context(content);
1379 let result = rule.check(&ctx).unwrap();
1380
1381 assert_eq!(result.len(), 2, "Should flag two incorrect capitalizations");
1382 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1383 assert_eq!(result[0].line, 1);
1384 assert_eq!(result[0].column, 20);
1385 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1386 assert_eq!(result[1].line, 1);
1387 assert_eq!(result[1].column, 35);
1388 }
1389
1390 #[test]
1391 fn test_names_at_beginning_of_sentences() {
1392 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "Python".to_string()], true);
1393
1394 let content = "javascript is a great language. python is also popular.";
1395 let ctx = create_context(content);
1396 let result = rule.check(&ctx).unwrap();
1397
1398 assert_eq!(result.len(), 2, "Should flag names at beginning of sentences");
1399 assert_eq!(result[0].line, 1);
1400 assert_eq!(result[0].column, 1);
1401 assert_eq!(result[1].line, 1);
1402 assert_eq!(result[1].column, 33);
1403 }
1404
1405 #[test]
1406 fn test_names_in_code_blocks_checked_by_default() {
1407 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1408
1409 let content = r#"Here is some text with JavaScript.
1410
1411```javascript
1412// This javascript should be checked
1413const lang = "javascript";
1414```
1415
1416But this javascript should be flagged."#;
1417
1418 let ctx = create_context(content);
1419 let result = rule.check(&ctx).unwrap();
1420
1421 assert_eq!(result.len(), 3, "Should flag javascript inside and outside code blocks");
1422 assert_eq!(result[0].line, 4);
1423 assert_eq!(result[1].line, 5);
1424 assert_eq!(result[2].line, 8);
1425 }
1426
1427 #[test]
1428 fn test_names_in_code_blocks_ignored_when_disabled() {
1429 let rule = MD044ProperNames::new(
1430 vec!["JavaScript".to_string()],
1431 false, );
1433
1434 let content = r#"```
1435javascript in code block
1436```"#;
1437
1438 let ctx = create_context(content);
1439 let result = rule.check(&ctx).unwrap();
1440
1441 assert_eq!(
1442 result.len(),
1443 0,
1444 "Should not flag javascript in code blocks when code_blocks is false"
1445 );
1446 }
1447
1448 #[test]
1449 fn test_names_in_inline_code_checked_by_default() {
1450 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1451
1452 let content = "This is `javascript` in inline code and javascript outside.";
1453 let ctx = create_context(content);
1454 let result = rule.check(&ctx).unwrap();
1455
1456 assert_eq!(result.len(), 2, "Should flag javascript inside and outside inline code");
1458 assert_eq!(result[0].column, 10); assert_eq!(result[1].column, 41); }
1461
1462 #[test]
1463 fn test_multiple_names_in_same_line() {
1464 let rule = MD044ProperNames::new(
1465 vec!["JavaScript".to_string(), "TypeScript".to_string(), "React".to_string()],
1466 true,
1467 );
1468
1469 let content = "I use javascript, typescript, and react in my projects.";
1470 let ctx = create_context(content);
1471 let result = rule.check(&ctx).unwrap();
1472
1473 assert_eq!(result.len(), 3, "Should flag all three incorrect names");
1474 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1475 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1476 assert_eq!(result[2].message, "Proper name 'react' should be 'React'");
1477 }
1478
1479 #[test]
1480 fn test_case_sensitivity() {
1481 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1482
1483 let content = "JAVASCRIPT, Javascript, javascript, and JavaScript variations.";
1484 let ctx = create_context(content);
1485 let result = rule.check(&ctx).unwrap();
1486
1487 assert_eq!(result.len(), 3, "Should flag all incorrect case variations");
1488 assert!(result.iter().all(|w| w.message.contains("should be 'JavaScript'")));
1490 }
1491
1492 #[test]
1493 fn test_configuration_with_custom_name_list() {
1494 let config = MD044Config {
1495 names: vec!["GitHub".to_string(), "GitLab".to_string(), "DevOps".to_string()],
1496 code_blocks: true,
1497 ..Default::default()
1498 };
1499 let rule = MD044ProperNames::from_config_struct(config);
1500
1501 let content = "We use github, gitlab, and devops for our workflow.";
1502 let ctx = create_context(content);
1503 let result = rule.check(&ctx).unwrap();
1504
1505 assert_eq!(result.len(), 3, "Should flag all custom names");
1506 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
1507 assert_eq!(result[1].message, "Proper name 'gitlab' should be 'GitLab'");
1508 assert_eq!(result[2].message, "Proper name 'devops' should be 'DevOps'");
1509 }
1510
1511 #[test]
1512 fn test_empty_configuration() {
1513 let rule = MD044ProperNames::new(vec![], true);
1514
1515 let content = "This has javascript and typescript but no configured names.";
1516 let ctx = create_context(content);
1517 let result = rule.check(&ctx).unwrap();
1518
1519 assert!(result.is_empty(), "Should not flag anything with empty configuration");
1520 }
1521
1522 #[test]
1523 fn test_names_with_special_characters() {
1524 let rule = MD044ProperNames::new(
1525 vec!["Node.js".to_string(), "ASP.NET".to_string(), "C++".to_string()],
1526 true,
1527 );
1528
1529 let content = "We use nodejs, asp.net, ASP.NET, and c++ in our stack.";
1530 let ctx = create_context(content);
1531 let result = rule.check(&ctx).unwrap();
1532
1533 assert_eq!(result.len(), 3, "Should handle special characters correctly");
1538
1539 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
1540 assert!(messages.contains(&"Proper name 'nodejs' should be 'Node.js'"));
1541 assert!(messages.contains(&"Proper name 'asp.net' should be 'ASP.NET'"));
1542 assert!(messages.contains(&"Proper name 'c++' should be 'C++'"));
1543 }
1544
1545 #[test]
1546 fn test_word_boundaries() {
1547 let rule = MD044ProperNames::new(vec!["Java".to_string(), "Script".to_string()], true);
1548
1549 let content = "JavaScript is not java or script, but Java and Script are separate.";
1550 let ctx = create_context(content);
1551 let result = rule.check(&ctx).unwrap();
1552
1553 assert_eq!(result.len(), 2, "Should respect word boundaries");
1555 assert!(result.iter().any(|w| w.column == 19)); assert!(result.iter().any(|w| w.column == 27)); }
1558
1559 #[test]
1560 fn test_fix_method() {
1561 let rule = MD044ProperNames::new(
1562 vec![
1563 "JavaScript".to_string(),
1564 "TypeScript".to_string(),
1565 "Node.js".to_string(),
1566 ],
1567 true,
1568 );
1569
1570 let content = "I love javascript, typescript, and nodejs!";
1571 let ctx = create_context(content);
1572 let fixed = rule.fix(&ctx).unwrap();
1573
1574 assert_eq!(fixed, "I love JavaScript, TypeScript, and Node.js!");
1575 }
1576
1577 #[test]
1578 fn test_fix_multiple_occurrences() {
1579 let rule = MD044ProperNames::new(vec!["Python".to_string()], true);
1580
1581 let content = "python is great. I use python daily. PYTHON is powerful.";
1582 let ctx = create_context(content);
1583 let fixed = rule.fix(&ctx).unwrap();
1584
1585 assert_eq!(fixed, "Python is great. I use Python daily. Python is powerful.");
1586 }
1587
1588 #[test]
1589 fn test_fix_checks_code_blocks_by_default() {
1590 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1591
1592 let content = r#"I love javascript.
1593
1594```
1595const lang = "javascript";
1596```
1597
1598More javascript here."#;
1599
1600 let ctx = create_context(content);
1601 let fixed = rule.fix(&ctx).unwrap();
1602
1603 let expected = r#"I love JavaScript.
1604
1605```
1606const lang = "JavaScript";
1607```
1608
1609More JavaScript here."#;
1610
1611 assert_eq!(fixed, expected);
1612 }
1613
1614 #[test]
1615 fn test_multiline_content() {
1616 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
1617
1618 let content = r#"First line with rust.
1619Second line with python.
1620Third line with RUST and PYTHON."#;
1621
1622 let ctx = create_context(content);
1623 let result = rule.check(&ctx).unwrap();
1624
1625 assert_eq!(result.len(), 4, "Should flag all incorrect occurrences");
1626 assert_eq!(result[0].line, 1);
1627 assert_eq!(result[1].line, 2);
1628 assert_eq!(result[2].line, 3);
1629 assert_eq!(result[3].line, 3);
1630 }
1631
1632 #[test]
1633 fn test_default_config() {
1634 let config = MD044Config::default();
1635 assert!(config.names.is_empty());
1636 assert!(!config.code_blocks);
1637 assert!(config.html_elements);
1638 assert!(config.html_comments);
1639 }
1640
1641 #[test]
1642 fn test_default_config_checks_html_comments() {
1643 let config = MD044Config {
1644 names: vec!["JavaScript".to_string()],
1645 ..MD044Config::default()
1646 };
1647 let rule = MD044ProperNames::from_config_struct(config);
1648
1649 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1650 let ctx = create_context(content);
1651 let result = rule.check(&ctx).unwrap();
1652
1653 assert_eq!(result.len(), 1, "Default config should check HTML comments");
1654 assert_eq!(result[0].line, 3);
1655 }
1656
1657 #[test]
1658 fn test_default_config_skips_code_blocks() {
1659 let config = MD044Config {
1660 names: vec!["JavaScript".to_string()],
1661 ..MD044Config::default()
1662 };
1663 let rule = MD044ProperNames::from_config_struct(config);
1664
1665 let content = "# Guide\n\n```\njavascript in code\n```\n";
1666 let ctx = create_context(content);
1667 let result = rule.check(&ctx).unwrap();
1668
1669 assert_eq!(result.len(), 0, "Default config should skip code blocks");
1670 }
1671
1672 #[test]
1673 fn test_standalone_html_comment_checked() {
1674 let config = MD044Config {
1675 names: vec!["Test".to_string()],
1676 ..MD044Config::default()
1677 };
1678 let rule = MD044ProperNames::from_config_struct(config);
1679
1680 let content = "# Heading\n\n<!-- this is a test example -->\n";
1681 let ctx = create_context(content);
1682 let result = rule.check(&ctx).unwrap();
1683
1684 assert_eq!(result.len(), 1, "Should flag proper name in standalone HTML comment");
1685 assert_eq!(result[0].line, 3);
1686 }
1687
1688 #[test]
1689 fn test_inline_config_comments_not_flagged() {
1690 let config = MD044Config {
1691 names: vec!["RUMDL".to_string()],
1692 ..MD044Config::default()
1693 };
1694 let rule = MD044ProperNames::from_config_struct(config);
1695
1696 let content = "<!-- rumdl-disable MD044 -->\nSome rumdl text here.\n<!-- rumdl-enable MD044 -->\n<!-- markdownlint-disable -->\nMore rumdl text.\n<!-- markdownlint-enable -->\n";
1700 let ctx = create_context(content);
1701 let result = rule.check(&ctx).unwrap();
1702
1703 assert_eq!(result.len(), 2, "Should only flag body lines, not config comments");
1704 assert_eq!(result[0].line, 2);
1705 assert_eq!(result[1].line, 5);
1706 }
1707
1708 #[test]
1709 fn test_html_comment_skipped_when_disabled() {
1710 let config = MD044Config {
1711 names: vec!["Test".to_string()],
1712 code_blocks: true,
1713 html_comments: false,
1714 ..Default::default()
1715 };
1716 let rule = MD044ProperNames::from_config_struct(config);
1717
1718 let content = "# Heading\n\n<!-- this is a test example -->\n\nRegular test here.\n";
1719 let ctx = create_context(content);
1720 let result = rule.check(&ctx).unwrap();
1721
1722 assert_eq!(
1723 result.len(),
1724 1,
1725 "Should only flag 'test' outside HTML comment when html_comments=false"
1726 );
1727 assert_eq!(result[0].line, 5);
1728 }
1729
1730 #[test]
1731 fn test_fix_corrects_html_comment_content() {
1732 let config = MD044Config {
1733 names: vec!["JavaScript".to_string()],
1734 ..MD044Config::default()
1735 };
1736 let rule = MD044ProperNames::from_config_struct(config);
1737
1738 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1739 let ctx = create_context(content);
1740 let fixed = rule.fix(&ctx).unwrap();
1741
1742 assert_eq!(fixed, "# Guide\n\n<!-- JavaScript mentioned here -->\n");
1743 }
1744
1745 #[test]
1746 fn test_fix_does_not_modify_inline_config_comments() {
1747 let config = MD044Config {
1748 names: vec!["RUMDL".to_string()],
1749 ..MD044Config::default()
1750 };
1751 let rule = MD044ProperNames::from_config_struct(config);
1752
1753 let content = "<!-- rumdl-disable -->\nSome rumdl text.\n<!-- rumdl-enable -->\n";
1754 let ctx = create_context(content);
1755 let fixed = rule.fix(&ctx).unwrap();
1756
1757 assert!(fixed.contains("<!-- rumdl-disable -->"));
1759 assert!(fixed.contains("<!-- rumdl-enable -->"));
1760 assert!(
1762 fixed.contains("Some rumdl text."),
1763 "Line inside rumdl-disable block should not be modified by fix()"
1764 );
1765 }
1766
1767 #[test]
1768 fn test_fix_respects_inline_disable_partial() {
1769 let config = MD044Config {
1770 names: vec!["RUMDL".to_string()],
1771 ..MD044Config::default()
1772 };
1773 let rule = MD044ProperNames::from_config_struct(config);
1774
1775 let content =
1776 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
1777 let ctx = create_context(content);
1778 let fixed = rule.fix(&ctx).unwrap();
1779
1780 assert!(
1782 fixed.contains("Some rumdl text.\n<!-- rumdl-enable"),
1783 "Line inside disable block should not be modified"
1784 );
1785 assert!(
1787 fixed.contains("Some RUMDL text outside."),
1788 "Line outside disable block should be fixed"
1789 );
1790 }
1791
1792 #[test]
1793 fn test_performance_with_many_names() {
1794 let mut names = vec![];
1795 for i in 0..50 {
1796 names.push(format!("ProperName{i}"));
1797 }
1798
1799 let rule = MD044ProperNames::new(names, true);
1800
1801 let content = "This has propername0, propername25, and propername49 incorrectly.";
1802 let ctx = create_context(content);
1803 let result = rule.check(&ctx).unwrap();
1804
1805 assert_eq!(result.len(), 3, "Should handle many configured names efficiently");
1806 }
1807
1808 #[test]
1809 fn test_large_name_count_performance() {
1810 let names = (0..1000).map(|i| format!("ProperName{i}")).collect::<Vec<_>>();
1813
1814 let rule = MD044ProperNames::new(names, true);
1815
1816 assert!(rule.combined_pattern.is_some());
1818
1819 let content = "This has propername0 and propername999 in it.";
1821 let ctx = create_context(content);
1822 let result = rule.check(&ctx).unwrap();
1823
1824 assert_eq!(result.len(), 2, "Should handle 1000 names without issues");
1826 }
1827
1828 #[test]
1829 fn test_cache_behavior() {
1830 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1831
1832 let content = "Using javascript here.";
1833 let ctx = create_context(content);
1834
1835 let result1 = rule.check(&ctx).unwrap();
1837 assert_eq!(result1.len(), 1);
1838
1839 let result2 = rule.check(&ctx).unwrap();
1841 assert_eq!(result2.len(), 1);
1842
1843 assert_eq!(result1[0].line, result2[0].line);
1845 assert_eq!(result1[0].column, result2[0].column);
1846 }
1847
1848 #[test]
1849 fn test_html_comments_not_checked_when_disabled() {
1850 let config = MD044Config {
1851 names: vec!["JavaScript".to_string()],
1852 code_blocks: true, html_comments: false, ..Default::default()
1855 };
1856 let rule = MD044ProperNames::from_config_struct(config);
1857
1858 let content = r#"Regular javascript here.
1859<!-- This javascript in HTML comment should be ignored -->
1860More javascript outside."#;
1861
1862 let ctx = create_context(content);
1863 let result = rule.check(&ctx).unwrap();
1864
1865 assert_eq!(result.len(), 2, "Should only flag javascript outside HTML comments");
1866 assert_eq!(result[0].line, 1);
1867 assert_eq!(result[1].line, 3);
1868 }
1869
1870 #[test]
1871 fn test_html_comments_checked_when_enabled() {
1872 let config = MD044Config {
1873 names: vec!["JavaScript".to_string()],
1874 code_blocks: true, ..Default::default()
1876 };
1877 let rule = MD044ProperNames::from_config_struct(config);
1878
1879 let content = r#"Regular javascript here.
1880<!-- This javascript in HTML comment should be checked -->
1881More javascript outside."#;
1882
1883 let ctx = create_context(content);
1884 let result = rule.check(&ctx).unwrap();
1885
1886 assert_eq!(
1887 result.len(),
1888 3,
1889 "Should flag all javascript occurrences including in HTML comments"
1890 );
1891 }
1892
1893 #[test]
1894 fn test_indented_html_comment_escapes_via_link_and_backticks() {
1895 let config = MD044Config {
1900 names: vec!["Test".to_string()],
1901 ..Default::default()
1902 };
1903 let rule = MD044ProperNames::from_config_struct(config);
1904
1905 let content = "<!-- see the [relevant page](test.md). -->\n<!-- see `test.md` -->\n <!-- see the [relevant page](test.md). -->\n <!-- see `test.md` -->\n";
1906
1907 let ctx = create_context(content);
1908 let result = rule.check(&ctx).unwrap();
1909
1910 assert!(
1911 result.is_empty(),
1912 "'test' inside a link URL or backticks must be ignored in both column-0 and indented comments, got: {result:?}"
1913 );
1914 }
1915
1916 #[test]
1917 fn test_indented_html_comment_still_checks_bare_prose() {
1918 let config = MD044Config {
1921 names: vec!["Test".to_string()],
1922 ..Default::default()
1923 };
1924 let rule = MD044ProperNames::from_config_struct(config);
1925
1926 let content = " <!-- this is a test comment -->\n";
1927
1928 let ctx = create_context(content);
1929 let result = rule.check(&ctx).unwrap();
1930
1931 assert_eq!(
1932 result.len(),
1933 1,
1934 "bare 'test' in an indented comment is still a violation"
1935 );
1936 assert_eq!(result[0].line, 1);
1937 }
1938
1939 #[test]
1940 fn test_multiline_html_comments() {
1941 let config = MD044Config {
1942 names: vec!["Python".to_string(), "JavaScript".to_string()],
1943 code_blocks: true, html_comments: false, ..Default::default()
1946 };
1947 let rule = MD044ProperNames::from_config_struct(config);
1948
1949 let content = r#"Regular python here.
1950<!--
1951This is a multiline comment
1952with javascript and python
1953that should be ignored
1954-->
1955More javascript outside."#;
1956
1957 let ctx = create_context(content);
1958 let result = rule.check(&ctx).unwrap();
1959
1960 assert_eq!(result.len(), 2, "Should only flag names outside HTML comments");
1961 assert_eq!(result[0].line, 1); assert_eq!(result[1].line, 7); }
1964
1965 #[test]
1966 fn test_fix_preserves_html_comments_when_disabled() {
1967 let config = MD044Config {
1968 names: vec!["JavaScript".to_string()],
1969 code_blocks: true, html_comments: false, ..Default::default()
1972 };
1973 let rule = MD044ProperNames::from_config_struct(config);
1974
1975 let content = r#"javascript here.
1976<!-- javascript in comment -->
1977More javascript."#;
1978
1979 let ctx = create_context(content);
1980 let fixed = rule.fix(&ctx).unwrap();
1981
1982 let expected = r#"JavaScript here.
1983<!-- javascript in comment -->
1984More JavaScript."#;
1985
1986 assert_eq!(
1987 fixed, expected,
1988 "Should not fix names inside HTML comments when disabled"
1989 );
1990 }
1991
1992 #[test]
1993 fn test_proper_names_in_link_text_are_flagged() {
1994 let rule = MD044ProperNames::new(
1995 vec!["JavaScript".to_string(), "Node.js".to_string(), "Python".to_string()],
1996 true,
1997 );
1998
1999 let content = r#"Check this [javascript documentation](https://javascript.info) for info.
2000
2001Visit [node.js homepage](https://nodejs.org) and [python tutorial](https://python.org).
2002
2003Real javascript should be flagged.
2004
2005Also see the [typescript guide][ts-ref] for more.
2006
2007Real python should be flagged too.
2008
2009[ts-ref]: https://typescript.org/handbook"#;
2010
2011 let ctx = create_context(content);
2012 let result = rule.check(&ctx).unwrap();
2013
2014 assert_eq!(result.len(), 5, "Expected 5 warnings: 3 in link text + 2 standalone");
2021
2022 let line_1_warnings: Vec<_> = result.iter().filter(|w| w.line == 1).collect();
2024 assert_eq!(line_1_warnings.len(), 1);
2025 assert!(
2026 line_1_warnings[0]
2027 .message
2028 .contains("'javascript' should be 'JavaScript'")
2029 );
2030
2031 let line_3_warnings: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
2032 assert_eq!(line_3_warnings.len(), 2); assert!(result.iter().any(|w| w.line == 5 && w.message.contains("'javascript'")));
2036 assert!(result.iter().any(|w| w.line == 9 && w.message.contains("'python'")));
2037 }
2038
2039 #[test]
2040 fn test_link_urls_not_flagged() {
2041 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2042
2043 let content = r#"[Link Text](https://javascript.info/guide)"#;
2045
2046 let ctx = create_context(content);
2047 let result = rule.check(&ctx).unwrap();
2048
2049 assert!(result.is_empty(), "URLs should not be checked for proper names");
2051 }
2052
2053 #[test]
2054 fn test_bare_urls_not_flagged() {
2055 let rule = MD044ProperNames::new(vec!["Foo".to_string(), "JavaScript".to_string()], true);
2056
2057 let content =
2060 "https://foo.com\n\nSee https://javascript.info/foo/guide for details.\n\nMail foo@foo.com about it.\n";
2061
2062 let ctx = create_context(content);
2063 let result = rule.check(&ctx).unwrap();
2064
2065 assert!(
2066 result.is_empty(),
2067 "Bare URLs and emails should not be checked for proper names: {result:?}"
2068 );
2069 }
2070
2071 #[test]
2072 fn test_prose_around_bare_url_still_flagged() {
2073 let rule = MD044ProperNames::new(vec!["Foo".to_string()], true);
2074
2075 let content = "Use foo at https://foo.com because foo is great.\n";
2078
2079 let ctx = create_context(content);
2080 let result = rule.check(&ctx).unwrap();
2081
2082 assert_eq!(
2083 result.len(),
2084 2,
2085 "Prose occurrences around a bare URL must still be flagged: {result:?}"
2086 );
2087 assert!(result.iter().all(|w| w.message.contains("'foo' should be 'Foo'")));
2088 }
2089
2090 #[test]
2091 fn test_proper_names_in_image_alt_text_are_flagged() {
2092 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2093
2094 let content = r#"Here is a  image.
2095
2096Real javascript should be flagged."#;
2097
2098 let ctx = create_context(content);
2099 let result = rule.check(&ctx).unwrap();
2100
2101 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in alt text + 1 standalone");
2105 assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
2106 assert!(result[0].line == 1); assert!(result[1].message.contains("'javascript' should be 'JavaScript'"));
2108 assert!(result[1].line == 3); }
2110
2111 #[test]
2112 fn test_image_urls_not_flagged() {
2113 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2114
2115 let content = r#""#;
2117
2118 let ctx = create_context(content);
2119 let result = rule.check(&ctx).unwrap();
2120
2121 assert!(result.is_empty(), "Image URLs should not be checked for proper names");
2123 }
2124
2125 #[test]
2126 fn test_reference_link_text_flagged_but_definition_not() {
2127 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2128
2129 let content = r#"Check the [javascript guide][js-ref] for details.
2130
2131Real javascript should be flagged.
2132
2133[js-ref]: https://javascript.info/typescript/guide"#;
2134
2135 let ctx = create_context(content);
2136 let result = rule.check(&ctx).unwrap();
2137
2138 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in link text + 1 standalone");
2143 assert!(result.iter().any(|w| w.line == 1 && w.message.contains("'javascript'")));
2144 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2145 }
2146
2147 #[test]
2148 fn test_reference_definitions_not_flagged() {
2149 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2150
2151 let content = r#"[js-ref]: https://javascript.info/guide"#;
2153
2154 let ctx = create_context(content);
2155 let result = rule.check(&ctx).unwrap();
2156
2157 assert!(result.is_empty(), "Reference definitions should not be checked");
2159 }
2160
2161 #[test]
2162 fn test_wikilinks_text_is_flagged() {
2163 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2164
2165 let content = r#"[[javascript]]
2167
2168Regular javascript here.
2169
2170[[JavaScript|display text]]"#;
2171
2172 let ctx = create_context(content);
2173 let result = rule.check(&ctx).unwrap();
2174
2175 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in WikiLink + 1 standalone");
2179 assert!(
2180 result
2181 .iter()
2182 .any(|w| w.line == 1 && w.column == 3 && w.message.contains("'javascript'"))
2183 );
2184 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2185 }
2186
2187 #[test]
2188 fn test_url_link_text_not_flagged() {
2189 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2190
2191 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2193
2194[http://github.com/org/repo](http://github.com/org/repo)
2195
2196[www.github.com/org/repo](https://www.github.com/org/repo)"#;
2197
2198 let ctx = create_context(content);
2199 let result = rule.check(&ctx).unwrap();
2200
2201 assert!(
2202 result.is_empty(),
2203 "URL-like link text should not be flagged, got: {result:?}"
2204 );
2205 }
2206
2207 #[test]
2208 fn test_url_link_text_with_leading_space_not_flagged() {
2209 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2210
2211 let content = r#"[ https://github.com/org/repo](https://github.com/org/repo)"#;
2213
2214 let ctx = create_context(content);
2215 let result = rule.check(&ctx).unwrap();
2216
2217 assert!(
2218 result.is_empty(),
2219 "URL-like link text with leading space should not be flagged, got: {result:?}"
2220 );
2221 }
2222
2223 #[test]
2224 fn test_url_link_text_uppercase_scheme_not_flagged() {
2225 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2226
2227 let content = r#"[HTTPS://GITHUB.COM/org/repo](https://github.com/org/repo)"#;
2228
2229 let ctx = create_context(content);
2230 let result = rule.check(&ctx).unwrap();
2231
2232 assert!(
2233 result.is_empty(),
2234 "URL-like link text with uppercase scheme should not be flagged, got: {result:?}"
2235 );
2236 }
2237
2238 #[test]
2239 fn test_non_url_link_text_still_flagged() {
2240 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2241
2242 let content = r#"[github.com/org/repo](https://github.com/org/repo)
2246
2247[Visit github](https://github.com/org/repo)
2248
2249[//github.com/org/repo](//github.com/org/repo)
2250
2251[ftp://github.com/org/repo](ftp://github.com/org/repo)"#;
2252
2253 let ctx = create_context(content);
2254 let result = rule.check(&ctx).unwrap();
2255
2256 assert_eq!(
2261 result.len(),
2262 1,
2263 "Only prose link text should be flagged, got: {result:?}"
2264 );
2265 assert!(
2266 result.iter().any(|w| w.line == 3),
2267 "Expected 'Visit github' on line 3 to be flagged"
2268 );
2269 }
2270
2271 #[test]
2272 fn test_url_link_text_fix_not_applied() {
2273 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2274
2275 let content = "[https://github.com/org/repo](https://github.com/org/repo)\n";
2276
2277 let ctx = create_context(content);
2278 let result = rule.fix(&ctx).unwrap();
2279
2280 assert_eq!(result, content, "Fix should not modify URL-like link text");
2281 }
2282
2283 #[test]
2284 fn test_mixed_url_and_regular_link_text() {
2285 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2286
2287 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2289
2290Visit [github documentation](https://github.com/docs) for details.
2291
2292[www.github.com/pricing](https://www.github.com/pricing)"#;
2293
2294 let ctx = create_context(content);
2295 let result = rule.check(&ctx).unwrap();
2296
2297 assert_eq!(
2299 result.len(),
2300 1,
2301 "Only non-URL link text should be flagged, got: {result:?}"
2302 );
2303 assert_eq!(result[0].line, 3);
2304 }
2305
2306 #[test]
2307 fn test_html_attribute_values_not_flagged() {
2308 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2311 let content = "# Heading\n\ntest\n\n<img src=\"www.example.test/test_image.png\">\n";
2312 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2313 let result = rule.check(&ctx).unwrap();
2314
2315 let line5_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2317 assert!(
2318 line5_violations.is_empty(),
2319 "Should not flag anything inside HTML tag attributes: {line5_violations:?}"
2320 );
2321
2322 let line3_violations: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
2324 assert_eq!(line3_violations.len(), 1, "Plain 'test' on line 3 should be flagged");
2325 }
2326
2327 #[test]
2328 fn test_html_text_content_still_flagged() {
2329 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2331 let content = "# Heading\n\n<a href=\"https://example.test/page\">test link</a>\n";
2332 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2333 let result = rule.check(&ctx).unwrap();
2334
2335 assert_eq!(
2338 result.len(),
2339 1,
2340 "Should flag only 'test' in anchor text, not in href: {result:?}"
2341 );
2342 assert_eq!(result[0].column, 37, "Should flag col 37 ('test link' in anchor text)");
2343 }
2344
2345 #[test]
2346 fn test_html_attribute_various_not_flagged() {
2347 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2349 let content = concat!(
2350 "# Heading\n\n",
2351 "<img src=\"test.png\" alt=\"test image\">\n",
2352 "<span class=\"test-class\" data-test=\"value\">test content</span>\n",
2353 );
2354 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2355 let result = rule.check(&ctx).unwrap();
2356
2357 assert_eq!(
2359 result.len(),
2360 1,
2361 "Should flag only 'test content' between tags: {result:?}"
2362 );
2363 assert_eq!(result[0].line, 4);
2364 }
2365
2366 #[test]
2367 fn test_plain_text_underscore_boundary_unchanged() {
2368 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2371 let content = "# Heading\n\ntest_image is here and just_test ends here\n";
2372 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2373 let result = rule.check(&ctx).unwrap();
2374
2375 assert_eq!(
2378 result.len(),
2379 2,
2380 "Should flag 'test' in both 'test_image' and 'just_test': {result:?}"
2381 );
2382 let cols: Vec<usize> = result.iter().map(|w| w.column).collect();
2383 assert!(cols.contains(&1), "Should flag col 1 (test_image): {cols:?}");
2384 assert!(cols.contains(&29), "Should flag col 29 (just_test): {cols:?}");
2385 }
2386
2387 #[test]
2388 fn test_frontmatter_yaml_keys_not_flagged() {
2389 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2392
2393 let content = "---\ntitle: Heading\ntest: Some Test value\n---\n\nTest\n";
2394 let ctx = create_context(content);
2395 let result = rule.check(&ctx).unwrap();
2396
2397 assert!(
2401 result.is_empty(),
2402 "Should not flag YAML keys or correctly capitalized values: {result:?}"
2403 );
2404 }
2405
2406 #[test]
2407 fn test_frontmatter_yaml_values_flagged() {
2408 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2410
2411 let content = "---\ntitle: Heading\nkey: a test value\n---\n\nTest\n";
2412 let ctx = create_context(content);
2413 let result = rule.check(&ctx).unwrap();
2414
2415 assert_eq!(result.len(), 1, "Should flag 'test' in YAML value: {result:?}");
2417 assert_eq!(result[0].line, 3);
2418 assert_eq!(result[0].column, 8); }
2420
2421 #[test]
2422 fn test_frontmatter_key_matches_name_not_flagged() {
2423 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2425
2426 let content = "---\ntest: other value\n---\n\nBody text\n";
2427 let ctx = create_context(content);
2428 let result = rule.check(&ctx).unwrap();
2429
2430 assert!(
2431 result.is_empty(),
2432 "Should not flag YAML key that matches configured name: {result:?}"
2433 );
2434 }
2435
2436 #[test]
2437 fn test_frontmatter_empty_value_not_flagged() {
2438 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2440
2441 let content = "---\ntest:\ntest: \n---\n\nBody text\n";
2442 let ctx = create_context(content);
2443 let result = rule.check(&ctx).unwrap();
2444
2445 assert!(
2446 result.is_empty(),
2447 "Should not flag YAML keys with empty values: {result:?}"
2448 );
2449 }
2450
2451 #[test]
2452 fn test_frontmatter_nested_yaml_key_not_flagged() {
2453 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2455
2456 let content = "---\nparent:\n test: nested value\n---\n\nBody text\n";
2457 let ctx = create_context(content);
2458 let result = rule.check(&ctx).unwrap();
2459
2460 assert!(result.is_empty(), "Should not flag nested YAML keys: {result:?}");
2462 }
2463
2464 #[test]
2465 fn test_frontmatter_list_items_checked() {
2466 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2468
2469 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2470 let ctx = create_context(content);
2471 let result = rule.check(&ctx).unwrap();
2472
2473 assert_eq!(result.len(), 1, "Should flag 'test' in YAML list item: {result:?}");
2475 assert_eq!(result[0].line, 3);
2476 }
2477
2478 #[test]
2479 fn test_frontmatter_value_with_multiple_colons() {
2480 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2482
2483 let content = "---\ntest: description: a test thing\n---\n\nBody text\n";
2484 let ctx = create_context(content);
2485 let result = rule.check(&ctx).unwrap();
2486
2487 assert_eq!(
2490 result.len(),
2491 1,
2492 "Should flag 'test' in value after first colon: {result:?}"
2493 );
2494 assert_eq!(result[0].line, 2);
2495 assert!(result[0].column > 6, "Violation column should be in value portion");
2496 }
2497
2498 #[test]
2499 fn test_frontmatter_does_not_affect_body() {
2500 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2502
2503 let content = "---\ntitle: Heading\n---\n\ntest should be flagged here\n";
2504 let ctx = create_context(content);
2505 let result = rule.check(&ctx).unwrap();
2506
2507 assert_eq!(result.len(), 1, "Should flag 'test' in body text: {result:?}");
2508 assert_eq!(result[0].line, 5);
2509 }
2510
2511 #[test]
2512 fn test_frontmatter_fix_corrects_values_preserves_keys() {
2513 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2515
2516 let content = "---\ntest: a test value\n---\n\ntest here\n";
2517 let ctx = create_context(content);
2518 let fixed = rule.fix(&ctx).unwrap();
2519
2520 assert_eq!(fixed, "---\ntest: a Test value\n---\n\nTest here\n");
2522 }
2523
2524 #[test]
2525 fn test_frontmatter_multiword_value_flagged() {
2526 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2528
2529 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2530 let ctx = create_context(content);
2531 let result = rule.check(&ctx).unwrap();
2532
2533 assert_eq!(result.len(), 2, "Should flag both names in YAML value: {result:?}");
2534 assert!(result.iter().all(|w| w.line == 2));
2535 }
2536
2537 #[test]
2538 fn test_frontmatter_yaml_comments_not_checked() {
2539 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2541
2542 let content = "---\n# test comment\ntitle: Heading\n---\n\nBody text\n";
2543 let ctx = create_context(content);
2544 let result = rule.check(&ctx).unwrap();
2545
2546 assert!(result.is_empty(), "Should not flag names in YAML comments: {result:?}");
2547 }
2548
2549 #[test]
2550 fn test_frontmatter_delimiters_not_checked() {
2551 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2553
2554 let content = "---\ntitle: Heading\n---\n\ntest here\n";
2555 let ctx = create_context(content);
2556 let result = rule.check(&ctx).unwrap();
2557
2558 assert_eq!(result.len(), 1, "Should only flag body text: {result:?}");
2560 assert_eq!(result[0].line, 5);
2561 }
2562
2563 #[test]
2564 fn test_frontmatter_continuation_lines_checked() {
2565 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2567
2568 let content = "---\ndescription: >\n a test value\n continued here\n---\n\nBody\n";
2569 let ctx = create_context(content);
2570 let result = rule.check(&ctx).unwrap();
2571
2572 assert_eq!(result.len(), 1, "Should flag 'test' in continuation line: {result:?}");
2574 assert_eq!(result[0].line, 3);
2575 }
2576
2577 #[test]
2578 fn test_frontmatter_quoted_values_checked() {
2579 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2581
2582 let content = "---\ntitle: \"a test title\"\n---\n\nBody\n";
2583 let ctx = create_context(content);
2584 let result = rule.check(&ctx).unwrap();
2585
2586 assert_eq!(result.len(), 1, "Should flag 'test' in quoted YAML value: {result:?}");
2587 assert_eq!(result[0].line, 2);
2588 }
2589
2590 #[test]
2591 fn test_frontmatter_single_quoted_values_checked() {
2592 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2594
2595 let content = "---\ntitle: 'a test title'\n---\n\nBody\n";
2596 let ctx = create_context(content);
2597 let result = rule.check(&ctx).unwrap();
2598
2599 assert_eq!(
2600 result.len(),
2601 1,
2602 "Should flag 'test' in single-quoted YAML value: {result:?}"
2603 );
2604 assert_eq!(result[0].line, 2);
2605 }
2606
2607 #[test]
2608 fn test_frontmatter_fix_multiword_values() {
2609 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2611
2612 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2613 let ctx = create_context(content);
2614 let fixed = rule.fix(&ctx).unwrap();
2615
2616 assert_eq!(
2617 fixed,
2618 "---\ndescription: Learn JavaScript and TypeScript\n---\n\nBody\n"
2619 );
2620 }
2621
2622 #[test]
2623 fn test_frontmatter_fix_preserves_yaml_structure() {
2624 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2626
2627 let content = "---\ntags:\n - test\n - other\ntitle: a test doc\n---\n\ntest body\n";
2628 let ctx = create_context(content);
2629 let fixed = rule.fix(&ctx).unwrap();
2630
2631 assert_eq!(
2632 fixed,
2633 "---\ntags:\n - Test\n - other\ntitle: a Test doc\n---\n\nTest body\n"
2634 );
2635 }
2636
2637 #[test]
2638 fn test_frontmatter_toml_delimiters_not_checked() {
2639 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2641
2642 let content = "+++\ntitle = \"a test title\"\n+++\n\ntest body\n";
2643 let ctx = create_context(content);
2644 let result = rule.check(&ctx).unwrap();
2645
2646 assert_eq!(result.len(), 2, "Should flag TOML value and body: {result:?}");
2650 let fm_violations: Vec<_> = result.iter().filter(|w| w.line == 2).collect();
2651 assert_eq!(fm_violations.len(), 1, "Should flag 'test' in TOML value: {result:?}");
2652 let body_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2653 assert_eq!(body_violations.len(), 1, "Should flag body 'test': {result:?}");
2654 }
2655
2656 #[test]
2657 fn test_frontmatter_toml_key_not_flagged() {
2658 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2660
2661 let content = "+++\ntest = \"other value\"\n+++\n\nBody text\n";
2662 let ctx = create_context(content);
2663 let result = rule.check(&ctx).unwrap();
2664
2665 assert!(
2666 result.is_empty(),
2667 "Should not flag TOML key that matches configured name: {result:?}"
2668 );
2669 }
2670
2671 #[test]
2672 fn test_frontmatter_toml_fix_preserves_keys() {
2673 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2675
2676 let content = "+++\ntest = \"a test value\"\n+++\n\ntest here\n";
2677 let ctx = create_context(content);
2678 let fixed = rule.fix(&ctx).unwrap();
2679
2680 assert_eq!(fixed, "+++\ntest = \"a Test value\"\n+++\n\nTest here\n");
2682 }
2683
2684 #[test]
2685 fn test_frontmatter_list_item_mapping_key_not_flagged() {
2686 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2689
2690 let content = "---\nitems:\n - test: nested value\n---\n\nBody text\n";
2691 let ctx = create_context(content);
2692 let result = rule.check(&ctx).unwrap();
2693
2694 assert!(
2695 result.is_empty(),
2696 "Should not flag YAML key in list-item mapping: {result:?}"
2697 );
2698 }
2699
2700 #[test]
2701 fn test_frontmatter_list_item_mapping_value_flagged() {
2702 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2704
2705 let content = "---\nitems:\n - key: a test value\n---\n\nBody text\n";
2706 let ctx = create_context(content);
2707 let result = rule.check(&ctx).unwrap();
2708
2709 assert_eq!(
2710 result.len(),
2711 1,
2712 "Should flag 'test' in list-item mapping value: {result:?}"
2713 );
2714 assert_eq!(result[0].line, 3);
2715 }
2716
2717 #[test]
2718 fn test_frontmatter_bare_list_item_still_flagged() {
2719 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2721
2722 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2723 let ctx = create_context(content);
2724 let result = rule.check(&ctx).unwrap();
2725
2726 assert_eq!(result.len(), 1, "Should flag 'test' in bare list item: {result:?}");
2727 assert_eq!(result[0].line, 3);
2728 }
2729
2730 #[test]
2731 fn test_frontmatter_flow_mapping_not_flagged() {
2732 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2735
2736 let content = "---\nflow_map: {test: value, other: test}\n---\n\nBody text\n";
2737 let ctx = create_context(content);
2738 let result = rule.check(&ctx).unwrap();
2739
2740 assert!(
2741 result.is_empty(),
2742 "Should not flag names inside flow mappings: {result:?}"
2743 );
2744 }
2745
2746 #[test]
2747 fn test_frontmatter_flow_sequence_not_flagged() {
2748 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2750
2751 let content = "---\nitems: [test, other, test]\n---\n\nBody text\n";
2752 let ctx = create_context(content);
2753 let result = rule.check(&ctx).unwrap();
2754
2755 assert!(
2756 result.is_empty(),
2757 "Should not flag names inside flow sequences: {result:?}"
2758 );
2759 }
2760
2761 #[test]
2762 fn test_frontmatter_list_item_mapping_fix_preserves_key() {
2763 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2765
2766 let content = "---\nitems:\n - test: a test value\n---\n\ntest here\n";
2767 let ctx = create_context(content);
2768 let fixed = rule.fix(&ctx).unwrap();
2769
2770 assert_eq!(fixed, "---\nitems:\n - test: a Test value\n---\n\nTest here\n");
2773 }
2774
2775 #[test]
2776 fn test_frontmatter_backtick_code_not_flagged() {
2777 let config = MD044Config {
2779 names: vec!["GoodApplication".to_string()],
2780 code_blocks: false,
2781 ..MD044Config::default()
2782 };
2783 let rule = MD044ProperNames::from_config_struct(config);
2784
2785 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2786 let ctx = create_context(content);
2787 let result = rule.check(&ctx).unwrap();
2788
2789 assert!(
2791 result.is_empty(),
2792 "Should not flag names inside backticks in frontmatter or body: {result:?}"
2793 );
2794 }
2795
2796 #[test]
2797 fn test_frontmatter_unquoted_backtick_code_not_flagged() {
2798 let config = MD044Config {
2800 names: vec!["GoodApplication".to_string()],
2801 code_blocks: false,
2802 ..MD044Config::default()
2803 };
2804 let rule = MD044ProperNames::from_config_struct(config);
2805
2806 let content = "---\ntitle: `goodapplication` CLI\n---\n\nIntroductory `goodapplication` CLI text.\n";
2807 let ctx = create_context(content);
2808 let result = rule.check(&ctx).unwrap();
2809
2810 assert!(
2811 result.is_empty(),
2812 "Should not flag names inside backticks in unquoted YAML frontmatter: {result:?}"
2813 );
2814 }
2815
2816 #[test]
2817 fn test_frontmatter_bare_name_still_flagged_with_backtick_nearby() {
2818 let config = MD044Config {
2820 names: vec!["GoodApplication".to_string()],
2821 code_blocks: false,
2822 ..MD044Config::default()
2823 };
2824 let rule = MD044ProperNames::from_config_struct(config);
2825
2826 let content = "---\ntitle: goodapplication `goodapplication` CLI\n---\n\nBody\n";
2827 let ctx = create_context(content);
2828 let result = rule.check(&ctx).unwrap();
2829
2830 assert_eq!(
2832 result.len(),
2833 1,
2834 "Should flag bare name but not backtick-wrapped name: {result:?}"
2835 );
2836 assert_eq!(result[0].line, 2);
2837 assert_eq!(result[0].column, 8); }
2839
2840 #[test]
2841 fn test_frontmatter_backtick_code_with_code_blocks_true() {
2842 let config = MD044Config {
2844 names: vec!["GoodApplication".to_string()],
2845 code_blocks: true,
2846 ..MD044Config::default()
2847 };
2848 let rule = MD044ProperNames::from_config_struct(config);
2849
2850 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nBody\n";
2851 let ctx = create_context(content);
2852 let result = rule.check(&ctx).unwrap();
2853
2854 assert_eq!(
2856 result.len(),
2857 1,
2858 "Should flag backtick-wrapped name when code_blocks=true: {result:?}"
2859 );
2860 assert_eq!(result[0].line, 2);
2861 }
2862
2863 #[test]
2864 fn test_frontmatter_fix_preserves_backtick_code() {
2865 let config = MD044Config {
2867 names: vec!["GoodApplication".to_string()],
2868 code_blocks: false,
2869 ..MD044Config::default()
2870 };
2871 let rule = MD044ProperNames::from_config_struct(config);
2872
2873 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2874 let ctx = create_context(content);
2875 let fixed = rule.fix(&ctx).unwrap();
2876
2877 assert_eq!(
2879 fixed, content,
2880 "Fix should not modify names inside backticks in frontmatter"
2881 );
2882 }
2883
2884 fn rule_ignoring(names: &[&str], ignore: &[&str]) -> MD044ProperNames {
2885 MD044ProperNames::from_config_struct(MD044Config {
2886 names: names.iter().map(ToString::to_string).collect(),
2887 ignore_frontmatter_fields: Some(ignore.iter().map(ToString::to_string).collect()),
2888 ..Default::default()
2889 })
2890 }
2891
2892 #[test]
2893 fn test_ignore_frontmatter_field_suppresses_only_that_field() {
2894 let content = "---\ntitle: Heading for myapp\nslug: myapp-guide\n---\n";
2895 let rule = rule_ignoring(&["MyApp"], &["slug"]);
2896 let result = rule.check(&create_context(content)).unwrap();
2897 assert_eq!(result.len(), 1, "only title is flagged: {result:?}");
2898 assert_eq!(result[0].line, 2);
2899 }
2900
2901 #[test]
2902 fn test_ignore_frontmatter_field_is_case_insensitive() {
2903 let content = "---\nSlug: myapp-guide\n---\n";
2904 let rule = rule_ignoring(&["MyApp"], &["SLUG"]);
2905 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2906 }
2907
2908 #[test]
2909 fn test_ignore_frontmatter_field_covers_nested_subtree() {
2910 let content = "---\nseo:\n canonical: myapp\n keywords:\n - myapp\n---\n";
2911 let rule = rule_ignoring(&["MyApp"], &["seo"]);
2912 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2913 }
2914
2915 #[test]
2916 fn test_ignore_frontmatter_field_does_not_affect_body() {
2917 let content = "---\nslug: myapp\n---\n\nBody mentions myapp.\n";
2918 let rule = rule_ignoring(&["MyApp"], &["slug"]);
2919 let result = rule.check(&create_context(content)).unwrap();
2920 assert_eq!(result.len(), 1);
2921 assert_eq!(result[0].line, 5);
2922 }
2923
2924 #[test]
2925 fn test_ignore_frontmatter_field_toml_table() {
2926 let content = "+++\n[seo]\ncanonical = \"myapp\"\n+++\n";
2927 let rule = rule_ignoring(&["MyApp"], &["seo"]);
2928 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2929 }
2930
2931 #[test]
2934 fn test_angle_bracket_url_in_html_comment_not_flagged() {
2935 let config = MD044Config {
2937 names: vec!["Test".to_string()],
2938 ..MD044Config::default()
2939 };
2940 let rule = MD044ProperNames::from_config_struct(config);
2941
2942 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";
2943 let ctx = create_context(content);
2944 let result = rule.check(&ctx).unwrap();
2945
2946 let line8_warnings: Vec<_> = result.iter().filter(|w| w.line == 8).collect();
2954 assert!(
2955 line8_warnings.is_empty(),
2956 "Should not flag names inside angle-bracket URLs in HTML comments: {line8_warnings:?}"
2957 );
2958 }
2959
2960 #[test]
2961 fn test_bare_url_in_html_comment_still_flagged() {
2962 let config = MD044Config {
2964 names: vec!["Test".to_string()],
2965 ..MD044Config::default()
2966 };
2967 let rule = MD044ProperNames::from_config_struct(config);
2968
2969 let content = "<!-- This is a test https://www.example.test -->\n";
2970 let ctx = create_context(content);
2971 let result = rule.check(&ctx).unwrap();
2972
2973 assert!(
2976 !result.is_empty(),
2977 "Should flag 'test' in prose text of HTML comment with bare URL"
2978 );
2979 }
2980
2981 #[test]
2982 fn test_angle_bracket_url_in_regular_markdown_not_flagged() {
2983 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2986
2987 let content = "<https://www.example.test>\n";
2988 let ctx = create_context(content);
2989 let result = rule.check(&ctx).unwrap();
2990
2991 assert!(
2992 result.is_empty(),
2993 "Should not flag names inside angle-bracket URLs in regular markdown: {result:?}"
2994 );
2995 }
2996
2997 #[test]
2998 fn test_multiple_angle_bracket_urls_in_one_comment() {
2999 let config = MD044Config {
3000 names: vec!["Test".to_string()],
3001 ..MD044Config::default()
3002 };
3003 let rule = MD044ProperNames::from_config_struct(config);
3004
3005 let content = "<!-- See <https://test.example.com> and <https://www.example.test> for details -->\n";
3006 let ctx = create_context(content);
3007 let result = rule.check(&ctx).unwrap();
3008
3009 assert!(
3011 result.is_empty(),
3012 "Should not flag names inside multiple angle-bracket URLs: {result:?}"
3013 );
3014 }
3015
3016 #[test]
3017 fn test_angle_bracket_non_url_still_flagged() {
3018 assert!(
3021 !MD044ProperNames::is_in_angle_bracket_url("<test> which is not a URL.", 1),
3022 "is_in_angle_bracket_url should return false for non-URL angle brackets"
3023 );
3024 }
3025
3026 #[test]
3027 fn test_angle_bracket_mailto_url_not_flagged() {
3028 let config = MD044Config {
3029 names: vec!["Test".to_string()],
3030 ..MD044Config::default()
3031 };
3032 let rule = MD044ProperNames::from_config_struct(config);
3033
3034 let content = "<!-- Contact <mailto:test@example.com> for help -->\n";
3035 let ctx = create_context(content);
3036 let result = rule.check(&ctx).unwrap();
3037
3038 assert!(
3039 result.is_empty(),
3040 "Should not flag names inside angle-bracket mailto URLs: {result:?}"
3041 );
3042 }
3043
3044 #[test]
3045 fn test_angle_bracket_ftp_url_not_flagged() {
3046 let config = MD044Config {
3047 names: vec!["Test".to_string()],
3048 ..MD044Config::default()
3049 };
3050 let rule = MD044ProperNames::from_config_struct(config);
3051
3052 let content = "<!-- Download from <ftp://test.example.com/file> -->\n";
3053 let ctx = create_context(content);
3054 let result = rule.check(&ctx).unwrap();
3055
3056 assert!(
3057 result.is_empty(),
3058 "Should not flag names inside angle-bracket FTP URLs: {result:?}"
3059 );
3060 }
3061
3062 #[test]
3063 fn test_angle_bracket_url_fix_preserves_url() {
3064 let config = MD044Config {
3066 names: vec!["Test".to_string()],
3067 ..MD044Config::default()
3068 };
3069 let rule = MD044ProperNames::from_config_struct(config);
3070
3071 let content = "<!-- test text <https://www.example.test> -->\n";
3072 let ctx = create_context(content);
3073 let fixed = rule.fix(&ctx).unwrap();
3074
3075 assert!(
3077 fixed.contains("<https://www.example.test>"),
3078 "Fix should preserve angle-bracket URLs: {fixed}"
3079 );
3080 assert!(
3081 fixed.contains("Test text"),
3082 "Fix should correct prose 'test' to 'Test': {fixed}"
3083 );
3084 }
3085
3086 #[test]
3087 fn test_is_in_angle_bracket_url_helper() {
3088 let line = "text <https://example.test> more text";
3090
3091 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));
3104
3105 assert!(MD044ProperNames::is_in_angle_bracket_url(
3107 "<mailto:test@example.com>",
3108 10
3109 ));
3110
3111 assert!(MD044ProperNames::is_in_angle_bracket_url(
3113 "<ftp://test.example.com>",
3114 10
3115 ));
3116 }
3117
3118 #[test]
3119 fn test_is_in_angle_bracket_url_uppercase_scheme() {
3120 assert!(MD044ProperNames::is_in_angle_bracket_url(
3122 "<HTTPS://test.example.com>",
3123 10
3124 ));
3125 assert!(MD044ProperNames::is_in_angle_bracket_url(
3126 "<Http://test.example.com>",
3127 10
3128 ));
3129 }
3130
3131 #[test]
3132 fn test_is_in_angle_bracket_url_uncommon_schemes() {
3133 assert!(MD044ProperNames::is_in_angle_bracket_url(
3135 "<ssh://test@example.com>",
3136 10
3137 ));
3138 assert!(MD044ProperNames::is_in_angle_bracket_url("<file:///test/path>", 10));
3140 assert!(MD044ProperNames::is_in_angle_bracket_url("<data:text/plain;test>", 10));
3142 }
3143
3144 #[test]
3145 fn test_is_in_angle_bracket_url_unclosed() {
3146 assert!(!MD044ProperNames::is_in_angle_bracket_url(
3148 "<https://test.example.com",
3149 10
3150 ));
3151 }
3152
3153 #[test]
3154 fn test_vale_inline_config_comments_not_flagged() {
3155 let config = MD044Config {
3156 names: vec!["Vale".to_string(), "JavaScript".to_string()],
3157 ..MD044Config::default()
3158 };
3159 let rule = MD044ProperNames::from_config_struct(config);
3160
3161 let content = "\
3162<!-- vale off -->
3163Some javascript text here.
3164<!-- vale on -->
3165<!-- vale Style.Rule = NO -->
3166More javascript text.
3167<!-- vale Style.Rule = YES -->
3168<!-- vale JavaScript.Grammar = NO -->
3169";
3170 let ctx = create_context(content);
3171 let result = rule.check(&ctx).unwrap();
3172
3173 assert_eq!(result.len(), 2, "Should only flag body lines, not Vale config comments");
3175 assert_eq!(result[0].line, 2);
3176 assert_eq!(result[1].line, 5);
3177 }
3178
3179 #[test]
3180 fn test_remark_lint_inline_config_comments_not_flagged() {
3181 let config = MD044Config {
3182 names: vec!["JavaScript".to_string()],
3183 ..MD044Config::default()
3184 };
3185 let rule = MD044ProperNames::from_config_struct(config);
3186
3187 let content = "\
3188<!-- lint disable remark-lint-some-rule -->
3189Some javascript text here.
3190<!-- lint enable remark-lint-some-rule -->
3191<!-- lint ignore remark-lint-some-rule -->
3192More javascript text.
3193";
3194 let ctx = create_context(content);
3195 let result = rule.check(&ctx).unwrap();
3196
3197 assert_eq!(
3198 result.len(),
3199 2,
3200 "Should only flag body lines, not remark-lint config comments"
3201 );
3202 assert_eq!(result[0].line, 2);
3203 assert_eq!(result[1].line, 5);
3204 }
3205
3206 #[test]
3207 fn test_fix_does_not_modify_vale_remark_lint_comments() {
3208 let config = MD044Config {
3209 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3210 ..MD044Config::default()
3211 };
3212 let rule = MD044ProperNames::from_config_struct(config);
3213
3214 let content = "\
3215<!-- vale off -->
3216Some javascript text.
3217<!-- vale on -->
3218<!-- lint disable remark-lint-some-rule -->
3219More javascript text.
3220<!-- lint enable remark-lint-some-rule -->
3221";
3222 let ctx = create_context(content);
3223 let fixed = rule.fix(&ctx).unwrap();
3224
3225 assert!(fixed.contains("<!-- vale off -->"));
3227 assert!(fixed.contains("<!-- vale on -->"));
3228 assert!(fixed.contains("<!-- lint disable remark-lint-some-rule -->"));
3229 assert!(fixed.contains("<!-- lint enable remark-lint-some-rule -->"));
3230 assert!(fixed.contains("Some JavaScript text."));
3232 assert!(fixed.contains("More JavaScript text."));
3233 }
3234
3235 #[test]
3236 fn test_mixed_tool_directives_all_skipped() {
3237 let config = MD044Config {
3238 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3239 ..MD044Config::default()
3240 };
3241 let rule = MD044ProperNames::from_config_struct(config);
3242
3243 let content = "\
3244<!-- rumdl-disable MD044 -->
3245Some javascript text.
3246<!-- markdownlint-disable -->
3247More javascript text.
3248<!-- vale off -->
3249Even more javascript text.
3250<!-- lint disable some-rule -->
3251Final javascript text.
3252<!-- rumdl-enable MD044 -->
3253<!-- markdownlint-enable -->
3254<!-- vale on -->
3255<!-- lint enable some-rule -->
3256";
3257 let ctx = create_context(content);
3258 let result = rule.check(&ctx).unwrap();
3259
3260 assert_eq!(
3262 result.len(),
3263 4,
3264 "Should only flag body lines, not any tool directive comments"
3265 );
3266 assert_eq!(result[0].line, 2);
3267 assert_eq!(result[1].line, 4);
3268 assert_eq!(result[2].line, 6);
3269 assert_eq!(result[3].line, 8);
3270 }
3271
3272 #[test]
3273 fn test_vale_remark_lint_edge_cases_not_matched() {
3274 let config = MD044Config {
3275 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3276 ..MD044Config::default()
3277 };
3278 let rule = MD044ProperNames::from_config_struct(config);
3279
3280 let content = "\
3288<!-- vale -->
3289<!-- vale is a tool for writing -->
3290<!-- valedictorian javascript -->
3291<!-- linting javascript tips -->
3292<!-- vale javascript -->
3293<!-- lint your javascript code -->
3294";
3295 let ctx = create_context(content);
3296 let result = rule.check(&ctx).unwrap();
3297
3298 assert_eq!(
3305 result.len(),
3306 7,
3307 "Should flag proper names in non-directive HTML comments: got {result:?}"
3308 );
3309 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); }
3317
3318 #[test]
3319 fn test_vale_style_directives_skipped() {
3320 let config = MD044Config {
3321 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3322 ..MD044Config::default()
3323 };
3324 let rule = MD044ProperNames::from_config_struct(config);
3325
3326 let content = "\
3328<!-- vale style = MyStyle -->
3329<!-- vale styles = Style1, Style2 -->
3330<!-- vale MyRule.Name = YES -->
3331<!-- vale MyRule.Name = NO -->
3332Some javascript text.
3333";
3334 let ctx = create_context(content);
3335 let result = rule.check(&ctx).unwrap();
3336
3337 assert_eq!(
3339 result.len(),
3340 1,
3341 "Should only flag body lines, not Vale style/rule directives: got {result:?}"
3342 );
3343 assert_eq!(result[0].line, 5);
3344 }
3345
3346 #[test]
3349 fn test_backtick_code_single_backticks() {
3350 let line = "hello `world` bye";
3351 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 7));
3353 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3355 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 14));
3357 }
3358
3359 #[test]
3360 fn test_backtick_code_double_backticks() {
3361 let line = "a ``code`` b";
3362 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3364 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3366 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 11));
3368 }
3369
3370 #[test]
3371 fn test_backtick_code_unclosed() {
3372 let line = "a `code b";
3373 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3375 }
3376
3377 #[test]
3378 fn test_backtick_code_mismatched_count() {
3379 let line = "a `code`` b";
3381 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3384 }
3385
3386 #[test]
3387 fn test_backtick_code_multiple_spans() {
3388 let line = "`first` and `second`";
3389 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
3391 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 8));
3393 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 13));
3395 }
3396
3397 #[test]
3398 fn test_backtick_code_on_backtick_boundary() {
3399 let line = "`code`";
3400 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3402 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 5));
3404 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
3406 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3407 }
3408
3409 #[test]
3415 fn test_double_bracket_link_url_not_flagged() {
3416 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3417 let content = "[[rumdl]](https://github.com/rvben/rumdl)";
3419 let ctx = create_context(content);
3420 let result = rule.check(&ctx).unwrap();
3421 assert!(
3422 result.is_empty(),
3423 "URL inside [[text]](url) must not be flagged, got: {result:?}"
3424 );
3425 }
3426
3427 #[test]
3428 fn test_double_bracket_link_url_not_fixed() {
3429 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3430 let content = "[[rumdl]](https://github.com/rvben/rumdl)\n";
3431 let ctx = create_context(content);
3432 let fixed = rule.fix(&ctx).unwrap();
3433 assert_eq!(
3434 fixed, content,
3435 "fix() must leave the URL inside [[text]](url) unchanged"
3436 );
3437 }
3438
3439 #[test]
3440 fn test_double_bracket_link_text_still_flagged() {
3441 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3442 let content = "[[github]](https://example.com)";
3444 let ctx = create_context(content);
3445 let result = rule.check(&ctx).unwrap();
3446 assert_eq!(
3447 result.len(),
3448 1,
3449 "Incorrect name in [[text]] link text should still be flagged, got: {result:?}"
3450 );
3451 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
3452 }
3453
3454 #[test]
3455 fn test_double_bracket_link_mixed_line() {
3456 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3457 let content = "See [[rumdl]](https://github.com/rvben/rumdl) and github for more.";
3459 let ctx = create_context(content);
3460 let result = rule.check(&ctx).unwrap();
3461 assert_eq!(
3462 result.len(),
3463 1,
3464 "Only the standalone 'github' after the link should be flagged, got: {result:?}"
3465 );
3466 assert!(result[0].message.contains("'github'"));
3467 assert_eq!(
3469 result[0].column, 51,
3470 "Flagged column should be the trailing 'github', not the one in the URL"
3471 );
3472 }
3473
3474 #[test]
3475 fn test_regular_link_url_still_not_flagged() {
3476 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3478 let content = "[rumdl](https://github.com/rvben/rumdl)";
3479 let ctx = create_context(content);
3480 let result = rule.check(&ctx).unwrap();
3481 assert!(
3482 result.is_empty(),
3483 "URL inside regular [text](url) must still not be flagged, got: {result:?}"
3484 );
3485 }
3486
3487 #[test]
3488 fn test_link_like_text_in_code_span_still_flagged_when_code_blocks_enabled() {
3489 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
3494 let content = "`[foo](https://github.com/org/repo)`";
3495 let ctx = create_context(content);
3496 let result = rule.check(&ctx).unwrap();
3497 assert_eq!(
3498 result.len(),
3499 1,
3500 "Proper name inside a code span must be flagged when code-blocks=true, got: {result:?}"
3501 );
3502 assert!(result[0].message.contains("'github'"));
3503 }
3504
3505 #[test]
3506 fn test_malformed_link_not_treated_as_url() {
3507 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3510 let content = "See [rumdl](github repo) for details.";
3511 let ctx = create_context(content);
3512 let result = rule.check(&ctx).unwrap();
3513 assert_eq!(
3514 result.len(),
3515 1,
3516 "Name inside malformed [text](url with spaces) must still be flagged, got: {result:?}"
3517 );
3518 assert!(result[0].message.contains("'github'"));
3519 }
3520
3521 #[test]
3522 fn test_wikilink_followed_by_prose_parens_still_flagged() {
3523 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3527 let content = "[[note]](github repo)";
3528 let ctx = create_context(content);
3529 let result = rule.check(&ctx).unwrap();
3530 assert_eq!(
3531 result.len(),
3532 1,
3533 "Name inside [[wikilink]](prose with spaces) must still be flagged, got: {result:?}"
3534 );
3535 assert!(result[0].message.contains("'github'"));
3536 }
3537
3538 #[test]
3540 fn test_roundtrip_fix_then_check_basic() {
3541 let rule = MD044ProperNames::new(
3542 vec![
3543 "JavaScript".to_string(),
3544 "TypeScript".to_string(),
3545 "Node.js".to_string(),
3546 ],
3547 true,
3548 );
3549 let content = "I love javascript, typescript, and nodejs!";
3550 let ctx = create_context(content);
3551 let fixed = rule.fix(&ctx).unwrap();
3552 let ctx2 = create_context(&fixed);
3553 let warnings = rule.check(&ctx2).unwrap();
3554 assert!(
3555 warnings.is_empty(),
3556 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3557 );
3558 }
3559
3560 #[test]
3562 fn test_roundtrip_fix_then_check_multiline() {
3563 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
3564 let content = "First line with rust.\nSecond line with python.\nThird line with RUST and PYTHON.\n";
3565 let ctx = create_context(content);
3566 let fixed = rule.fix(&ctx).unwrap();
3567 let ctx2 = create_context(&fixed);
3568 let warnings = rule.check(&ctx2).unwrap();
3569 assert!(
3570 warnings.is_empty(),
3571 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3572 );
3573 }
3574
3575 #[test]
3577 fn test_roundtrip_fix_then_check_inline_config() {
3578 let config = MD044Config {
3579 names: vec!["RUMDL".to_string()],
3580 ..MD044Config::default()
3581 };
3582 let rule = MD044ProperNames::from_config_struct(config);
3583 let content =
3584 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
3585 let ctx = create_context(content);
3586 let fixed = rule.fix(&ctx).unwrap();
3587 assert!(
3589 fixed.contains("Some rumdl text.\n"),
3590 "Disabled block text should be preserved"
3591 );
3592 assert!(
3593 fixed.contains("Some RUMDL text outside."),
3594 "Outside text should be fixed"
3595 );
3596 }
3597
3598 #[test]
3600 fn test_roundtrip_fix_then_check_html_comments() {
3601 let config = MD044Config {
3602 names: vec!["JavaScript".to_string()],
3603 ..MD044Config::default()
3604 };
3605 let rule = MD044ProperNames::from_config_struct(config);
3606 let content = "# Guide\n\n<!-- javascript mentioned here -->\n\njavascript outside\n";
3607 let ctx = create_context(content);
3608 let fixed = rule.fix(&ctx).unwrap();
3609 let ctx2 = create_context(&fixed);
3610 let warnings = rule.check(&ctx2).unwrap();
3611 assert!(
3612 warnings.is_empty(),
3613 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3614 );
3615 }
3616
3617 #[test]
3619 fn test_roundtrip_no_op_when_correct() {
3620 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
3621 let content = "This uses JavaScript and TypeScript correctly.\n";
3622 let ctx = create_context(content);
3623 let fixed = rule.fix(&ctx).unwrap();
3624 assert_eq!(fixed, content, "Fix should be a no-op when content is already correct");
3625 }
3626
3627 #[test]
3630 fn test_bare_domain_link_text_not_flagged() {
3631 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3635 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3636 let ctx = create_context(content);
3637 let result = rule.check(&ctx).unwrap();
3638 assert!(
3639 result.is_empty(),
3640 "Should not flag 'github' in a bare-domain link text that matches the link URL: {result:?}"
3641 );
3642 }
3643
3644 #[test]
3645 fn test_bare_domain_link_text_not_fixed() {
3646 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3648 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3649 let ctx = create_context(content);
3650 let fixed = rule.fix(&ctx).unwrap();
3651 assert_eq!(
3652 fixed, content,
3653 "fix() must not alter bare-domain link text that matches the destination URL"
3654 );
3655 }
3656
3657 #[test]
3658 fn test_bare_domain_link_text_with_path_not_flagged() {
3659 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3661 let content = "Visit [ravencentric.github.io](https://ravencentric.github.io/projects).\n";
3662 let ctx = create_context(content);
3663 let result = rule.check(&ctx).unwrap();
3664 assert!(
3665 result.is_empty(),
3666 "Should not flag 'github' when bare-domain text is the hostname of its destination URL: {result:?}"
3667 );
3668 }
3669
3670 #[test]
3671 fn test_bare_domain_link_text_full_path_not_flagged() {
3672 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3674 let content = "See [ravencentric.github.io/blog](https://ravencentric.github.io/blog).\n";
3675 let ctx = create_context(content);
3676 let result = rule.check(&ctx).unwrap();
3677 assert!(
3678 result.is_empty(),
3679 "Should not flag 'github' when link text is the full URL path without scheme: {result:?}"
3680 );
3681 }
3682
3683 #[test]
3684 fn test_github_product_name_in_link_text_still_flagged() {
3685 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3688 let content = "Hosted on [github pages](https://pages.github.com).\n";
3689 let ctx = create_context(content);
3690 let result = rule.check(&ctx).unwrap();
3691 assert!(
3692 !result.is_empty(),
3693 "Should still flag 'github' in descriptive link text that does not match the destination URL"
3694 );
3695 }
3696
3697 #[test]
3698 fn test_protocol_relative_bare_domain_link_text_not_flagged() {
3699 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3701 let content = "See [github.io](//github.io).\n";
3702 let ctx = create_context(content);
3703 let result = rule.check(&ctx).unwrap();
3704 assert!(
3705 result.is_empty(),
3706 "Should not flag 'github' in bare-domain text matching a protocol-relative destination: {result:?}"
3707 );
3708 }
3709
3710 #[test]
3711 fn test_dotted_wikilink_target_still_flagged() {
3712 let rule = MD044ProperNames::new(vec!["Node.js".to_string()], false);
3717 let content = "See [[node.js]] for details.\n";
3718 let ctx = create_context(content);
3719 let result = rule.check(&ctx).unwrap();
3720 assert!(
3721 !result.is_empty(),
3722 "Should flag 'node.js' in a dotted WikiLink target: {result:?}"
3723 );
3724 }
3725
3726 #[test]
3727 fn test_bare_domain_link_text_case_insensitive_url() {
3728 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3731 let content = "See [github.io](HTTPS://github.io).\n";
3732 let ctx = create_context(content);
3733 let result = rule.check(&ctx).unwrap();
3734 assert!(
3735 result.is_empty(),
3736 "Should not flag bare-domain text when destination URL has an uppercase scheme: {result:?}"
3737 );
3738 }
3739
3740 #[test]
3741 fn test_frontmatter_value_span_strips_trailing_comment() {
3742 let line = "link: docs/guide/myapp # canonical path";
3743 let (s, e) = frontmatter_values::value_span(line).unwrap();
3744 assert_eq!(&line[s..e], "docs/guide/myapp");
3745 }
3746
3747 #[test]
3748 fn test_frontmatter_value_span_quoted_keeps_hash_and_spaces() {
3749 let line = "link: 'docs/My App/a#b'";
3750 let (s, e) = frontmatter_values::value_span(line).unwrap();
3751 assert_eq!(&line[s..e], "docs/My App/a#b");
3752 }
3753
3754 #[test]
3755 fn test_frontmatter_value_span_plain_value() {
3756 let line = "title: Heading for myapp";
3757 let (s, e) = frontmatter_values::value_span(line).unwrap();
3758 assert_eq!(&line[s..e], "Heading for myapp");
3759 }
3760
3761 #[test]
3762 fn test_frontmatter_value_span_none_for_key_only() {
3763 assert!(frontmatter_values::value_span("seo:").is_none());
3764 assert!(frontmatter_values::value_span("---").is_none());
3765 }
3766
3767 #[test]
3768 fn test_frontmatter_value_span_quoted_strips_trailing_comment() {
3769 let line = "link: 'docs/guide' # canonical path";
3770 let (s, e) = frontmatter_values::value_span(line).unwrap();
3771 assert_eq!(&line[s..e], "docs/guide");
3772 }
3773
3774 #[test]
3775 fn test_frontmatter_value_span_empty_quoted_value_is_none() {
3776 assert!(frontmatter_values::value_span("key: ''").is_none());
3777 }
3778
3779 #[test]
3780 fn test_frontmatter_value_span_unterminated_quote_strips_leading_quote() {
3781 let line = "link: 'docs/a";
3782 let (s, e) = frontmatter_values::value_span(line).unwrap();
3783 assert_eq!(&line[s..e], "docs/a");
3784 }
3785
3786 fn at(line: &str, needle: &str) -> usize {
3788 line.find(needle).expect("needle present")
3789 }
3790
3791 #[test]
3792 fn test_path_like_exempts_single_token_frontmatter_paths() {
3793 for line in [
3794 "link: this/is/a/link/to/myapp.md",
3795 "link: docs/myapp.md",
3796 "link: /abs/path/myapp.md",
3797 "link: ./myapp.md",
3798 "link: ../shared/myapp.md",
3799 ] {
3800 let span = frontmatter_values::value_span(line).unwrap();
3801 let pos = at(line, "myapp");
3802 assert!(
3803 MD044ProperNames::is_in_path_like_token(line, pos, span),
3804 "should treat as a path: {line}"
3805 );
3806 }
3807 }
3808
3809 #[test]
3810 fn test_path_like_does_not_exempt_slash_conjunction_prose() {
3811 let line = "description: We support github/gitlab/bitbucket imports.";
3814 let span = frontmatter_values::value_span(line).unwrap();
3815 assert!(
3816 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
3817 "slash-separated prose is not a path"
3818 );
3819
3820 let line = "description: The javascript/typescript ecosystem is large.";
3821 let span = frontmatter_values::value_span(line).unwrap();
3822 assert!(!MD044ProperNames::is_in_path_like_token(
3823 line,
3824 at(line, "javascript"),
3825 span
3826 ));
3827 }
3828
3829 #[test]
3830 fn test_path_like_requires_a_slash_so_dotted_names_survive() {
3831 let line = "title: Use nodejs and myapp.md today.";
3832 let span = frontmatter_values::value_span(line).unwrap();
3833 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3834 }
3835
3836 #[test]
3837 fn test_path_like_no_slash_frontmatter_value_still_flagged() {
3838 let line = "slug: myapp-guide";
3843 let span = frontmatter_values::value_span(line).unwrap();
3844 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3845 }
3846
3847 #[test]
3848 fn test_path_like_returns_false_outside_value_span() {
3849 let line = "myapp: docs/guide/myapp";
3852 let span = frontmatter_values::value_span(line).unwrap();
3853 let key_pos = 0;
3854 assert!(!MD044ProperNames::is_in_path_like_token(line, key_pos, span));
3855 }
3856
3857 #[test]
3858 fn test_path_like_three_segments_only_as_sole_frontmatter_value() {
3859 let line = "link: docs/guide/myapp";
3860 let span = frontmatter_values::value_span(line).unwrap();
3861 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3862
3863 let line = "description: We support github/gitlab/bitbucket now";
3864 let span = frontmatter_values::value_span(line).unwrap();
3865 assert!(
3866 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
3867 "multi-token value gets body treatment"
3868 );
3869 }
3870
3871 #[test]
3872 fn test_path_like_quoted_value_with_spaces() {
3873 let line = "link: 'docs/My App/myapp.md'";
3876 let span = frontmatter_values::value_span(line).unwrap();
3877 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3878 }
3879
3880 #[test]
3881 fn test_path_like_quoted_value_with_spaces_no_extension_not_exempt() {
3882 let line = "link: 'docs/My App/myapp'";
3889 let span = frontmatter_values::value_span(line).unwrap();
3890 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3891 }
3892
3893 #[test]
3894 fn test_path_like_trailing_comment_is_still_sole_value() {
3895 let line = "link: docs/guide/myapp # canonical path";
3896 let span = frontmatter_values::value_span(line).unwrap();
3897 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3898 }
3899
3900 #[test]
3901 fn test_path_like_trailing_punctuation_trimmed() {
3902 let line = "link: docs/myapp.md, then leave.";
3903 let span = frontmatter_values::value_span(line).unwrap();
3904 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3905 }
3906
3907 #[test]
3908 fn test_trim_token_bounds_reaches_fixpoint_after_punctuation_exposes_wrapper() {
3909 let line = r#"See "docs/myapp.md", then leave."#;
3910 let raw_start = at(line, "\"docs");
3911 let raw_end = raw_start + r#""docs/myapp.md","#.len();
3912 assert_eq!(&line[raw_start..raw_end], r#""docs/myapp.md","#);
3913 let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
3914 assert_eq!(&line[start..end], "docs/myapp.md");
3915 }
3916
3917 #[test]
3918 fn test_trim_token_bounds_reaches_fixpoint_with_multiple_trailing_wrappers() {
3919 let line = r#"("docs/myapp.md")."#;
3920 let (start, end) = frontmatter_values::trim_token_bounds(line, 0, line.len());
3921 assert_eq!(&line[start..end], "docs/myapp.md");
3922 }
3923
3924 #[test]
3925 fn test_frontmatter_link_path_not_flagged() {
3926 let content = "---\ntitle: Heading for MyApp\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
3927 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3928 let ctx = create_context(content);
3929 let result = rule.check(&ctx).unwrap();
3930 assert!(
3931 result.is_empty(),
3932 "path in a frontmatter value must not be flagged: {result:?}"
3933 );
3934 }
3935
3936 #[test]
3937 fn test_fix_does_not_corrupt_frontmatter_link_path() {
3938 let content = "---\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
3939 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3940 let ctx = create_context(content);
3941 assert_eq!(rule.fix(&ctx).unwrap(), content, "fix must not rewrite a path");
3942 }
3943
3944 #[test]
3955 fn test_body_prose_parenthesized_disambiguator_is_case_corrected() {
3956 let content = "See docs/myapp(1).md here.\n";
3957 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3958 let ctx = create_context(content);
3959 let result = rule.check(&ctx).unwrap();
3960 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3961 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp(1).md here.\n");
3962 }
3963
3964 #[test]
3965 fn test_body_prose_bracketed_dynamic_segment_is_case_corrected() {
3966 let content = "See docs/[myapp].md here.\n";
3967 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3968 let ctx = create_context(content);
3969 let result = rule.check(&ctx).unwrap();
3970 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3971 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/[MyApp].md here.\n");
3972 }
3973
3974 #[test]
3975 fn test_body_prose_nextjs_catch_all_segment_is_case_corrected() {
3976 let content = "pages/[[...myapp]].tsx are catch-all routes.\n";
3979 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3980 let ctx = create_context(content);
3981 let result = rule.check(&ctx).unwrap();
3982 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3983 assert_eq!(
3984 rule.fix(&ctx).unwrap(),
3985 "pages/[[...MyApp]].tsx are catch-all routes.\n"
3986 );
3987 }
3988
3989 #[test]
3990 fn test_two_adjacent_whitespace_free_links_both_flagged() {
3991 let content = "[myapp](https://a.com)[github](https://b.com)\n";
3995 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitHub".to_string()], false);
3996 let ctx = create_context(content);
3997 let result = rule.check(&ctx).unwrap();
3998 assert_eq!(result.len(), 2, "both link texts must be flagged: {result:?}");
3999 assert!(result.iter().any(|w| w.message.contains("'myapp'")));
4000 assert!(result.iter().any(|w| w.message.contains("'github'")));
4001 }
4002
4003 #[test]
4004 fn test_fix_does_not_corrupt_frontmatter_path_with_route_group_named_after_proper_name() {
4005 let content = "---\nlink: src/(myapp)/page.tsx\n---\n\nBody.\n";
4008 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4009 let ctx = create_context(content);
4010 assert_eq!(
4011 rule.fix(&ctx).unwrap(),
4012 content,
4013 "fix must not rewrite a frontmatter path whose route-group directory name is the proper name"
4014 );
4015 }
4016
4017 #[test]
4018 fn test_quoted_frontmatter_value_slash_conjunction_prose_still_flagged() {
4019 let content = "---\ndescription: \"We support github/gitlab/bitbucket now\"\n---\n\nBody.\n";
4024 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4025 let ctx = create_context(content);
4026 let result = rule.check(&ctx).unwrap();
4027 assert_eq!(
4028 result.len(),
4029 1,
4030 "quoted prose value must still flag 'github': {result:?}"
4031 );
4032 }
4033
4034 #[test]
4035 fn test_quoted_frontmatter_value_single_slash_word_with_unrelated_dot_still_flagged() {
4036 let content = "---\ndescription: \"We use myapp/gitlab and version 1.0 e.g. weekly\"\n---\n\nBody.\n";
4039 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4040 let ctx = create_context(content);
4041 let result = rule.check(&ctx).unwrap();
4042 assert_eq!(
4043 result.len(),
4044 1,
4045 "quoted prose value must still flag 'myapp': {result:?}"
4046 );
4047 }
4048
4049 #[test]
4050 fn test_quoted_toml_frontmatter_value_slash_conjunction_prose_still_flagged() {
4051 let content = "+++\ndescription = \"We support github/gitlab/bitbucket now\"\n+++\n\nBody.\n";
4054 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4055 let ctx = create_context(content);
4056 let result = rule.check(&ctx).unwrap();
4057 assert_eq!(
4058 result.len(),
4059 1,
4060 "TOML quoted prose value must still flag 'github': {result:?}"
4061 );
4062 }
4063
4064 #[test]
4065 fn test_path_like_collapsed_multiword_no_extension_not_exempt() {
4066 for line in [
4073 r#"description: "myapp/gitlab github/bitbucket""#,
4074 r#"description: "and/or this/that myapp/gitlab""#,
4075 r#"description: "he/him she/her myapp/gitlab""#,
4076 ] {
4077 let span = frontmatter_values::value_span(line).unwrap();
4078 for needle in ["myapp", "gitlab"] {
4079 if let Some(byte_pos) = line.find(needle) {
4080 assert!(
4081 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
4082 "collapsed multi-word value must not exempt '{needle}': {line}"
4083 );
4084 }
4085 }
4086 }
4087 }
4088
4089 #[test]
4090 fn test_path_like_collapsed_multiword_no_extension_not_exempt_toml() {
4091 let line = r#"description = "myapp/gitlab github/bitbucket""#;
4092 let span = frontmatter_values::value_span(line).unwrap();
4093 for needle in ["myapp", "gitlab", "github", "bitbucket"] {
4094 let byte_pos = at(line, needle);
4095 assert!(
4096 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
4097 "collapsed multi-word TOML value must not exempt '{needle}'"
4098 );
4099 }
4100 }
4101
4102 #[test]
4103 fn test_frontmatter_collapsed_multiword_names_all_flagged_yaml() {
4104 let content = "---\ndescription: \"myapp/gitlab github/bitbucket\"\n---\n\nBody.\n";
4105 let rule = MD044ProperNames::new(
4106 vec![
4107 "MyApp".to_string(),
4108 "GitLab".to_string(),
4109 "GitHub".to_string(),
4110 "Bitbucket".to_string(),
4111 ],
4112 false,
4113 );
4114 let ctx = create_context(content);
4115 let result = rule.check(&ctx).unwrap();
4116 assert_eq!(
4117 result.len(),
4118 4,
4119 "all four names in the collapsed multi-word value must be flagged: {result:?}"
4120 );
4121 }
4122
4123 #[test]
4124 fn test_frontmatter_collapsed_multiword_names_all_flagged_toml() {
4125 let content = "+++\ndescription = \"myapp/gitlab github/bitbucket\"\n+++\n\nBody.\n";
4126 let rule = MD044ProperNames::new(
4127 vec![
4128 "MyApp".to_string(),
4129 "GitLab".to_string(),
4130 "GitHub".to_string(),
4131 "Bitbucket".to_string(),
4132 ],
4133 false,
4134 );
4135 let ctx = create_context(content);
4136 let result = rule.check(&ctx).unwrap();
4137 assert_eq!(
4138 result.len(),
4139 4,
4140 "all four names in the collapsed multi-word TOML value must be flagged: {result:?}"
4141 );
4142 }
4143
4144 #[test]
4145 fn test_frontmatter_collapsed_multiword_conjunction_pairs_flagged() {
4146 let content = "---\ndescription: \"and/or this/that myapp/gitlab\"\n---\n\nBody.\n";
4147 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4148 let ctx = create_context(content);
4149 let result = rule.check(&ctx).unwrap();
4150 assert_eq!(
4151 result.len(),
4152 2,
4153 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4154 );
4155 }
4156
4157 #[test]
4158 fn test_frontmatter_collapsed_multiword_pronoun_pairs_flagged() {
4159 let content = "---\ndescription: \"he/him she/her myapp/gitlab\"\n---\n\nBody.\n";
4160 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4161 let ctx = create_context(content);
4162 let result = rule.check(&ctx).unwrap();
4163 assert_eq!(
4164 result.len(),
4165 2,
4166 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4167 );
4168 }
4169
4170 #[test]
4176 fn test_body_prose_path_is_flagged_frontmatter_only_scope() {
4177 let content = "See docs/myapp.md for details about myapp.\n";
4178 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4179 let ctx = create_context(content);
4180 let result = rule.check(&ctx).unwrap();
4181 assert_eq!(
4182 result.len(),
4183 2,
4184 "both the path occurrence and the prose occurrence are flagged in body text: {result:?}"
4185 );
4186 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp.md for details about MyApp.\n");
4187 }
4188
4189 #[test]
4190 fn test_slash_conjunction_prose_still_flagged() {
4191 let content = "We support github/gitlab imports.\n";
4192 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4193 let ctx = create_context(content);
4194 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
4195 }
4196}