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); fn is_inline_config_comment(trimmed: &str) -> bool {
72 trimmed.starts_with("<!-- rumdl-")
73 || trimmed.starts_with("<!-- markdownlint-")
74 || trimmed.starts_with("<!-- vale off")
75 || trimmed.starts_with("<!-- vale on")
76 || (trimmed.starts_with("<!-- vale ") && trimmed.contains(" = "))
77 || trimmed.starts_with("<!-- vale style")
78 || trimmed.starts_with("<!-- lint disable ")
79 || trimmed.starts_with("<!-- lint enable ")
80 || trimmed.starts_with("<!-- lint ignore ")
81}
82
83#[derive(Clone)]
84pub struct MD044ProperNames {
85 config: MD044Config,
86 combined_pattern: Option<String>,
88 name_variants: Vec<String>,
90 ignore_fields: HashSet<String>,
92 content_cache: Arc<Mutex<HashMap<u64, Vec<WarningPosition>>>>,
101}
102
103impl MD044ProperNames {
104 pub fn new(names: Vec<String>, code_blocks: bool) -> Self {
105 let config = MD044Config {
106 names,
107 code_blocks,
108 ..Default::default()
109 };
110 let combined_pattern = Self::create_combined_pattern(&config);
111 let name_variants = Self::build_name_variants(&config);
112 let ignore_fields = config
113 .ignore_frontmatter_fields
114 .iter()
115 .flatten()
116 .map(|f| f.to_lowercase())
117 .collect();
118 Self {
119 config,
120 combined_pattern,
121 name_variants,
122 ignore_fields,
123 content_cache: Arc::new(Mutex::new(HashMap::new())),
124 }
125 }
126
127 fn ascii_normalize(s: &str) -> String {
129 s.replace(['é', 'è', 'ê', 'ë'], "e")
130 .replace(['à', 'á', 'â', 'ä', 'ã', 'å'], "a")
131 .replace(['ï', 'î', 'í', 'ì'], "i")
132 .replace(['ü', 'ú', 'ù', 'û'], "u")
133 .replace(['ö', 'ó', 'ò', 'ô', 'õ'], "o")
134 .replace('ñ', "n")
135 .replace('ç', "c")
136 }
137
138 pub fn from_config_struct(config: MD044Config) -> Self {
139 let combined_pattern = Self::create_combined_pattern(&config);
140 let name_variants = Self::build_name_variants(&config);
141 let ignore_fields = config
142 .ignore_frontmatter_fields
143 .iter()
144 .flatten()
145 .map(|f| f.to_lowercase())
146 .collect();
147 Self {
148 config,
149 combined_pattern,
150 name_variants,
151 ignore_fields,
152 content_cache: Arc::new(Mutex::new(HashMap::new())),
153 }
154 }
155
156 fn create_combined_pattern(config: &MD044Config) -> Option<String> {
158 if config.names.is_empty() {
159 return None;
160 }
161
162 let mut patterns: Vec<String> = config
164 .names
165 .iter()
166 .flat_map(|name| {
167 let mut variations = vec![];
168 let lower_name = name.to_lowercase();
169
170 variations.push(escape_regex(&lower_name));
172
173 let lower_name_no_dots = lower_name.replace('.', "");
175 if lower_name != lower_name_no_dots {
176 variations.push(escape_regex(&lower_name_no_dots));
177 }
178
179 let ascii_normalized = Self::ascii_normalize(&lower_name);
181
182 if ascii_normalized != lower_name {
183 variations.push(escape_regex(&ascii_normalized));
184
185 let ascii_no_dots = ascii_normalized.replace('.', "");
187 if ascii_normalized != ascii_no_dots {
188 variations.push(escape_regex(&ascii_no_dots));
189 }
190 }
191
192 variations
193 })
194 .collect();
195
196 patterns.sort_by_key(|b| std::cmp::Reverse(b.len()));
198
199 Some(format!(r"(?i)({})", patterns.join("|")))
202 }
203
204 fn build_name_variants(config: &MD044Config) -> Vec<String> {
205 let mut variants = HashSet::new();
206 for name in &config.names {
207 let lower_name = name.to_lowercase();
208 variants.insert(lower_name.clone());
209
210 let lower_no_dots = lower_name.replace('.', "");
211 if lower_name != lower_no_dots {
212 variants.insert(lower_no_dots);
213 }
214
215 let ascii_normalized = Self::ascii_normalize(&lower_name);
216 if ascii_normalized != lower_name {
217 variants.insert(ascii_normalized.clone());
218
219 let ascii_no_dots = ascii_normalized.replace('.', "");
220 if ascii_normalized != ascii_no_dots {
221 variants.insert(ascii_no_dots);
222 }
223 }
224 }
225
226 variants.into_iter().collect()
227 }
228
229 fn find_name_violations(
232 &self,
233 content: &str,
234 ctx: &crate::lint_context::LintContext,
235 content_lower: &str,
236 ) -> Vec<WarningPosition> {
237 if self.config.names.is_empty() || content.is_empty() || self.combined_pattern.is_none() {
239 return Vec::new();
240 }
241
242 let has_potential_matches = self.name_variants.iter().any(|name| content_lower.contains(name));
244
245 if !has_potential_matches {
246 return Vec::new();
247 }
248
249 let hash = fast_hash(content);
251 {
252 if let Ok(cache) = self.content_cache.lock()
254 && let Some(cached) = cache.get(&hash)
255 {
256 return cached.clone();
257 }
258 }
259
260 let mut violations = Vec::new();
261
262 let combined_regex = match &self.combined_pattern {
264 Some(pattern) => match get_cached_regex(pattern) {
265 Ok(regex) => regex,
266 Err(_) => return Vec::new(),
267 },
268 None => return Vec::new(),
269 };
270
271 let field_map = if self.ignore_fields.is_empty() {
273 Vec::new()
274 } else {
275 frontmatter_values::field_map(ctx)
276 };
277
278 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
280 let line_num = line_idx + 1;
281 let line = line_info.content(ctx.content);
282
283 let trimmed = line.trim_start();
285 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
286 continue;
287 }
288
289 if !self.config.code_blocks && line_info.in_code_block {
291 continue;
292 }
293
294 if !self.config.html_elements && line_info.in_html_block {
296 continue;
297 }
298
299 if !self.config.html_comments && line_info.in_html_comment {
301 continue;
302 }
303
304 if line_info.in_jsx_expression || line_info.in_mdx_comment {
306 continue;
307 }
308
309 if line_info.in_obsidian_comment {
311 continue;
312 }
313
314 let fm_value_offset = if line_info.in_front_matter {
317 frontmatter_values::value_offset(line)
318 } else {
319 0
320 };
321 if fm_value_offset == usize::MAX {
322 continue;
323 }
324 if line_info.in_front_matter
325 && let Some(Some(field)) = field_map.get(line_idx)
326 && self.ignore_fields.contains(field)
327 {
328 continue;
329 }
330 let fm_value_span = if line_info.in_front_matter {
331 frontmatter_values::value_span(line)
332 } else {
333 None
334 };
335
336 if is_inline_config_comment(trimmed) {
338 continue;
339 }
340
341 let line_lower = line.to_lowercase();
343 let has_line_matches = self.name_variants.iter().any(|name| line_lower.contains(name));
344
345 if !has_line_matches {
346 continue;
347 }
348
349 for cap in combined_regex.find_iter(line) {
351 let found_name = &line[cap.start()..cap.end()];
352
353 let start_pos = cap.start();
355 let end_pos = cap.end();
356
357 if start_pos < fm_value_offset {
359 continue;
360 }
361
362 let byte_pos = line_info.byte_offset + start_pos;
364 if ctx.is_in_html_tag(byte_pos) {
365 continue;
366 }
367
368 if !Self::is_at_word_boundary(line, start_pos, true) || !Self::is_at_word_boundary(line, end_pos, false)
369 {
370 continue; }
372
373 if !self.config.code_blocks {
375 if ctx.is_in_code_block_or_span(byte_pos) {
376 continue;
377 }
378 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
382 && Self::is_in_backtick_code_in_line(line, start_pos)
383 {
384 continue;
385 }
386 }
387
388 if Self::is_in_link(ctx, byte_pos) {
390 continue;
391 }
392
393 if Self::is_in_angle_bracket_url(line, start_pos) {
397 continue;
398 }
399
400 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
404 && Self::is_in_markdown_link_url(line, start_pos)
405 {
406 continue;
407 }
408
409 if Self::is_in_wikilink_url(ctx, byte_pos) {
414 continue;
415 }
416
417 if Self::is_in_bare_url(ctx, byte_pos) {
423 continue;
424 }
425
426 if let Some(fm_value) = fm_value_span
433 && Self::is_in_path_like_token(line, start_pos, fm_value)
434 {
435 continue;
436 }
437
438 if let Some(proper_name) = self.get_proper_name_for(found_name) {
440 if found_name != proper_name {
442 violations.push((line_num, cap.start() + 1, found_name.to_string()));
443 }
444 }
445 }
446 }
447
448 if let Ok(mut cache) = self.content_cache.lock() {
450 cache.insert(hash, violations.clone());
451 }
452 violations
453 }
454
455 fn is_in_bare_url(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
458 let bare_urls = ctx.bare_urls();
459 let idx = bare_urls.partition_point(|url| url.byte_offset <= byte_pos);
461 idx > 0 && byte_pos < bare_urls[idx - 1].byte_end
462 }
463
464 fn is_in_link(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
472 use pulldown_cmark::LinkType;
473
474 let link_idx = ctx.links.partition_point(|link| link.byte_offset <= byte_pos);
476 if link_idx > 0 {
477 let link = &ctx.links[link_idx - 1];
478 if byte_pos < link.byte_end {
479 let (text_start, text_end) = if matches!(link.link_type, LinkType::WikiLink { .. }) {
481 let span = &ctx.content[link.byte_offset..link.byte_end];
488 let start = match span.find('|') {
489 Some(pipe) => link.byte_offset + pipe + 1,
490 None => link.byte_offset + 2,
491 };
492 (start, link.byte_end.saturating_sub(2))
493 } else {
494 let start = link.byte_offset + 1;
495 (start, start + link.text.len())
496 };
497
498 if byte_pos >= text_start && byte_pos < text_end {
502 let is_wikilink = matches!(link.link_type, LinkType::WikiLink { .. });
503 if Self::link_text_is_url(&link.text)
504 || (!is_wikilink && Self::link_text_matches_link_url(&link.text, &link.url))
505 {
506 return true;
507 }
508 return Self::image_verdict(ctx, byte_pos).unwrap_or(false);
514 }
515 return true;
517 }
518 }
519
520 if let Some(verdict) = Self::image_verdict(ctx, byte_pos) {
521 return verdict;
522 }
523
524 ctx.is_in_reference_def(byte_pos)
526 }
527
528 fn image_verdict(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> Option<bool> {
533 let image_idx = ctx.images.partition_point(|img| img.byte_offset <= byte_pos);
535 let image = ctx.images.get(image_idx.checked_sub(1)?)?;
536 if byte_pos >= image.byte_end {
537 return None;
538 }
539
540 let alt_start = image.byte_offset + 2;
542 let alt_end = alt_start + image.alt_text.len();
543
544 Some(!(byte_pos >= alt_start && byte_pos < alt_end))
546 }
547
548 fn link_text_is_url(text: &str) -> bool {
550 let lower = text.trim().to_ascii_lowercase();
551 lower.starts_with("http://")
552 || lower.starts_with("https://")
553 || lower.starts_with("www.")
554 || lower.starts_with("//")
555 }
556
557 fn link_text_matches_link_url(text: &str, url: &str) -> bool {
569 let text = text.trim();
570 if !text.contains('.') {
572 return false;
573 }
574 let url_lower = url.to_ascii_lowercase();
575 let url_without_scheme = url_lower
576 .strip_prefix("https://")
577 .or_else(|| url_lower.strip_prefix("http://"))
578 .or_else(|| url_lower.strip_prefix("//"))
579 .unwrap_or(&url_lower);
580 let text_lower = text.to_ascii_lowercase();
581 if url_without_scheme == text_lower.as_str() {
583 return true;
584 }
585 url_without_scheme.len() > text_lower.len()
587 && url_without_scheme.starts_with(text_lower.as_str())
588 && matches!(
589 url_without_scheme.as_bytes().get(text_lower.len()),
590 Some(b'/') | Some(b'?') | Some(b'#')
591 )
592 }
593
594 fn is_in_angle_bracket_url(line: &str, pos: usize) -> bool {
600 let bytes = line.as_bytes();
601 let len = bytes.len();
602 let mut i = 0;
603 while i < len {
604 if bytes[i] == b'<' {
605 let after_open = i + 1;
606 if after_open < len && bytes[after_open].is_ascii_alphabetic() {
610 let mut s = after_open + 1;
611 let scheme_max = (after_open + 32).min(len);
612 while s < scheme_max
613 && (bytes[s].is_ascii_alphanumeric()
614 || bytes[s] == b'+'
615 || bytes[s] == b'-'
616 || bytes[s] == b'.')
617 {
618 s += 1;
619 }
620 if s < len && bytes[s] == b':' {
621 let mut j = s + 1;
623 let mut found_close = false;
624 while j < len {
625 match bytes[j] {
626 b'>' => {
627 found_close = true;
628 break;
629 }
630 b' ' | b'<' => break,
631 _ => j += 1,
632 }
633 }
634 if found_close && pos >= i && pos <= j {
635 return true;
636 }
637 if found_close {
638 i = j + 1;
639 continue;
640 }
641 }
642 }
643 }
644 i += 1;
645 }
646 false
647 }
648
649 fn is_in_wikilink_url(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
662 use pulldown_cmark::LinkType;
663 let content = ctx.content.as_bytes();
664
665 let end = ctx.links.partition_point(|l| l.byte_offset <= byte_pos);
668
669 for link in &ctx.links[..end] {
670 if !matches!(link.link_type, LinkType::WikiLink { .. }) {
671 continue;
672 }
673 let wiki_end = link.byte_end;
674 if wiki_end >= byte_pos || wiki_end >= content.len() || content[wiki_end] != b'(' {
676 continue;
677 }
678 let mut depth: u32 = 1;
683 let mut k = wiki_end + 1;
684 let mut valid_destination = true;
685 while k < content.len() && depth > 0 {
686 match content[k] {
687 b'\\' => {
688 k += 1; }
690 b'(' => depth += 1,
691 b')' => depth -= 1,
692 b' ' | b'\t' | b'\n' | b'\r' => {
693 valid_destination = false;
694 break;
695 }
696 _ => {}
697 }
698 k += 1;
699 }
700 if valid_destination && depth == 0 && byte_pos > wiki_end && byte_pos < k {
703 return true;
704 }
705 }
706 false
707 }
708
709 fn is_in_markdown_link_url(line: &str, pos: usize) -> bool {
719 let bytes = line.as_bytes();
720 let len = bytes.len();
721 let mut i = 0;
722
723 while i < len {
724 if bytes[i] == b'[' && (i == 0 || bytes[i - 1] != b'\\' || (i >= 2 && bytes[i - 2] == b'\\')) {
726 let mut depth: u32 = 1;
728 let mut j = i + 1;
729 while j < len && depth > 0 {
730 match bytes[j] {
731 b'\\' => {
732 j += 1; }
734 b'[' => depth += 1,
735 b']' => depth -= 1,
736 _ => {}
737 }
738 j += 1;
739 }
740
741 if depth == 0 && j < len {
743 if bytes[j] == b'(' {
744 let url_start = j;
746 let mut paren_depth: u32 = 1;
747 let mut k = j + 1;
748 while k < len && paren_depth > 0 {
749 match bytes[k] {
750 b'\\' => {
751 k += 1; }
753 b'(' => paren_depth += 1,
754 b')' => paren_depth -= 1,
755 _ => {}
756 }
757 k += 1;
758 }
759
760 if paren_depth == 0 {
761 if pos > url_start && pos < k {
762 return true;
763 }
764 i = k;
765 continue;
766 }
767 } else if bytes[j] == b'[' {
768 let ref_start = j;
770 let mut ref_depth: u32 = 1;
771 let mut k = j + 1;
772 while k < len && ref_depth > 0 {
773 match bytes[k] {
774 b'\\' => {
775 k += 1;
776 }
777 b'[' => ref_depth += 1,
778 b']' => ref_depth -= 1,
779 _ => {}
780 }
781 k += 1;
782 }
783
784 if ref_depth == 0 {
785 if pos > ref_start && pos < k {
786 return true;
787 }
788 i = k;
789 continue;
790 }
791 }
792 }
793 }
794 i += 1;
795 }
796 false
797 }
798
799 fn is_in_backtick_code_in_line(line: &str, pos: usize) -> bool {
807 let bytes = line.as_bytes();
808 let len = bytes.len();
809 let mut i = 0;
810 while i < len {
811 if bytes[i] == b'`' {
812 let open_start = i;
814 while i < len && bytes[i] == b'`' {
815 i += 1;
816 }
817 let tick_len = i - open_start;
818
819 while i < len {
821 if bytes[i] == b'`' {
822 let close_start = i;
823 while i < len && bytes[i] == b'`' {
824 i += 1;
825 }
826 if i - close_start == tick_len {
827 let content_start = open_start + tick_len;
831 let content_end = close_start;
832 if pos >= content_start && pos < content_end {
833 return true;
834 }
835 break;
837 }
838 } else {
840 i += 1;
841 }
842 }
843 } else {
844 i += 1;
845 }
846 }
847 false
848 }
849
850 fn is_word_boundary_char(c: char) -> bool {
852 !c.is_alphanumeric()
853 }
854
855 fn is_at_word_boundary(content: &str, pos: usize, is_start: bool) -> bool {
857 if is_start {
858 if pos == 0 {
859 return true;
860 }
861 match content[..pos].chars().next_back() {
862 None => true,
863 Some(c) => Self::is_word_boundary_char(c),
864 }
865 } else {
866 if pos >= content.len() {
867 return true;
868 }
869 match content[pos..].chars().next() {
870 None => true,
871 Some(c) => Self::is_word_boundary_char(c),
872 }
873 }
874 }
875
876 fn is_in_path_like_token(line: &str, match_start: usize, fm_value: (usize, usize)) -> bool {
904 let (value_start, value_end) = fm_value;
905 if match_start < value_start || match_start >= value_end {
906 return false;
907 }
908
909 let quoted_words: Vec<&str> = if frontmatter_values::value_is_quoted(line, value_start) {
917 line[value_start..value_end].split_whitespace().collect()
918 } else {
919 Vec::new()
920 };
921 let is_single_quoted_path = !quoted_words.is_empty() && quoted_words.iter().all(|word| word.contains('/'));
922 let is_multi_word_collapse = is_single_quoted_path && quoted_words.len() > 1;
930
931 let (raw_start, raw_end) = if is_single_quoted_path {
932 (value_start, value_end)
933 } else {
934 frontmatter_values::token_bounds(line, match_start, value_start, value_end)
935 };
936
937 let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
938 if match_start < start || match_start >= end {
939 return false;
940 }
941
942 let token = &line[start..end];
943 if !token.contains('/') {
944 return false;
945 }
946 if token.starts_with('/') || token.starts_with("./") || token.starts_with("../") || token.starts_with("~/") {
947 return true;
948 }
949 if token.rsplit('/').next().is_some_and(|seg| seg.contains('.')) {
950 return true;
951 }
952
953 if is_multi_word_collapse {
954 return false;
955 }
956
957 let sole_value = {
961 let (ts, te) = frontmatter_values::trim_token_bounds(line, value_start, value_end);
962 ts == start && te == end
963 };
964 sole_value && token.split('/').filter(|s| !s.is_empty()).count() >= 3
965 }
966
967 fn get_proper_name_for(&self, found_name: &str) -> Option<String> {
969 let found_lower = found_name.to_lowercase();
970
971 for name in &self.config.names {
973 let lower_name = name.to_lowercase();
974 let lower_name_no_dots = lower_name.replace('.', "");
975
976 if found_lower == lower_name || found_lower == lower_name_no_dots {
978 return Some(name.clone());
979 }
980
981 let ascii_normalized = Self::ascii_normalize(&lower_name);
983
984 let ascii_no_dots = ascii_normalized.replace('.', "");
985
986 if found_lower == ascii_normalized || found_lower == ascii_no_dots {
987 return Some(name.clone());
988 }
989 }
990 None
991 }
992}
993
994impl Rule for MD044ProperNames {
995 fn name(&self) -> &'static str {
996 "MD044"
997 }
998
999 fn description(&self) -> &'static str {
1000 "Proper names should have the correct capitalization"
1001 }
1002
1003 fn category(&self) -> RuleCategory {
1004 RuleCategory::Other
1005 }
1006
1007 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1008 if self.config.names.is_empty() {
1009 return true;
1010 }
1011 let content_lower = if ctx.content.is_ascii() {
1013 ctx.content.to_ascii_lowercase()
1014 } else {
1015 ctx.content.to_lowercase()
1016 };
1017 !self.name_variants.iter().any(|name| content_lower.contains(name))
1018 }
1019
1020 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1021 let content = ctx.content;
1022 if content.is_empty() || self.config.names.is_empty() || self.combined_pattern.is_none() {
1023 return Ok(Vec::new());
1024 }
1025
1026 let content_lower = if content.is_ascii() {
1028 content.to_ascii_lowercase()
1029 } else {
1030 content.to_lowercase()
1031 };
1032
1033 let has_potential_matches = self.name_variants.iter().any(|name| content_lower.contains(name));
1035
1036 if !has_potential_matches {
1037 return Ok(Vec::new());
1038 }
1039
1040 let line_index = &ctx.line_index;
1041 let violations = self.find_name_violations(content, ctx, &content_lower);
1042
1043 let warnings = violations
1044 .into_iter()
1045 .filter_map(|(line, column, found_name)| {
1046 self.get_proper_name_for(&found_name).map(|proper_name| {
1047 let line_start = line_index.get_line_start_byte(line).unwrap_or(0);
1052 let byte_start = line_start + (column - 1);
1053 let byte_end = byte_start + found_name.len();
1054 let line_text = ctx.line_info(line).map_or("", |li| li.content(ctx.content));
1057 let char_col = byte_to_char_count(line_text, column - 1);
1058 LintWarning {
1059 rule_name: Some(self.name().to_string()),
1060 line,
1061 column: char_col,
1062 end_line: line,
1063 end_column: char_col + found_name.chars().count(),
1064 message: format!("Proper name '{found_name}' should be '{proper_name}'"),
1065 severity: Severity::Warning,
1066 fix: Some(Fix::new(byte_start..byte_end, proper_name)),
1067 }
1068 })
1069 })
1070 .collect();
1071
1072 Ok(warnings)
1073 }
1074
1075 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1076 if self.should_skip(ctx) {
1077 return Ok(ctx.content.to_string());
1078 }
1079 let warnings = self.check(ctx)?;
1080 if warnings.is_empty() {
1081 return Ok(ctx.content.to_string());
1082 }
1083 let warnings =
1084 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1085 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
1086 .map_err(crate::rule::LintError::InvalidInput)
1087 }
1088
1089 fn as_any(&self) -> &dyn std::any::Any {
1090 self
1091 }
1092
1093 crate::impl_rule_config_methods!(MD044Config, nullable);
1094}
1095
1096#[cfg(test)]
1097mod tests {
1098 use super::*;
1099 use crate::lint_context::LintContext;
1100
1101 fn create_context(content: &str) -> LintContext<'_> {
1102 LintContext::new(content, crate::config::MarkdownFlavor::Standard, None)
1103 }
1104
1105 fn field_map_for(content: &str) -> Vec<Option<String>> {
1106 let ctx = create_context(content);
1107 frontmatter_values::field_map(&ctx)
1108 }
1109
1110 #[test]
1111 fn test_field_map_nested_lines_inherit_top_level_key() {
1112 let map = field_map_for("---\nseo:\n canonical: docs/a.md\n keywords:\n - myapp\ntitle: x\n---\n");
1113 assert_eq!(map[2].as_deref(), Some("seo"));
1114 assert_eq!(map[4].as_deref(), Some("seo"));
1115 assert_eq!(map[5].as_deref(), Some("title"));
1116 }
1117
1118 #[test]
1119 fn test_field_map_block_scalar_bracket_does_not_swallow_next_key() {
1120 let map = field_map_for("---\ndescription: |\n [myapp\ntitle: myapp\n---\n");
1121 assert_eq!(map[2].as_deref(), Some("description"));
1122 assert_eq!(
1123 map[3].as_deref(),
1124 Some("title"),
1125 "an indent-0 key always starts a new key"
1126 );
1127 }
1128
1129 #[test]
1130 fn test_field_map_quoted_key_with_colon() {
1131 let map = field_map_for("---\n\"og:title\": myapp\n---\n");
1132 assert_eq!(map[1].as_deref(), Some("og:title"));
1133 }
1134
1135 #[test]
1136 fn test_field_map_top_level_sequence_clears_attribution() {
1137 let map = field_map_for("---\n- myapp\n---\n");
1138 assert_eq!(map[1], None);
1139 }
1140
1141 #[test]
1142 fn test_field_map_toml_table_body_belongs_to_table_root() {
1143 let map = field_map_for("+++\n[seo]\ncanonical = \"docs/a.md\"\n\n[[authors]]\nname = \"myapp\"\n+++\n");
1144 assert_eq!(map[2].as_deref(), Some("seo"));
1145 assert_eq!(map[5].as_deref(), Some("authors"));
1146 }
1147
1148 #[test]
1149 fn test_field_map_toml_dotted_assignment_uses_root() {
1150 let map = field_map_for("+++\nseo.canonical = \"docs/a.md\"\n+++\n");
1151 assert_eq!(map[1].as_deref(), Some("seo"));
1152 }
1153
1154 #[test]
1155 fn test_field_map_toml_array_continuation_inherits() {
1156 let map = field_map_for("+++\nseo = [\n\"docs/guide/myapp\"\n]\n+++\n");
1157 assert_eq!(map[2].as_deref(), Some("seo"));
1158 }
1159
1160 #[test]
1161 fn test_field_map_indent_zero_flow_continuation_is_not_attributed_to_parent() {
1162 let map = field_map_for("---\nseo: [\n{name: myapp}\n]\n---\n");
1165 assert_eq!(map[2].as_deref(), Some("{name"));
1166 }
1167
1168 #[test]
1169 fn test_field_map_toml_nested_array_inherits_and_title_not_corrupted() {
1170 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\ntitle = \"x\"\n+++\n");
1171 assert_eq!(map[2].as_deref(), Some("matrix"), "nested array line inherits matrix");
1172 assert_eq!(map[3].as_deref(), Some("matrix"), "nested array line inherits matrix");
1173 assert_eq!(
1174 map[5].as_deref(),
1175 Some("title"),
1176 "title must not inherit stale attribution from a closed nested array"
1177 );
1178 }
1179
1180 #[test]
1181 fn test_field_map_toml_nested_array_last_element_without_trailing_comma() {
1182 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [2]\n]\ntitle = \"x\"\n+++\n");
1183 assert_eq!(
1184 map[3].as_deref(),
1185 Some("matrix"),
1186 "last element without a trailing comma still inherits matrix"
1187 );
1188 assert_eq!(
1189 map[5].as_deref(),
1190 Some("title"),
1191 "title must not inherit stale attribution from a closed nested array"
1192 );
1193 }
1194
1195 #[test]
1196 fn test_field_map_toml_nested_array_then_real_table_header_non_regression_guard() {
1197 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\n\n[seo]\ncanonical = \"docs/a.md\"\n+++\n");
1204 assert_eq!(map[6].as_deref(), Some("seo"), "real table header after a closed array");
1205 assert_eq!(
1206 map[7].as_deref(),
1207 Some("seo"),
1208 "table body still attributes to the table"
1209 );
1210 }
1211
1212 #[test]
1213 fn test_field_map_toml_unclosed_array_resyncs_on_next_assignment() {
1214 let map = field_map_for("+++\nmatrix = [\n [1, 2],\ntitle = \"x\"\n+++\n");
1220 assert_eq!(
1221 map[3].as_deref(),
1222 Some("title"),
1223 "title must resync even though the array was never closed"
1224 );
1225 }
1226
1227 #[test]
1228 fn test_field_map_toml_column_zero_array_elements_inherit_and_title_not_corrupted() {
1229 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[3, 4],\n]\ntitle = \"x\"\n+++\n");
1235 assert_eq!(
1236 map[2].as_deref(),
1237 Some("matrix"),
1238 "column-0 array element inherits matrix"
1239 );
1240 assert_eq!(
1241 map[3].as_deref(),
1242 Some("matrix"),
1243 "column-0 array element inherits matrix"
1244 );
1245 assert_eq!(
1246 map[5].as_deref(),
1247 Some("title"),
1248 "title must not inherit stale attribution from a misread array element"
1249 );
1250 }
1251
1252 #[test]
1253 fn test_field_map_toml_column_zero_array_last_element_without_trailing_comma() {
1254 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[2]\n]\ntitle = \"x\"\n+++\n");
1255 assert_eq!(
1256 map[3].as_deref(),
1257 Some("matrix"),
1258 "column-0 last element without a trailing comma still inherits matrix"
1259 );
1260 assert_eq!(
1261 map[5].as_deref(),
1262 Some("title"),
1263 "title must not inherit stale attribution from a misread array element"
1264 );
1265 }
1266
1267 #[test]
1268 fn test_correctly_capitalized_names() {
1269 let rule = MD044ProperNames::new(
1270 vec![
1271 "JavaScript".to_string(),
1272 "TypeScript".to_string(),
1273 "Node.js".to_string(),
1274 ],
1275 true,
1276 );
1277
1278 let content = "This document uses JavaScript, TypeScript, and Node.js correctly.";
1279 let ctx = create_context(content);
1280 let result = rule.check(&ctx).unwrap();
1281 assert!(result.is_empty(), "Should not flag correctly capitalized names");
1282 }
1283
1284 #[test]
1285 fn test_incorrectly_capitalized_names() {
1286 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1287
1288 let content = "This document uses javascript and typescript incorrectly.";
1289 let ctx = create_context(content);
1290 let result = rule.check(&ctx).unwrap();
1291
1292 assert_eq!(result.len(), 2, "Should flag two incorrect capitalizations");
1293 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1294 assert_eq!(result[0].line, 1);
1295 assert_eq!(result[0].column, 20);
1296 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1297 assert_eq!(result[1].line, 1);
1298 assert_eq!(result[1].column, 35);
1299 }
1300
1301 #[test]
1302 fn test_names_at_beginning_of_sentences() {
1303 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "Python".to_string()], true);
1304
1305 let content = "javascript is a great language. python is also popular.";
1306 let ctx = create_context(content);
1307 let result = rule.check(&ctx).unwrap();
1308
1309 assert_eq!(result.len(), 2, "Should flag names at beginning of sentences");
1310 assert_eq!(result[0].line, 1);
1311 assert_eq!(result[0].column, 1);
1312 assert_eq!(result[1].line, 1);
1313 assert_eq!(result[1].column, 33);
1314 }
1315
1316 #[test]
1317 fn test_names_in_code_blocks_checked_by_default() {
1318 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1319
1320 let content = r#"Here is some text with JavaScript.
1321
1322```javascript
1323// This javascript should be checked
1324const lang = "javascript";
1325```
1326
1327But this javascript should be flagged."#;
1328
1329 let ctx = create_context(content);
1330 let result = rule.check(&ctx).unwrap();
1331
1332 assert_eq!(result.len(), 3, "Should flag javascript inside and outside code blocks");
1333 assert_eq!(result[0].line, 4);
1334 assert_eq!(result[1].line, 5);
1335 assert_eq!(result[2].line, 8);
1336 }
1337
1338 #[test]
1339 fn test_names_in_code_blocks_ignored_when_disabled() {
1340 let rule = MD044ProperNames::new(
1341 vec!["JavaScript".to_string()],
1342 false, );
1344
1345 let content = r#"```
1346javascript in code block
1347```"#;
1348
1349 let ctx = create_context(content);
1350 let result = rule.check(&ctx).unwrap();
1351
1352 assert_eq!(
1353 result.len(),
1354 0,
1355 "Should not flag javascript in code blocks when code_blocks is false"
1356 );
1357 }
1358
1359 #[test]
1360 fn test_names_in_inline_code_checked_by_default() {
1361 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1362
1363 let content = "This is `javascript` in inline code and javascript outside.";
1364 let ctx = create_context(content);
1365 let result = rule.check(&ctx).unwrap();
1366
1367 assert_eq!(result.len(), 2, "Should flag javascript inside and outside inline code");
1369 assert_eq!(result[0].column, 10); assert_eq!(result[1].column, 41); }
1372
1373 #[test]
1374 fn test_multiple_names_in_same_line() {
1375 let rule = MD044ProperNames::new(
1376 vec!["JavaScript".to_string(), "TypeScript".to_string(), "React".to_string()],
1377 true,
1378 );
1379
1380 let content = "I use javascript, typescript, and react in my projects.";
1381 let ctx = create_context(content);
1382 let result = rule.check(&ctx).unwrap();
1383
1384 assert_eq!(result.len(), 3, "Should flag all three incorrect names");
1385 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1386 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1387 assert_eq!(result[2].message, "Proper name 'react' should be 'React'");
1388 }
1389
1390 #[test]
1391 fn test_case_sensitivity() {
1392 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1393
1394 let content = "JAVASCRIPT, Javascript, javascript, and JavaScript variations.";
1395 let ctx = create_context(content);
1396 let result = rule.check(&ctx).unwrap();
1397
1398 assert_eq!(result.len(), 3, "Should flag all incorrect case variations");
1399 assert!(result.iter().all(|w| w.message.contains("should be 'JavaScript'")));
1401 }
1402
1403 #[test]
1404 fn test_configuration_with_custom_name_list() {
1405 let config = MD044Config {
1406 names: vec!["GitHub".to_string(), "GitLab".to_string(), "DevOps".to_string()],
1407 code_blocks: true,
1408 ..Default::default()
1409 };
1410 let rule = MD044ProperNames::from_config_struct(config);
1411
1412 let content = "We use github, gitlab, and devops for our workflow.";
1413 let ctx = create_context(content);
1414 let result = rule.check(&ctx).unwrap();
1415
1416 assert_eq!(result.len(), 3, "Should flag all custom names");
1417 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
1418 assert_eq!(result[1].message, "Proper name 'gitlab' should be 'GitLab'");
1419 assert_eq!(result[2].message, "Proper name 'devops' should be 'DevOps'");
1420 }
1421
1422 #[test]
1423 fn test_empty_configuration() {
1424 let rule = MD044ProperNames::new(vec![], true);
1425
1426 let content = "This has javascript and typescript but no configured names.";
1427 let ctx = create_context(content);
1428 let result = rule.check(&ctx).unwrap();
1429
1430 assert!(result.is_empty(), "Should not flag anything with empty configuration");
1431 }
1432
1433 #[test]
1434 fn test_names_with_special_characters() {
1435 let rule = MD044ProperNames::new(
1436 vec!["Node.js".to_string(), "ASP.NET".to_string(), "C++".to_string()],
1437 true,
1438 );
1439
1440 let content = "We use nodejs, asp.net, ASP.NET, and c++ in our stack.";
1441 let ctx = create_context(content);
1442 let result = rule.check(&ctx).unwrap();
1443
1444 assert_eq!(result.len(), 3, "Should handle special characters correctly");
1449
1450 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
1451 assert!(messages.contains(&"Proper name 'nodejs' should be 'Node.js'"));
1452 assert!(messages.contains(&"Proper name 'asp.net' should be 'ASP.NET'"));
1453 assert!(messages.contains(&"Proper name 'c++' should be 'C++'"));
1454 }
1455
1456 #[test]
1457 fn test_word_boundaries() {
1458 let rule = MD044ProperNames::new(vec!["Java".to_string(), "Script".to_string()], true);
1459
1460 let content = "JavaScript is not java or script, but Java and Script are separate.";
1461 let ctx = create_context(content);
1462 let result = rule.check(&ctx).unwrap();
1463
1464 assert_eq!(result.len(), 2, "Should respect word boundaries");
1466 assert!(result.iter().any(|w| w.column == 19)); assert!(result.iter().any(|w| w.column == 27)); }
1469
1470 #[test]
1471 fn test_fix_method() {
1472 let rule = MD044ProperNames::new(
1473 vec![
1474 "JavaScript".to_string(),
1475 "TypeScript".to_string(),
1476 "Node.js".to_string(),
1477 ],
1478 true,
1479 );
1480
1481 let content = "I love javascript, typescript, and nodejs!";
1482 let ctx = create_context(content);
1483 let fixed = rule.fix(&ctx).unwrap();
1484
1485 assert_eq!(fixed, "I love JavaScript, TypeScript, and Node.js!");
1486 }
1487
1488 #[test]
1489 fn test_fix_multiple_occurrences() {
1490 let rule = MD044ProperNames::new(vec!["Python".to_string()], true);
1491
1492 let content = "python is great. I use python daily. PYTHON is powerful.";
1493 let ctx = create_context(content);
1494 let fixed = rule.fix(&ctx).unwrap();
1495
1496 assert_eq!(fixed, "Python is great. I use Python daily. Python is powerful.");
1497 }
1498
1499 #[test]
1500 fn test_fix_checks_code_blocks_by_default() {
1501 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1502
1503 let content = r#"I love javascript.
1504
1505```
1506const lang = "javascript";
1507```
1508
1509More javascript here."#;
1510
1511 let ctx = create_context(content);
1512 let fixed = rule.fix(&ctx).unwrap();
1513
1514 let expected = r#"I love JavaScript.
1515
1516```
1517const lang = "JavaScript";
1518```
1519
1520More JavaScript here."#;
1521
1522 assert_eq!(fixed, expected);
1523 }
1524
1525 #[test]
1526 fn test_multiline_content() {
1527 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
1528
1529 let content = r#"First line with rust.
1530Second line with python.
1531Third line with RUST and PYTHON."#;
1532
1533 let ctx = create_context(content);
1534 let result = rule.check(&ctx).unwrap();
1535
1536 assert_eq!(result.len(), 4, "Should flag all incorrect occurrences");
1537 assert_eq!(result[0].line, 1);
1538 assert_eq!(result[1].line, 2);
1539 assert_eq!(result[2].line, 3);
1540 assert_eq!(result[3].line, 3);
1541 }
1542
1543 #[test]
1544 fn test_default_config() {
1545 let config = MD044Config::default();
1546 assert!(config.names.is_empty());
1547 assert!(!config.code_blocks);
1548 assert!(config.html_elements);
1549 assert!(config.html_comments);
1550 }
1551
1552 #[test]
1553 fn test_default_config_checks_html_comments() {
1554 let config = MD044Config {
1555 names: vec!["JavaScript".to_string()],
1556 ..MD044Config::default()
1557 };
1558 let rule = MD044ProperNames::from_config_struct(config);
1559
1560 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1561 let ctx = create_context(content);
1562 let result = rule.check(&ctx).unwrap();
1563
1564 assert_eq!(result.len(), 1, "Default config should check HTML comments");
1565 assert_eq!(result[0].line, 3);
1566 }
1567
1568 #[test]
1569 fn test_default_config_skips_code_blocks() {
1570 let config = MD044Config {
1571 names: vec!["JavaScript".to_string()],
1572 ..MD044Config::default()
1573 };
1574 let rule = MD044ProperNames::from_config_struct(config);
1575
1576 let content = "# Guide\n\n```\njavascript in code\n```\n";
1577 let ctx = create_context(content);
1578 let result = rule.check(&ctx).unwrap();
1579
1580 assert_eq!(result.len(), 0, "Default config should skip code blocks");
1581 }
1582
1583 #[test]
1584 fn test_standalone_html_comment_checked() {
1585 let config = MD044Config {
1586 names: vec!["Test".to_string()],
1587 ..MD044Config::default()
1588 };
1589 let rule = MD044ProperNames::from_config_struct(config);
1590
1591 let content = "# Heading\n\n<!-- this is a test example -->\n";
1592 let ctx = create_context(content);
1593 let result = rule.check(&ctx).unwrap();
1594
1595 assert_eq!(result.len(), 1, "Should flag proper name in standalone HTML comment");
1596 assert_eq!(result[0].line, 3);
1597 }
1598
1599 #[test]
1600 fn test_inline_config_comments_not_flagged() {
1601 let config = MD044Config {
1602 names: vec!["RUMDL".to_string()],
1603 ..MD044Config::default()
1604 };
1605 let rule = MD044ProperNames::from_config_struct(config);
1606
1607 let content = "<!-- rumdl-disable MD044 -->\nSome rumdl text here.\n<!-- rumdl-enable MD044 -->\n<!-- markdownlint-disable -->\nMore rumdl text.\n<!-- markdownlint-enable -->\n";
1611 let ctx = create_context(content);
1612 let result = rule.check(&ctx).unwrap();
1613
1614 assert_eq!(result.len(), 2, "Should only flag body lines, not config comments");
1615 assert_eq!(result[0].line, 2);
1616 assert_eq!(result[1].line, 5);
1617 }
1618
1619 #[test]
1620 fn test_html_comment_skipped_when_disabled() {
1621 let config = MD044Config {
1622 names: vec!["Test".to_string()],
1623 code_blocks: true,
1624 html_comments: false,
1625 ..Default::default()
1626 };
1627 let rule = MD044ProperNames::from_config_struct(config);
1628
1629 let content = "# Heading\n\n<!-- this is a test example -->\n\nRegular test here.\n";
1630 let ctx = create_context(content);
1631 let result = rule.check(&ctx).unwrap();
1632
1633 assert_eq!(
1634 result.len(),
1635 1,
1636 "Should only flag 'test' outside HTML comment when html_comments=false"
1637 );
1638 assert_eq!(result[0].line, 5);
1639 }
1640
1641 #[test]
1642 fn test_fix_corrects_html_comment_content() {
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 fixed = rule.fix(&ctx).unwrap();
1652
1653 assert_eq!(fixed, "# Guide\n\n<!-- JavaScript mentioned here -->\n");
1654 }
1655
1656 #[test]
1657 fn test_fix_does_not_modify_inline_config_comments() {
1658 let config = MD044Config {
1659 names: vec!["RUMDL".to_string()],
1660 ..MD044Config::default()
1661 };
1662 let rule = MD044ProperNames::from_config_struct(config);
1663
1664 let content = "<!-- rumdl-disable -->\nSome rumdl text.\n<!-- rumdl-enable -->\n";
1665 let ctx = create_context(content);
1666 let fixed = rule.fix(&ctx).unwrap();
1667
1668 assert!(fixed.contains("<!-- rumdl-disable -->"));
1670 assert!(fixed.contains("<!-- rumdl-enable -->"));
1671 assert!(
1673 fixed.contains("Some rumdl text."),
1674 "Line inside rumdl-disable block should not be modified by fix()"
1675 );
1676 }
1677
1678 #[test]
1679 fn test_fix_respects_inline_disable_partial() {
1680 let config = MD044Config {
1681 names: vec!["RUMDL".to_string()],
1682 ..MD044Config::default()
1683 };
1684 let rule = MD044ProperNames::from_config_struct(config);
1685
1686 let content =
1687 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
1688 let ctx = create_context(content);
1689 let fixed = rule.fix(&ctx).unwrap();
1690
1691 assert!(
1693 fixed.contains("Some rumdl text.\n<!-- rumdl-enable"),
1694 "Line inside disable block should not be modified"
1695 );
1696 assert!(
1698 fixed.contains("Some RUMDL text outside."),
1699 "Line outside disable block should be fixed"
1700 );
1701 }
1702
1703 #[test]
1704 fn test_performance_with_many_names() {
1705 let mut names = vec![];
1706 for i in 0..50 {
1707 names.push(format!("ProperName{i}"));
1708 }
1709
1710 let rule = MD044ProperNames::new(names, true);
1711
1712 let content = "This has propername0, propername25, and propername49 incorrectly.";
1713 let ctx = create_context(content);
1714 let result = rule.check(&ctx).unwrap();
1715
1716 assert_eq!(result.len(), 3, "Should handle many configured names efficiently");
1717 }
1718
1719 #[test]
1720 fn test_large_name_count_performance() {
1721 let names = (0..1000).map(|i| format!("ProperName{i}")).collect::<Vec<_>>();
1724
1725 let rule = MD044ProperNames::new(names, true);
1726
1727 assert!(rule.combined_pattern.is_some());
1729
1730 let content = "This has propername0 and propername999 in it.";
1732 let ctx = create_context(content);
1733 let result = rule.check(&ctx).unwrap();
1734
1735 assert_eq!(result.len(), 2, "Should handle 1000 names without issues");
1737 }
1738
1739 #[test]
1740 fn test_cache_behavior() {
1741 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1742
1743 let content = "Using javascript here.";
1744 let ctx = create_context(content);
1745
1746 let result1 = rule.check(&ctx).unwrap();
1748 assert_eq!(result1.len(), 1);
1749
1750 let result2 = rule.check(&ctx).unwrap();
1752 assert_eq!(result2.len(), 1);
1753
1754 assert_eq!(result1[0].line, result2[0].line);
1756 assert_eq!(result1[0].column, result2[0].column);
1757 }
1758
1759 #[test]
1760 fn test_html_comments_not_checked_when_disabled() {
1761 let config = MD044Config {
1762 names: vec!["JavaScript".to_string()],
1763 code_blocks: true, html_comments: false, ..Default::default()
1766 };
1767 let rule = MD044ProperNames::from_config_struct(config);
1768
1769 let content = r#"Regular javascript here.
1770<!-- This javascript in HTML comment should be ignored -->
1771More javascript outside."#;
1772
1773 let ctx = create_context(content);
1774 let result = rule.check(&ctx).unwrap();
1775
1776 assert_eq!(result.len(), 2, "Should only flag javascript outside HTML comments");
1777 assert_eq!(result[0].line, 1);
1778 assert_eq!(result[1].line, 3);
1779 }
1780
1781 #[test]
1782 fn test_html_comments_checked_when_enabled() {
1783 let config = MD044Config {
1784 names: vec!["JavaScript".to_string()],
1785 code_blocks: true, ..Default::default()
1787 };
1788 let rule = MD044ProperNames::from_config_struct(config);
1789
1790 let content = r#"Regular javascript here.
1791<!-- This javascript in HTML comment should be checked -->
1792More javascript outside."#;
1793
1794 let ctx = create_context(content);
1795 let result = rule.check(&ctx).unwrap();
1796
1797 assert_eq!(
1798 result.len(),
1799 3,
1800 "Should flag all javascript occurrences including in HTML comments"
1801 );
1802 }
1803
1804 #[test]
1805 fn test_indented_html_comment_escapes_via_link_and_backticks() {
1806 let config = MD044Config {
1811 names: vec!["Test".to_string()],
1812 ..Default::default()
1813 };
1814 let rule = MD044ProperNames::from_config_struct(config);
1815
1816 let content = "<!-- see the [relevant page](test.md). -->\n<!-- see `test.md` -->\n <!-- see the [relevant page](test.md). -->\n <!-- see `test.md` -->\n";
1817
1818 let ctx = create_context(content);
1819 let result = rule.check(&ctx).unwrap();
1820
1821 assert!(
1822 result.is_empty(),
1823 "'test' inside a link URL or backticks must be ignored in both column-0 and indented comments, got: {result:?}"
1824 );
1825 }
1826
1827 #[test]
1828 fn test_indented_html_comment_still_checks_bare_prose() {
1829 let config = MD044Config {
1832 names: vec!["Test".to_string()],
1833 ..Default::default()
1834 };
1835 let rule = MD044ProperNames::from_config_struct(config);
1836
1837 let content = " <!-- this is a test comment -->\n";
1838
1839 let ctx = create_context(content);
1840 let result = rule.check(&ctx).unwrap();
1841
1842 assert_eq!(
1843 result.len(),
1844 1,
1845 "bare 'test' in an indented comment is still a violation"
1846 );
1847 assert_eq!(result[0].line, 1);
1848 }
1849
1850 #[test]
1851 fn test_multiline_html_comments() {
1852 let config = MD044Config {
1853 names: vec!["Python".to_string(), "JavaScript".to_string()],
1854 code_blocks: true, html_comments: false, ..Default::default()
1857 };
1858 let rule = MD044ProperNames::from_config_struct(config);
1859
1860 let content = r#"Regular python here.
1861<!--
1862This is a multiline comment
1863with javascript and python
1864that should be ignored
1865-->
1866More javascript outside."#;
1867
1868 let ctx = create_context(content);
1869 let result = rule.check(&ctx).unwrap();
1870
1871 assert_eq!(result.len(), 2, "Should only flag names outside HTML comments");
1872 assert_eq!(result[0].line, 1); assert_eq!(result[1].line, 7); }
1875
1876 #[test]
1877 fn test_fix_preserves_html_comments_when_disabled() {
1878 let config = MD044Config {
1879 names: vec!["JavaScript".to_string()],
1880 code_blocks: true, html_comments: false, ..Default::default()
1883 };
1884 let rule = MD044ProperNames::from_config_struct(config);
1885
1886 let content = r#"javascript here.
1887<!-- javascript in comment -->
1888More javascript."#;
1889
1890 let ctx = create_context(content);
1891 let fixed = rule.fix(&ctx).unwrap();
1892
1893 let expected = r#"JavaScript here.
1894<!-- javascript in comment -->
1895More JavaScript."#;
1896
1897 assert_eq!(
1898 fixed, expected,
1899 "Should not fix names inside HTML comments when disabled"
1900 );
1901 }
1902
1903 #[test]
1904 fn test_proper_names_in_link_text_are_flagged() {
1905 let rule = MD044ProperNames::new(
1906 vec!["JavaScript".to_string(), "Node.js".to_string(), "Python".to_string()],
1907 true,
1908 );
1909
1910 let content = r#"Check this [javascript documentation](https://javascript.info) for info.
1911
1912Visit [node.js homepage](https://nodejs.org) and [python tutorial](https://python.org).
1913
1914Real javascript should be flagged.
1915
1916Also see the [typescript guide][ts-ref] for more.
1917
1918Real python should be flagged too.
1919
1920[ts-ref]: https://typescript.org/handbook"#;
1921
1922 let ctx = create_context(content);
1923 let result = rule.check(&ctx).unwrap();
1924
1925 assert_eq!(result.len(), 5, "Expected 5 warnings: 3 in link text + 2 standalone");
1932
1933 let line_1_warnings: Vec<_> = result.iter().filter(|w| w.line == 1).collect();
1935 assert_eq!(line_1_warnings.len(), 1);
1936 assert!(
1937 line_1_warnings[0]
1938 .message
1939 .contains("'javascript' should be 'JavaScript'")
1940 );
1941
1942 let line_3_warnings: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
1943 assert_eq!(line_3_warnings.len(), 2); assert!(result.iter().any(|w| w.line == 5 && w.message.contains("'javascript'")));
1947 assert!(result.iter().any(|w| w.line == 9 && w.message.contains("'python'")));
1948 }
1949
1950 #[test]
1951 fn test_link_urls_not_flagged() {
1952 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1953
1954 let content = r#"[Link Text](https://javascript.info/guide)"#;
1956
1957 let ctx = create_context(content);
1958 let result = rule.check(&ctx).unwrap();
1959
1960 assert!(result.is_empty(), "URLs should not be checked for proper names");
1962 }
1963
1964 #[test]
1965 fn test_bare_urls_not_flagged() {
1966 let rule = MD044ProperNames::new(vec!["Foo".to_string(), "JavaScript".to_string()], true);
1967
1968 let content =
1971 "https://foo.com\n\nSee https://javascript.info/foo/guide for details.\n\nMail foo@foo.com about it.\n";
1972
1973 let ctx = create_context(content);
1974 let result = rule.check(&ctx).unwrap();
1975
1976 assert!(
1977 result.is_empty(),
1978 "Bare URLs and emails should not be checked for proper names: {result:?}"
1979 );
1980 }
1981
1982 #[test]
1983 fn test_prose_around_bare_url_still_flagged() {
1984 let rule = MD044ProperNames::new(vec!["Foo".to_string()], true);
1985
1986 let content = "Use foo at https://foo.com because foo is great.\n";
1989
1990 let ctx = create_context(content);
1991 let result = rule.check(&ctx).unwrap();
1992
1993 assert_eq!(
1994 result.len(),
1995 2,
1996 "Prose occurrences around a bare URL must still be flagged: {result:?}"
1997 );
1998 assert!(result.iter().all(|w| w.message.contains("'foo' should be 'Foo'")));
1999 }
2000
2001 #[test]
2002 fn test_proper_names_in_image_alt_text_are_flagged() {
2003 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2004
2005 let content = r#"Here is a  image.
2006
2007Real javascript should be flagged."#;
2008
2009 let ctx = create_context(content);
2010 let result = rule.check(&ctx).unwrap();
2011
2012 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in alt text + 1 standalone");
2016 assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
2017 assert!(result[0].line == 1); assert!(result[1].message.contains("'javascript' should be 'JavaScript'"));
2019 assert!(result[1].line == 3); }
2021
2022 #[test]
2023 fn test_image_urls_not_flagged() {
2024 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2025
2026 let content = r#""#;
2028
2029 let ctx = create_context(content);
2030 let result = rule.check(&ctx).unwrap();
2031
2032 assert!(result.is_empty(), "Image URLs should not be checked for proper names");
2034 }
2035
2036 #[test]
2037 fn test_reference_link_text_flagged_but_definition_not() {
2038 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2039
2040 let content = r#"Check the [javascript guide][js-ref] for details.
2041
2042Real javascript should be flagged.
2043
2044[js-ref]: https://javascript.info/typescript/guide"#;
2045
2046 let ctx = create_context(content);
2047 let result = rule.check(&ctx).unwrap();
2048
2049 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in link text + 1 standalone");
2054 assert!(result.iter().any(|w| w.line == 1 && w.message.contains("'javascript'")));
2055 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2056 }
2057
2058 #[test]
2059 fn test_reference_definitions_not_flagged() {
2060 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2061
2062 let content = r#"[js-ref]: https://javascript.info/guide"#;
2064
2065 let ctx = create_context(content);
2066 let result = rule.check(&ctx).unwrap();
2067
2068 assert!(result.is_empty(), "Reference definitions should not be checked");
2070 }
2071
2072 #[test]
2073 fn test_wikilinks_text_is_flagged() {
2074 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2075
2076 let content = r#"[[javascript]]
2078
2079Regular javascript here.
2080
2081[[JavaScript|display text]]"#;
2082
2083 let ctx = create_context(content);
2084 let result = rule.check(&ctx).unwrap();
2085
2086 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in WikiLink + 1 standalone");
2090 assert!(
2091 result
2092 .iter()
2093 .any(|w| w.line == 1 && w.column == 3 && w.message.contains("'javascript'"))
2094 );
2095 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2096 }
2097
2098 #[test]
2099 fn test_url_link_text_not_flagged() {
2100 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2101
2102 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2104
2105[http://github.com/org/repo](http://github.com/org/repo)
2106
2107[www.github.com/org/repo](https://www.github.com/org/repo)"#;
2108
2109 let ctx = create_context(content);
2110 let result = rule.check(&ctx).unwrap();
2111
2112 assert!(
2113 result.is_empty(),
2114 "URL-like link text should not be flagged, got: {result:?}"
2115 );
2116 }
2117
2118 #[test]
2119 fn test_url_link_text_with_leading_space_not_flagged() {
2120 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2121
2122 let content = r#"[ https://github.com/org/repo](https://github.com/org/repo)"#;
2124
2125 let ctx = create_context(content);
2126 let result = rule.check(&ctx).unwrap();
2127
2128 assert!(
2129 result.is_empty(),
2130 "URL-like link text with leading space should not be flagged, got: {result:?}"
2131 );
2132 }
2133
2134 #[test]
2135 fn test_url_link_text_uppercase_scheme_not_flagged() {
2136 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2137
2138 let content = r#"[HTTPS://GITHUB.COM/org/repo](https://github.com/org/repo)"#;
2139
2140 let ctx = create_context(content);
2141 let result = rule.check(&ctx).unwrap();
2142
2143 assert!(
2144 result.is_empty(),
2145 "URL-like link text with uppercase scheme should not be flagged, got: {result:?}"
2146 );
2147 }
2148
2149 #[test]
2150 fn test_non_url_link_text_still_flagged() {
2151 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2152
2153 let content = r#"[github.com/org/repo](https://github.com/org/repo)
2157
2158[Visit github](https://github.com/org/repo)
2159
2160[//github.com/org/repo](//github.com/org/repo)
2161
2162[ftp://github.com/org/repo](ftp://github.com/org/repo)"#;
2163
2164 let ctx = create_context(content);
2165 let result = rule.check(&ctx).unwrap();
2166
2167 assert_eq!(
2172 result.len(),
2173 1,
2174 "Only prose link text should be flagged, got: {result:?}"
2175 );
2176 assert!(
2177 result.iter().any(|w| w.line == 3),
2178 "Expected 'Visit github' on line 3 to be flagged"
2179 );
2180 }
2181
2182 #[test]
2183 fn test_url_link_text_fix_not_applied() {
2184 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2185
2186 let content = "[https://github.com/org/repo](https://github.com/org/repo)\n";
2187
2188 let ctx = create_context(content);
2189 let result = rule.fix(&ctx).unwrap();
2190
2191 assert_eq!(result, content, "Fix should not modify URL-like link text");
2192 }
2193
2194 #[test]
2195 fn test_mixed_url_and_regular_link_text() {
2196 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2197
2198 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2200
2201Visit [github documentation](https://github.com/docs) for details.
2202
2203[www.github.com/pricing](https://www.github.com/pricing)"#;
2204
2205 let ctx = create_context(content);
2206 let result = rule.check(&ctx).unwrap();
2207
2208 assert_eq!(
2210 result.len(),
2211 1,
2212 "Only non-URL link text should be flagged, got: {result:?}"
2213 );
2214 assert_eq!(result[0].line, 3);
2215 }
2216
2217 #[test]
2218 fn test_html_attribute_values_not_flagged() {
2219 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2222 let content = "# Heading\n\ntest\n\n<img src=\"www.example.test/test_image.png\">\n";
2223 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2224 let result = rule.check(&ctx).unwrap();
2225
2226 let line5_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2228 assert!(
2229 line5_violations.is_empty(),
2230 "Should not flag anything inside HTML tag attributes: {line5_violations:?}"
2231 );
2232
2233 let line3_violations: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
2235 assert_eq!(line3_violations.len(), 1, "Plain 'test' on line 3 should be flagged");
2236 }
2237
2238 #[test]
2239 fn test_html_text_content_still_flagged() {
2240 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2242 let content = "# Heading\n\n<a href=\"https://example.test/page\">test link</a>\n";
2243 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2244 let result = rule.check(&ctx).unwrap();
2245
2246 assert_eq!(
2249 result.len(),
2250 1,
2251 "Should flag only 'test' in anchor text, not in href: {result:?}"
2252 );
2253 assert_eq!(result[0].column, 37, "Should flag col 37 ('test link' in anchor text)");
2254 }
2255
2256 #[test]
2257 fn test_html_attribute_various_not_flagged() {
2258 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2260 let content = concat!(
2261 "# Heading\n\n",
2262 "<img src=\"test.png\" alt=\"test image\">\n",
2263 "<span class=\"test-class\" data-test=\"value\">test content</span>\n",
2264 );
2265 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2266 let result = rule.check(&ctx).unwrap();
2267
2268 assert_eq!(
2270 result.len(),
2271 1,
2272 "Should flag only 'test content' between tags: {result:?}"
2273 );
2274 assert_eq!(result[0].line, 4);
2275 }
2276
2277 #[test]
2278 fn test_plain_text_underscore_boundary_unchanged() {
2279 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2282 let content = "# Heading\n\ntest_image is here and just_test ends here\n";
2283 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2284 let result = rule.check(&ctx).unwrap();
2285
2286 assert_eq!(
2289 result.len(),
2290 2,
2291 "Should flag 'test' in both 'test_image' and 'just_test': {result:?}"
2292 );
2293 let cols: Vec<usize> = result.iter().map(|w| w.column).collect();
2294 assert!(cols.contains(&1), "Should flag col 1 (test_image): {cols:?}");
2295 assert!(cols.contains(&29), "Should flag col 29 (just_test): {cols:?}");
2296 }
2297
2298 #[test]
2299 fn test_frontmatter_yaml_keys_not_flagged() {
2300 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2303
2304 let content = "---\ntitle: Heading\ntest: Some Test value\n---\n\nTest\n";
2305 let ctx = create_context(content);
2306 let result = rule.check(&ctx).unwrap();
2307
2308 assert!(
2312 result.is_empty(),
2313 "Should not flag YAML keys or correctly capitalized values: {result:?}"
2314 );
2315 }
2316
2317 #[test]
2318 fn test_frontmatter_yaml_values_flagged() {
2319 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2321
2322 let content = "---\ntitle: Heading\nkey: a test value\n---\n\nTest\n";
2323 let ctx = create_context(content);
2324 let result = rule.check(&ctx).unwrap();
2325
2326 assert_eq!(result.len(), 1, "Should flag 'test' in YAML value: {result:?}");
2328 assert_eq!(result[0].line, 3);
2329 assert_eq!(result[0].column, 8); }
2331
2332 #[test]
2333 fn test_frontmatter_key_matches_name_not_flagged() {
2334 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2336
2337 let content = "---\ntest: other value\n---\n\nBody text\n";
2338 let ctx = create_context(content);
2339 let result = rule.check(&ctx).unwrap();
2340
2341 assert!(
2342 result.is_empty(),
2343 "Should not flag YAML key that matches configured name: {result:?}"
2344 );
2345 }
2346
2347 #[test]
2348 fn test_frontmatter_empty_value_not_flagged() {
2349 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2351
2352 let content = "---\ntest:\ntest: \n---\n\nBody text\n";
2353 let ctx = create_context(content);
2354 let result = rule.check(&ctx).unwrap();
2355
2356 assert!(
2357 result.is_empty(),
2358 "Should not flag YAML keys with empty values: {result:?}"
2359 );
2360 }
2361
2362 #[test]
2363 fn test_frontmatter_nested_yaml_key_not_flagged() {
2364 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2366
2367 let content = "---\nparent:\n test: nested value\n---\n\nBody text\n";
2368 let ctx = create_context(content);
2369 let result = rule.check(&ctx).unwrap();
2370
2371 assert!(result.is_empty(), "Should not flag nested YAML keys: {result:?}");
2373 }
2374
2375 #[test]
2376 fn test_frontmatter_list_items_checked() {
2377 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2379
2380 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2381 let ctx = create_context(content);
2382 let result = rule.check(&ctx).unwrap();
2383
2384 assert_eq!(result.len(), 1, "Should flag 'test' in YAML list item: {result:?}");
2386 assert_eq!(result[0].line, 3);
2387 }
2388
2389 #[test]
2390 fn test_frontmatter_value_with_multiple_colons() {
2391 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2393
2394 let content = "---\ntest: description: a test thing\n---\n\nBody text\n";
2395 let ctx = create_context(content);
2396 let result = rule.check(&ctx).unwrap();
2397
2398 assert_eq!(
2401 result.len(),
2402 1,
2403 "Should flag 'test' in value after first colon: {result:?}"
2404 );
2405 assert_eq!(result[0].line, 2);
2406 assert!(result[0].column > 6, "Violation column should be in value portion");
2407 }
2408
2409 #[test]
2410 fn test_frontmatter_does_not_affect_body() {
2411 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2413
2414 let content = "---\ntitle: Heading\n---\n\ntest should be flagged here\n";
2415 let ctx = create_context(content);
2416 let result = rule.check(&ctx).unwrap();
2417
2418 assert_eq!(result.len(), 1, "Should flag 'test' in body text: {result:?}");
2419 assert_eq!(result[0].line, 5);
2420 }
2421
2422 #[test]
2423 fn test_frontmatter_fix_corrects_values_preserves_keys() {
2424 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2426
2427 let content = "---\ntest: a test value\n---\n\ntest here\n";
2428 let ctx = create_context(content);
2429 let fixed = rule.fix(&ctx).unwrap();
2430
2431 assert_eq!(fixed, "---\ntest: a Test value\n---\n\nTest here\n");
2433 }
2434
2435 #[test]
2436 fn test_frontmatter_multiword_value_flagged() {
2437 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2439
2440 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2441 let ctx = create_context(content);
2442 let result = rule.check(&ctx).unwrap();
2443
2444 assert_eq!(result.len(), 2, "Should flag both names in YAML value: {result:?}");
2445 assert!(result.iter().all(|w| w.line == 2));
2446 }
2447
2448 #[test]
2449 fn test_frontmatter_yaml_comments_not_checked() {
2450 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2452
2453 let content = "---\n# test comment\ntitle: Heading\n---\n\nBody text\n";
2454 let ctx = create_context(content);
2455 let result = rule.check(&ctx).unwrap();
2456
2457 assert!(result.is_empty(), "Should not flag names in YAML comments: {result:?}");
2458 }
2459
2460 #[test]
2461 fn test_frontmatter_delimiters_not_checked() {
2462 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2464
2465 let content = "---\ntitle: Heading\n---\n\ntest here\n";
2466 let ctx = create_context(content);
2467 let result = rule.check(&ctx).unwrap();
2468
2469 assert_eq!(result.len(), 1, "Should only flag body text: {result:?}");
2471 assert_eq!(result[0].line, 5);
2472 }
2473
2474 #[test]
2475 fn test_frontmatter_continuation_lines_checked() {
2476 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2478
2479 let content = "---\ndescription: >\n a test value\n continued here\n---\n\nBody\n";
2480 let ctx = create_context(content);
2481 let result = rule.check(&ctx).unwrap();
2482
2483 assert_eq!(result.len(), 1, "Should flag 'test' in continuation line: {result:?}");
2485 assert_eq!(result[0].line, 3);
2486 }
2487
2488 #[test]
2489 fn test_frontmatter_quoted_values_checked() {
2490 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2492
2493 let content = "---\ntitle: \"a test title\"\n---\n\nBody\n";
2494 let ctx = create_context(content);
2495 let result = rule.check(&ctx).unwrap();
2496
2497 assert_eq!(result.len(), 1, "Should flag 'test' in quoted YAML value: {result:?}");
2498 assert_eq!(result[0].line, 2);
2499 }
2500
2501 #[test]
2502 fn test_frontmatter_single_quoted_values_checked() {
2503 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2505
2506 let content = "---\ntitle: 'a test title'\n---\n\nBody\n";
2507 let ctx = create_context(content);
2508 let result = rule.check(&ctx).unwrap();
2509
2510 assert_eq!(
2511 result.len(),
2512 1,
2513 "Should flag 'test' in single-quoted YAML value: {result:?}"
2514 );
2515 assert_eq!(result[0].line, 2);
2516 }
2517
2518 #[test]
2519 fn test_frontmatter_fix_multiword_values() {
2520 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2522
2523 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2524 let ctx = create_context(content);
2525 let fixed = rule.fix(&ctx).unwrap();
2526
2527 assert_eq!(
2528 fixed,
2529 "---\ndescription: Learn JavaScript and TypeScript\n---\n\nBody\n"
2530 );
2531 }
2532
2533 #[test]
2534 fn test_frontmatter_fix_preserves_yaml_structure() {
2535 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2537
2538 let content = "---\ntags:\n - test\n - other\ntitle: a test doc\n---\n\ntest body\n";
2539 let ctx = create_context(content);
2540 let fixed = rule.fix(&ctx).unwrap();
2541
2542 assert_eq!(
2543 fixed,
2544 "---\ntags:\n - Test\n - other\ntitle: a Test doc\n---\n\nTest body\n"
2545 );
2546 }
2547
2548 #[test]
2549 fn test_frontmatter_toml_delimiters_not_checked() {
2550 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2552
2553 let content = "+++\ntitle = \"a test title\"\n+++\n\ntest body\n";
2554 let ctx = create_context(content);
2555 let result = rule.check(&ctx).unwrap();
2556
2557 assert_eq!(result.len(), 2, "Should flag TOML value and body: {result:?}");
2561 let fm_violations: Vec<_> = result.iter().filter(|w| w.line == 2).collect();
2562 assert_eq!(fm_violations.len(), 1, "Should flag 'test' in TOML value: {result:?}");
2563 let body_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2564 assert_eq!(body_violations.len(), 1, "Should flag body 'test': {result:?}");
2565 }
2566
2567 #[test]
2568 fn test_frontmatter_toml_key_not_flagged() {
2569 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2571
2572 let content = "+++\ntest = \"other value\"\n+++\n\nBody text\n";
2573 let ctx = create_context(content);
2574 let result = rule.check(&ctx).unwrap();
2575
2576 assert!(
2577 result.is_empty(),
2578 "Should not flag TOML key that matches configured name: {result:?}"
2579 );
2580 }
2581
2582 #[test]
2583 fn test_frontmatter_toml_fix_preserves_keys() {
2584 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2586
2587 let content = "+++\ntest = \"a test value\"\n+++\n\ntest here\n";
2588 let ctx = create_context(content);
2589 let fixed = rule.fix(&ctx).unwrap();
2590
2591 assert_eq!(fixed, "+++\ntest = \"a Test value\"\n+++\n\nTest here\n");
2593 }
2594
2595 #[test]
2596 fn test_frontmatter_list_item_mapping_key_not_flagged() {
2597 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2600
2601 let content = "---\nitems:\n - test: nested value\n---\n\nBody text\n";
2602 let ctx = create_context(content);
2603 let result = rule.check(&ctx).unwrap();
2604
2605 assert!(
2606 result.is_empty(),
2607 "Should not flag YAML key in list-item mapping: {result:?}"
2608 );
2609 }
2610
2611 #[test]
2612 fn test_frontmatter_list_item_mapping_value_flagged() {
2613 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2615
2616 let content = "---\nitems:\n - key: a test value\n---\n\nBody text\n";
2617 let ctx = create_context(content);
2618 let result = rule.check(&ctx).unwrap();
2619
2620 assert_eq!(
2621 result.len(),
2622 1,
2623 "Should flag 'test' in list-item mapping value: {result:?}"
2624 );
2625 assert_eq!(result[0].line, 3);
2626 }
2627
2628 #[test]
2629 fn test_frontmatter_bare_list_item_still_flagged() {
2630 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2632
2633 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2634 let ctx = create_context(content);
2635 let result = rule.check(&ctx).unwrap();
2636
2637 assert_eq!(result.len(), 1, "Should flag 'test' in bare list item: {result:?}");
2638 assert_eq!(result[0].line, 3);
2639 }
2640
2641 #[test]
2642 fn test_frontmatter_flow_mapping_not_flagged() {
2643 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2646
2647 let content = "---\nflow_map: {test: value, other: test}\n---\n\nBody text\n";
2648 let ctx = create_context(content);
2649 let result = rule.check(&ctx).unwrap();
2650
2651 assert!(
2652 result.is_empty(),
2653 "Should not flag names inside flow mappings: {result:?}"
2654 );
2655 }
2656
2657 #[test]
2658 fn test_frontmatter_flow_sequence_not_flagged() {
2659 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2661
2662 let content = "---\nitems: [test, other, test]\n---\n\nBody text\n";
2663 let ctx = create_context(content);
2664 let result = rule.check(&ctx).unwrap();
2665
2666 assert!(
2667 result.is_empty(),
2668 "Should not flag names inside flow sequences: {result:?}"
2669 );
2670 }
2671
2672 #[test]
2673 fn test_frontmatter_list_item_mapping_fix_preserves_key() {
2674 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2676
2677 let content = "---\nitems:\n - test: a test value\n---\n\ntest here\n";
2678 let ctx = create_context(content);
2679 let fixed = rule.fix(&ctx).unwrap();
2680
2681 assert_eq!(fixed, "---\nitems:\n - test: a Test value\n---\n\nTest here\n");
2684 }
2685
2686 #[test]
2687 fn test_frontmatter_backtick_code_not_flagged() {
2688 let config = MD044Config {
2690 names: vec!["GoodApplication".to_string()],
2691 code_blocks: false,
2692 ..MD044Config::default()
2693 };
2694 let rule = MD044ProperNames::from_config_struct(config);
2695
2696 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2697 let ctx = create_context(content);
2698 let result = rule.check(&ctx).unwrap();
2699
2700 assert!(
2702 result.is_empty(),
2703 "Should not flag names inside backticks in frontmatter or body: {result:?}"
2704 );
2705 }
2706
2707 #[test]
2708 fn test_frontmatter_unquoted_backtick_code_not_flagged() {
2709 let config = MD044Config {
2711 names: vec!["GoodApplication".to_string()],
2712 code_blocks: false,
2713 ..MD044Config::default()
2714 };
2715 let rule = MD044ProperNames::from_config_struct(config);
2716
2717 let content = "---\ntitle: `goodapplication` CLI\n---\n\nIntroductory `goodapplication` CLI text.\n";
2718 let ctx = create_context(content);
2719 let result = rule.check(&ctx).unwrap();
2720
2721 assert!(
2722 result.is_empty(),
2723 "Should not flag names inside backticks in unquoted YAML frontmatter: {result:?}"
2724 );
2725 }
2726
2727 #[test]
2728 fn test_frontmatter_bare_name_still_flagged_with_backtick_nearby() {
2729 let config = MD044Config {
2731 names: vec!["GoodApplication".to_string()],
2732 code_blocks: false,
2733 ..MD044Config::default()
2734 };
2735 let rule = MD044ProperNames::from_config_struct(config);
2736
2737 let content = "---\ntitle: goodapplication `goodapplication` CLI\n---\n\nBody\n";
2738 let ctx = create_context(content);
2739 let result = rule.check(&ctx).unwrap();
2740
2741 assert_eq!(
2743 result.len(),
2744 1,
2745 "Should flag bare name but not backtick-wrapped name: {result:?}"
2746 );
2747 assert_eq!(result[0].line, 2);
2748 assert_eq!(result[0].column, 8); }
2750
2751 #[test]
2752 fn test_frontmatter_backtick_code_with_code_blocks_true() {
2753 let config = MD044Config {
2755 names: vec!["GoodApplication".to_string()],
2756 code_blocks: true,
2757 ..MD044Config::default()
2758 };
2759 let rule = MD044ProperNames::from_config_struct(config);
2760
2761 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nBody\n";
2762 let ctx = create_context(content);
2763 let result = rule.check(&ctx).unwrap();
2764
2765 assert_eq!(
2767 result.len(),
2768 1,
2769 "Should flag backtick-wrapped name when code_blocks=true: {result:?}"
2770 );
2771 assert_eq!(result[0].line, 2);
2772 }
2773
2774 #[test]
2775 fn test_frontmatter_fix_preserves_backtick_code() {
2776 let config = MD044Config {
2778 names: vec!["GoodApplication".to_string()],
2779 code_blocks: false,
2780 ..MD044Config::default()
2781 };
2782 let rule = MD044ProperNames::from_config_struct(config);
2783
2784 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2785 let ctx = create_context(content);
2786 let fixed = rule.fix(&ctx).unwrap();
2787
2788 assert_eq!(
2790 fixed, content,
2791 "Fix should not modify names inside backticks in frontmatter"
2792 );
2793 }
2794
2795 fn rule_ignoring(names: &[&str], ignore: &[&str]) -> MD044ProperNames {
2796 MD044ProperNames::from_config_struct(MD044Config {
2797 names: names.iter().map(ToString::to_string).collect(),
2798 ignore_frontmatter_fields: Some(ignore.iter().map(ToString::to_string).collect()),
2799 ..Default::default()
2800 })
2801 }
2802
2803 #[test]
2804 fn test_ignore_frontmatter_field_suppresses_only_that_field() {
2805 let content = "---\ntitle: Heading for myapp\nslug: myapp-guide\n---\n";
2806 let rule = rule_ignoring(&["MyApp"], &["slug"]);
2807 let result = rule.check(&create_context(content)).unwrap();
2808 assert_eq!(result.len(), 1, "only title is flagged: {result:?}");
2809 assert_eq!(result[0].line, 2);
2810 }
2811
2812 #[test]
2813 fn test_ignore_frontmatter_field_is_case_insensitive() {
2814 let content = "---\nSlug: myapp-guide\n---\n";
2815 let rule = rule_ignoring(&["MyApp"], &["SLUG"]);
2816 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2817 }
2818
2819 #[test]
2820 fn test_ignore_frontmatter_field_covers_nested_subtree() {
2821 let content = "---\nseo:\n canonical: myapp\n keywords:\n - myapp\n---\n";
2822 let rule = rule_ignoring(&["MyApp"], &["seo"]);
2823 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2824 }
2825
2826 #[test]
2827 fn test_ignore_frontmatter_field_does_not_affect_body() {
2828 let content = "---\nslug: myapp\n---\n\nBody mentions myapp.\n";
2829 let rule = rule_ignoring(&["MyApp"], &["slug"]);
2830 let result = rule.check(&create_context(content)).unwrap();
2831 assert_eq!(result.len(), 1);
2832 assert_eq!(result[0].line, 5);
2833 }
2834
2835 #[test]
2836 fn test_ignore_frontmatter_field_toml_table() {
2837 let content = "+++\n[seo]\ncanonical = \"myapp\"\n+++\n";
2838 let rule = rule_ignoring(&["MyApp"], &["seo"]);
2839 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2840 }
2841
2842 #[test]
2845 fn test_angle_bracket_url_in_html_comment_not_flagged() {
2846 let config = MD044Config {
2848 names: vec!["Test".to_string()],
2849 ..MD044Config::default()
2850 };
2851 let rule = MD044ProperNames::from_config_struct(config);
2852
2853 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";
2854 let ctx = create_context(content);
2855 let result = rule.check(&ctx).unwrap();
2856
2857 let line8_warnings: Vec<_> = result.iter().filter(|w| w.line == 8).collect();
2865 assert!(
2866 line8_warnings.is_empty(),
2867 "Should not flag names inside angle-bracket URLs in HTML comments: {line8_warnings:?}"
2868 );
2869 }
2870
2871 #[test]
2872 fn test_bare_url_in_html_comment_still_flagged() {
2873 let config = MD044Config {
2875 names: vec!["Test".to_string()],
2876 ..MD044Config::default()
2877 };
2878 let rule = MD044ProperNames::from_config_struct(config);
2879
2880 let content = "<!-- This is a test https://www.example.test -->\n";
2881 let ctx = create_context(content);
2882 let result = rule.check(&ctx).unwrap();
2883
2884 assert!(
2887 !result.is_empty(),
2888 "Should flag 'test' in prose text of HTML comment with bare URL"
2889 );
2890 }
2891
2892 #[test]
2893 fn test_angle_bracket_url_in_regular_markdown_not_flagged() {
2894 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2897
2898 let content = "<https://www.example.test>\n";
2899 let ctx = create_context(content);
2900 let result = rule.check(&ctx).unwrap();
2901
2902 assert!(
2903 result.is_empty(),
2904 "Should not flag names inside angle-bracket URLs in regular markdown: {result:?}"
2905 );
2906 }
2907
2908 #[test]
2909 fn test_multiple_angle_bracket_urls_in_one_comment() {
2910 let config = MD044Config {
2911 names: vec!["Test".to_string()],
2912 ..MD044Config::default()
2913 };
2914 let rule = MD044ProperNames::from_config_struct(config);
2915
2916 let content = "<!-- See <https://test.example.com> and <https://www.example.test> for details -->\n";
2917 let ctx = create_context(content);
2918 let result = rule.check(&ctx).unwrap();
2919
2920 assert!(
2922 result.is_empty(),
2923 "Should not flag names inside multiple angle-bracket URLs: {result:?}"
2924 );
2925 }
2926
2927 #[test]
2928 fn test_angle_bracket_non_url_still_flagged() {
2929 assert!(
2932 !MD044ProperNames::is_in_angle_bracket_url("<test> which is not a URL.", 1),
2933 "is_in_angle_bracket_url should return false for non-URL angle brackets"
2934 );
2935 }
2936
2937 #[test]
2938 fn test_angle_bracket_mailto_url_not_flagged() {
2939 let config = MD044Config {
2940 names: vec!["Test".to_string()],
2941 ..MD044Config::default()
2942 };
2943 let rule = MD044ProperNames::from_config_struct(config);
2944
2945 let content = "<!-- Contact <mailto:test@example.com> for help -->\n";
2946 let ctx = create_context(content);
2947 let result = rule.check(&ctx).unwrap();
2948
2949 assert!(
2950 result.is_empty(),
2951 "Should not flag names inside angle-bracket mailto URLs: {result:?}"
2952 );
2953 }
2954
2955 #[test]
2956 fn test_angle_bracket_ftp_url_not_flagged() {
2957 let config = MD044Config {
2958 names: vec!["Test".to_string()],
2959 ..MD044Config::default()
2960 };
2961 let rule = MD044ProperNames::from_config_struct(config);
2962
2963 let content = "<!-- Download from <ftp://test.example.com/file> -->\n";
2964 let ctx = create_context(content);
2965 let result = rule.check(&ctx).unwrap();
2966
2967 assert!(
2968 result.is_empty(),
2969 "Should not flag names inside angle-bracket FTP URLs: {result:?}"
2970 );
2971 }
2972
2973 #[test]
2974 fn test_angle_bracket_url_fix_preserves_url() {
2975 let config = MD044Config {
2977 names: vec!["Test".to_string()],
2978 ..MD044Config::default()
2979 };
2980 let rule = MD044ProperNames::from_config_struct(config);
2981
2982 let content = "<!-- test text <https://www.example.test> -->\n";
2983 let ctx = create_context(content);
2984 let fixed = rule.fix(&ctx).unwrap();
2985
2986 assert!(
2988 fixed.contains("<https://www.example.test>"),
2989 "Fix should preserve angle-bracket URLs: {fixed}"
2990 );
2991 assert!(
2992 fixed.contains("Test text"),
2993 "Fix should correct prose 'test' to 'Test': {fixed}"
2994 );
2995 }
2996
2997 #[test]
2998 fn test_is_in_angle_bracket_url_helper() {
2999 let line = "text <https://example.test> more text";
3001
3002 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));
3015
3016 assert!(MD044ProperNames::is_in_angle_bracket_url(
3018 "<mailto:test@example.com>",
3019 10
3020 ));
3021
3022 assert!(MD044ProperNames::is_in_angle_bracket_url(
3024 "<ftp://test.example.com>",
3025 10
3026 ));
3027 }
3028
3029 #[test]
3030 fn test_is_in_angle_bracket_url_uppercase_scheme() {
3031 assert!(MD044ProperNames::is_in_angle_bracket_url(
3033 "<HTTPS://test.example.com>",
3034 10
3035 ));
3036 assert!(MD044ProperNames::is_in_angle_bracket_url(
3037 "<Http://test.example.com>",
3038 10
3039 ));
3040 }
3041
3042 #[test]
3043 fn test_is_in_angle_bracket_url_uncommon_schemes() {
3044 assert!(MD044ProperNames::is_in_angle_bracket_url(
3046 "<ssh://test@example.com>",
3047 10
3048 ));
3049 assert!(MD044ProperNames::is_in_angle_bracket_url("<file:///test/path>", 10));
3051 assert!(MD044ProperNames::is_in_angle_bracket_url("<data:text/plain;test>", 10));
3053 }
3054
3055 #[test]
3056 fn test_is_in_angle_bracket_url_unclosed() {
3057 assert!(!MD044ProperNames::is_in_angle_bracket_url(
3059 "<https://test.example.com",
3060 10
3061 ));
3062 }
3063
3064 #[test]
3065 fn test_vale_inline_config_comments_not_flagged() {
3066 let config = MD044Config {
3067 names: vec!["Vale".to_string(), "JavaScript".to_string()],
3068 ..MD044Config::default()
3069 };
3070 let rule = MD044ProperNames::from_config_struct(config);
3071
3072 let content = "\
3073<!-- vale off -->
3074Some javascript text here.
3075<!-- vale on -->
3076<!-- vale Style.Rule = NO -->
3077More javascript text.
3078<!-- vale Style.Rule = YES -->
3079<!-- vale JavaScript.Grammar = NO -->
3080";
3081 let ctx = create_context(content);
3082 let result = rule.check(&ctx).unwrap();
3083
3084 assert_eq!(result.len(), 2, "Should only flag body lines, not Vale config comments");
3086 assert_eq!(result[0].line, 2);
3087 assert_eq!(result[1].line, 5);
3088 }
3089
3090 #[test]
3091 fn test_remark_lint_inline_config_comments_not_flagged() {
3092 let config = MD044Config {
3093 names: vec!["JavaScript".to_string()],
3094 ..MD044Config::default()
3095 };
3096 let rule = MD044ProperNames::from_config_struct(config);
3097
3098 let content = "\
3099<!-- lint disable remark-lint-some-rule -->
3100Some javascript text here.
3101<!-- lint enable remark-lint-some-rule -->
3102<!-- lint ignore remark-lint-some-rule -->
3103More javascript text.
3104";
3105 let ctx = create_context(content);
3106 let result = rule.check(&ctx).unwrap();
3107
3108 assert_eq!(
3109 result.len(),
3110 2,
3111 "Should only flag body lines, not remark-lint config comments"
3112 );
3113 assert_eq!(result[0].line, 2);
3114 assert_eq!(result[1].line, 5);
3115 }
3116
3117 #[test]
3118 fn test_fix_does_not_modify_vale_remark_lint_comments() {
3119 let config = MD044Config {
3120 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3121 ..MD044Config::default()
3122 };
3123 let rule = MD044ProperNames::from_config_struct(config);
3124
3125 let content = "\
3126<!-- vale off -->
3127Some javascript text.
3128<!-- vale on -->
3129<!-- lint disable remark-lint-some-rule -->
3130More javascript text.
3131<!-- lint enable remark-lint-some-rule -->
3132";
3133 let ctx = create_context(content);
3134 let fixed = rule.fix(&ctx).unwrap();
3135
3136 assert!(fixed.contains("<!-- vale off -->"));
3138 assert!(fixed.contains("<!-- vale on -->"));
3139 assert!(fixed.contains("<!-- lint disable remark-lint-some-rule -->"));
3140 assert!(fixed.contains("<!-- lint enable remark-lint-some-rule -->"));
3141 assert!(fixed.contains("Some JavaScript text."));
3143 assert!(fixed.contains("More JavaScript text."));
3144 }
3145
3146 #[test]
3147 fn test_mixed_tool_directives_all_skipped() {
3148 let config = MD044Config {
3149 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3150 ..MD044Config::default()
3151 };
3152 let rule = MD044ProperNames::from_config_struct(config);
3153
3154 let content = "\
3155<!-- rumdl-disable MD044 -->
3156Some javascript text.
3157<!-- markdownlint-disable -->
3158More javascript text.
3159<!-- vale off -->
3160Even more javascript text.
3161<!-- lint disable some-rule -->
3162Final javascript text.
3163<!-- rumdl-enable MD044 -->
3164<!-- markdownlint-enable -->
3165<!-- vale on -->
3166<!-- lint enable some-rule -->
3167";
3168 let ctx = create_context(content);
3169 let result = rule.check(&ctx).unwrap();
3170
3171 assert_eq!(
3173 result.len(),
3174 4,
3175 "Should only flag body lines, not any tool directive comments"
3176 );
3177 assert_eq!(result[0].line, 2);
3178 assert_eq!(result[1].line, 4);
3179 assert_eq!(result[2].line, 6);
3180 assert_eq!(result[3].line, 8);
3181 }
3182
3183 #[test]
3184 fn test_vale_remark_lint_edge_cases_not_matched() {
3185 let config = MD044Config {
3186 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3187 ..MD044Config::default()
3188 };
3189 let rule = MD044ProperNames::from_config_struct(config);
3190
3191 let content = "\
3199<!-- vale -->
3200<!-- vale is a tool for writing -->
3201<!-- valedictorian javascript -->
3202<!-- linting javascript tips -->
3203<!-- vale javascript -->
3204<!-- lint your javascript code -->
3205";
3206 let ctx = create_context(content);
3207 let result = rule.check(&ctx).unwrap();
3208
3209 assert_eq!(
3216 result.len(),
3217 7,
3218 "Should flag proper names in non-directive HTML comments: got {result:?}"
3219 );
3220 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); }
3228
3229 #[test]
3230 fn test_vale_style_directives_skipped() {
3231 let config = MD044Config {
3232 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3233 ..MD044Config::default()
3234 };
3235 let rule = MD044ProperNames::from_config_struct(config);
3236
3237 let content = "\
3239<!-- vale style = MyStyle -->
3240<!-- vale styles = Style1, Style2 -->
3241<!-- vale MyRule.Name = YES -->
3242<!-- vale MyRule.Name = NO -->
3243Some javascript text.
3244";
3245 let ctx = create_context(content);
3246 let result = rule.check(&ctx).unwrap();
3247
3248 assert_eq!(
3250 result.len(),
3251 1,
3252 "Should only flag body lines, not Vale style/rule directives: got {result:?}"
3253 );
3254 assert_eq!(result[0].line, 5);
3255 }
3256
3257 #[test]
3260 fn test_backtick_code_single_backticks() {
3261 let line = "hello `world` bye";
3262 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 7));
3264 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3266 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 14));
3268 }
3269
3270 #[test]
3271 fn test_backtick_code_double_backticks() {
3272 let line = "a ``code`` b";
3273 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3275 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3277 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 11));
3279 }
3280
3281 #[test]
3282 fn test_backtick_code_unclosed() {
3283 let line = "a `code b";
3284 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3286 }
3287
3288 #[test]
3289 fn test_backtick_code_mismatched_count() {
3290 let line = "a `code`` b";
3292 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3295 }
3296
3297 #[test]
3298 fn test_backtick_code_multiple_spans() {
3299 let line = "`first` and `second`";
3300 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
3302 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 8));
3304 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 13));
3306 }
3307
3308 #[test]
3309 fn test_backtick_code_on_backtick_boundary() {
3310 let line = "`code`";
3311 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3313 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 5));
3315 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
3317 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3318 }
3319
3320 #[test]
3326 fn test_double_bracket_link_url_not_flagged() {
3327 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3328 let content = "[[rumdl]](https://github.com/rvben/rumdl)";
3330 let ctx = create_context(content);
3331 let result = rule.check(&ctx).unwrap();
3332 assert!(
3333 result.is_empty(),
3334 "URL inside [[text]](url) must not be flagged, got: {result:?}"
3335 );
3336 }
3337
3338 #[test]
3339 fn test_double_bracket_link_url_not_fixed() {
3340 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3341 let content = "[[rumdl]](https://github.com/rvben/rumdl)\n";
3342 let ctx = create_context(content);
3343 let fixed = rule.fix(&ctx).unwrap();
3344 assert_eq!(
3345 fixed, content,
3346 "fix() must leave the URL inside [[text]](url) unchanged"
3347 );
3348 }
3349
3350 #[test]
3351 fn test_double_bracket_link_text_still_flagged() {
3352 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3353 let content = "[[github]](https://example.com)";
3355 let ctx = create_context(content);
3356 let result = rule.check(&ctx).unwrap();
3357 assert_eq!(
3358 result.len(),
3359 1,
3360 "Incorrect name in [[text]] link text should still be flagged, got: {result:?}"
3361 );
3362 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
3363 }
3364
3365 #[test]
3366 fn test_double_bracket_link_mixed_line() {
3367 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3368 let content = "See [[rumdl]](https://github.com/rvben/rumdl) and github for more.";
3370 let ctx = create_context(content);
3371 let result = rule.check(&ctx).unwrap();
3372 assert_eq!(
3373 result.len(),
3374 1,
3375 "Only the standalone 'github' after the link should be flagged, got: {result:?}"
3376 );
3377 assert!(result[0].message.contains("'github'"));
3378 assert_eq!(
3380 result[0].column, 51,
3381 "Flagged column should be the trailing 'github', not the one in the URL"
3382 );
3383 }
3384
3385 #[test]
3386 fn test_regular_link_url_still_not_flagged() {
3387 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3389 let content = "[rumdl](https://github.com/rvben/rumdl)";
3390 let ctx = create_context(content);
3391 let result = rule.check(&ctx).unwrap();
3392 assert!(
3393 result.is_empty(),
3394 "URL inside regular [text](url) must still not be flagged, got: {result:?}"
3395 );
3396 }
3397
3398 #[test]
3399 fn test_link_like_text_in_code_span_still_flagged_when_code_blocks_enabled() {
3400 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
3405 let content = "`[foo](https://github.com/org/repo)`";
3406 let ctx = create_context(content);
3407 let result = rule.check(&ctx).unwrap();
3408 assert_eq!(
3409 result.len(),
3410 1,
3411 "Proper name inside a code span must be flagged when code-blocks=true, got: {result:?}"
3412 );
3413 assert!(result[0].message.contains("'github'"));
3414 }
3415
3416 #[test]
3417 fn test_malformed_link_not_treated_as_url() {
3418 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3421 let content = "See [rumdl](github repo) for details.";
3422 let ctx = create_context(content);
3423 let result = rule.check(&ctx).unwrap();
3424 assert_eq!(
3425 result.len(),
3426 1,
3427 "Name inside malformed [text](url with spaces) must still be flagged, got: {result:?}"
3428 );
3429 assert!(result[0].message.contains("'github'"));
3430 }
3431
3432 #[test]
3433 fn test_wikilink_followed_by_prose_parens_still_flagged() {
3434 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3438 let content = "[[note]](github repo)";
3439 let ctx = create_context(content);
3440 let result = rule.check(&ctx).unwrap();
3441 assert_eq!(
3442 result.len(),
3443 1,
3444 "Name inside [[wikilink]](prose with spaces) must still be flagged, got: {result:?}"
3445 );
3446 assert!(result[0].message.contains("'github'"));
3447 }
3448
3449 #[test]
3451 fn test_roundtrip_fix_then_check_basic() {
3452 let rule = MD044ProperNames::new(
3453 vec![
3454 "JavaScript".to_string(),
3455 "TypeScript".to_string(),
3456 "Node.js".to_string(),
3457 ],
3458 true,
3459 );
3460 let content = "I love javascript, typescript, and nodejs!";
3461 let ctx = create_context(content);
3462 let fixed = rule.fix(&ctx).unwrap();
3463 let ctx2 = create_context(&fixed);
3464 let warnings = rule.check(&ctx2).unwrap();
3465 assert!(
3466 warnings.is_empty(),
3467 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3468 );
3469 }
3470
3471 #[test]
3473 fn test_roundtrip_fix_then_check_multiline() {
3474 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
3475 let content = "First line with rust.\nSecond line with python.\nThird line with RUST and PYTHON.\n";
3476 let ctx = create_context(content);
3477 let fixed = rule.fix(&ctx).unwrap();
3478 let ctx2 = create_context(&fixed);
3479 let warnings = rule.check(&ctx2).unwrap();
3480 assert!(
3481 warnings.is_empty(),
3482 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3483 );
3484 }
3485
3486 #[test]
3488 fn test_roundtrip_fix_then_check_inline_config() {
3489 let config = MD044Config {
3490 names: vec!["RUMDL".to_string()],
3491 ..MD044Config::default()
3492 };
3493 let rule = MD044ProperNames::from_config_struct(config);
3494 let content =
3495 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
3496 let ctx = create_context(content);
3497 let fixed = rule.fix(&ctx).unwrap();
3498 assert!(
3500 fixed.contains("Some rumdl text.\n"),
3501 "Disabled block text should be preserved"
3502 );
3503 assert!(
3504 fixed.contains("Some RUMDL text outside."),
3505 "Outside text should be fixed"
3506 );
3507 }
3508
3509 #[test]
3511 fn test_roundtrip_fix_then_check_html_comments() {
3512 let config = MD044Config {
3513 names: vec!["JavaScript".to_string()],
3514 ..MD044Config::default()
3515 };
3516 let rule = MD044ProperNames::from_config_struct(config);
3517 let content = "# Guide\n\n<!-- javascript mentioned here -->\n\njavascript outside\n";
3518 let ctx = create_context(content);
3519 let fixed = rule.fix(&ctx).unwrap();
3520 let ctx2 = create_context(&fixed);
3521 let warnings = rule.check(&ctx2).unwrap();
3522 assert!(
3523 warnings.is_empty(),
3524 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3525 );
3526 }
3527
3528 #[test]
3530 fn test_roundtrip_no_op_when_correct() {
3531 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
3532 let content = "This uses JavaScript and TypeScript correctly.\n";
3533 let ctx = create_context(content);
3534 let fixed = rule.fix(&ctx).unwrap();
3535 assert_eq!(fixed, content, "Fix should be a no-op when content is already correct");
3536 }
3537
3538 #[test]
3541 fn test_bare_domain_link_text_not_flagged() {
3542 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3546 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3547 let ctx = create_context(content);
3548 let result = rule.check(&ctx).unwrap();
3549 assert!(
3550 result.is_empty(),
3551 "Should not flag 'github' in a bare-domain link text that matches the link URL: {result:?}"
3552 );
3553 }
3554
3555 #[test]
3556 fn test_bare_domain_link_text_not_fixed() {
3557 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3559 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3560 let ctx = create_context(content);
3561 let fixed = rule.fix(&ctx).unwrap();
3562 assert_eq!(
3563 fixed, content,
3564 "fix() must not alter bare-domain link text that matches the destination URL"
3565 );
3566 }
3567
3568 #[test]
3569 fn test_bare_domain_link_text_with_path_not_flagged() {
3570 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3572 let content = "Visit [ravencentric.github.io](https://ravencentric.github.io/projects).\n";
3573 let ctx = create_context(content);
3574 let result = rule.check(&ctx).unwrap();
3575 assert!(
3576 result.is_empty(),
3577 "Should not flag 'github' when bare-domain text is the hostname of its destination URL: {result:?}"
3578 );
3579 }
3580
3581 #[test]
3582 fn test_bare_domain_link_text_full_path_not_flagged() {
3583 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3585 let content = "See [ravencentric.github.io/blog](https://ravencentric.github.io/blog).\n";
3586 let ctx = create_context(content);
3587 let result = rule.check(&ctx).unwrap();
3588 assert!(
3589 result.is_empty(),
3590 "Should not flag 'github' when link text is the full URL path without scheme: {result:?}"
3591 );
3592 }
3593
3594 #[test]
3595 fn test_github_product_name_in_link_text_still_flagged() {
3596 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3599 let content = "Hosted on [github pages](https://pages.github.com).\n";
3600 let ctx = create_context(content);
3601 let result = rule.check(&ctx).unwrap();
3602 assert!(
3603 !result.is_empty(),
3604 "Should still flag 'github' in descriptive link text that does not match the destination URL"
3605 );
3606 }
3607
3608 #[test]
3609 fn test_protocol_relative_bare_domain_link_text_not_flagged() {
3610 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3612 let content = "See [github.io](//github.io).\n";
3613 let ctx = create_context(content);
3614 let result = rule.check(&ctx).unwrap();
3615 assert!(
3616 result.is_empty(),
3617 "Should not flag 'github' in bare-domain text matching a protocol-relative destination: {result:?}"
3618 );
3619 }
3620
3621 #[test]
3622 fn test_dotted_wikilink_target_still_flagged() {
3623 let rule = MD044ProperNames::new(vec!["Node.js".to_string()], false);
3628 let content = "See [[node.js]] for details.\n";
3629 let ctx = create_context(content);
3630 let result = rule.check(&ctx).unwrap();
3631 assert!(
3632 !result.is_empty(),
3633 "Should flag 'node.js' in a dotted WikiLink target: {result:?}"
3634 );
3635 }
3636
3637 #[test]
3638 fn test_bare_domain_link_text_case_insensitive_url() {
3639 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3642 let content = "See [github.io](HTTPS://github.io).\n";
3643 let ctx = create_context(content);
3644 let result = rule.check(&ctx).unwrap();
3645 assert!(
3646 result.is_empty(),
3647 "Should not flag bare-domain text when destination URL has an uppercase scheme: {result:?}"
3648 );
3649 }
3650
3651 #[test]
3652 fn test_frontmatter_value_span_strips_trailing_comment() {
3653 let line = "link: docs/guide/myapp # canonical path";
3654 let (s, e) = frontmatter_values::value_span(line).unwrap();
3655 assert_eq!(&line[s..e], "docs/guide/myapp");
3656 }
3657
3658 #[test]
3659 fn test_frontmatter_value_span_quoted_keeps_hash_and_spaces() {
3660 let line = "link: 'docs/My App/a#b'";
3661 let (s, e) = frontmatter_values::value_span(line).unwrap();
3662 assert_eq!(&line[s..e], "docs/My App/a#b");
3663 }
3664
3665 #[test]
3666 fn test_frontmatter_value_span_plain_value() {
3667 let line = "title: Heading for myapp";
3668 let (s, e) = frontmatter_values::value_span(line).unwrap();
3669 assert_eq!(&line[s..e], "Heading for myapp");
3670 }
3671
3672 #[test]
3673 fn test_frontmatter_value_span_none_for_key_only() {
3674 assert!(frontmatter_values::value_span("seo:").is_none());
3675 assert!(frontmatter_values::value_span("---").is_none());
3676 }
3677
3678 #[test]
3679 fn test_frontmatter_value_span_quoted_strips_trailing_comment() {
3680 let line = "link: 'docs/guide' # canonical path";
3681 let (s, e) = frontmatter_values::value_span(line).unwrap();
3682 assert_eq!(&line[s..e], "docs/guide");
3683 }
3684
3685 #[test]
3686 fn test_frontmatter_value_span_empty_quoted_value_is_none() {
3687 assert!(frontmatter_values::value_span("key: ''").is_none());
3688 }
3689
3690 #[test]
3691 fn test_frontmatter_value_span_unterminated_quote_strips_leading_quote() {
3692 let line = "link: 'docs/a";
3693 let (s, e) = frontmatter_values::value_span(line).unwrap();
3694 assert_eq!(&line[s..e], "docs/a");
3695 }
3696
3697 fn at(line: &str, needle: &str) -> usize {
3699 line.find(needle).expect("needle present")
3700 }
3701
3702 #[test]
3703 fn test_path_like_exempts_single_token_frontmatter_paths() {
3704 for line in [
3705 "link: this/is/a/link/to/myapp.md",
3706 "link: docs/myapp.md",
3707 "link: /abs/path/myapp.md",
3708 "link: ./myapp.md",
3709 "link: ../shared/myapp.md",
3710 ] {
3711 let span = frontmatter_values::value_span(line).unwrap();
3712 let pos = at(line, "myapp");
3713 assert!(
3714 MD044ProperNames::is_in_path_like_token(line, pos, span),
3715 "should treat as a path: {line}"
3716 );
3717 }
3718 }
3719
3720 #[test]
3721 fn test_path_like_does_not_exempt_slash_conjunction_prose() {
3722 let line = "description: We support github/gitlab/bitbucket imports.";
3725 let span = frontmatter_values::value_span(line).unwrap();
3726 assert!(
3727 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
3728 "slash-separated prose is not a path"
3729 );
3730
3731 let line = "description: The javascript/typescript ecosystem is large.";
3732 let span = frontmatter_values::value_span(line).unwrap();
3733 assert!(!MD044ProperNames::is_in_path_like_token(
3734 line,
3735 at(line, "javascript"),
3736 span
3737 ));
3738 }
3739
3740 #[test]
3741 fn test_path_like_requires_a_slash_so_dotted_names_survive() {
3742 let line = "title: Use nodejs and myapp.md today.";
3743 let span = frontmatter_values::value_span(line).unwrap();
3744 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3745 }
3746
3747 #[test]
3748 fn test_path_like_no_slash_frontmatter_value_still_flagged() {
3749 let line = "slug: myapp-guide";
3754 let span = frontmatter_values::value_span(line).unwrap();
3755 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3756 }
3757
3758 #[test]
3759 fn test_path_like_returns_false_outside_value_span() {
3760 let line = "myapp: docs/guide/myapp";
3763 let span = frontmatter_values::value_span(line).unwrap();
3764 let key_pos = 0;
3765 assert!(!MD044ProperNames::is_in_path_like_token(line, key_pos, span));
3766 }
3767
3768 #[test]
3769 fn test_path_like_three_segments_only_as_sole_frontmatter_value() {
3770 let line = "link: docs/guide/myapp";
3771 let span = frontmatter_values::value_span(line).unwrap();
3772 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3773
3774 let line = "description: We support github/gitlab/bitbucket now";
3775 let span = frontmatter_values::value_span(line).unwrap();
3776 assert!(
3777 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
3778 "multi-token value gets body treatment"
3779 );
3780 }
3781
3782 #[test]
3783 fn test_path_like_quoted_value_with_spaces() {
3784 let line = "link: 'docs/My App/myapp.md'";
3787 let span = frontmatter_values::value_span(line).unwrap();
3788 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3789 }
3790
3791 #[test]
3792 fn test_path_like_quoted_value_with_spaces_no_extension_not_exempt() {
3793 let line = "link: 'docs/My App/myapp'";
3800 let span = frontmatter_values::value_span(line).unwrap();
3801 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3802 }
3803
3804 #[test]
3805 fn test_path_like_trailing_comment_is_still_sole_value() {
3806 let line = "link: docs/guide/myapp # canonical path";
3807 let span = frontmatter_values::value_span(line).unwrap();
3808 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3809 }
3810
3811 #[test]
3812 fn test_path_like_trailing_punctuation_trimmed() {
3813 let line = "link: docs/myapp.md, then leave.";
3814 let span = frontmatter_values::value_span(line).unwrap();
3815 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3816 }
3817
3818 #[test]
3819 fn test_trim_token_bounds_reaches_fixpoint_after_punctuation_exposes_wrapper() {
3820 let line = r#"See "docs/myapp.md", then leave."#;
3821 let raw_start = at(line, "\"docs");
3822 let raw_end = raw_start + r#""docs/myapp.md","#.len();
3823 assert_eq!(&line[raw_start..raw_end], r#""docs/myapp.md","#);
3824 let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
3825 assert_eq!(&line[start..end], "docs/myapp.md");
3826 }
3827
3828 #[test]
3829 fn test_trim_token_bounds_reaches_fixpoint_with_multiple_trailing_wrappers() {
3830 let line = r#"("docs/myapp.md")."#;
3831 let (start, end) = frontmatter_values::trim_token_bounds(line, 0, line.len());
3832 assert_eq!(&line[start..end], "docs/myapp.md");
3833 }
3834
3835 #[test]
3836 fn test_frontmatter_link_path_not_flagged() {
3837 let content = "---\ntitle: Heading for MyApp\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
3838 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3839 let ctx = create_context(content);
3840 let result = rule.check(&ctx).unwrap();
3841 assert!(
3842 result.is_empty(),
3843 "path in a frontmatter value must not be flagged: {result:?}"
3844 );
3845 }
3846
3847 #[test]
3848 fn test_fix_does_not_corrupt_frontmatter_link_path() {
3849 let content = "---\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
3850 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3851 let ctx = create_context(content);
3852 assert_eq!(rule.fix(&ctx).unwrap(), content, "fix must not rewrite a path");
3853 }
3854
3855 #[test]
3866 fn test_body_prose_parenthesized_disambiguator_is_case_corrected() {
3867 let content = "See docs/myapp(1).md here.\n";
3868 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3869 let ctx = create_context(content);
3870 let result = rule.check(&ctx).unwrap();
3871 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3872 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp(1).md here.\n");
3873 }
3874
3875 #[test]
3876 fn test_body_prose_bracketed_dynamic_segment_is_case_corrected() {
3877 let content = "See docs/[myapp].md here.\n";
3878 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3879 let ctx = create_context(content);
3880 let result = rule.check(&ctx).unwrap();
3881 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3882 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/[MyApp].md here.\n");
3883 }
3884
3885 #[test]
3886 fn test_body_prose_nextjs_catch_all_segment_is_case_corrected() {
3887 let content = "pages/[[...myapp]].tsx are catch-all routes.\n";
3890 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3891 let ctx = create_context(content);
3892 let result = rule.check(&ctx).unwrap();
3893 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3894 assert_eq!(
3895 rule.fix(&ctx).unwrap(),
3896 "pages/[[...MyApp]].tsx are catch-all routes.\n"
3897 );
3898 }
3899
3900 #[test]
3901 fn test_two_adjacent_whitespace_free_links_both_flagged() {
3902 let content = "[myapp](https://a.com)[github](https://b.com)\n";
3906 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitHub".to_string()], false);
3907 let ctx = create_context(content);
3908 let result = rule.check(&ctx).unwrap();
3909 assert_eq!(result.len(), 2, "both link texts must be flagged: {result:?}");
3910 assert!(result.iter().any(|w| w.message.contains("'myapp'")));
3911 assert!(result.iter().any(|w| w.message.contains("'github'")));
3912 }
3913
3914 #[test]
3915 fn test_fix_does_not_corrupt_frontmatter_path_with_route_group_named_after_proper_name() {
3916 let content = "---\nlink: src/(myapp)/page.tsx\n---\n\nBody.\n";
3919 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3920 let ctx = create_context(content);
3921 assert_eq!(
3922 rule.fix(&ctx).unwrap(),
3923 content,
3924 "fix must not rewrite a frontmatter path whose route-group directory name is the proper name"
3925 );
3926 }
3927
3928 #[test]
3929 fn test_quoted_frontmatter_value_slash_conjunction_prose_still_flagged() {
3930 let content = "---\ndescription: \"We support github/gitlab/bitbucket now\"\n---\n\nBody.\n";
3935 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3936 let ctx = create_context(content);
3937 let result = rule.check(&ctx).unwrap();
3938 assert_eq!(
3939 result.len(),
3940 1,
3941 "quoted prose value must still flag 'github': {result:?}"
3942 );
3943 }
3944
3945 #[test]
3946 fn test_quoted_frontmatter_value_single_slash_word_with_unrelated_dot_still_flagged() {
3947 let content = "---\ndescription: \"We use myapp/gitlab and version 1.0 e.g. weekly\"\n---\n\nBody.\n";
3950 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3951 let ctx = create_context(content);
3952 let result = rule.check(&ctx).unwrap();
3953 assert_eq!(
3954 result.len(),
3955 1,
3956 "quoted prose value must still flag 'myapp': {result:?}"
3957 );
3958 }
3959
3960 #[test]
3961 fn test_quoted_toml_frontmatter_value_slash_conjunction_prose_still_flagged() {
3962 let content = "+++\ndescription = \"We support github/gitlab/bitbucket now\"\n+++\n\nBody.\n";
3965 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3966 let ctx = create_context(content);
3967 let result = rule.check(&ctx).unwrap();
3968 assert_eq!(
3969 result.len(),
3970 1,
3971 "TOML quoted prose value must still flag 'github': {result:?}"
3972 );
3973 }
3974
3975 #[test]
3976 fn test_path_like_collapsed_multiword_no_extension_not_exempt() {
3977 for line in [
3984 r#"description: "myapp/gitlab github/bitbucket""#,
3985 r#"description: "and/or this/that myapp/gitlab""#,
3986 r#"description: "he/him she/her myapp/gitlab""#,
3987 ] {
3988 let span = frontmatter_values::value_span(line).unwrap();
3989 for needle in ["myapp", "gitlab"] {
3990 if let Some(byte_pos) = line.find(needle) {
3991 assert!(
3992 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
3993 "collapsed multi-word value must not exempt '{needle}': {line}"
3994 );
3995 }
3996 }
3997 }
3998 }
3999
4000 #[test]
4001 fn test_path_like_collapsed_multiword_no_extension_not_exempt_toml() {
4002 let line = r#"description = "myapp/gitlab github/bitbucket""#;
4003 let span = frontmatter_values::value_span(line).unwrap();
4004 for needle in ["myapp", "gitlab", "github", "bitbucket"] {
4005 let byte_pos = at(line, needle);
4006 assert!(
4007 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
4008 "collapsed multi-word TOML value must not exempt '{needle}'"
4009 );
4010 }
4011 }
4012
4013 #[test]
4014 fn test_frontmatter_collapsed_multiword_names_all_flagged_yaml() {
4015 let content = "---\ndescription: \"myapp/gitlab github/bitbucket\"\n---\n\nBody.\n";
4016 let rule = MD044ProperNames::new(
4017 vec![
4018 "MyApp".to_string(),
4019 "GitLab".to_string(),
4020 "GitHub".to_string(),
4021 "Bitbucket".to_string(),
4022 ],
4023 false,
4024 );
4025 let ctx = create_context(content);
4026 let result = rule.check(&ctx).unwrap();
4027 assert_eq!(
4028 result.len(),
4029 4,
4030 "all four names in the collapsed multi-word value must be flagged: {result:?}"
4031 );
4032 }
4033
4034 #[test]
4035 fn test_frontmatter_collapsed_multiword_names_all_flagged_toml() {
4036 let content = "+++\ndescription = \"myapp/gitlab github/bitbucket\"\n+++\n\nBody.\n";
4037 let rule = MD044ProperNames::new(
4038 vec![
4039 "MyApp".to_string(),
4040 "GitLab".to_string(),
4041 "GitHub".to_string(),
4042 "Bitbucket".to_string(),
4043 ],
4044 false,
4045 );
4046 let ctx = create_context(content);
4047 let result = rule.check(&ctx).unwrap();
4048 assert_eq!(
4049 result.len(),
4050 4,
4051 "all four names in the collapsed multi-word TOML value must be flagged: {result:?}"
4052 );
4053 }
4054
4055 #[test]
4056 fn test_frontmatter_collapsed_multiword_conjunction_pairs_flagged() {
4057 let content = "---\ndescription: \"and/or this/that myapp/gitlab\"\n---\n\nBody.\n";
4058 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4059 let ctx = create_context(content);
4060 let result = rule.check(&ctx).unwrap();
4061 assert_eq!(
4062 result.len(),
4063 2,
4064 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4065 );
4066 }
4067
4068 #[test]
4069 fn test_frontmatter_collapsed_multiword_pronoun_pairs_flagged() {
4070 let content = "---\ndescription: \"he/him she/her myapp/gitlab\"\n---\n\nBody.\n";
4071 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4072 let ctx = create_context(content);
4073 let result = rule.check(&ctx).unwrap();
4074 assert_eq!(
4075 result.len(),
4076 2,
4077 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4078 );
4079 }
4080
4081 #[test]
4087 fn test_body_prose_path_is_flagged_frontmatter_only_scope() {
4088 let content = "See docs/myapp.md for details about myapp.\n";
4089 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4090 let ctx = create_context(content);
4091 let result = rule.check(&ctx).unwrap();
4092 assert_eq!(
4093 result.len(),
4094 2,
4095 "both the path occurrence and the prose occurrence are flagged in body text: {result:?}"
4096 );
4097 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp.md for details about MyApp.\n");
4098 }
4099
4100 #[test]
4101 fn test_slash_conjunction_prose_still_flagged() {
4102 let content = "We support github/gitlab imports.\n";
4103 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4104 let ctx = create_context(content);
4105 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
4106 }
4107}